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;
|
|||
|
|
}
|