Files
Neon-Desk/lib/server/repos/holdings.ts

267 lines
6.5 KiB
TypeScript

import { and, eq } from 'drizzle-orm';
import type { Holding } from '@/lib/types';
import { recalculateHolding } from '@/lib/server/portfolio';
import { db } from '@/lib/server/db';
import { holding } from '@/lib/server/db/schema';
type HoldingRow = typeof holding.$inferSelect;
function toHolding(row: HoldingRow): Holding {
return {
id: row.id,
user_id: row.user_id,
ticker: row.ticker,
shares: row.shares,
avg_cost: row.avg_cost,
current_price: row.current_price,
market_value: row.market_value,
gain_loss: row.gain_loss,
gain_loss_pct: row.gain_loss_pct,
last_price_at: row.last_price_at,
created_at: row.created_at,
updated_at: row.updated_at
};
}
function sortByMarketValueDesc(rows: Holding[]) {
return rows.slice().sort((a, b) => Number(b.market_value) - Number(a.market_value));
}
function normalizeHoldingInput(input: { ticker: string; shares: number; avgCost: number; currentPrice: number }) {
return {
ticker: input.ticker.trim().toUpperCase(),
shares: input.shares.toFixed(6),
avg_cost: input.avgCost.toFixed(6),
current_price: input.currentPrice.toFixed(6)
};
}
export async function listUserHoldings(userId: string) {
const rows = await db
.select()
.from(holding)
.where(eq(holding.user_id, userId));
return sortByMarketValueDesc(rows.map(toHolding));
}
export async function upsertHoldingRecord(input: {
userId: string;
ticker: string;
shares: number;
avgCost: number;
currentPrice?: number;
}) {
const ticker = input.ticker.trim().toUpperCase();
const now = new Date().toISOString();
const [existing] = await db
.select()
.from(holding)
.where(and(eq(holding.user_id, input.userId), eq(holding.ticker, ticker)))
.limit(1);
const currentPrice = Number.isFinite(input.currentPrice)
? Number(input.currentPrice)
: input.avgCost;
if (existing) {
const normalized = normalizeHoldingInput({
ticker,
shares: input.shares,
avgCost: input.avgCost,
currentPrice
});
const next = recalculateHolding({
...toHolding(existing),
...normalized,
updated_at: now,
last_price_at: now
});
const [updated] = await db
.update(holding)
.set({
ticker: next.ticker,
shares: next.shares,
avg_cost: next.avg_cost,
current_price: next.current_price,
market_value: next.market_value,
gain_loss: next.gain_loss,
gain_loss_pct: next.gain_loss_pct,
updated_at: next.updated_at,
last_price_at: next.last_price_at
})
.where(eq(holding.id, existing.id))
.returning();
return {
holding: toHolding(updated),
created: false
};
}
const normalized = normalizeHoldingInput({
ticker,
shares: input.shares,
avgCost: input.avgCost,
currentPrice
});
const createdBase: Holding = {
id: 0,
user_id: input.userId,
ticker: normalized.ticker,
shares: normalized.shares,
avg_cost: normalized.avg_cost,
current_price: normalized.current_price,
market_value: '0',
gain_loss: '0',
gain_loss_pct: '0',
last_price_at: now,
created_at: now,
updated_at: now
};
const created = recalculateHolding(createdBase);
const [inserted] = await db
.insert(holding)
.values({
user_id: created.user_id,
ticker: created.ticker,
shares: created.shares,
avg_cost: created.avg_cost,
current_price: created.current_price,
market_value: created.market_value,
gain_loss: created.gain_loss,
gain_loss_pct: created.gain_loss_pct,
last_price_at: created.last_price_at,
created_at: created.created_at,
updated_at: created.updated_at
})
.returning();
return {
holding: toHolding(inserted),
created: true
};
}
export async function updateHoldingByIdRecord(input: {
userId: string;
id: number;
shares?: number;
avgCost?: number;
currentPrice?: number;
}) {
const [existing] = await db
.select()
.from(holding)
.where(and(eq(holding.id, input.id), eq(holding.user_id, input.userId)))
.limit(1);
if (!existing) {
return null;
}
const current = toHolding(existing);
const shares = Number.isFinite(input.shares)
? Number(input.shares)
: Number(current.shares);
const avgCost = Number.isFinite(input.avgCost)
? Number(input.avgCost)
: Number(current.avg_cost);
const currentPrice = Number.isFinite(input.currentPrice)
? Number(input.currentPrice)
: Number(current.current_price ?? current.avg_cost);
const next = recalculateHolding({
...current,
shares: shares.toFixed(6),
avg_cost: avgCost.toFixed(6),
current_price: currentPrice.toFixed(6),
updated_at: new Date().toISOString(),
last_price_at: new Date().toISOString()
});
const [updated] = await db
.update(holding)
.set({
shares: next.shares,
avg_cost: next.avg_cost,
current_price: next.current_price,
market_value: next.market_value,
gain_loss: next.gain_loss,
gain_loss_pct: next.gain_loss_pct,
updated_at: next.updated_at,
last_price_at: next.last_price_at
})
.where(eq(holding.id, existing.id))
.returning();
return toHolding(updated);
}
export async function deleteHoldingByIdRecord(userId: string, id: number) {
const rows = await db
.delete(holding)
.where(and(eq(holding.user_id, userId), eq(holding.id, id)))
.returning({ id: holding.id });
return rows.length > 0;
}
export async function listHoldingsForPriceRefresh(userId: string) {
const rows = await db
.select()
.from(holding)
.where(eq(holding.user_id, userId));
return rows.map(toHolding);
}
export async function applyRefreshedPrices(
userId: string,
quotes: Map<string, number>,
updateTime: string
) {
const rows = await db
.select()
.from(holding)
.where(eq(holding.user_id, userId));
let updatedCount = 0;
for (const row of rows) {
const quote = quotes.get(row.ticker);
if (quote === undefined) {
continue;
}
const next = recalculateHolding({
...toHolding(row),
current_price: quote.toFixed(6),
last_price_at: updateTime,
updated_at: updateTime
});
await db
.update(holding)
.set({
current_price: next.current_price,
market_value: next.market_value,
gain_loss: next.gain_loss,
gain_loss_pct: next.gain_loss_pct,
last_price_at: next.last_price_at,
updated_at: next.updated_at
})
.where(eq(holding.id, row.id));
updatedCount += 1;
}
return updatedCount;
}