106 lines
3.1 KiB
TypeScript
106 lines
3.1 KiB
TypeScript
import { Customer } from './auth';
|
||
import { TierConfig } from './pricing';
|
||
import { UsageEntry } from './usage';
|
||
|
||
export interface InvoiceLine {
|
||
endpointId: string;
|
||
calls: number;
|
||
cents: number;
|
||
}
|
||
|
||
export interface Invoice {
|
||
/** INV-<period>-<zero-padded sequence>, e.g. INV-2026-07-0007 */
|
||
id: string;
|
||
customerId: string;
|
||
/** Billing period, YYYY-MM */
|
||
period: string;
|
||
status: 'draft' | 'issued' | 'paid';
|
||
lines: InvoiceLine[];
|
||
/** Gross usage in cents (sum of lines, before credit). */
|
||
totalCents: number;
|
||
/** Monthly credit actually consumed. */
|
||
creditCents: number;
|
||
/** totalCents − creditCents, floored at 0. */
|
||
billableCents: number;
|
||
billingType: 'stripe' | 'purchase_order';
|
||
poNumber?: string;
|
||
issuedAtMs?: number;
|
||
dueAtMs?: number;
|
||
paidAtMs?: number;
|
||
}
|
||
|
||
export interface InvoiceRepo {
|
||
save(invoice: Invoice): void;
|
||
get(id: string): Invoice | undefined;
|
||
list(filter: {
|
||
customerId?: string;
|
||
period?: string;
|
||
status?: Invoice['status'];
|
||
}): Invoice[];
|
||
/** 1-based sequence for the next invoice id within a period. */
|
||
nextSequence(period: string): number;
|
||
}
|
||
|
||
/** Test/dev adapter; production uses SqliteInvoiceRepo. */
|
||
export class InMemoryInvoiceRepo implements InvoiceRepo {
|
||
private invoices = new Map<string, Invoice>();
|
||
|
||
save(invoice: Invoice): void {
|
||
this.invoices.set(invoice.id, { ...invoice, lines: invoice.lines.map((l) => ({ ...l })) });
|
||
}
|
||
|
||
get(id: string): Invoice | undefined {
|
||
return this.invoices.get(id);
|
||
}
|
||
|
||
list(filter: {
|
||
customerId?: string;
|
||
period?: string;
|
||
status?: Invoice['status'];
|
||
}): Invoice[] {
|
||
return [...this.invoices.values()]
|
||
.filter((i) => (filter.customerId ? i.customerId === filter.customerId : true))
|
||
.filter((i) => (filter.period ? i.period === filter.period : true))
|
||
.filter((i) => (filter.status ? i.status === filter.status : true))
|
||
.sort((a, b) => a.id.localeCompare(b.id));
|
||
}
|
||
|
||
nextSequence(period: string): number {
|
||
return [...this.invoices.values()].filter((i) => i.period === period).length + 1;
|
||
}
|
||
}
|
||
|
||
export function buildInvoice(args: {
|
||
customer: Customer;
|
||
period: string;
|
||
sequence: number;
|
||
entries: UsageEntry[];
|
||
tier: TierConfig;
|
||
poNumber?: string;
|
||
}): Invoice {
|
||
const { customer, period, sequence, entries, tier, poNumber } = args;
|
||
const byEndpoint = new Map<string, InvoiceLine>();
|
||
for (const e of entries) {
|
||
const line = byEndpoint.get(e.endpointId) ?? { endpointId: e.endpointId, calls: 0, cents: 0 };
|
||
line.calls += 1;
|
||
line.cents += e.cents;
|
||
byEndpoint.set(e.endpointId, line);
|
||
}
|
||
const lines = [...byEndpoint.values()].sort((a, b) =>
|
||
a.endpointId.localeCompare(b.endpointId),
|
||
);
|
||
const totalCents = lines.reduce((sum, l) => sum + l.cents, 0);
|
||
const creditCents = Math.min(totalCents, tier.monthlyCreditCents);
|
||
return {
|
||
id: `INV-${period}-${String(sequence).padStart(4, '0')}`,
|
||
customerId: customer.id,
|
||
period,
|
||
status: 'draft',
|
||
lines,
|
||
totalCents,
|
||
creditCents,
|
||
billableCents: totalCents - creditCents,
|
||
billingType: customer.billingType ?? 'stripe',
|
||
...(poNumber !== undefined ? { poNumber } : {}),
|
||
};
|
||
}
|