diff --git a/app/server/src/db/portfolioRepository.ts b/app/server/src/db/portfolioRepository.ts new file mode 100644 index 0000000..505eccb --- /dev/null +++ b/app/server/src/db/portfolioRepository.ts @@ -0,0 +1,222 @@ +// 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 +// ); + +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 { + 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 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 — update in place (ON CONFLICT branch handles qty/avg_cost merge). + const row = existing[0]; + s.updateHolding.run(shares, avgCost, userId, upper); + + // If qty didn't change and avg_cost didn't change, treat as no-op. + const updated = s.selectByOwnerAndSymbol.all(userId, upper) as PortfolioRow[]; + const updatedRow = updated[0]; + + return ( + updatedRow.qty !== row.qty || updatedRow.avg_cost !== row.avg_cost + ); +} + +/** + * 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 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; + + // Capture before-state for change detection. + const beforeQty = row.qty; + const beforeAvgCost = row.avg_cost; + + s.updateHolding.run(qtyParam, avgCostParam, userId, upper); + + return beforeQty !== row.qty || beforeAvgCost !== row.avg_cost; +} + +/** + * Remove a holding from the user's portfolio. The row is hard-deleted (not + * soft-closed) so it no longer appears in listHoldings. + * + * @returns true if a row was deleted, false if no matching holding exists. + */ +export function removeHolding( + db: DatabaseSync, + userId: string, + symbol: string, +): boolean { + const s = stmts(db); + const upper = symbol.toUpperCase(); + + const existing = s.selectByOwnerAndSymbol.all(userId, upper) as PortfolioRow[]; + if (existing.length === 0) return false; + + s.deleteByOwnerAndSymbol.run(userId, upper); + return true; +} + +/** + * 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 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)}`; +}