POSTGRESQL REFERENCE · WORKING SOURCE
src/billing.ts
This file ships in the runnable source archive. Synthetic fixtures and demonstration policies are identified in the README.
Download the complete project ↓import type { Actor, InvoiceLine, InvoiceResponse, InvoiceSummary, Row } from "./contracts.ts";
import { type DB, warehouseTx } from "./db.ts";
import { reservationAccess } from "./access.ts";
import { uid } from "./errors.ts";
export async function calendarDates(db: DB, start: string, end: string, timezone: string): Promise<string[]> {
// Enumerate local DATEs and compare timezone-aware midnight instants. This handles
// midnight release, 23/25-hour days, leap dates, and intervals shorter than 24h.
const result = await db.query(
`WITH days AS (SELECT d::date AS day FROM generate_series(($1::timestamptz AT TIME ZONE $3)::date::timestamp,($2::timestamptz AT TIME ZONE $3)::date::timestamp,interval '1 day') d) SELECT day::text AS day FROM days WHERE day::timestamp AT TIME ZONE $3 < $2::timestamptz AND (day+1)::timestamp AT TIME ZONE $3 > $1::timestamptz AND $1::timestamptz < $2::timestamptz ORDER BY day`,
[start, end, timezone],
);
return result.rows.map((r) => r.day);
}
export async function rate(db: DB, id: string) {
return (await db.query("SELECT * FROM rate_cards WHERE id=$1", [id])).rows[0];
}
export async function estimate(
db: DB,
count: number,
start: string,
end: string,
timezone: string,
tariff: Row,
) {
const days = (await calendarDates(db, start, end, timezone)).length;
const lines = [{
kind: "storage",
quantity: count * days,
unitCents: tariff.storage_cents,
totalCents: count * days * tariff.storage_cents,
}, {
kind: "receiving",
quantity: count,
unitCents: tariff.receiving_cents,
totalCents: count * tariff.receiving_cents,
}, {
kind: "release",
quantity: count,
unitCents: tariff.release_cents,
totalCents: count * tariff.release_cents,
}];
return { lines, totalCents: lines.reduce((sum, l) => sum + l.totalCents, 0), palletDays: count * days };
}
export async function handlingCharge(
db: DB,
r: Row,
palletId: string,
custodyId: string,
kind: "receiving" | "release",
movementId: string,
tariff: Row,
) {
const cents = kind === "receiving" ? tariff.receiving_cents : tariff.release_cents;
await db.query(
"INSERT INTO charge_lines(id,reservation_id,pallet_id,custody_id,kind,quantity,unit_cents,total_cents,rate_card_id,movement_ids) VALUES($1,$2,$3,$4,$5,1,$6,$6,$7,$8)",
[uid("charge"), r.id, palletId, custodyId, kind, cents, r.rate_card_id, [movementId]],
);
}
export async function storageCharges(db: DB, r: Row, p: Row, releaseMovementId: string, tariff: Row) {
const receipt =
(await db.query("SELECT id FROM movements WHERE custody_id=$1 AND kind='received'", [p.custody_id]))
.rows[0];
const days = await calendarDates(db, p.received_at, p.released_at, r.timezone);
for (const day of days) {
await db.query(
"INSERT INTO charge_lines(id,reservation_id,pallet_id,custody_id,kind,service_date,quantity,unit_cents,total_cents,rate_card_id,movement_ids) VALUES($1,$2,$3,$4,'storage',$5,1,$6,$6,$7,$8)",
[uid("charge"), r.id, p.id, p.custody_id, day, tariff.storage_cents, r.rate_card_id, [
receipt.id,
releaseMovementId,
]],
);
}
}
export async function invoice(db: DB, actor: Actor, id: string, clock: string): Promise<InvoiceResponse> {
const r = await reservationAccess(db, actor, id), tariff = await rate(db, r.rate_card_id);
const rows = (await db.query(
"SELECT l.*,p.label AS pallet_label FROM charge_lines l LEFT JOIN pallets p ON p.id=l.pallet_id WHERE l.reservation_id=$1 ORDER BY l.kind,l.service_date,p.label,l.id",
[id],
)).rows;
const lines: InvoiceLine[] = rows.map((l) => ({
id: l.id,
kind: l.kind,
palletId: l.pallet_id,
palletLabel: l.pallet_label,
serviceDate: l.service_date,
quantity: l.quantity,
unitCents: l.unit_cents,
totalCents: l.total_cents,
movementIds: l.movement_ids,
rateVersion: tariff.version,
rateCardId: l.rate_card_id,
provisional: false,
}));
const open = (await db.query(
"SELECT p.*,c.id AS custody_id,c.received_at,m.id AS receipt_id FROM pallets p JOIN custodies c ON c.pallet_id=p.id JOIN movements m ON m.custody_id=c.id AND m.kind='received' WHERE p.reservation_id=$1 AND c.released_at IS NULL",
[id],
)).rows;
for (const p of open) {
for (const day of await calendarDates(db, p.received_at, clock, r.timezone)) {
lines.push({
id: `accrued-${p.custody_id}-${day}`,
kind: "storage",
palletId: p.id,
palletLabel: p.label,
serviceDate: day,
quantity: 1,
unitCents: tariff.storage_cents,
totalCents: tariff.storage_cents,
movementIds: [p.receipt_id],
rateVersion: tariff.version,
rateCardId: r.rate_card_id,
provisional: true,
});
}
}
const summary: InvoiceSummary = {
palletDays: 0,
storageCents: 0,
receivingCents: 0,
releaseCents: 0,
adjustmentCents: 0,
totalCents: 0,
};
for (const l of lines) {
if (l.kind === "storage") {
summary.palletDays += l.quantity;
summary.storageCents += l.totalCents;
} else if (l.kind === "receiving") summary.receivingCents += l.totalCents;
else if (l.kind === "release") summary.releaseCents += l.totalCents;
else summary.adjustmentCents += l.totalCents;
summary.totalCents += l.totalCents;
}
const events = (await db.query(
"SELECT m.*,u.name AS actor_name FROM movements m JOIN users u ON u.id=m.actor_id WHERE m.reservation_id=$1 ORDER BY m.occurred_at,m.recorded_at,m.id",
[id],
)).rows.map((m) => ({
id: m.id,
kind: m.kind,
palletId: m.pallet_id,
occurredAt: m.occurred_at,
recordedAt: m.recorded_at,
actorName: m.actor_name,
ownerOrgId: m.owner_org_id,
fromPositionId: m.from_position_id,
toPositionId: m.to_position_id,
reason: m.reason,
adjustmentOf: m.adjustment_of,
data: m.data,
}));
const q = (await db.query("SELECT estimate_cents,terms FROM quotes WHERE id=$1", [r.quote_id])).rows[0];
return {
reservationId: id,
customerName: r.customer_name,
currency: tariff.currency,
rateVersion: tariff.version,
rateCardId: tariff.id,
terms: q.terms,
quoteEstimateCents: q.estimate_cents,
currentEstimateCents: r.estimate_cents,
asOf: clock,
summary,
lines,
events,
};
}
export async function getInvoice(actor: Actor, id: string) {
const { pool } = await import("./db.ts");
const db = await pool.connect();
let r;
try {
r = await reservationAccess(db, actor, id);
} finally {
db.release();
}
return warehouseTx(actor, r.warehouse_id, (db, clock) => invoice(db, actor, id, clock));
}