337 lines
8.6 KiB
TypeScript
337 lines
8.6 KiB
TypeScript
import { and, desc, eq } from 'drizzle-orm';
|
|
import type { Holding } from '@/lib/types';
|
|
import { recalculateHolding } from '@/lib/server/portfolio';
|
|
import { db } from '@/lib/server/db';
|
|
import { filing, holding, watchlistItem } 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,
|
|
company_name: row.company_name ?? null,
|
|
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)
|
|
};
|
|
}
|
|
|
|
async function resolveHoldingCompanyName(input: {
|
|
userId: string;
|
|
ticker: string;
|
|
companyName?: string;
|
|
existingCompanyName?: string | null;
|
|
}) {
|
|
const explicitCompanyName = input.companyName?.trim();
|
|
if (explicitCompanyName) {
|
|
return explicitCompanyName;
|
|
}
|
|
|
|
if (input.existingCompanyName?.trim()) {
|
|
return input.existingCompanyName.trim();
|
|
}
|
|
|
|
const [watchlistMatch] = await db
|
|
.select({ company_name: watchlistItem.company_name })
|
|
.from(watchlistItem)
|
|
.where(and(eq(watchlistItem.user_id, input.userId), eq(watchlistItem.ticker, input.ticker)))
|
|
.limit(1);
|
|
|
|
if (watchlistMatch?.company_name?.trim()) {
|
|
return watchlistMatch.company_name.trim();
|
|
}
|
|
|
|
const [filingMatch] = await db
|
|
.select({ company_name: filing.company_name })
|
|
.from(filing)
|
|
.where(eq(filing.ticker, input.ticker))
|
|
.orderBy(desc(filing.filing_date), desc(filing.updated_at))
|
|
.limit(1);
|
|
|
|
return filingMatch?.company_name?.trim() ? filingMatch.company_name.trim() : null;
|
|
}
|
|
|
|
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 getHoldingByTicker(userId: string, ticker: string) {
|
|
const normalizedTicker = ticker.trim().toUpperCase();
|
|
if (!normalizedTicker) {
|
|
return null;
|
|
}
|
|
|
|
const [row] = await db
|
|
.select()
|
|
.from(holding)
|
|
.where(and(eq(holding.user_id, userId), eq(holding.ticker, normalizedTicker)))
|
|
.limit(1);
|
|
|
|
return row ? toHolding(row) : null;
|
|
}
|
|
|
|
export async function upsertHoldingRecord(input: {
|
|
userId: string;
|
|
ticker: string;
|
|
shares: number;
|
|
avgCost: number;
|
|
currentPrice?: number;
|
|
companyName?: string;
|
|
}) {
|
|
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;
|
|
const companyName = await resolveHoldingCompanyName({
|
|
userId: input.userId,
|
|
ticker,
|
|
companyName: input.companyName,
|
|
existingCompanyName: existing?.company_name ?? null
|
|
});
|
|
|
|
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,
|
|
company_name: companyName,
|
|
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,
|
|
company_name: companyName,
|
|
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,
|
|
company_name: created.company_name,
|
|
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;
|
|
companyName?: string;
|
|
}) {
|
|
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 companyName = await resolveHoldingCompanyName({
|
|
userId: input.userId,
|
|
ticker: current.ticker,
|
|
companyName: input.companyName,
|
|
existingCompanyName: current.company_name
|
|
});
|
|
|
|
const next = recalculateHolding({
|
|
...current,
|
|
company_name: companyName,
|
|
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({
|
|
company_name: companyName,
|
|
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;
|
|
}
|