2026-06-30 13:52:51 -04:00
|
|
|
// 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
|
|
|
|
|
// );
|
2026-07-23 18:02:24 -04:00
|
|
|
// CREATE UNIQUE INDEX IF NOT EXISTS uq_portfolio_owner_symbol
|
|
|
|
|
// ON portfolio_holdings(owner_id, symbol);
|
2026-06-30 13:52:51 -04:00
|
|
|
|
|
|
|
|
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 {
|
2026-07-23 18:02:24 -04:00
|
|
|
if (shares <= 0) {
|
|
|
|
|
throw new Error('portfolioRepository: shares must be > 0');
|
|
|
|
|
}
|
|
|
|
|
if (avgCost < 0) {
|
|
|
|
|
throw new Error('portfolioRepository: avgCost must be >= 0');
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-30 13:52:51 -04:00
|
|
|
const s = stmts(db);
|
|
|
|
|
const upper = symbol.toUpperCase();
|
|
|
|
|
|
|
|
|
|
// Check if a holding already exists for this user + symbol.
|
2026-07-23 18:02:24 -04:00
|
|
|
const existing = s.selectByOwnerAndSymbol.all(userId, upper) as unknown as PortfolioRow[];
|
2026-06-30 13:52:51 -04:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 18:02:24 -04:00
|
|
|
// 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);
|
2026-06-30 13:52:51 -04:00
|
|
|
|
2026-07-23 18:02:24 -04:00
|
|
|
// We already knew the row existed (existing.length > 0), so this is an accumulation.
|
|
|
|
|
return false;
|
2026-06-30 13:52:51 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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();
|
|
|
|
|
|
2026-07-23 18:02:24 -04:00
|
|
|
const existing = s.selectByOwnerAndSymbol.all(userId, upper) as unknown as PortfolioRow[];
|
2026-06-30 13:52:51 -04:00
|
|
|
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;
|
|
|
|
|
|
2026-07-23 18:02:24 -04:00
|
|
|
// 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');
|
|
|
|
|
}
|
2026-06-30 13:52:51 -04:00
|
|
|
|
2026-07-23 18:02:24 -04:00
|
|
|
const result = s.updateHolding.run(qtyParam, avgCostParam, userId, upper);
|
2026-06-30 13:52:51 -04:00
|
|
|
|
2026-07-23 18:02:24 -04:00
|
|
|
// Use changes() to report whether the DB row was actually modified.
|
|
|
|
|
return result.changes > 0;
|
2026-06-30 13:52:51 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-07-23 18:02:24 -04:00
|
|
|
* 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.
|
2026-06-30 13:52:51 -04:00
|
|
|
*
|
2026-07-23 18:02:24 -04:00
|
|
|
* When `permanent` is true, performs a hard DELETE instead.
|
|
|
|
|
*
|
|
|
|
|
* @returns true if a row was closed/deleted, false if no matching holding exists.
|
2026-06-30 13:52:51 -04:00
|
|
|
*/
|
|
|
|
|
export function removeHolding(
|
|
|
|
|
db: DatabaseSync,
|
|
|
|
|
userId: string,
|
|
|
|
|
symbol: string,
|
2026-07-23 18:02:24 -04:00
|
|
|
options?: { permanent?: boolean },
|
2026-06-30 13:52:51 -04:00
|
|
|
): boolean {
|
|
|
|
|
const s = stmts(db);
|
|
|
|
|
const upper = symbol.toUpperCase();
|
|
|
|
|
|
2026-07-23 18:02:24 -04:00
|
|
|
const existing = s.selectByOwnerAndSymbol.all(userId, upper) as unknown as PortfolioRow[];
|
2026-06-30 13:52:51 -04:00
|
|
|
if (existing.length === 0) return false;
|
|
|
|
|
|
2026-07-23 18:02:24 -04:00
|
|
|
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;
|
2026-06-30 13:52:51 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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);
|
2026-07-23 18:02:24 -04:00
|
|
|
const rows = s.selectOpenByOwner.all(userId) as unknown as PortfolioRow[];
|
2026-06-30 13:52:51 -04:00
|
|
|
|
|
|
|
|
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)}`;
|
|
|
|
|
}
|