import { and, desc, eq } from 'drizzle-orm'; import type { Holding } from '@/lib/types'; import { recalculateHolding } from '@/lib/server/portfolio'; import { db } from '@/lib/server/db'; import { filing, holding, watchlistItem } from '@/lib/server/db/schema'; import { normalizeTicker, nowIso } from '@/lib/server/utils'; type HoldingRow = typeof holding.$inferSelect; function toHolding(row: HoldingRow): Holding { return { id: row.id, user_id: row.user_id, ticker: row.ticker, company_name: row.company_name ?? null, 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: normalizeTicker(input.ticker), shares: input.shares.toFixed(6), avg_cost: input.avgCost.toFixed(6), current_price: input.currentPrice.toFixed(6) }; } async function resolveHoldingCompanyName(input: { userId: string; ticker: string; companyName?: string; existingCompanyName?: string | null; }) { const explicitCompanyName = input.companyName?.trim(); if (explicitCompanyName) { return explicitCompanyName; } if (input.existingCompanyName?.trim()) { return input.existingCompanyName.trim(); } const [watchlistMatch] = await db .select({ company_name: watchlistItem.company_name }) .from(watchlistItem) .where(and(eq(watchlistItem.user_id, input.userId), eq(watchlistItem.ticker, input.ticker))) .limit(1); if (watchlistMatch?.company_name?.trim()) { return watchlistMatch.company_name.trim(); } const [filingMatch] = await db .select({ company_name: filing.company_name }) .from(filing) .where(eq(filing.ticker, input.ticker)) .orderBy(desc(filing.filing_date), desc(filing.updated_at)) .limit(1); return filingMatch?.company_name?.trim() ? filingMatch.company_name.trim() : null; } 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 getHoldingByTicker(userId: string, ticker: string) { const normalizedTicker = normalizeTicker(ticker); if (!normalizedTicker) { return null; } const [row] = await db .select() .from(holding) .where(and(eq(holding.user_id, userId), eq(holding.ticker, normalizedTicker))) .limit(1); return row ? toHolding(row) : null; } export async function upsertHoldingRecord(input: { userId: string; ticker: string; shares: number; avgCost: number; currentPrice?: number; companyName?: string; }) { const ticker = normalizeTicker(input.ticker); const now = nowIso(); 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; const companyName = await resolveHoldingCompanyName({ userId: input.userId, ticker, companyName: input.companyName, existingCompanyName: existing?.company_name ?? null }); 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, company_name: companyName, 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 { holding: toHolding(updated), created: false }; } const normalized = normalizeHoldingInput({ ticker, shares: input.shares, avgCost: input.avgCost, currentPrice }); const createdBase: Holding = { id: 0, user_id: input.userId, ticker: normalized.ticker, company_name: companyName, 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, company_name: created.company_name, 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 { holding: toHolding(inserted), created: true }; } export async function updateHoldingByIdRecord(input: { userId: string; id: number; shares?: number; avgCost?: number; currentPrice?: number; companyName?: string; }) { 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 companyName = await resolveHoldingCompanyName({ userId: input.userId, ticker: current.ticker, companyName: input.companyName, existingCompanyName: current.company_name }); const next = recalculateHolding({ ...current, company_name: companyName, shares: shares.toFixed(6), avg_cost: avgCost.toFixed(6), current_price: currentPrice.toFixed(6), updated_at: nowIso(), last_price_at: nowIso() }); const [updated] = await db .update(holding) .set({ company_name: companyName, 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; }