// Investor Flow — Portfolio Repository (Slice 10: portfolio-repository) // // Data-access layer over the `portfolio_holdings` table. Read/write only — no trade verbs // per ADR-0007 (Primary-Rule: no imperative-trade-verb in any string). "Holding" is // neutral descriptive language — the user's own records, never generated by the system. // // Schema (schema.sql): // CREATE TABLE IF NOT EXISTS portfolio_holdings ( // id TEXT PRIMARY KEY, // owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, // symbol TEXT NOT NULL, // qty REAL NOT NULL, // avg_cost REAL NOT NULL, // acquired_at TEXT NOT NULL, // status TEXT NOT NULL DEFAULT 'open' -- open | closed // ); // CREATE UNIQUE INDEX IF NOT EXISTS uq_portfolio_owner_symbol // ON portfolio_holdings(owner_id, symbol); import type { DatabaseSync } from 'node:sqlite'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- /** A single portfolio holding returned by listHoldings. */ export interface PortfolioHolding { symbol: string; shares: number; avg_cost: number; added_at: string; } /** Internal row shape from the database. */ interface PortfolioRow { id: string; owner_id: string; symbol: string; qty: number; avg_cost: number; acquired_at: string; status: string; } // --------------------------------------------------------------------------- // Prepared statements (lazy, one per method) // --------------------------------------------------------------------------- function stmts(db: DatabaseSync) { return { /** Insert a new holding (idempotent by owner_id + symbol). */ insertHolding: db.prepare( `INSERT INTO portfolio_holdings (id, owner_id, symbol, qty, avg_cost, acquired_at, status) VALUES (?, ?, ?, ?, ?, ?, 'open') ON CONFLICT(owner_id, symbol) DO UPDATE SET qty = qty + excluded.qty, avg_cost = (avg_cost * qty + excluded.avg_cost * excluded.qty) / (qty + excluded.qty)`, ), /** Update qty and/or avg_cost for an existing holding. */ updateHolding: db.prepare( `UPDATE portfolio_holdings SET qty = COALESCE(?, qty), avg_cost = COALESCE(?, avg_cost) WHERE owner_id = ? AND symbol = ?`, ), /** Soft-delete a holding by marking status='closed'. */ closeHolding: db.prepare( `UPDATE portfolio_holdings SET status = 'closed' WHERE owner_id = ? AND symbol = ?`, ), /** Select all open holdings for a user, newest first. */ selectOpenByOwner: db.prepare( `SELECT id, owner_id, symbol, qty, avg_cost, acquired_at FROM portfolio_holdings WHERE owner_id = ? AND status = 'open' ORDER BY acquired_at DESC`, ), /** Select a single holding by owner + symbol (any status). */ selectByOwnerAndSymbol: db.prepare( `SELECT id, owner_id, symbol, qty, avg_cost, acquired_at, status FROM portfolio_holdings WHERE owner_id = ? AND symbol = ?`, ), /** Hard-delete a holding row by owner + symbol. */ deleteByOwnerAndSymbol: db.prepare( `DELETE FROM portfolio_holdings WHERE owner_id = ? AND symbol = ?`, ), }; } // --------------------------------------------------------------------------- // Repository — public API (all methods parameterized, no string interpolation) // --------------------------------------------------------------------------- /** * Add or accumulate a holding for the user. If a holding for (owner_id, symbol) * already exists, the new shares are blended into the existing position — avg_cost * is recalculated as a volume-weighted average. Idempotent: re-adding the same * (userId, symbol) with identical values is a no-op on the row. * * Per ADR-0007, "holding" is neutral descriptive language — the system never * generates directional or trade-verb text. * * @returns true if a new row was inserted, false if the existing row was updated. */ export function addHolding( db: DatabaseSync, userId: string, symbol: string, shares: number, avgCost: number, ): boolean { if (shares <= 0) { throw new Error('portfolioRepository: shares must be > 0'); } if (avgCost < 0) { throw new Error('portfolioRepository: avgCost must be >= 0'); } const s = stmts(db); const upper = symbol.toUpperCase(); // Check if a holding already exists for this user + symbol. const existing = s.selectByOwnerAndSymbol.all(userId, upper) as unknown as PortfolioRow[]; if (existing.length === 0) { // New holding — insert with a generated id and current timestamp. const id = generateId(); const now = new Date().toISOString(); s.insertHolding.run(id, userId, upper, shares, avgCost, now); return true; } // Existing holding — route through insertHolding so ON CONFLICT does VWAP accumulation. const id = generateId(); const now = new Date().toISOString(); s.insertHolding.run(id, userId, upper, shares, avgCost, now); // We already knew the row existed (existing.length > 0), so this is an accumulation. return false; } /** * Update an existing holding's quantity and/or average cost. Only the provided * fields are touched — omitted fields retain their current values via COALESCE. * * @returns true if a row was modified, false if no matching holding exists. */ export function updateHolding( db: DatabaseSync, userId: string, symbol: string, updates: { shares?: number; avgCost?: number }, ): boolean { const s = stmts(db); const upper = symbol.toUpperCase(); const existing = s.selectByOwnerAndSymbol.all(userId, upper) as unknown as PortfolioRow[]; if (existing.length === 0) return false; const row = existing[0]; if (row.status === 'closed') return false; const qtyParam = updates.shares !== undefined ? updates.shares : null; const avgCostParam = updates.avgCost !== undefined ? updates.avgCost : null; // Input validation. if (qtyParam !== null && qtyParam <= 0) { throw new Error('portfolioRepository: shares must be > 0'); } if (avgCostParam !== null && avgCostParam < 0) { throw new Error('portfolioRepository: avgCost must be >= 0'); } const result = s.updateHolding.run(qtyParam, avgCostParam, userId, upper); // Use changes() to report whether the DB row was actually modified. return result.changes > 0; } /** * Remove a holding from the user's portfolio. By default performs a SOFT-DELETE * (marks status='closed') so the row remains in the database for audit/restore. * * When `permanent` is true, performs a hard DELETE instead. * * @returns true if a row was closed/deleted, false if no matching holding exists. */ export function removeHolding( db: DatabaseSync, userId: string, symbol: string, options?: { permanent?: boolean }, ): boolean { const s = stmts(db); const upper = symbol.toUpperCase(); const existing = s.selectByOwnerAndSymbol.all(userId, upper) as unknown as PortfolioRow[]; if (existing.length === 0) return false; if (options?.permanent === true) { const result = s.deleteByOwnerAndSymbol.run(userId, upper); return result.changes > 0; } // Default: soft-delete (close). const result = s.closeHolding.run(userId, upper); return result.changes > 0; } /** * List all open holdings for a user, ordered by acquisition date (newest first). * Returns an array of simplified holding objects. */ export function listHoldings( db: DatabaseSync, userId: string, ): PortfolioHolding[] { const s = stmts(db); const rows = s.selectOpenByOwner.all(userId) as unknown as PortfolioRow[]; return rows.map((row) => ({ symbol: row.symbol, shares: row.qty, avg_cost: row.avg_cost, added_at: row.acquired_at, })); } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** Generate a simple unique id for a new holding row. */ function generateId(): string { return `ph_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; }