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>
633 lines
19 KiB
TypeScript
633 lines
19 KiB
TypeScript
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;
|
||
}
|