Files
investor-flow/app/server/src/db/portfolioRepository.ts
T

242 lines
8.0 KiB
TypeScript
Raw Normal View History

// 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)}`;
}