feat(reports): Financial report (Initiative 1 Phase 4)
Builds the Financial report on the canonical payments + expenses tables (the CRM records money received; it does not invoice — invoices module is off, dev DB has zero invoice rows). The invoice-centric spec is reframed onto the payments model: "outstanding AR" → expected-deposit shortfall on active deals; "AR aging" → outstanding deposits bucketed by deal age. Service (financial.service.ts): - 7 KPIs: revenue collected (net of refunds), deposits, balance, pipeline expected, outstanding deposits, expenses, net contribution - 6 chart datasets: revenue by month (deposit/balance), collection funnel (EOI→deposit→contract→won), expected-deposit aging, cash flow (inflow vs outflow), expense breakdown by category - 4 tables: outstanding deposits, recent payments, refund log, expense ledger - every money figure normalised to port currency via a shared resolvePortCurrency/normalizeAmount helper (new reports/currency.ts) UI (financial-report-client.tsx): KPI strip + recharts (stacked bar / horizontal bar / line / donut) + month/quarter/year toggle + branded empty states; DateRangePicker + Templates + Export wired. Un-hidden the Financial card on the reports landing. Plumbing: added '1y' (trailing 12mo) preset to the shared range system (financial trends want a year); added 'financial'/'marketing' to the report-template kind enum for template parity. TDD: 6 financial-math unit tests (aging buckets, month keys/range, net contribution). tsc clean; full unit suite green except pre-existing Redis/storage-dependent integration tests. Browser-verified against live data: API 200, KPIs correct ($5,849 expenses / -$5,849 net, $0 revenue correct given 0 payment rows), expense ledger + breakdown populate, payment-derived sections show graceful empty states. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,7 @@
|
||||
/**
|
||||
* Preset date ranges used by the dashboard's quick-pick tabs.
|
||||
*/
|
||||
export type PresetDateRange = '7d' | '30d' | '90d' | 'today';
|
||||
export type PresetDateRange = '7d' | '30d' | '90d' | '1y' | 'today';
|
||||
|
||||
/**
|
||||
* A custom date range expressed as a pair of ISO date strings (YYYY-MM-DD).
|
||||
@@ -27,7 +27,7 @@ export interface CustomDateRange {
|
||||
|
||||
export type DateRange = PresetDateRange | CustomDateRange;
|
||||
|
||||
export const ALL_RANGES: readonly PresetDateRange[] = ['today', '7d', '30d', '90d'] as const;
|
||||
export const ALL_RANGES: readonly PresetDateRange[] = ['today', '7d', '30d', '90d', '1y'] as const;
|
||||
|
||||
export function isCustomRange(range: DateRange): range is CustomDateRange {
|
||||
return typeof range === 'object' && range.kind === 'custom';
|
||||
@@ -87,6 +87,8 @@ export function rangeToDays(range: PresetDateRange): number {
|
||||
return 30;
|
||||
case '90d':
|
||||
return 90;
|
||||
case '1y':
|
||||
return 365;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
32
src/lib/services/reports/currency.ts
Normal file
32
src/lib/services/reports/currency.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { convert } from '@/lib/services/currency';
|
||||
|
||||
/**
|
||||
* Port's default currency for money normalisation. Falls back to USD
|
||||
* when the column is null (legacy ports). Shared across the report
|
||||
* services so every money figure lands in one reporting currency.
|
||||
*/
|
||||
export async function resolvePortCurrency(portId: string): Promise<string> {
|
||||
const [row] = await db
|
||||
.select({ defaultCurrency: ports.defaultCurrency })
|
||||
.from(ports)
|
||||
.where(eq(ports.id, portId));
|
||||
return row?.defaultCurrency ?? 'USD';
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert `amount` from `from` → `to`. Returns the amount unchanged when
|
||||
* the currencies match or a rate is unavailable (so a missing FX rate
|
||||
* degrades to "report in source units" rather than dropping the figure).
|
||||
*/
|
||||
export async function normalizeAmount(amount: number, from: string, to: string): Promise<number> {
|
||||
if (!amount) return amount;
|
||||
const f = from.toUpperCase();
|
||||
const t = to.toUpperCase();
|
||||
if (f === t) return amount;
|
||||
const converted = await convert(amount, f, t);
|
||||
return converted?.result ?? amount;
|
||||
}
|
||||
51
src/lib/services/reports/financial-math.ts
Normal file
51
src/lib/services/reports/financial-math.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Pure helpers for the Financial report. No DB / no currency I/O — kept
|
||||
* separate from `financial.service.ts` so the bucketing + date maths are
|
||||
* unit-testable in isolation.
|
||||
*/
|
||||
|
||||
export type AgingBucket = '0-30' | '31-60' | '61-90' | '90+';
|
||||
|
||||
/** Ascending so chart rows render oldest-band-last in a stable order. */
|
||||
export const AGING_BUCKETS: AgingBucket[] = ['0-30', '31-60', '61-90', '90+'];
|
||||
|
||||
/**
|
||||
* Map a day count to one of the four age bands. Used for the
|
||||
* "expected deposits outstanding by deal age" chart — the payments-model
|
||||
* analogue of invoice AR aging (we have no invoice due dates, so age is
|
||||
* measured from when the deal started expecting a deposit).
|
||||
*/
|
||||
export function agingBucket(daysOutstanding: number): AgingBucket {
|
||||
if (daysOutstanding <= 30) return '0-30';
|
||||
if (daysOutstanding <= 60) return '31-60';
|
||||
if (daysOutstanding <= 90) return '61-90';
|
||||
return '90+';
|
||||
}
|
||||
|
||||
/** Zero-padded `YYYY-MM` key in UTC, for grouping money rows by month. */
|
||||
export function monthKey(d: Date): string {
|
||||
const y = d.getUTCFullYear();
|
||||
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
|
||||
return `${y}-${m}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Continuous ascending list of `YYYY-MM` keys spanning `from`..`to`
|
||||
* inclusive. Gives the month/cash-flow charts a gap-free x-axis even for
|
||||
* months with no rows (which then render as zero, not a missing bar).
|
||||
*/
|
||||
export function monthRange(from: Date, to: Date): string[] {
|
||||
const keys: string[] = [];
|
||||
const cursor = new Date(Date.UTC(from.getUTCFullYear(), from.getUTCMonth(), 1));
|
||||
const end = new Date(Date.UTC(to.getUTCFullYear(), to.getUTCMonth(), 1));
|
||||
while (cursor <= end) {
|
||||
keys.push(monthKey(cursor));
|
||||
cursor.setUTCMonth(cursor.getUTCMonth() + 1);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/** Net contribution = revenue collected − expenses. */
|
||||
export function netContribution(revenue: number, expenses: number): number {
|
||||
return revenue - expenses;
|
||||
}
|
||||
632
src/lib/services/reports/financial.service.ts
Normal file
632
src/lib/services/reports/financial.service.ts
Normal file
@@ -0,0 +1,632 @@
|
||||
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<DepositPosition[]> {
|
||||
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<string, number>();
|
||||
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<FinancialKpis> {
|
||||
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<number> {
|
||||
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<RevenueByMonthRow[]> {
|
||||
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<string, { deposit: number; balance: number }>();
|
||||
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<CollectionFunnelRow[]> {
|
||||
const [eoiRow] = await db
|
||||
.select({ count: sql<number>`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<number>`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<number>`count(*)::int` })
|
||||
.from(interests)
|
||||
.where(
|
||||
and(
|
||||
eq(interests.portId, portId),
|
||||
sql`${interests.pipelineStage} = 'contract'`,
|
||||
isNull(interests.archivedAt),
|
||||
),
|
||||
);
|
||||
|
||||
const [wonRow] = await db
|
||||
.select({ count: sql<number>`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<AgingRow[]> {
|
||||
const positions = await getDepositPositions(portId);
|
||||
const byBucket = new Map<AgingBucket, { count: number; value: number }>();
|
||||
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<CashFlowRow[]> {
|
||||
const currency = await resolvePortCurrency(portId);
|
||||
const byMonth = new Map<string, { inflow: number; outflow: number }>();
|
||||
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<ExpenseBreakdownRow[]> {
|
||||
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<string, number>();
|
||||
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<OutstandingDepositRow[]> {
|
||||
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<RecentPaymentRow[]> {
|
||||
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<RefundRow[]> {
|
||||
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<ExpenseLedgerRow[]> {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user