import { NextResponse } from 'next/server'; import { db } from '@/db'; import { transactions, assets, assetPricesHistory, exchangeRatesHistory, } from '@/db/schema'; import { and, asc, desc, eq, lte } from 'drizzle-orm'; import Big from 'big.js'; export const dynamic = 'force-dynamic'; export const runtime = 'nodejs'; function formatDateString(date: Date): string { const yyyy = date.getFullYear(); const mm = String(date.getMonth() + 1).padStart(2, '0'); const dd = String(date.getDate()).padStart(2, '0'); return `${yyyy}-${mm}-${dd}`; } interface RateRecord { rate: string; fetchTime: Date; } async function buildDailyRatesMap(targetDateStr: string): Promise> { const allRates = await db .select({ fromCurrency: exchangeRatesHistory.fromCurrency, toCurrency: exchangeRatesHistory.toCurrency, rate: exchangeRatesHistory.rate, fetchTime: exchangeRatesHistory.fetchTime, }) .from(exchangeRatesHistory) .where(lte(exchangeRatesHistory.fetchTime, new Date(targetDateStr + 'T23:59:59'))) .orderBy(asc(exchangeRatesHistory.fetchTime)); const ratesCache = new Map(); for (const rec of allRates) { const key = `${rec.fromCurrency}_${rec.toCurrency}`; if (!ratesCache.has(key)) { ratesCache.set(key, []); } ratesCache.get(key)!.push({ rate: rec.rate, fetchTime: rec.fetchTime }); } function getClosestRateForDate(currencyPair: string): string | null { const records = ratesCache.get(currencyPair); if (!records || records.length === 0) return null; const targetDt = new Date(targetDateStr + 'T00:00:00Z'); let closest: RateRecord | null = null; for (const rec of records) { if (rec.fetchTime <= targetDt) { closest = rec; } else { break; } } return closest?.rate ?? null; } function resolveRate(from: string, to: string): string | null { const directKey = `${from}_${to}`; const directRate = getClosestRateForDate(directKey); if (directRate) return directRate; const usdKey = `USD_${to}`; const usdRate = getClosestRateForDate(usdKey); if (!usdRate) return null; if (from === 'USD') return usdRate; const fromToUsdKey = `${from}_USD`; const fromToUsdRate = getClosestRateForDate(fromToUsdKey); if (fromToUsdRate) { return new Big(fromToUsdRate).times(new Big(usdRate)).toString(); } return null; } const hkdRate = resolveRate('HKD', 'CNY'); const usdRate = resolveRate('USD', 'CNY'); return { USD: new Big(usdRate || '7.22'), HKD: new Big(hkdRate || '0.92'), CNY: new Big(1), }; } async function getHistoricalPriceWithFallback(assetId: string, dateStr: string, fallbackCostPrice: string): Promise { const [record] = await db .select({ price: assetPricesHistory.price }) .from(assetPricesHistory) .where( and( eq(assetPricesHistory.assetId, assetId), lte(assetPricesHistory.date, dateStr) ) ) .orderBy(desc(assetPricesHistory.date)) .limit(1); if (record?.price) { return record.price; } return fallbackCostPrice; } export async function GET(req: Request) { const { searchParams } = new URL(req.url); const targetDateParam = searchParams.get('date') ?? searchParams.get('targetDate'); let targetDateStr: string; if (targetDateParam) { targetDateStr = targetDateParam.trim(); if (!/^\d{4}-\d{2}-\d{2}$/.test(targetDateStr)) { return NextResponse.json( { error: 'Invalid date format. Use YYYY-MM-DD, e.g. 2026-04-30' }, { status: 400 } ); } } else { targetDateStr = '2026-04-30'; } const targetDate = new Date(targetDateStr + 'T23:59:59'); try { const allTransactions = await db .select({ assetId: transactions.assetId, txType: transactions.txType, quantity: transactions.quantity, price: transactions.price, exchangeRate: transactions.exchangeRate, executedAt: transactions.executedAt, }) .from(transactions) .where(lte(transactions.executedAt, targetDate)) .orderBy(asc(transactions.executedAt)); const holdings = new Map(); for (const tx of allTransactions) { if (!tx.assetId) continue; const existing = holdings.get(tx.assetId); if (!existing) { holdings.set(tx.assetId, { quantity: new Big('0'), totalCost: new Big('0'), }); } const holding = holdings.get(tx.assetId)!; const qty = new Big(tx.quantity); if (tx.txType === 'BUY') { holding.quantity = holding.quantity.plus(qty); const cost = qty.times(new Big(tx.price)).times(new Big(tx.exchangeRate || '1')); holding.totalCost = holding.totalCost.plus(cost); } else if (tx.txType === 'SELL') { let avgCostPerUnit = new Big('0'); if (holding.quantity.gt(0)) { avgCostPerUnit = holding.totalCost.div(holding.quantity); } const sellCost = avgCostPerUnit.times(qty); holding.quantity = holding.quantity.minus(qty); holding.totalCost = holding.totalCost.minus(sellCost); } else if (tx.txType === 'AIRDROP') { holding.quantity = holding.quantity.plus(qty); } } const allAssets = await db .select({ id: assets.id, symbol: assets.symbol, baseCurrency: assets.baseCurrency, }) .from(assets); const assetMap = new Map(); for (const a of allAssets) { assetMap.set(a.id, { symbol: a.symbol, baseCurrency: a.baseCurrency || 'USD' }); } const dailyRates = await buildDailyRatesMap(targetDateStr); const details: Array<{ symbol: string; quantity: number; snapshotPrice: string; snapshotFxRate: string; calculatedMarketValueCny: string; calculatedCostCny: string; }> = []; let totalMarketValue = new Big('0'); let totalCost = new Big('0'); for (const [assetId, holding] of holdings) { if (holding.quantity.lte(0)) continue; const assetInfo = assetMap.get(assetId); if (!assetInfo) continue; const costPrice = holding.totalCost.div(holding.quantity).toString(); const snapshotPrice = await getHistoricalPriceWithFallback(assetId, targetDateStr, costPrice); const currency = (assetInfo.baseCurrency || 'CNY').toUpperCase(); const snapshotFxRate = dailyRates[currency] || dailyRates['USD'] || new Big(1); const qtyNum = Number(holding.quantity.toString()); const priceNum = new Big(snapshotPrice); const fxNum = snapshotFxRate; const calcMarketValueCny = holding.quantity.times(priceNum).times(fxNum); const calcCostCny = holding.totalCost.times(fxNum); totalMarketValue = totalMarketValue.plus(calcMarketValueCny); totalCost = totalCost.plus(calcCostCny); details.push({ symbol: assetInfo.symbol, quantity: qtyNum, snapshotPrice: new Big(snapshotPrice).toString(), snapshotFxRate: new Big(fxNum.toString()).toString(), calculatedMarketValueCny: calcMarketValueCny.toString(), calculatedCostCny: calcCostCny.toString(), }); } details.sort((a, b) => new Big(b.calculatedMarketValueCny).minus(new Big(a.calculatedMarketValueCny)).toNumber()); return NextResponse.json({ targetDate: targetDateStr, totalMarketValue: Number(totalMarketValue.toString()), totalCost: Number(totalCost.toString()), details, }); } catch (error) { console.error('[Snapshot Debug Error]', error); return NextResponse.json( { error: 'Internal server error', details: String(error) }, { status: 500 } ); } }