diff --git a/frontend/src/services/investments.ts b/frontend/src/services/investments.ts index 9d978a9..74921d6 100644 --- a/frontend/src/services/investments.ts +++ b/frontend/src/services/investments.ts @@ -1,5 +1,5 @@ import api from './api' -import type { InvestmentHolding, InvestmentPerformance } from '@/types' +import type { InvestmentHolding, InvestmentPerformance, CreateInvestmentHoldingRequest, UpdateInvestmentHoldingRequest } from '@/types' export const investmentsApi = { getHoldings: (accountId: string) => @@ -7,4 +7,13 @@ export const investmentsApi = { getPerformance: (accountId: string) => api.get(`/investments/${accountId}/performance`), + + createHolding: (accountId: string, data: CreateInvestmentHoldingRequest) => + api.post(`/investments/${accountId}/holdings`, data), + + updateHolding: (accountId: string, holdingId: string, data: UpdateInvestmentHoldingRequest) => + api.put(`/investments/${accountId}/holdings/${holdingId}`, data), + + deleteHolding: (accountId: string, holdingId: string) => + api.delete(`/investments/${accountId}/holdings/${holdingId}`), } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index bda41da..3d6c63e 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -364,6 +364,21 @@ export interface InvestmentPerformance { holdings: InvestmentHolding[] } +export interface CreateInvestmentHoldingRequest { + symbol: string + securityName: string + securityType?: string | null + shares: number + costBasis: number + currentPrice?: number | null +} + +export interface UpdateInvestmentHoldingRequest { + shares: number + costBasis: number + currentPrice?: number | null +} + // Reconciliation export interface ReconciliationResponse { id: string diff --git a/frontend/src/views/investments/InvestmentsView.vue b/frontend/src/views/investments/InvestmentsView.vue index e0bb257..0d60c0d 100644 --- a/frontend/src/views/investments/InvestmentsView.vue +++ b/frontend/src/views/investments/InvestmentsView.vue @@ -38,7 +38,8 @@
Account Balance
mdi-chart-pie

No individual holdings tracked for this account.

-

Add holdings to see allocation, cost basis, and performance.

+

Add holdings to see allocation, cost basis, and performance.

+ Add Holding @@ -97,7 +98,11 @@ - Holdings + + Holdings + + Add Holding + + @@ -134,7 +149,74 @@ - {{ snackbarText }} + + + + {{ editingHolding ? 'Edit Holding' : 'Add Holding' }} + + + + + +
+ Total Cost Basis: {{ formatCurrency(holdingForm.shares * holdingForm.costBasisPerShare) }} +
+ +
+ + + Cancel + Save + +
+
+ + + + + Delete Holding + + Are you sure you want to delete the holding for "{{ deletingHolding?.symbol }}"? This cannot be undone. + + + + Cancel + Delete + + + + + {{ snackbarText }} @@ -144,7 +226,7 @@ import VueApexCharts from 'vue3-apexcharts' import { investmentsApi } from '@/services/investments' import { useAccountsStore } from '@/stores/accounts' import { formatCurrency, formatPercent, accountTypeLabel, accountTypeIcon } from '@/utils/formatters' -import type { InvestmentPerformance } from '@/types' +import type { InvestmentPerformance, InvestmentHolding } from '@/types' const apexchart = VueApexCharts @@ -153,8 +235,23 @@ const accountsStore = useAccountsStore() const selectedAccountId = ref(null) const performance = ref(null) const loading = ref(false) +const saving = ref(false) const snackbar = ref(false) const snackbarText = ref('') +const snackbarColor = ref('success') + +// Dialog state +const showHoldingDialog = ref(false) +const showDeleteDialog = ref(false) +const editingHolding = ref(null) +const deletingHolding = ref(null) +const holdingForm = ref({ + symbol: '', + securityName: '', + shares: 0, + costBasisPerShare: 0, + currentPrice: null as number | null, +}) const investmentAccountTypes = ['Retirement401k', 'IRA', 'Brokerage'] @@ -202,6 +299,7 @@ const holdingsHeaders = [ { title: 'Market Value', key: 'marketValue', align: 'end' as const }, { title: 'Gain/Loss', key: 'gainLoss', align: 'end' as const }, { title: 'Return', key: 'gainLossPercent', align: 'end' as const }, + { title: 'Actions', key: 'actions', sortable: false, width: '100px' }, ] async function loadPerformance() { @@ -214,14 +312,89 @@ async function loadPerformance() { const { data } = await investmentsApi.getPerformance(selectedAccountId.value) performance.value = data } catch { - snackbarText.value = 'Failed to load investment performance' - snackbar.value = true + showSnackbar('Failed to load investment performance', 'error') performance.value = null } finally { loading.value = false } } +function openAddDialog() { + editingHolding.value = null + holdingForm.value = { symbol: '', securityName: '', shares: 0, costBasisPerShare: 0, currentPrice: null } + showHoldingDialog.value = true +} + +function openEditDialog(holding: InvestmentHolding) { + editingHolding.value = holding + const costBasisPerShare = holding.shares > 0 ? holding.costBasis / holding.shares : 0 + holdingForm.value = { + symbol: holding.symbol, + securityName: holding.securityName, + shares: holding.shares, + costBasisPerShare: Math.round(costBasisPerShare * 100) / 100, + currentPrice: holding.currentPrice || null, + } + showHoldingDialog.value = true +} + +async function saveHolding() { + if (!selectedAccountId.value) return + saving.value = true + try { + const totalCostBasis = holdingForm.value.shares * holdingForm.value.costBasisPerShare + if (editingHolding.value) { + await investmentsApi.updateHolding(selectedAccountId.value, editingHolding.value.id, { + shares: holdingForm.value.shares, + costBasis: totalCostBasis, + currentPrice: holdingForm.value.currentPrice || undefined, + }) + showSnackbar('Holding updated') + } else { + await investmentsApi.createHolding(selectedAccountId.value, { + symbol: holdingForm.value.symbol, + securityName: holdingForm.value.securityName, + shares: holdingForm.value.shares, + costBasis: totalCostBasis, + currentPrice: holdingForm.value.currentPrice || undefined, + }) + showSnackbar('Holding added') + } + showHoldingDialog.value = false + await loadPerformance() + } catch { + showSnackbar('Failed to save holding', 'error') + } finally { + saving.value = false + } +} + +function confirmDelete(holding: InvestmentHolding) { + deletingHolding.value = holding + showDeleteDialog.value = true +} + +async function deleteHolding() { + if (!selectedAccountId.value || !deletingHolding.value) return + saving.value = true + try { + await investmentsApi.deleteHolding(selectedAccountId.value, deletingHolding.value.id) + showDeleteDialog.value = false + showSnackbar('Holding deleted') + await loadPerformance() + } catch { + showSnackbar('Failed to delete holding', 'error') + } finally { + saving.value = false + } +} + +function showSnackbar(text: string, color = 'success') { + snackbarText.value = text + snackbarColor.value = color + snackbar.value = true +} + watch(selectedAccountId, () => loadPerformance()) onMounted(async () => { diff --git a/src/Purrse.Api/Controllers/InvestmentsController.cs b/src/Purrse.Api/Controllers/InvestmentsController.cs index 5a13771..801fddc 100644 --- a/src/Purrse.Api/Controllers/InvestmentsController.cs +++ b/src/Purrse.Api/Controllers/InvestmentsController.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Purrse.Core.DTOs; +using Purrse.Core.Models; using Purrse.Data; using System.Security.Claims; @@ -17,11 +18,14 @@ public class InvestmentsController : ControllerBase public InvestmentsController(PurrseDbContext db) => _db = db; + private async Task GetAccountIfOwned(Guid accountId) => + await _db.Accounts.FirstOrDefaultAsync(a => a.Id == accountId && a.UserId == UserId); + [HttpGet("{accountId}/holdings")] public async Task>> GetHoldings(Guid accountId) { - var account = await _db.Accounts.FirstOrDefaultAsync(a => a.Id == accountId && a.UserId == UserId) - ?? throw new KeyNotFoundException("Account not found"); + if (await GetAccountIfOwned(accountId) is null) + return NotFound(); var holdings = await _db.InvestmentHoldings .Include(h => h.Security).ThenInclude(s => s.Prices) @@ -55,4 +59,121 @@ public class InvestmentsController : ControllerBase return Ok(new InvestmentPerformanceResponse(totalMarket, totalCost, totalGL, totalPct, holdings)); } + + [HttpPost("{accountId}/holdings")] + public async Task> CreateHolding(Guid accountId, CreateInvestmentHoldingRequest request) + { + if (await GetAccountIfOwned(accountId) is null) + return NotFound(); + + var normalizedSymbol = request.Symbol.Trim().ToUpperInvariant(); + + var security = await _db.Securities.FirstOrDefaultAsync(s => s.Symbol == normalizedSymbol); + if (security is null) + { + security = new Security + { + Id = Guid.NewGuid(), + Symbol = normalizedSymbol, + Name = request.SecurityName.Trim(), + SecurityType = request.SecurityType?.Trim() + }; + _db.Securities.Add(security); + } + + var holding = new InvestmentHolding + { + Id = Guid.NewGuid(), + AccountId = accountId, + SecurityId = security.Id, + Shares = request.Shares, + CostBasis = request.CostBasis, + AsOfDate = DateTime.UtcNow + }; + _db.InvestmentHoldings.Add(holding); + + if (request.CurrentPrice.HasValue) + await UpsertSecurityPrice(security.Id, DateTime.UtcNow.Date, request.CurrentPrice.Value); + + await _db.SaveChangesAsync(); + + var latestPrice = request.CurrentPrice ?? 0; + var marketValue = holding.Shares * latestPrice; + var gainLoss = marketValue - holding.CostBasis; + var gainLossPct = holding.CostBasis != 0 ? (gainLoss / holding.CostBasis) * 100 : 0; + + return Ok(new InvestmentHoldingResponse(holding.Id, security.Symbol, security.Name, + holding.Shares, holding.CostBasis, latestPrice, marketValue, gainLoss, gainLossPct, holding.AsOfDate)); + } + + [HttpPut("{accountId}/holdings/{holdingId}")] + public async Task> UpdateHolding(Guid accountId, Guid holdingId, UpdateInvestmentHoldingRequest request) + { + if (await GetAccountIfOwned(accountId) is null) + return NotFound(); + + var holding = await _db.InvestmentHoldings + .Include(h => h.Security).ThenInclude(s => s.Prices) + .FirstOrDefaultAsync(h => h.Id == holdingId && h.AccountId == accountId); + + if (holding is null) + return NotFound(); + + holding.Shares = request.Shares; + holding.CostBasis = request.CostBasis; + holding.AsOfDate = DateTime.UtcNow; + + if (request.CurrentPrice.HasValue) + await UpsertSecurityPrice(holding.SecurityId, DateTime.UtcNow.Date, request.CurrentPrice.Value); + + await _db.SaveChangesAsync(); + + var latestPrice = request.CurrentPrice + ?? holding.Security.Prices.OrderByDescending(p => p.Date).FirstOrDefault()?.Price ?? 0; + var marketValue = holding.Shares * latestPrice; + var gainLoss = marketValue - holding.CostBasis; + var gainLossPct = holding.CostBasis != 0 ? (gainLoss / holding.CostBasis) * 100 : 0; + + return Ok(new InvestmentHoldingResponse(holding.Id, holding.Security.Symbol, holding.Security.Name, + holding.Shares, holding.CostBasis, latestPrice, marketValue, gainLoss, gainLossPct, holding.AsOfDate)); + } + + [HttpDelete("{accountId}/holdings/{holdingId}")] + public async Task DeleteHolding(Guid accountId, Guid holdingId) + { + if (await GetAccountIfOwned(accountId) is null) + return NotFound(); + + var holding = await _db.InvestmentHoldings + .FirstOrDefaultAsync(h => h.Id == holdingId && h.AccountId == accountId); + + if (holding is null) + return NotFound(); + + _db.InvestmentHoldings.Remove(holding); + await _db.SaveChangesAsync(); + + return NoContent(); + } + + private async Task UpsertSecurityPrice(Guid securityId, DateTime date, decimal price) + { + var existing = await _db.SecurityPrices + .FirstOrDefaultAsync(p => p.SecurityId == securityId && p.Date == date); + + if (existing is not null) + { + existing.Price = price; + } + else + { + _db.SecurityPrices.Add(new SecurityPrice + { + Id = Guid.NewGuid(), + SecurityId = securityId, + Date = date, + Price = price + }); + } + } } diff --git a/src/Purrse.Core/DTOs/InvestmentDtos.cs b/src/Purrse.Core/DTOs/InvestmentDtos.cs index 90676e1..76786e6 100644 --- a/src/Purrse.Core/DTOs/InvestmentDtos.cs +++ b/src/Purrse.Core/DTOs/InvestmentDtos.cs @@ -20,3 +20,22 @@ public record InvestmentPerformanceResponse( decimal TotalGainLossPercent, List Holdings ); + +public record CreateInvestmentHoldingRequest( + string Symbol, + string SecurityName, + string? SecurityType, + decimal Shares, + decimal CostBasis, + decimal? CurrentPrice +); + +public record UpdateInvestmentHoldingRequest( + decimal Shares, + decimal CostBasis, + decimal? CurrentPrice +); + +public record UpdateSecurityPriceRequest( + decimal Price +);