import { and, eq } from 'drizzle-orm'; import type { Holding } from '@/lib/types'; import { recalculateHolding } from '@/lib/server/portfolio'; import { db } from '@/lib/server/db'; import { holding } from '@/lib/server/db/schema'; type HoldingRow = typeof holding.$inferSelect; function toHolding(row: HoldingRow): Holding { return { id: row.id, user_id: row.user_id, ticker: row.ticker, shares: row.shares, avg_cost: row.avg_cost, current_price: row.current_price, market_value: row.market_value, gain_loss: row.gain_loss, gain_loss_pct: row.gain_loss_pct, last_price_at: row.last_price_at, created_at: row.created_at, updated_at: row.updated_at }; } function sortByMarketValueDesc(rows: Holding[]) { return rows.slice().sort((a, b) => Number(b.market_value) - Number(a.market_value)); } function normalizeHoldingInput(input: { ticker: string; shares: number; avgCost: number; currentPrice: number }) { return { ticker: input.ticker.trim().toUpperCase(), shares: input.shares.toFixed(6), avg_cost: input.avgCost.toFixed(6), current_price: input.currentPrice.toFixed(6) }; } export async function listUserHoldings(userId: string) { const rows = await db .select() .from(holding) .where(eq(holding.user_id, userId)); return sortByMarketValueDesc(rows.map(toHolding)); } export async function upsertHoldingRecord(input: { userId: string; ticker: string; shares: number; avgCost: number; currentPrice?: number; }) { const ticker = input.ticker.trim().toUpperCase(); const now = new Date().toISOString(); const [existing] = await db .select() .from(holding) .where(and(eq(holding.user_id, input.userId), eq(holding.ticker, ticker))) .limit(1); const currentPrice = Number.isFinite(input.currentPrice) ? Number(input.currentPrice) : input.avgCost; if (existing) { const normalized = normalizeHoldingInput({ ticker, shares: input.shares, avgCost: input.avgCost, currentPrice }); const next = recalculateHolding({ ...toHolding(existing), ...normalized, updated_at: now, last_price_at: now }); const [updated] = await db .update(holding) .set({ ticker: next.ticker, shares: next.shares, avg_cost: next.avg_cost, current_price: next.current_price, market_value: next.market_value, gain_loss: next.gain_loss, gain_loss_pct: next.gain_loss_pct, updated_at: next.updated_at, last_price_at: next.last_price_at }) .where(eq(holding.id, existing.id)) .returning(); return toHolding(updated); } const normalized = normalizeHoldingInput({ ticker, shares: input.shares, avgCost: input.avgCost, currentPrice }); const createdBase: Holding = { id: 0, user_id: input.userId, ticker: normalized.ticker, shares: normalized.shares, avg_cost: normalized.avg_cost, current_price: normalized.current_price, market_value: '0', gain_loss: '0', gain_loss_pct: '0', last_price_at: now, created_at: now, updated_at: now }; const created = recalculateHolding(createdBase); const [inserted] = await db .insert(holding) .values({ user_id: created.user_id, ticker: created.ticker, shares: created.shares, avg_cost: created.avg_cost, current_price: created.current_price, market_value: created.market_value, gain_loss: created.gain_loss, gain_loss_pct: created.gain_loss_pct, last_price_at: created.last_price_at, created_at: created.created_at, updated_at: created.updated_at }) .returning(); return toHolding(inserted); } export async function updateHoldingByIdRecord(input: { userId: string; id: number; shares?: number; avgCost?: number; currentPrice?: number; }) { const [existing] = await db .select() .from(holding) .where(and(eq(holding.id, input.id), eq(holding.user_id, input.userId))) .limit(1); if (!existing) { return null; } const current = toHolding(existing); const shares = Number.isFinite(input.shares) ? Number(input.shares) : Number(current.shares); const avgCost = Number.isFinite(input.avgCost) ? Number(input.avgCost) : Number(current.avg_cost); const currentPrice = Number.isFinite(input.currentPrice) ? Number(input.currentPrice) : Number(current.current_price ?? current.avg_cost); const next = recalculateHolding({ ...current, shares: shares.toFixed(6), avg_cost: avgCost.toFixed(6), current_price: currentPrice.toFixed(6), updated_at: new Date().toISOString(), last_price_at: new Date().toISOString() }); const [updated] = await db .update(holding) .set({ shares: next.shares, avg_cost: next.avg_cost, current_price: next.current_price, market_value: next.market_value, gain_loss: next.gain_loss, gain_loss_pct: next.gain_loss_pct, updated_at: next.updated_at, last_price_at: next.last_price_at }) .where(eq(holding.id, existing.id)) .returning(); return toHolding(updated); } export async function deleteHoldingByIdRecord(userId: string, id: number) { const rows = await db .delete(holding) .where(and(eq(holding.user_id, userId), eq(holding.id, id))) .returning({ id: holding.id }); return rows.length > 0; } export async function listHoldingsForPriceRefresh(userId: string) { const rows = await db .select() .from(holding) .where(eq(holding.user_id, userId)); return rows.map(toHolding); } export async function applyRefreshedPrices( userId: string, quotes: Map, updateTime: string ) { const rows = await db .select() .from(holding) .where(eq(holding.user_id, userId)); let updatedCount = 0; for (const row of rows) { const quote = quotes.get(row.ticker); if (quote === undefined) { continue; } const next = recalculateHolding({ ...toHolding(row), current_price: quote.toFixed(6), last_price_at: updateTime, updated_at: updateTime }); await db .update(holding) .set({ current_price: next.current_price, market_value: next.market_value, gain_loss: next.gain_loss, gain_loss_pct: next.gain_loss_pct, last_price_at: next.last_price_at, updated_at: next.updated_at }) .where(eq(holding.id, row.id)); updatedCount += 1; } return updatedCount; }