← back to Lifestyle Asset Intel
lib/portfolio.js
160 lines
// portfolio.js — shared helpers for portfolio_holdings.
//
// The enriched-list query joins each holding to its canonical asset, brand,
// family, and the latest valuation_snapshot via a LATERAL subquery (mirrors
// lib/valuation.js listAssets()), then computes unrealized_pl and pl_pct in
// JS so callers (API + console) get identical shapes.
const { pool } = require('./db');
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function isValidEmail(s) {
return typeof s === 'string' && EMAIL_RE.test(s.trim());
}
async function findAssetIdBySlug(slug) {
if (typeof slug !== 'string' || !slug.trim()) return null;
const r = await pool.query(
`SELECT id FROM canonical_assets WHERE slug = $1`,
[slug.trim()]
);
return r.rows[0] ? r.rows[0].id : null;
}
async function listHoldings(ownerEmail) {
const r = await pool.query(
`SELECT ph.id,
ph.owner_email,
ph.canonical_asset_id,
ph.acquired_at,
ph.acquisition_basis,
ph.notes,
ph.created_at,
ph.updated_at,
ca.slug AS asset_slug,
mf.name AS family,
mf.slug AS family_slug,
b.name AS brand,
b.slug AS brand_slug,
ca.size AS size,
ca.material AS material,
ca.color AS color,
vs.q10 AS q10,
vs.q50 AS q50,
vs.q90 AS q90,
vs.as_of AS snapshot_as_of
FROM portfolio_holdings ph
JOIN canonical_assets ca ON ca.id = ph.canonical_asset_id
JOIN model_families mf ON mf.id = ca.model_family_id
JOIN brands b ON b.id = mf.brand_id
LEFT JOIN LATERAL (
SELECT q10, q50, q90, as_of
FROM valuation_snapshots vs
WHERE vs.canonical_asset_id = ca.id
ORDER BY vs.as_of DESC
LIMIT 1
) vs ON true
WHERE ph.owner_email = $1
ORDER BY ph.acquired_at DESC NULLS LAST, ph.created_at DESC`,
[ownerEmail]
);
return r.rows.map(enrich);
}
function enrich(row) {
const basis = numOrNull(row.acquisition_basis);
const q50 = numOrNull(row.q50);
let unrealized_pl = null;
let pl_pct = null;
if (basis !== null && q50 !== null) {
unrealized_pl = +(q50 - basis).toFixed(2);
if (basis !== 0) pl_pct = +((q50 - basis) / basis).toFixed(4);
}
let days_held = null;
if (row.acquired_at) {
const acq = new Date(row.acquired_at);
if (!isNaN(acq.getTime())) {
days_held = Math.max(0, Math.floor((Date.now() - acq.getTime()) / 86400000));
}
}
return {
...row,
acquisition_basis: basis,
q10: numOrNull(row.q10),
q50,
q90: numOrNull(row.q90),
unrealized_pl,
pl_pct,
days_held
};
}
function numOrNull(v) {
if (v === null || v === undefined) return null;
const n = typeof v === 'string' ? parseFloat(v) : v;
return Number.isFinite(n) ? n : null;
}
async function insertHolding({ owner_email, asset_slug, acquired_at, acquisition_basis, notes }) {
const assetId = await findAssetIdBySlug(asset_slug);
if (!assetId) {
const err = new Error('invalid_asset_slug');
err.code = 'invalid_asset_slug';
throw err;
}
const r = await pool.query(
`INSERT INTO portfolio_holdings (owner_email, canonical_asset_id, acquired_at, acquisition_basis, notes)
VALUES ($1, $2, $3, $4, $5)
RETURNING *`,
[
owner_email.trim(),
assetId,
acquired_at || null,
acquisition_basis === undefined || acquisition_basis === null || acquisition_basis === '' ? null : acquisition_basis,
notes || null
]
);
return r.rows[0];
}
async function patchHolding(id, fields) {
const allowed = ['acquired_at', 'acquisition_basis', 'notes'];
const sets = [];
const vals = [];
let i = 1;
for (const key of allowed) {
if (Object.prototype.hasOwnProperty.call(fields, key)) {
sets.push(`${key} = $${i++}`);
vals.push(fields[key] === '' ? null : fields[key]);
}
}
if (!sets.length) return { error: 'no_fields_to_update' };
sets.push(`updated_at = now()`);
vals.push(id);
const r = await pool.query(
`UPDATE portfolio_holdings SET ${sets.join(', ')} WHERE id = $${i} RETURNING *`,
vals
);
if (!r.rows[0]) return { error: 'not_found' };
return { row: r.rows[0] };
}
async function deleteHolding(id) {
const r = await pool.query(
`DELETE FROM portfolio_holdings WHERE id = $1 RETURNING id`,
[id]
);
return r.rowCount > 0;
}
module.exports = {
EMAIL_RE,
isValidEmail,
findAssetIdBySlug,
listHoldings,
insertHolding,
patchHolding,
deleteHolding
};