import { and, desc, eq, gte, isNull, lte, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; import { clients } from '@/lib/db/schema/clients'; import { interests, interestBerths } from '@/lib/db/schema/interests'; import { berths } from '@/lib/db/schema/berths'; import { payments } from '@/lib/db/schema/pipeline'; import { expenses } from '@/lib/db/schema/financial'; import { activeInterestsWhere } from '@/lib/services/active-interest'; import { resolvePortCurrency, normalizeAmount } from './currency'; import { agingBucket, AGING_BUCKETS, monthKey, monthRange, netContribution, type AgingBucket, } from './financial-math'; import type { DateRange } from './sales.service'; // ─── KPI strip ─────────────────────────────────────────────────────────────── export interface FinancialKpis { /** Σ of every payment received in range (refunds net out — they carry * negative amounts). In port currency. */ revenueCollected: number; depositsCollected: number; balanceCollected: number; /** Absolute value of refund-type payments issued in range. */ refundsIssued: number; /** Σ expected deposit across active (open, non-archived) deals — the * pipeline forecast in deposit terms. */ pipelineExpected: number; /** Σ (expected − collected) across active deals, floored at 0 per deal. * The payments-model analogue of outstanding AR. */ expectedDepositsOutstanding: number; /** Σ expenses booked in range, normalised to port currency. */ expensesTotal: number; /** revenueCollected − expensesTotal. */ netContribution: number; currency: string; } /** Per-deal deposit position — expected vs collected. Internal; feeds the * pipeline/outstanding KPIs, the aging chart, and the outstanding table. */ interface DepositPosition { interestId: string; clientName: string; mooring: string | null; expected: number; collected: number; remaining: number; currency: string; daysOutstanding: number; } /** * Expected-vs-collected deposit position for every active deal that has * an expected deposit set. Collected = Σ deposit-type payments for that * interest. Amounts normalised to port currency. Age measured from deal * creation (no invoice due dates exist in the payments model). */ async function getDepositPositions(portId: string): Promise { const targetCurrency = await resolvePortCurrency(portId); const now = Date.now(); const rows = await db .select({ interestId: interests.id, clientName: clients.fullName, mooring: berths.mooringNumber, expected: interests.depositExpectedAmount, expectedCurrency: interests.depositExpectedCurrency, createdAt: interests.createdAt, }) .from(interests) .innerJoin(clients, eq(clients.id, interests.clientId)) .leftJoin( interestBerths, and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)), ) .leftJoin(berths, eq(interestBerths.berthId, berths.id)) .where(and(activeInterestsWhere(portId), sql`${interests.depositExpectedAmount} IS NOT NULL`)); // Deposits collected per interest (deposit-type payments only). const collectedRows = await db .select({ interestId: payments.interestId, amount: payments.amount, currency: payments.currency, }) .from(payments) .where(and(eq(payments.portId, portId), eq(payments.paymentType, 'deposit'))); const collectedByInterest = new Map(); for (const r of collectedRows) { const amount = await normalizeAmount( Number(r.amount ?? 0), r.currency ?? targetCurrency, targetCurrency, ); collectedByInterest.set(r.interestId, (collectedByInterest.get(r.interestId) ?? 0) + amount); } const positions: DepositPosition[] = []; for (const r of rows) { const expected = await normalizeAmount( Number(r.expected ?? 0), r.expectedCurrency ?? targetCurrency, targetCurrency, ); const collected = collectedByInterest.get(r.interestId) ?? 0; const remaining = Math.max(0, expected - collected); const daysOutstanding = r.createdAt ? Math.floor((now - new Date(r.createdAt).getTime()) / 86_400_000) : 0; positions.push({ interestId: r.interestId, clientName: r.clientName, mooring: r.mooring, expected, collected, remaining, currency: targetCurrency, daysOutstanding, }); } return positions; } /** Σ of all payments in range, bucketed by payment type, normalised. */ async function sumPaymentsInRange( portId: string, range: DateRange, targetCurrency: string, ): Promise<{ total: number; deposit: number; balance: number; refund: number }> { const rows = await db .select({ paymentType: payments.paymentType, amount: payments.amount, currency: payments.currency, }) .from(payments) .where( and( eq(payments.portId, portId), gte(payments.receivedAt, range.from), lte(payments.receivedAt, range.to), ), ); const acc = { total: 0, deposit: 0, balance: 0, refund: 0 }; for (const r of rows) { const amount = await normalizeAmount( Number(r.amount ?? 0), r.currency ?? targetCurrency, targetCurrency, ); acc.total += amount; if (r.paymentType === 'deposit') acc.deposit += amount; else if (r.paymentType === 'balance') acc.balance += amount; else if (r.paymentType === 'refund') acc.refund += amount; // already negative } return acc; } export async function getFinancialKpis(portId: string, range: DateRange): Promise { const currency = await resolvePortCurrency(portId); const [paymentsAgg, positions, expensesTotal] = await Promise.all([ sumPaymentsInRange(portId, range, currency), getDepositPositions(portId), sumExpensesInRange(portId, range, currency), ]); const pipelineExpected = positions.reduce((s, p) => s + p.expected, 0); const expectedDepositsOutstanding = positions.reduce((s, p) => s + p.remaining, 0); return { revenueCollected: paymentsAgg.total, depositsCollected: paymentsAgg.deposit, balanceCollected: paymentsAgg.balance, refundsIssued: Math.abs(paymentsAgg.refund), pipelineExpected, expectedDepositsOutstanding, expensesTotal, netContribution: netContribution(paymentsAgg.total, expensesTotal), currency, }; } async function sumExpensesInRange( portId: string, range: DateRange, targetCurrency: string, ): Promise { const rows = await db .select({ amount: expenses.amount, currency: expenses.currency }) .from(expenses) .where( and( eq(expenses.portId, portId), isNull(expenses.archivedAt), gte(expenses.expenseDate, range.from), lte(expenses.expenseDate, range.to), ), ); let total = 0; for (const r of rows) { total += await normalizeAmount( Number(r.amount ?? 0), r.currency ?? targetCurrency, targetCurrency, ); } return total; } // ─── Charts ────────────────────────────────────────────────────────────────── export interface RevenueByMonthRow { month: string; // YYYY-MM deposit: number; balance: number; } /** Monthly collected revenue, split deposit vs balance. Continuous month * axis (zero-filled) over the selected range, normalised to port currency. */ export async function getRevenueByMonth( portId: string, range: DateRange, ): Promise { const currency = await resolvePortCurrency(portId); const rows = await db .select({ paymentType: payments.paymentType, amount: payments.amount, paymentCurrency: payments.currency, receivedAt: payments.receivedAt, }) .from(payments) .where( and( eq(payments.portId, portId), gte(payments.receivedAt, range.from), lte(payments.receivedAt, range.to), ), ); const byMonth = new Map(); for (const key of monthRange(range.from, range.to)) byMonth.set(key, { deposit: 0, balance: 0 }); for (const r of rows) { if (!r.receivedAt) continue; const key = monthKey(new Date(r.receivedAt)); const bucket = byMonth.get(key); if (!bucket) continue; const amount = await normalizeAmount( Number(r.amount ?? 0), r.paymentCurrency ?? currency, currency, ); if (r.paymentType === 'balance') bucket.balance += amount; else if (r.paymentType !== 'refund') bucket.deposit += amount; // deposit + other → deposit bar } return Array.from(byMonth.entries()).map(([month, v]) => ({ month, ...v })); } export interface CollectionFunnelRow { stage: 'eoi' | 'deposit' | 'contract' | 'won'; label: string; count: number; } /** Money funnel: EOI sent → deposit received → contract reached → won, * counting distinct deals reaching each step within the range. */ export async function getCollectionFunnel( portId: string, range: DateRange, ): Promise { const [eoiRow] = await db .select({ count: sql`count(*)::int` }) .from(interests) .where( and( eq(interests.portId, portId), gte(interests.dateEoiSent, range.from), lte(interests.dateEoiSent, range.to), ), ); const [depositRow] = await db .select({ count: sql`count(distinct ${payments.interestId})::int` }) .from(payments) .where( and( eq(payments.portId, portId), eq(payments.paymentType, 'deposit'), gte(payments.receivedAt, range.from), lte(payments.receivedAt, range.to), ), ); const [contractRow] = await db .select({ count: sql`count(*)::int` }) .from(interests) .where( and( eq(interests.portId, portId), sql`${interests.pipelineStage} = 'contract'`, isNull(interests.archivedAt), ), ); const [wonRow] = await db .select({ count: sql`count(*)::int` }) .from(interests) .where( and( eq(interests.portId, portId), eq(interests.outcome, 'won'), gte(interests.outcomeAt, range.from), lte(interests.outcomeAt, range.to), ), ); return [ { stage: 'eoi', label: 'EOI sent', count: Number(eoiRow?.count ?? 0) }, { stage: 'deposit', label: 'Deposit received', count: Number(depositRow?.count ?? 0) }, { stage: 'contract', label: 'At contract', count: Number(contractRow?.count ?? 0) }, { stage: 'won', label: 'Won', count: Number(wonRow?.count ?? 0) }, ]; } export interface AgingRow { bucket: AgingBucket; count: number; value: number; } /** Expected deposits still outstanding, bucketed by deal age. */ export async function getExpectedDepositAging(portId: string): Promise { const positions = await getDepositPositions(portId); const byBucket = new Map(); for (const b of AGING_BUCKETS) byBucket.set(b, { count: 0, value: 0 }); for (const p of positions) { if (p.remaining <= 0) continue; const bucket = byBucket.get(agingBucket(p.daysOutstanding))!; bucket.count += 1; bucket.value += p.remaining; } return AGING_BUCKETS.map((bucket) => ({ bucket, ...byBucket.get(bucket)! })); } export interface CashFlowRow { month: string; inflow: number; outflow: number; } /** Monthly inflow (payments received) vs outflow (expenses booked). */ export async function getCashFlow(portId: string, range: DateRange): Promise { const currency = await resolvePortCurrency(portId); const byMonth = new Map(); for (const key of monthRange(range.from, range.to)) byMonth.set(key, { inflow: 0, outflow: 0 }); const paymentRows = await db .select({ amount: payments.amount, currency: payments.currency, receivedAt: payments.receivedAt, }) .from(payments) .where( and( eq(payments.portId, portId), gte(payments.receivedAt, range.from), lte(payments.receivedAt, range.to), ), ); for (const r of paymentRows) { if (!r.receivedAt) continue; const bucket = byMonth.get(monthKey(new Date(r.receivedAt))); if (!bucket) continue; bucket.inflow += await normalizeAmount(Number(r.amount ?? 0), r.currency ?? currency, currency); } const expenseRows = await db .select({ amount: expenses.amount, currency: expenses.currency, expenseDate: expenses.expenseDate, }) .from(expenses) .where( and( eq(expenses.portId, portId), isNull(expenses.archivedAt), gte(expenses.expenseDate, range.from), lte(expenses.expenseDate, range.to), ), ); for (const r of expenseRows) { if (!r.expenseDate) continue; const bucket = byMonth.get(monthKey(new Date(r.expenseDate))); if (!bucket) continue; bucket.outflow += await normalizeAmount( Number(r.amount ?? 0), r.currency ?? currency, currency, ); } return Array.from(byMonth.entries()).map(([month, v]) => ({ month, ...v })); } export interface ExpenseBreakdownRow { category: string; total: number; } /** Expenses for the period grouped by category, normalised, sorted desc. */ export async function getExpenseBreakdown( portId: string, range: DateRange, ): Promise { const currency = await resolvePortCurrency(portId); const rows = await db .select({ category: expenses.category, amount: expenses.amount, currency: expenses.currency }) .from(expenses) .where( and( eq(expenses.portId, portId), isNull(expenses.archivedAt), gte(expenses.expenseDate, range.from), lte(expenses.expenseDate, range.to), ), ); const byCategory = new Map(); for (const r of rows) { const cat = r.category ?? 'Uncategorised'; const amount = await normalizeAmount(Number(r.amount ?? 0), r.currency ?? currency, currency); byCategory.set(cat, (byCategory.get(cat) ?? 0) + amount); } return Array.from(byCategory.entries()) .map(([category, total]) => ({ category, total })) .sort((a, b) => b.total - a.total); } // ─── Tables ────────────────────────────────────────────────────────────────── export interface OutstandingDepositRow { interestId: string; clientName: string; mooring: string | null; expected: number; collected: number; remaining: number; currency: string; daysOutstanding: number; } /** Active deals with an expected deposit not yet fully collected, sorted * by remaining amount desc. The "chase these" list. */ export async function getOutstandingDeposits(portId: string): Promise { const positions = await getDepositPositions(portId); return positions .filter((p) => p.remaining > 0) .sort((a, b) => b.remaining - a.remaining) .slice(0, 50); } export interface RecentPaymentRow { id: string; receivedAt: string; clientName: string; mooring: string | null; paymentType: string; amount: number; currency: string; } export async function getRecentPayments( portId: string, range: DateRange, ): Promise { const currency = await resolvePortCurrency(portId); const rows = await db .select({ id: payments.id, receivedAt: payments.receivedAt, clientName: clients.fullName, mooring: berths.mooringNumber, paymentType: payments.paymentType, amount: payments.amount, paymentCurrency: payments.currency, }) .from(payments) .innerJoin(clients, eq(clients.id, payments.clientId)) .leftJoin( interestBerths, and(eq(interestBerths.interestId, payments.interestId), eq(interestBerths.isPrimary, true)), ) .leftJoin(berths, eq(interestBerths.berthId, berths.id)) .where( and( eq(payments.portId, portId), gte(payments.receivedAt, range.from), lte(payments.receivedAt, range.to), ), ) .orderBy(desc(payments.receivedAt)) .limit(50); const out: RecentPaymentRow[] = []; for (const r of rows) { out.push({ id: r.id, receivedAt: r.receivedAt ? new Date(r.receivedAt).toISOString() : '', clientName: r.clientName, mooring: r.mooring, paymentType: r.paymentType, amount: await normalizeAmount(Number(r.amount ?? 0), r.paymentCurrency ?? currency, currency), currency, }); } return out; } export interface RefundRow { id: string; receivedAt: string; clientName: string; amount: number; currency: string; notes: string | null; } /** Refund-type payments in range (write-off / refund log analogue). */ export async function getRefundLog(portId: string, range: DateRange): Promise { const currency = await resolvePortCurrency(portId); const rows = await db .select({ id: payments.id, receivedAt: payments.receivedAt, clientName: clients.fullName, amount: payments.amount, paymentCurrency: payments.currency, notes: payments.notes, }) .from(payments) .innerJoin(clients, eq(clients.id, payments.clientId)) .where( and( eq(payments.portId, portId), eq(payments.paymentType, 'refund'), gte(payments.receivedAt, range.from), lte(payments.receivedAt, range.to), ), ) .orderBy(desc(payments.receivedAt)) .limit(50); const out: RefundRow[] = []; for (const r of rows) { out.push({ id: r.id, receivedAt: r.receivedAt ? new Date(r.receivedAt).toISOString() : '', clientName: r.clientName, amount: Math.abs( await normalizeAmount(Number(r.amount ?? 0), r.paymentCurrency ?? currency, currency), ), currency, notes: r.notes, }); } return out; } export interface ExpenseLedgerRow { id: string; expenseDate: string; payer: string | null; category: string | null; establishmentName: string | null; amount: number; currency: string; paymentStatus: string | null; } export async function getExpenseLedger( portId: string, range: DateRange, ): Promise { const currency = await resolvePortCurrency(portId); const rows = await db .select({ id: expenses.id, expenseDate: expenses.expenseDate, payer: expenses.payer, category: expenses.category, establishmentName: expenses.establishmentName, amount: expenses.amount, expenseCurrency: expenses.currency, paymentStatus: expenses.paymentStatus, }) .from(expenses) .where( and( eq(expenses.portId, portId), isNull(expenses.archivedAt), gte(expenses.expenseDate, range.from), lte(expenses.expenseDate, range.to), ), ) .orderBy(desc(expenses.expenseDate)) .limit(50); const out: ExpenseLedgerRow[] = []; for (const r of rows) { out.push({ id: r.id, expenseDate: r.expenseDate ? new Date(r.expenseDate).toISOString() : '', payer: r.payer, category: r.category, establishmentName: r.establishmentName, amount: await normalizeAmount(Number(r.amount ?? 0), r.expenseCurrency ?? currency, currency), currency, paymentStatus: r.paymentStatus, }); } return out; }