portfolioRepository (ornith-35): addHolding/updateHolding/removeHolding/listHoldings, parameterized, idempotent, ADR-0007

This commit is contained in:
Investor Flow Build
2026-06-30 13:52:51 -04:00
parent eb557f2093
commit 4ddf95e711
+222
View File
@@ -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)}`;
}