Add investment holdings CRUD (create, edit, delete)
Add POST/PUT/DELETE endpoints to InvestmentsController with find-or-create Security by normalized symbol, price upsert helper, and ownership checks. Frontend gets add/edit dialog with per-share cost basis input, delete confirmation, actions column on holdings table, and success/error snackbar. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<InvestmentPerformance>(`/investments/${accountId}/performance`),
|
||||
|
||||
createHolding: (accountId: string, data: CreateInvestmentHoldingRequest) =>
|
||||
api.post<InvestmentHolding>(`/investments/${accountId}/holdings`, data),
|
||||
|
||||
updateHolding: (accountId: string, holdingId: string, data: UpdateInvestmentHoldingRequest) =>
|
||||
api.put<InvestmentHolding>(`/investments/${accountId}/holdings/${holdingId}`, data),
|
||||
|
||||
deleteHolding: (accountId: string, holdingId: string) =>
|
||||
api.delete(`/investments/${accountId}/holdings/${holdingId}`),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -38,7 +38,8 @@
|
||||
<div class="text-caption text-medium-emphasis mb-6">Account Balance</div>
|
||||
<v-icon size="48" color="grey-lighten-1" class="mb-4">mdi-chart-pie</v-icon>
|
||||
<p class="text-medium-emphasis">No individual holdings tracked for this account.</p>
|
||||
<p class="text-medium-emphasis text-caption">Add holdings to see allocation, cost basis, and performance.</p>
|
||||
<p class="text-medium-emphasis text-caption mb-4">Add holdings to see allocation, cost basis, and performance.</p>
|
||||
<v-btn color="primary" prepend-icon="mdi-plus" @click="openAddDialog">Add Holding</v-btn>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
@@ -97,7 +98,11 @@
|
||||
</v-col>
|
||||
<v-col cols="12" md="8">
|
||||
<v-card>
|
||||
<v-card-title>Holdings</v-card-title>
|
||||
<v-card-title class="d-flex align-center">
|
||||
Holdings
|
||||
<v-spacer />
|
||||
<v-btn color="primary" size="small" prepend-icon="mdi-plus" @click="openAddDialog">Add Holding</v-btn>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-data-table
|
||||
:headers="holdingsHeaders"
|
||||
@@ -120,6 +125,16 @@
|
||||
{{ formatPercent(item.gainLossPercent) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #item.actions="{ item }">
|
||||
<div class="d-flex align-center ga-1">
|
||||
<v-btn icon size="x-small" variant="text" @click="openEditDialog(item)">
|
||||
<v-icon size="small">mdi-pencil</v-icon>
|
||||
</v-btn>
|
||||
<v-btn icon size="x-small" variant="text" color="error" @click="confirmDelete(item)">
|
||||
<v-icon size="small">mdi-delete</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
@@ -134,7 +149,74 @@
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<v-snackbar v-model="snackbar" color="error" :timeout="3000">{{ snackbarText }}</v-snackbar>
|
||||
<!-- Add/Edit Holding Dialog -->
|
||||
<v-dialog v-model="showHoldingDialog" max-width="500" persistent>
|
||||
<v-card>
|
||||
<v-card-title>{{ editingHolding ? 'Edit Holding' : 'Add Holding' }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-if="!editingHolding"
|
||||
v-model="holdingForm.symbol"
|
||||
label="Symbol"
|
||||
placeholder="e.g. AAPL"
|
||||
:rules="[v => !!v || 'Symbol is required']"
|
||||
/>
|
||||
<v-text-field
|
||||
v-if="!editingHolding"
|
||||
v-model="holdingForm.securityName"
|
||||
label="Security Name"
|
||||
placeholder="e.g. Apple Inc."
|
||||
:rules="[v => !!v || 'Name is required']"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model.number="holdingForm.shares"
|
||||
label="Shares"
|
||||
type="number"
|
||||
:rules="[v => v > 0 || 'Shares must be greater than 0']"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model.number="holdingForm.costBasisPerShare"
|
||||
label="Cost Basis Per Share"
|
||||
type="number"
|
||||
prefix="$"
|
||||
:rules="[v => v >= 0 || 'Cost basis must be non-negative']"
|
||||
/>
|
||||
<div v-if="holdingForm.shares > 0 && holdingForm.costBasisPerShare > 0" class="text-caption text-medium-emphasis mb-4">
|
||||
Total Cost Basis: {{ formatCurrency(holdingForm.shares * holdingForm.costBasisPerShare) }}
|
||||
</div>
|
||||
<v-text-field
|
||||
v-model.number="holdingForm.currentPrice"
|
||||
label="Current Price (optional)"
|
||||
type="number"
|
||||
prefix="$"
|
||||
hint="Leave empty to keep existing price"
|
||||
persistent-hint
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn @click="showHoldingDialog = false">Cancel</v-btn>
|
||||
<v-btn color="primary" :loading="saving" @click="saveHolding">Save</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<v-dialog v-model="showDeleteDialog" max-width="400">
|
||||
<v-card>
|
||||
<v-card-title>Delete Holding</v-card-title>
|
||||
<v-card-text>
|
||||
Are you sure you want to delete the holding for "{{ deletingHolding?.symbol }}"? This cannot be undone.
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn @click="showDeleteDialog = false">Cancel</v-btn>
|
||||
<v-btn color="error" :loading="saving" @click="deleteHolding">Delete</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-snackbar v-model="snackbar" :color="snackbarColor" :timeout="3000">{{ snackbarText }}</v-snackbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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<string | null>(null)
|
||||
const performance = ref<InvestmentPerformance | null>(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<InvestmentHolding | null>(null)
|
||||
const deletingHolding = ref<InvestmentHolding | null>(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 () => {
|
||||
|
||||
@@ -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<Account?> GetAccountIfOwned(Guid accountId) =>
|
||||
await _db.Accounts.FirstOrDefaultAsync(a => a.Id == accountId && a.UserId == UserId);
|
||||
|
||||
[HttpGet("{accountId}/holdings")]
|
||||
public async Task<ActionResult<List<InvestmentHoldingResponse>>> 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<ActionResult<InvestmentHoldingResponse>> 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<ActionResult<InvestmentHoldingResponse>> 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<ActionResult> 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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,3 +20,22 @@ public record InvestmentPerformanceResponse(
|
||||
decimal TotalGainLossPercent,
|
||||
List<InvestmentHoldingResponse> 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
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user