Concept project · runs in your browserTypeScript · React.js · Next.js
Invoice and PDF Generator API Demo
An invoice API simulator. Build an invoice with line items, per-line discounts, tax rates and an invoice discount, and watch exact totals and an A4 preview update as you type. Post it with an idempotency key, send it twice or double-click to see replays and conflicts, move it through draft, sent and paid, and save the preview as a PDF.
Interactive demo
Loading totals…
preview: live draft from the form
| id | number | status | customer | total | actions |
|---|---|---|---|---|---|
| 3 | — | draft | Cedar Books Ltd | ||
| 2 | INV-2026-0002 | sent | Bluebird Logistics GmbH | ||
| 1 | INV-2026-0001 | paid | Acme Dental |
| key | fingerprint | state | status |
|---|---|---|---|
| No keys yet. POST /invoices stores one per request. | |||
request log
- $ waiting for requests…
## How it works
Amounts arrive as decimal strings such as "45.50" and are parsed straight into BigInt minor units, so no float ever holds money. Each line is rounded once (half-up), an invoice discount is spread over the lines with the largest-remainder method so the parts add up to the cent, and tax is rounded per line. The totals panel shows the same invoice computed with plain JS numbers for comparison.
POST /invoices requires an Idempotency-Key. The key is claimed with its request fingerprint before any work happens. A retry with the same body replays the stored 201; the same key with a different body is a 422; a second request while the first is still running gets a 409.
Drafts have no number. send takes the next number from a per-year counter row inside the transaction, so numbers are sequential with no gaps, and voided invoices keep theirs.
## View source
The code running in the window above, exactly as deployed.
app/lab/invoice-pdf-api/money.ts151 lines
/**
* Money maths in integer minor units (cents, paisa, yen). No floating point touches an amount:
* inputs arrive as decimal strings, are parsed to BigInt, and every rounding step is explicit.
*
* Rounding rule: half-up (away from zero) per line, the way most invoicing and tax rules expect.
* The same functions run on the server in the production design (NestJS service + bigint columns).
*/
export const CURRENCIES = {
USD: { exp: 2, locale: "en-US" },
EUR: { exp: 2, locale: "de-DE" },
GBP: { exp: 2, locale: "en-GB" },
PKR: { exp: 2, locale: "en-PK" },
JPY: { exp: 0, locale: "ja-JP" },
} as const;
export type Currency = keyof typeof CURRENCIES;
/** Parse "1234.5" into minor units for a currency with `exp` decimals. Returns null if not a valid amount. */
export function parseDecimal(input: string, exp: number): bigint | null {
const s = input.trim().replace(/,/g, "");
const m = /^(\d{1,12})(?:\.(\d+))?$/.exec(s);
if (!m) return null;
const frac = m[2] ?? "";
if (frac.length > exp) return null;
return BigInt(m[1]!) * 10n ** BigInt(exp) + BigInt((frac + "0".repeat(exp)).slice(0, exp) || "0");
}
/** a / b rounded half-up, for a >= 0 and b > 0. */
export function divRound(a: bigint, b: bigint): bigint {
return (a * 2n + b) / (b * 2n);
}
/** Quantity is stored in thousandths (3 decimals), rates and discounts in basis points of a percent (8.25% = 825). */
export const QTY_EXP = 3;
export const RATE_EXP = 2;
export type LineAmounts = { qty: bigint; unitPrice: bigint; taxBp: bigint; discountBp: bigint };
export type InvoiceDiscount = { type: "none" } | { type: "percent"; bp: bigint } | { type: "fixed"; amount: bigint };
export type LineResult = {
gross: bigint;
lineDiscount: bigint;
net: bigint;
/** Share of the invoice-level discount allocated to this line. */
allocated: bigint;
taxable: bigint;
tax: bigint;
total: bigint;
};
export type Totals = {
lines: LineResult[];
subtotal: bigint;
discount: bigint;
taxableTotal: bigint;
tax: bigint;
total: bigint;
taxByRate: { bp: bigint; taxable: bigint; tax: bigint }[];
};
/**
* Split `amount` across `weights` proportionally so the parts add up exactly to `amount`
* (largest-remainder method). Used to spread an invoice discount over lines before tax.
*/
export function allocate(amount: bigint, weights: bigint[]): bigint[] {
const sum = weights.reduce((a, b) => a + b, 0n);
if (sum === 0n) return weights.map(() => 0n);
const raw = weights.map((w) => amount * w);
const parts = raw.map((r) => r / sum);
let left = amount - parts.reduce((a, b) => a + b, 0n);
const order = raw.map((r, i) => ({ i, rem: r % sum })).sort((a, b) => (b.rem > a.rem ? 1 : b.rem < a.rem ? -1 : a.i - b.i));
for (const { i } of order) {
if (left <= 0n) break;
parts[i] = parts[i]! + 1n;
left -= 1n;
}
return parts;
}
export function computeTotals(lines: LineAmounts[], discount: InvoiceDiscount): Totals {
const base = lines.map((l) => {
// qty is in thousandths, so gross = qty * price / 1000, rounded once.
const gross = divRound(l.qty * l.unitPrice, 10n ** BigInt(QTY_EXP));
const lineDiscount = divRound(gross * l.discountBp, 10_000n);
return { gross, lineDiscount, net: gross - lineDiscount };
});
const subtotal = base.reduce((s, l) => s + l.net, 0n);
let invoiceDiscount = 0n;
if (discount.type === "percent") invoiceDiscount = divRound(subtotal * discount.bp, 10_000n);
if (discount.type === "fixed") invoiceDiscount = discount.amount > subtotal ? subtotal : discount.amount;
const shares = allocate(invoiceDiscount, base.map((l) => l.net));
const out: LineResult[] = base.map((l, i) => {
const allocated = shares[i]!;
const taxable = l.net - allocated;
const tax = divRound(taxable * lines[i]!.taxBp, 10_000n);
return { ...l, allocated, taxable, tax, total: taxable + tax };
});
const byRate = new Map<bigint, { bp: bigint; taxable: bigint; tax: bigint }>();
out.forEach((l, i) => {
const bp = lines[i]!.taxBp;
const g = byRate.get(bp) ?? { bp, taxable: 0n, tax: 0n };
g.taxable += l.taxable;
g.tax += l.tax;
byRate.set(bp, g);
});
const tax = out.reduce((s, l) => s + l.tax, 0n);
const taxableTotal = subtotal - invoiceDiscount;
return {
lines: out,
subtotal,
discount: invoiceDiscount,
taxableTotal,
tax,
total: taxableTotal + tax,
taxByRate: [...byRate.values()].sort((a, b) => (a.bp < b.bp ? -1 : 1)),
};
}
/**
* What a naive implementation with JS numbers would produce for the same invoice: floats all the way,
* no rounding until display. Shown next to the exact result so the difference is visible.
*/
export function naiveFloatTotal(lines: { qty: number; price: number; tax: number; disc: number }[], discountPct: number): number {
let sub = 0;
for (const l of lines) sub += l.qty * l.price * (1 - l.disc / 100);
let total = 0;
for (const l of lines) {
const net = l.qty * l.price * (1 - l.disc / 100);
const share = sub ? net / sub : 0;
total += (net - sub * (discountPct / 100) * share) * (1 + l.tax / 100);
}
return total;
}
/** Format minor units for display. The division is only for Intl output; amounts are never stored as floats. */
export function formatMoney(minor: bigint | number, currency: Currency): string {
const { exp, locale } = CURRENCIES[currency];
return new Intl.NumberFormat(locale, { style: "currency", currency, minimumFractionDigits: exp, maximumFractionDigits: exp }).format(Number(minor) / 10 ** exp);
}
/** Minor units back to a plain decimal string ("1999" → "19.99"), exact. */
export function toDecimalString(minor: bigint, exp: number): string {
if (exp === 0) return minor.toString();
const s = minor.toString().padStart(exp + 1, "0");
return `${s.slice(0, -exp)}.${s.slice(-exp)}`;
}app/lab/invoice-pdf-api/api.ts524 lines
/**
* The invoice API as the NestJS service would implement it: validation, totals on the server,
* gap-free numbering when an invoice is finalised, a status machine, and idempotency keys on POST /invoices.
*
* draft ──send──▶ sent ──pay──▶ paid
* │ │
* └────void──────┴──▶ void (only drafts can be edited)
*/
import {
BadRequestException,
ConflictException,
HttpException,
NotFoundException,
UnprocessableEntityException,
fnv1a,
header,
isRecord,
matchRoute,
parsePath,
type ApiRequest,
type ApiResponse,
type Endpoint,
type HttpMethod,
type TraceStep,
} from "@/components/lab/api/http";
import { CURRENCIES, QTY_EXP, RATE_EXP, computeTotals, parseDecimal, toDecimalString, type Currency, type InvoiceDiscount, type LineAmounts } from "./money";
export type Status = "draft" | "sent" | "paid" | "void";
export type LineInput = { description: string; quantity: string; unitPrice: string; taxRate: string; discountPct: string };
export type DiscountInput = { type: "none" | "percent" | "fixed"; value: string };
export type Customer = { id: number; name: string; address: string; email: string; currency: Currency };
export type Invoice = {
id: number;
number: string | null;
status: Status;
customerId: number;
currency: Currency;
issueDate: string;
dueDate: string;
lines: LineInput[];
discount: DiscountInput;
notes: string;
createdAt: string;
sentAt: string | null;
paidAt: string | null;
};
export type IdempotencyRecord = {
key: string;
fingerprint: string;
state: "processing" | "completed";
response: { status: number; body: unknown } | null;
createdAt: string;
};
export type Db = {
seller: { name: string; address: string; email: string; taxId: string };
customers: Customer[];
invoices: Invoice[];
/** invoice_counters: last number used per year. */
counters: Record<string, number>;
idempotency: Record<string, IdempotencyRecord>;
seq: number;
};
/* ------------------------------------------------------------------ seed */
const L = (description: string, quantity: string, unitPrice: string, taxRate: string, discountPct = "0"): LineInput => ({ description, quantity, unitPrice, taxRate, discountPct });
export function seedDb(): Db {
return {
seller: { name: "Northgate Studio", address: "12 Example Street\nSpringfield 10001", email: "billing@northgate.test", taxId: "VAT-000-DEMO" },
customers: [
{ id: 1, name: "Acme Dental", address: "4 Harbour Road\nPort Lane 2040", email: "accounts@acmedental.test", currency: "USD" },
{ id: 2, name: "Bluebird Logistics GmbH", address: "Lagerstraße 9\n20095 Hamburg", email: "ap@bluebird.test", currency: "EUR" },
{ id: 3, name: "Cedar Books Ltd", address: "18 Mill Row\nYork YO1 7AB", email: "finance@cedarbooks.test", currency: "GBP" },
{ id: 4, name: "Indus Textiles", address: "Plot 22, Industrial Area\nLahore", email: "payables@industextiles.test", currency: "PKR" },
{ id: 5, name: "Sakura Print", address: "3-1 Example-cho\nOsaka", email: "keiri@sakuraprint.test", currency: "JPY" },
],
invoices: [
{
id: 1, number: "INV-2026-0001", status: "paid", customerId: 1, currency: "USD", issueDate: "2026-10-01", dueDate: "2026-10-15",
lines: [L("Booking widget setup", "1", "850.00", "0"), L("Support hours", "6.5", "65.00", "0")],
discount: { type: "none", value: "0" }, notes: "Thank you!", createdAt: "2026-10-01T09:00:00.000Z", sentAt: "2026-10-01T09:05:00.000Z", paidAt: "2026-10-10T12:00:00.000Z",
},
{
id: 2, number: "INV-2026-0002", status: "sent", customerId: 2, currency: "EUR", issueDate: "2026-10-20", dueDate: "2026-11-19",
lines: [L("Route planner sprint", "1", "4200.00", "19"), L("Map tile hosting (month)", "1", "39.90", "19")],
discount: { type: "percent", value: "5" }, notes: "", createdAt: "2026-10-20T10:00:00.000Z", sentAt: "2026-10-20T10:30:00.000Z", paidAt: null,
},
{
id: 3, number: null, status: "draft", customerId: 3, currency: "GBP", issueDate: "2026-11-02", dueDate: "2026-12-02",
lines: [L("Catalogue import", "3", "0.10", "20"), L("Search tuning", "2.25", "95.00", "20", "10")],
discount: { type: "none", value: "0" }, notes: "Draft for review.", createdAt: "2026-11-02T08:00:00.000Z", sentAt: null, paidAt: null,
},
],
counters: { "2026": 2 },
idempotency: {},
seq: 3,
};
}
/* ------------------------------------------------------------ validation */
type FieldErrors = Record<string, string[]>;
const add = (e: FieldErrors, k: string, m: string) => (e[k] = [...(e[k] ?? []), m]);
const dateRe = /^\d{4}-\d{2}-\d{2}$/;
/** CreateInvoiceDto. Amounts must be decimal strings: a JSON number such as 0.1 is already a float. */
export function validateInvoice(db: Db, body: unknown, partial: boolean): { ok: true; value: Partial<Invoice> & { dueInDays?: number } } | { ok: false; errors: FieldErrors } {
const e: FieldErrors = {};
if (!isRecord(body)) return { ok: false, errors: { body: ["body must be a JSON object"] } };
const v: Partial<Invoice> & { dueInDays?: number } = {};
const allowed = new Set(["customerId", "currency", "issueDate", "dueInDays", "lines", "discount", "notes"]);
for (const k of Object.keys(body)) if (!allowed.has(k)) add(e, k, `property ${k} should not exist`);
if (body.customerId !== undefined || !partial) {
if (!db.customers.some((c) => c.id === body.customerId)) add(e, "customerId", "customerId must be an existing customer id");
else v.customerId = body.customerId as number;
}
if (body.currency !== undefined || !partial) {
if (typeof body.currency !== "string" || !(body.currency in CURRENCIES)) add(e, "currency", `currency must be one of: ${Object.keys(CURRENCIES).join(", ")}`);
else v.currency = body.currency as Currency;
}
if (body.issueDate !== undefined || !partial) {
if (typeof body.issueDate !== "string" || !dateRe.test(body.issueDate) || Number.isNaN(Date.parse(body.issueDate))) add(e, "issueDate", "issueDate must be a date (YYYY-MM-DD)");
else v.issueDate = body.issueDate;
}
if (body.dueInDays !== undefined) {
if (!Number.isInteger(body.dueInDays) || (body.dueInDays as number) < 0 || (body.dueInDays as number) > 120) add(e, "dueInDays", "dueInDays must be an integer from 0 to 120");
else v.dueInDays = body.dueInDays as number;
}
if (body.notes !== undefined) {
if (typeof body.notes !== "string" || body.notes.length > 500) add(e, "notes", "notes must be a string of at most 500 characters");
else v.notes = body.notes;
}
const cur = String(v.currency ?? body.currency ?? "USD");
const exp = cur in CURRENCIES ? CURRENCIES[cur as Currency].exp : 2;
if (body.lines !== undefined || !partial) {
if (!Array.isArray(body.lines) || body.lines.length === 0) add(e, "lines", "lines must contain at least 1 item");
else if (body.lines.length > 50) add(e, "lines", "lines must contain at most 50 items");
else {
v.lines = body.lines.map((raw: unknown, i: number) => {
const p = `lines[${i}]`;
const l = isRecord(raw) ? raw : {};
const s = (k: string) => (typeof l[k] === "string" ? (l[k] as string).trim() : typeof l[k] === "number" ? null : undefined);
const desc = typeof l.description === "string" ? l.description.trim() : "";
if (desc.length < 1 || desc.length > 120) add(e, `${p}.description`, "description must be 1–120 characters");
const q = s("quantity");
if (q === null) add(e, `${p}.quantity`, "quantity must be a decimal string such as \"2.5\", not a JSON number");
else if (q?.startsWith("-")) add(e, `${p}.quantity`, "quantity must be greater than 0");
else if (q === undefined || parseDecimal(q, QTY_EXP) === null) add(e, `${p}.quantity`, "quantity must be a decimal with at most 3 places");
else if (parseDecimal(q, QTY_EXP)! <= 0n) add(e, `${p}.quantity`, "quantity must be greater than 0");
const up = s("unitPrice");
if (up === null) add(e, `${p}.unitPrice`, "unitPrice must be a decimal string such as \"19.99\", not a JSON number");
else if (up === undefined || up === "") add(e, `${p}.unitPrice`, "unitPrice is required");
else if (parseDecimal(up, exp) === null) add(e, `${p}.unitPrice`, exp === 0 ? "unitPrice must be a whole number for this currency" : `unitPrice must be a non-negative amount with at most ${exp} decimals`);
const pct = (k: "taxRate" | "discountPct", label: string) => {
const raw = l[k] === undefined || l[k] === "" ? "0" : s(k);
const val = typeof raw === "string" ? parseDecimal(raw, RATE_EXP) : null;
if (val === null || val > 10_000n) add(e, `${p}.${k}`, `${k} must be ${label} from 0 to 100 as a string, with at most 2 decimals`);
return typeof raw === "string" ? raw : "0";
};
const taxRate = pct("taxRate", "a percentage");
const discountPct = pct("discountPct", "a percentage");
return { description: desc, quantity: q ?? "", unitPrice: up ?? "", taxRate, discountPct };
});
}
}
if (body.discount !== undefined) {
const d = isRecord(body.discount) ? body.discount : {};
const type = d.type;
const value = typeof d.value === "string" ? d.value : "0";
if (type !== "none" && type !== "percent" && type !== "fixed") add(e, "discount.type", "discount.type must be one of: none, percent, fixed");
else if (type === "percent" && (parseDecimal(value, RATE_EXP) ?? 10_001n) > 10_000n) add(e, "discount.value", "discount.value must be a percentage from 0 to 100");
else if (type === "fixed" && parseDecimal(value, exp) === null) add(e, "discount.value", `discount.value must be an amount with at most ${exp} decimals`);
else v.discount = { type, value };
}
return Object.keys(e).length ? { ok: false, errors: e } : { ok: true, value: v };
}
/* -------------------------------------------------------------- totals */
export function amountsOf(inv: Pick<Invoice, "lines" | "discount" | "currency">): { lines: LineAmounts[]; discount: InvoiceDiscount } {
const exp = CURRENCIES[inv.currency].exp;
const lines = inv.lines.map((l) => ({
qty: parseDecimal(l.quantity, QTY_EXP) ?? 0n,
unitPrice: parseDecimal(l.unitPrice, exp) ?? 0n,
taxBp: parseDecimal(l.taxRate || "0", RATE_EXP) ?? 0n,
discountBp: parseDecimal(l.discountPct || "0", RATE_EXP) ?? 0n,
}));
const d = inv.discount;
const discount: InvoiceDiscount =
d.type === "percent" ? { type: "percent", bp: parseDecimal(d.value, RATE_EXP) ?? 0n } : d.type === "fixed" ? { type: "fixed", amount: parseDecimal(d.value, exp) ?? 0n } : { type: "none" };
return { lines, discount };
}
export function totalsOf(inv: Pick<Invoice, "lines" | "discount" | "currency">) {
const a = amountsOf(inv);
return computeTotals(a.lines, a.discount);
}
/** Response shape: money as exact decimal strings plus integer minor units. */
export function serialize(db: Db, inv: Invoice) {
const t = totalsOf(inv);
const exp = CURRENCIES[inv.currency].exp;
const d = (x: bigint) => toDecimalString(x, exp);
return {
...inv,
customer: db.customers.find((c) => c.id === inv.customerId)?.name ?? null,
lines: inv.lines.map((l, i) => ({ ...l, net: d(t.lines[i]!.net), tax: d(t.lines[i]!.tax), total: d(t.lines[i]!.total) })),
totals: {
subtotal: d(t.subtotal),
discount: d(t.discount),
tax: d(t.tax),
total: d(t.total),
totalMinor: Number(t.total),
taxByRate: t.taxByRate.map((r) => ({ rate: toDecimalString(r.bp, RATE_EXP), taxable: d(r.taxable), tax: d(r.tax) })),
},
};
}
/* -------------------------------------------------------------- routes */
type Ctx = { db: Db; now: number; params: Record<string, string>; query: Record<string, string>; req: ApiRequest; sql: string[] };
type Result = { status: number; body?: unknown; headers?: Record<string, string> };
type Route = { method: HttpMethod; path: string; summary: string; tag: string; handler: string; example?: string; sampleBody?: unknown; run: (ctx: Ctx) => Result };
const today = (now: number) => new Date(now).toISOString().slice(0, 10);
const addDays = (date: string, days: number) => new Date(Date.parse(date) + days * 86_400_000).toISOString().slice(0, 10);
function find(ctx: Ctx): Invoice {
const id = Number(ctx.params.id);
ctx.sql.push(`SELECT * FROM invoices WHERE id = ${id} FOR UPDATE`);
const inv = ctx.db.invoices.find((i) => i.id === id);
if (!inv) throw new NotFoundException(`Invoice ${ctx.params.id} not found`);
return inv;
}
function transition(ctx: Ctx, from: Status[], to: Status): Invoice {
const inv = find(ctx);
if (!from.includes(inv.status)) throw new ConflictException(`Cannot change a ${inv.status} invoice to ${to}`);
return inv;
}
export const SAMPLE_CREATE = {
customerId: 1,
currency: "USD",
issueDate: "2026-11-10",
dueInDays: 14,
lines: [
{ description: "Website maintenance (Nov)", quantity: "1", unitPrice: "320.00", taxRate: "8.25" },
{ description: "Extra hours", quantity: "3.5", unitPrice: "45.50", taxRate: "8.25", discountPct: "10" },
],
discount: { type: "percent", value: "5" },
notes: "Payment by bank transfer.",
};
export const ROUTES: Route[] = [
{
method: "GET",
path: "/customers",
summary: "List customers",
tag: "customers",
handler: "CustomersController.findAll",
run: (ctx) => {
ctx.sql.push("SELECT * FROM customers ORDER BY id");
return { status: 200, body: { data: ctx.db.customers } };
},
},
{
method: "GET",
path: "/invoices",
summary: "List invoices (filter by ?status=)",
tag: "invoices",
handler: "InvoicesController.findAll",
example: "/invoices?status=draft",
run: (ctx) => {
const st = ctx.query.status;
if (st && !["draft", "sent", "paid", "void"].includes(st)) throw new BadRequestException("status must be one of: draft, sent, paid, void");
ctx.sql.push(`SELECT * FROM invoices${st ? ` WHERE status = '${st}'` : ""} ORDER BY id DESC`);
const rows = ctx.db.invoices.filter((i) => !st || i.status === st).sort((a, b) => b.id - a.id);
return { status: 200, body: { data: rows.map((i) => { const s = serialize(ctx.db, i); return { id: s.id, number: s.number, status: s.status, customer: s.customer, currency: s.currency, total: s.totals.total }; }) } };
},
},
{
method: "GET",
path: "/invoices/:id",
summary: "One invoice with computed totals",
tag: "invoices",
handler: "InvoicesController.findOne",
example: "/invoices/2",
run: (ctx) => ({ status: 200, body: serialize(ctx.db, find(ctx)) }),
},
{
method: "POST",
path: "/invoices",
summary: "Create a draft (requires Idempotency-Key)",
tag: "invoices",
handler: "InvoicesController.create",
sampleBody: SAMPLE_CREATE,
run: () => {
throw new Error("handled by createInvoice()");
},
},
{
method: "PATCH",
path: "/invoices/:id",
summary: "Edit a draft (409 once sent)",
tag: "invoices",
handler: "InvoicesController.update",
example: "/invoices/3",
sampleBody: { notes: "Updated notes", discount: { type: "fixed", value: "20.00" } },
run: (ctx) => {
const inv = find(ctx);
if (inv.status !== "draft") throw new ConflictException(`Invoice ${inv.number ?? inv.id} is ${inv.status}; only drafts can be edited. Void it and issue a new one.`);
const r = validateInvoice(ctx.db, ctx.req.body, true);
if (!r.ok) throw new BadRequestException("Validation failed", { fields: r.errors });
const { dueInDays, ...rest } = r.value;
Object.assign(inv, rest);
if (dueInDays !== undefined) inv.dueDate = addDays(inv.issueDate, dueInDays);
ctx.sql.push(`UPDATE invoices SET ${Object.keys(r.value).join(", ")} = … WHERE id = ${inv.id} AND status = 'draft'`);
return { status: 200, body: serialize(ctx.db, inv) };
},
},
{
method: "POST",
path: "/invoices/:id/send",
summary: "Finalise: assign the next number and mark sent",
tag: "status",
handler: "InvoicesController.send",
example: "/invoices/3/send",
run: (ctx) => {
const inv = transition(ctx, ["draft"], "sent");
const year = inv.issueDate.slice(0, 4);
// The counter row is locked by the UPDATE, so concurrent sends queue up and numbers never repeat or skip.
const next = (ctx.db.counters[year] ?? 0) + 1;
ctx.db.counters[year] = next;
ctx.sql.push(`INSERT INTO invoice_counters (year, last) VALUES (${year}, 1) ON CONFLICT (year) DO UPDATE SET last = invoice_counters.last + 1 RETURNING last -- ${next}`);
inv.number = `INV-${year}-${String(next).padStart(4, "0")}`;
inv.status = "sent";
inv.sentAt = new Date(ctx.now).toISOString();
ctx.sql.push(`UPDATE invoices SET number = '${inv.number}', status = 'sent', sent_at = now() WHERE id = ${inv.id}`);
return { status: 200, body: serialize(ctx.db, inv) };
},
},
{
method: "POST",
path: "/invoices/:id/pay",
summary: "Mark a sent invoice as paid",
tag: "status",
handler: "InvoicesController.pay",
example: "/invoices/2/pay",
run: (ctx) => {
const inv = transition(ctx, ["sent"], "paid");
inv.status = "paid";
inv.paidAt = new Date(ctx.now).toISOString();
ctx.sql.push(`UPDATE invoices SET status = 'paid', paid_at = now() WHERE id = ${inv.id} AND status = 'sent'`);
return { status: 200, body: serialize(ctx.db, inv) };
},
},
{
method: "POST",
path: "/invoices/:id/void",
summary: "Void a draft or sent invoice (keeps its number)",
tag: "status",
handler: "InvoicesController.void",
example: "/invoices/1/void",
run: (ctx) => {
const inv = transition(ctx, ["draft", "sent"], "void");
inv.status = "void";
ctx.sql.push(`UPDATE invoices SET status = 'void' WHERE id = ${inv.id} -- number stays used, so the sequence has no gaps`);
return { status: 200, body: serialize(ctx.db, inv) };
},
},
{
method: "GET",
path: "/invoices/:id/pdf",
summary: "Render the invoice as an A4 PDF",
tag: "pdf",
handler: "PdfController.render",
example: "/invoices/2/pdf",
run: (ctx) => {
const inv = find(ctx);
const name = `${inv.number ?? `draft-${inv.id}`}.pdf`;
return {
status: 200,
headers: { "Content-Type": "application/pdf", "Content-Disposition": `attachment; filename="${name}"` },
body: { note: "In this demo the PDF is produced by the print preview (Download PDF). In production pdfkit streams the bytes here.", filename: name, pages: 1, size: "A4" },
};
},
},
];
export const ENDPOINTS: Endpoint[] = ROUTES.map((r) => ({
method: r.method,
path: r.path,
summary: r.summary,
tag: r.tag,
auth: r.method === "POST" && r.path === "/invoices" ? "Idempotency-Key" : undefined,
example: r.example,
sampleBody: r.sampleBody,
}));
/* --------------------------------------------------------- idempotency */
/** Stable JSON (sorted keys) so {"a":1,"b":2} and {"b":2,"a":1} get the same fingerprint. */
function stable(v: unknown): string {
if (Array.isArray(v)) return `[${v.map(stable).join(",")}]`;
if (isRecord(v)) return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${stable(v[k])}`).join(",")}}`;
return JSON.stringify(v);
}
export type Pending = { key: string; body: unknown };
const IDEMPOTENCY_TTL = 24 * 3_600_000;
/**
* Phase 1 of POST /invoices: claim the idempotency key. In PostgreSQL this is
* INSERT INTO idempotency_keys (key, fingerprint, state) VALUES (…, 'processing') ON CONFLICT DO NOTHING.
*/
export function beginCreate(db: Db, req: ApiRequest, now: number): { db: Db; res: ApiResponse } | { db: Db; pending: Pending; trace: TraceStep[]; sql: string[] } {
const trace: TraceStep[] = [];
const sql: string[] = [];
const key = header(req, "idempotency-key")?.trim();
if (!key) {
trace.push({ stage: "IdempotencyInterceptor", ok: false, note: "missing key" });
return { db, res: { status: 400, body: new BadRequestException("Idempotency-Key header is required for POST /invoices").toBody(), trace } };
}
if (key.length < 8 || key.length > 64) {
trace.push({ stage: "IdempotencyInterceptor", ok: false, note: "bad key" });
return { db, res: { status: 400, body: new BadRequestException("Idempotency-Key must be 8–64 characters").toBody(), trace } };
}
const fingerprint = fnv1a(stable(req.body ?? null));
const draft = structuredClone(db);
for (const [k, r] of Object.entries(draft.idempotency)) if (Date.parse(r.createdAt) + IDEMPOTENCY_TTL < now) delete draft.idempotency[k];
sql.push(`INSERT INTO idempotency_keys (key, fingerprint, state) VALUES ('${key}', '${fingerprint}', 'processing') ON CONFLICT (key) DO NOTHING RETURNING key`);
const existing = draft.idempotency[key];
if (existing) {
sql.push(`SELECT * FROM idempotency_keys WHERE key = '${key}'`);
if (existing.fingerprint !== fingerprint) {
trace.push({ stage: "IdempotencyInterceptor", ok: false, note: "payload differs" });
return { db, res: { status: 422, body: new UnprocessableEntityException("This Idempotency-Key was already used with a different request body").toBody(), trace, sql } };
}
if (existing.state === "processing") {
trace.push({ stage: "IdempotencyInterceptor", ok: false, note: "in flight" });
return { db, res: { status: 409, body: new ConflictException("A request with this Idempotency-Key is still being processed. Retry shortly.").toBody(), headers: { "Retry-After": "1" }, trace, sql } };
}
trace.push({ stage: "IdempotencyInterceptor", ok: true, note: "replayed stored response" });
return { db, res: { status: existing.response!.status, body: existing.response!.body, headers: { "Idempotent-Replayed": "true" }, trace, sql } };
}
draft.idempotency[key] = { key, fingerprint, state: "processing", response: null, createdAt: new Date(now).toISOString() };
trace.push({ stage: "IdempotencyInterceptor", ok: true, note: "key claimed" });
return { db: draft, pending: { key, body: req.body }, trace, sql };
}
/** Phase 2: validate, insert the draft and store the response against the key, in one transaction. */
export function finishCreate(db: Db, p: Pending, now: number, trace: TraceStep[] = [], sql: string[] = []): { db: Db; res: ApiResponse } {
const draft = structuredClone(db);
const r = validateInvoice(draft, p.body, false);
if (!r.ok) {
// Release the key so the client can fix the body and retry with the same key.
delete draft.idempotency[p.key];
sql.push(`DELETE FROM idempotency_keys WHERE key = '${p.key}' -- validation failed, key released`);
trace.push({ stage: "ValidationPipe", ok: false, note: `${Object.keys(r.errors).length} field(s)` });
return { db: draft, res: { status: 400, body: { statusCode: 400, message: "Validation failed", error: "Bad Request", fields: r.errors }, trace, sql } };
}
trace.push({ stage: "ValidationPipe", ok: true });
const v = r.value;
const issueDate = v.issueDate!;
const inv: Invoice = {
id: ++draft.seq,
number: null,
status: "draft",
customerId: v.customerId!,
currency: v.currency!,
issueDate,
dueDate: addDays(issueDate, v.dueInDays ?? 30),
lines: v.lines!,
discount: v.discount ?? { type: "none", value: "0" },
notes: v.notes ?? "",
createdAt: new Date(now).toISOString(),
sentAt: null,
paidAt: null,
};
draft.invoices.push(inv);
const body = serialize(draft, inv);
draft.idempotency[p.key] = { ...draft.idempotency[p.key]!, state: "completed", response: { status: 201, body } };
sql.push("BEGIN", `INSERT INTO invoices (customer_id, currency, issue_date, due_date, status) VALUES (${inv.customerId}, '${inv.currency}', '${issueDate}', '${inv.dueDate}', 'draft') RETURNING id -- ${inv.id}`);
sql.push(`INSERT INTO invoice_lines (invoice_id, description, quantity_milli, unit_price_minor, tax_bp, discount_bp) VALUES … -- ${inv.lines.length} rows`);
sql.push(`UPDATE idempotency_keys SET state = 'completed', status = 201, response = $1 WHERE key = '${p.key}'`, "COMMIT");
trace.push({ stage: "InvoicesController.create", ok: true, note: "201" });
return { db: draft, res: { status: 201, body, headers: { Location: `/invoices/${inv.id}` }, trace, sql } };
}
export function handle(db: Db, req: ApiRequest, now: number): { db: Db; res: ApiResponse } {
const { pathname, query } = parsePath(req.path);
const candidates = ROUTES.filter((r) => matchRoute(r.path, pathname));
const route = candidates.find((r) => r.method === req.method);
if (!route) {
const e = new HttpException(candidates.length ? 405 : 404, `Cannot ${req.method} ${pathname}`);
return { db, res: { status: e.status, body: e.toBody() } };
}
if (route.path === "/invoices" && route.method === "POST") {
const b = beginCreate(db, req, now);
if ("res" in b) return b;
return finishCreate(b.db, b.pending, now, b.trace, b.sql);
}
const draft = structuredClone(db);
const sql: string[] = [];
try {
const out = route.run({ db: draft, now, params: matchRoute(route.path, pathname)!, query, req, sql });
if (req.method !== "GET") {
sql.unshift("BEGIN");
sql.push("COMMIT");
}
return { db: draft, res: { status: out.status, body: out.body ?? null, headers: out.headers, trace: [{ stage: route.handler, ok: true, note: String(out.status) }], sql } };
} catch (e) {
if (!(e instanceof HttpException)) throw e;
if (req.method !== "GET") sql.push("ROLLBACK");
return { db, res: { status: e.status, body: e.toBody(), trace: [{ stage: route.handler, ok: false, note: String(e.status) }], sql } };
}
}
export { today, addDays };app/lab/invoice-pdf-api/InvoicePreview.tsx129 lines
"use client";
import { CURRENCIES, formatMoney, toDecimalString, RATE_EXP } from "./money";
import { totalsOf, type Customer, type Invoice } from "./api";
/**
* The printable invoice. Paper is always white, so it uses fixed colours instead of theme tokens.
* "Download PDF" calls window.print(); the print stylesheet below hides everything except this sheet
* and sets an A4 page, so "Save as PDF" in the print dialog produces a one-page, text-selectable PDF.
*/
const PRINT_CSS = `
@media print {
@page { size: A4; margin: 14mm; }
body * { visibility: hidden !important; }
#invoice-print, #invoice-print * { visibility: visible !important; }
#invoice-print { position: absolute; inset: 0 auto auto 0; width: 100%; box-shadow: none !important; border: 0 !important; margin: 0 !important; }
}`;
const fmtDate = (d: string) => {
const t = Date.parse(d);
return Number.isNaN(t) ? d : new Date(t).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric", timeZone: "UTC" });
};
export type PreviewData = Pick<Invoice, "number" | "status" | "currency" | "issueDate" | "dueDate" | "lines" | "discount" | "notes">;
export function InvoicePreview({ inv, customer, seller }: { inv: PreviewData; customer: Customer | undefined; seller: { name: string; address: string; email: string; taxId: string } }) {
const t = totalsOf(inv);
const m = (x: bigint) => formatMoney(x, inv.currency);
const exp = CURRENCIES[inv.currency].exp;
const stamp = inv.status === "paid" ? "PAID" : inv.status === "void" ? "VOID" : inv.status === "draft" ? "DRAFT" : null;
return (
<>
<style>{PRINT_CSS}</style>
<article
id="invoice-print"
aria-label="Invoice preview"
className="relative overflow-hidden rounded-md border border-[#e4e4e7] bg-white p-5 text-[12px] leading-relaxed text-[#18181b] shadow-sm sm:p-8"
>
{stamp && (
<span
aria-hidden="true"
className={`pointer-events-none absolute top-6 right-[-2.5rem] rotate-12 border-2 px-10 py-0.5 font-mono text-sm font-bold tracking-widest ${
stamp === "PAID" ? "border-[#15803d] text-[#15803d]" : stamp === "VOID" ? "border-[#b91c1c] text-[#b91c1c]" : "border-[#a1a1aa] text-[#a1a1aa]"
}`}
>
{stamp}
</span>
)}
<header className="flex flex-wrap justify-between gap-4">
<div>
<p className="text-base font-bold">{seller.name}</p>
<p className="whitespace-pre-line text-[#52525b]">{seller.address}</p>
<p className="text-[#52525b]">{seller.email}</p>
<p className="text-[#52525b]">Tax ID {seller.taxId}</p>
</div>
<div className="text-right">
<p className="text-xl font-bold tracking-tight text-[#15803d]">INVOICE</p>
<p className="font-mono">{inv.number ?? "Draft (number assigned on send)"}</p>
<dl className="mt-1 grid grid-cols-[auto_auto] justify-end gap-x-3 text-[#52525b]">
<dt>Issued</dt>
<dd>{fmtDate(inv.issueDate)}</dd>
<dt>Due</dt>
<dd>{fmtDate(inv.dueDate)}</dd>
</dl>
</div>
</header>
<section className="mt-5">
<p className="text-[10px] font-semibold tracking-wider text-[#71717a] uppercase">Bill to</p>
<p className="font-medium">{customer?.name ?? "—"}</p>
<p className="whitespace-pre-line text-[#52525b]">{customer?.address}</p>
</section>
<div className="mt-5 overflow-x-auto">
<table className="w-full min-w-[30rem] border-collapse">
<thead>
<tr className="border-b-2 border-[#18181b] text-left text-[10px] tracking-wider text-[#52525b] uppercase">
<th className="py-1.5 pr-2 font-semibold">Description</th>
<th className="py-1.5 pr-2 text-right font-semibold">Qty</th>
<th className="py-1.5 pr-2 text-right font-semibold">Unit</th>
<th className="py-1.5 pr-2 text-right font-semibold">Disc.</th>
<th className="py-1.5 pr-2 text-right font-semibold">Tax</th>
<th className="py-1.5 text-right font-semibold">Amount</th>
</tr>
</thead>
<tbody>
{inv.lines.map((l, i) => (
<tr key={i} className="border-b border-[#e4e4e7] align-top">
<td className="py-1.5 pr-2">{l.description || <span className="text-[#a1a1aa]">(no description)</span>}</td>
<td className="py-1.5 pr-2 text-right font-mono">{l.quantity}</td>
<td className="py-1.5 pr-2 text-right font-mono">{l.unitPrice}</td>
<td className="py-1.5 pr-2 text-right font-mono">{l.discountPct && l.discountPct !== "0" ? `${l.discountPct}%` : ""}</td>
<td className="py-1.5 pr-2 text-right font-mono">{l.taxRate}%</td>
<td className="py-1.5 text-right font-mono">{t.lines[i] ? toDecimalString(t.lines[i]!.net, exp) : ""}</td>
</tr>
))}
</tbody>
</table>
</div>
<dl className="mt-4 ml-auto grid max-w-xs grid-cols-[1fr_auto] gap-x-6 gap-y-0.5">
<dt className="text-[#52525b]">Subtotal</dt>
<dd className="text-right font-mono">{m(t.subtotal)}</dd>
{t.discount > 0n && (
<>
<dt className="text-[#52525b]">Discount{inv.discount.type === "percent" ? ` (${inv.discount.value}%)` : ""}</dt>
<dd className="text-right font-mono">−{m(t.discount)}</dd>
</>
)}
{t.taxByRate.map((r) => (
<div key={String(r.bp)} className="contents">
<dt className="text-[#52525b]">
Tax {toDecimalString(r.bp, RATE_EXP).replace(/\.00$/, "")}% on {m(r.taxable)}
</dt>
<dd className="text-right font-mono">{m(r.tax)}</dd>
</div>
))}
<dt className="mt-1 border-t-2 border-[#18181b] pt-1 font-bold">Total {inv.currency}</dt>
<dd className="mt-1 border-t-2 border-[#18181b] pt-1 text-right font-mono font-bold">{m(t.total)}</dd>
</dl>
{inv.notes && <p className="mt-5 border-t border-[#e4e4e7] pt-3 whitespace-pre-line text-[#52525b]">{inv.notes}</p>}
<p className="mt-3 text-[10px] text-[#a1a1aa]">Fictional demo invoice. Amounts are calculated in integer minor units and rounded half-up per line.</p>
</article>
</>
);
}app/lab/invoice-pdf-api/Demo.tsx497 lines
"use client";
import { useMemo, useRef, useState } from "react";
import { ApiConsole, JsonCode, StatusBadge, type ConsoleResult } from "@/components/lab/api/ApiConsole";
import { DbTable } from "@/components/lab/api/DbTable";
import { RequestLog, appendLog, statusTone, type LogLine } from "@/components/lab/api/RequestLog";
import { STATUS_TEXT, isRecord, type ApiRequest, type ApiResponse, type HttpMethod } from "@/components/lab/api/http";
import { usePersisted } from "@/components/lab/api/usePersisted";
import { ENDPOINTS, addDays, beginCreate, finishCreate, handle, seedDb, serialize, totalsOf, validateInvoice, type Db, type DiscountInput, type LineInput, type Status } from "./api";
import { InvoicePreview } from "./InvoicePreview";
import { CURRENCIES, formatMoney, naiveFloatTotal, toDecimalString, type Currency } from "./money";
const KEY = "lab:invoice-pdf-api:v1";
type Line = LineInput & { uid: number };
type Form = { customerId: number; currency: Currency; issueDate: string; dueInDays: number; lines: Line[]; discount: DiscountInput; notes: string };
type State = { db: Db; form: Form; idemKey: string; nextUid: number };
const seedForm = (): Form => ({
customerId: 1,
currency: "USD",
issueDate: "2026-11-10",
dueInDays: 14,
lines: [
{ uid: 1, description: "Website maintenance (Nov)", quantity: "1", unitPrice: "320.00", taxRate: "8.25", discountPct: "0" },
{ uid: 2, description: "Extra hours", quantity: "3.5", unitPrice: "45.50", taxRate: "8.25", discountPct: "10" },
{ uid: 3, description: "SMS credits", quantity: "3", unitPrice: "0.10", taxRate: "8.25", discountPct: "0" },
],
discount: { type: "percent", value: "5" },
notes: "Payment by bank transfer within 14 days.",
});
const seed = (): State => ({ db: seedDb(), form: seedForm(), idemKey: "idem-demo-0001", nextUid: 4 });
const toBody = (f: Form) => ({
customerId: f.customerId,
currency: f.currency,
issueDate: f.issueDate,
dueInDays: f.dueInDays,
lines: f.lines.map((l) => ({ description: l.description, quantity: l.quantity, unitPrice: l.unitPrice, taxRate: l.taxRate, discountPct: l.discountPct })),
discount: f.discount,
notes: f.notes,
});
const STATUS_CLS: Record<Status, string> = { draft: "text-fg-muted", sent: "text-accent-2", paid: "text-accent", void: "text-danger" };
export default function Demo() {
const [state, setState, resetState, loaded] = usePersisted<State>(KEY, seed);
const stateRef = useRef(state);
stateRef.current = state;
const [view, setView] = useState<"builder" | "console">("builder");
const [log, setLog] = useState<LogLine[]>([]);
const [last, setLast] = useState<ApiResponse | null>(null);
const [busy, setBusy] = useState(false);
const [previewId, setPreviewId] = useState<number | null>(null);
const [headers, setHeaders] = useState(`{\n "Content-Type": "application/json",\n "Idempotency-Key": "idem-console-0001"\n}`);
const [touched, setTouched] = useState(false);
const { form, db } = state;
const body = useMemo(() => toBody(form), [form]);
const check = useMemo(() => validateInvoice(db, body, false), [db, body]);
const errs: Record<string, string[]> = check.ok ? {} : check.errors;
const exp = CURRENCIES[form.currency].exp;
const setForm = (patch: Partial<Form>) => setState((s) => ({ ...s, form: { ...s.form, ...patch } }));
const setLine = (uid: number, patch: Partial<LineInput>) => setForm({ lines: form.lines.map((l) => (l.uid === uid ? { ...l, ...patch } : l)) });
const moveLine = (i: number, d: -1 | 1) => {
const j = i + d;
if (j < 0 || j >= form.lines.length) return;
const next = [...form.lines];
[next[i], next[j]] = [next[j]!, next[i]!];
setForm({ lines: next });
};
function logCall(req: ApiRequest, res: ApiResponse, note = "") {
const msg = isRecord(res.body) && typeof res.body.message === "string" ? ` · ${res.body.message}` : "";
const replay = res.headers?.["Idempotent-Replayed"] ? " · Idempotent-Replayed: true" : "";
const key = req.headers["Idempotency-Key"] ? ` [Idempotency-Key: ${req.headers["Idempotency-Key"]}]` : "";
setLog((l) =>
appendLog(l, [
{ text: `→ ${req.method} ${req.path}${key}${note}`, tone: "info" },
...(res.sql ?? []).map((q) => ({ text: ` ${q}`, tone: "sql" as const })),
{ text: `← ${res.status} ${STATUS_TEXT[res.status] ?? ""}${replay}${msg}`, tone: statusTone(res.status) },
]),
);
}
/** Every write goes through the simulated API, exactly like the console. */
function send(req: ApiRequest): ApiResponse {
const cur = stateRef.current;
const { db: next, res } = handle(cur.db, req, Date.now());
const s = { ...cur, db: next };
stateRef.current = s;
setState(s);
return res;
}
function call(method: HttpMethod, path: string) {
const req: ApiRequest = { method, path, headers: {} };
const res = send(req);
logCall(req, res);
setLast(res);
}
function create() {
setTouched(true);
const req: ApiRequest = { method: "POST", path: "/invoices", headers: { "Idempotency-Key": state.idemKey }, body };
setBusy(true);
window.setTimeout(() => {
const res = send(req);
logCall(req, res);
setLast(res);
if (res.status === 201 && isRecord(res.body) && typeof res.body.id === "number") setPreviewId(res.body.id);
setBusy(false);
}, 250);
}
/** Two identical POSTs 50 ms apart, as from a double-click: the second finds the key still "processing". */
function doubleSubmit() {
setTouched(true);
const req: ApiRequest = { method: "POST", path: "/invoices", headers: { "Idempotency-Key": state.idemKey }, body };
const first = beginCreate(stateRef.current.db, req, Date.now());
if ("res" in first) {
logCall(req, first.res, " (click 1)");
setLast(first.res);
return;
}
stateRef.current = { ...stateRef.current, db: first.db };
setState(stateRef.current);
setLog((l) => appendLog(l, [{ text: `→ POST /invoices [Idempotency-Key: ${state.idemKey}] (click 1) … processing`, tone: "info" }]));
setBusy(true);
window.setTimeout(() => {
const second = beginCreate(stateRef.current.db, req, Date.now());
if ("res" in second) logCall(req, second.res, " (click 2, 50 ms later)");
}, 50);
window.setTimeout(() => {
const done = finishCreate(stateRef.current.db, first.pending, Date.now(), first.trace, first.sql);
stateRef.current = { ...stateRef.current, db: done.db };
setState(stateRef.current);
logCall(req, done.res, " (click 1 finishes)");
setLast(done.res);
if (done.res.status === 201 && isRecord(done.res.body) && typeof done.res.body.id === "number") setPreviewId(done.res.body.id);
setBusy(false);
}, 700);
}
function newKey() {
const k = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `idem-${Date.now().toString(36)}`;
setState((s) => ({ ...s, idemKey: k }));
}
function reset() {
resetState();
setLog([]);
setLast(null);
setPreviewId(null);
setTouched(false);
}
const totals = useMemo(() => totalsOf({ lines: form.lines, discount: form.discount, currency: form.currency }), [form]);
const naive = useMemo(
() =>
naiveFloatTotal(
form.lines.map((l) => ({ qty: parseFloat(l.quantity) || 0, price: parseFloat(l.unitPrice) || 0, tax: parseFloat(l.taxRate) || 0, disc: parseFloat(l.discountPct) || 0 })),
form.discount.type === "percent" ? parseFloat(form.discount.value) || 0 : 0,
),
[form],
);
const stored = previewId !== null ? db.invoices.find((i) => i.id === previewId) : undefined;
const preview = stored ?? {
number: null,
status: "draft" as const,
currency: form.currency,
issueDate: form.issueDate,
dueDate: /^\d{4}-\d{2}-\d{2}$/.test(form.issueDate) ? addDays(form.issueDate, form.dueInDays) : form.issueDate,
lines: form.lines,
discount: form.discount,
notes: form.notes,
};
const field = "w-full rounded-md border border-border bg-bg-elev px-2 py-1.5 text-sm outline-none focus:border-accent";
const btn = "rounded-md border border-border px-3 py-1.5 font-mono text-xs hover:border-accent hover:text-accent disabled:opacity-50";
const Err = ({ k }: { k: string }) =>
touched && errs[k] ? (
<p className="mt-0.5 text-xs text-danger" id={`err-${k}`}>
{errs[k]!.join("; ")}
</p>
) : null;
const inv = (k: string) => (touched && errs[k] ? { "aria-invalid": true, "aria-describedby": `err-${k}` } : {});
return (
<div>
<div role="tablist" aria-label="Demo view" className="flex gap-1 border-b border-border bg-bg-elev px-4 pt-3">
{(["builder", "console"] as const).map((v) => (
<button
key={v}
role="tab"
type="button"
aria-selected={view === v}
onClick={() => setView(v)}
className={`rounded-t-md border border-b-0 px-3 py-1.5 font-mono text-xs ${view === v ? "border-border bg-bg text-fg" : "border-transparent text-fg-muted hover:text-fg"}`}
>
{v === "builder" ? "invoice builder" : "API console"}
</button>
))}
</div>
{view === "console" && (
<ApiConsole
title="Invoice API console"
endpoints={ENDPOINTS}
send={send}
headers={headers}
onHeadersChange={setHeaders}
onResult={({ req, res }: ConsoleResult) => {
logCall(req, res);
setLast(res);
}}
/>
)}
{view === "builder" && (
<div className="grid lg:grid-cols-[1.1fr_1fr]">
<form
className="min-w-0 space-y-4 p-4"
noValidate
onSubmit={(e) => {
e.preventDefault();
create();
}}
>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="text-xs font-medium">Customer</span>
<select
value={form.customerId}
onChange={(e) => {
const c = db.customers.find((x) => x.id === Number(e.target.value));
setForm({ customerId: Number(e.target.value), ...(c ? { currency: c.currency } : {}) });
}}
className={`${field} mt-1`}
>
{db.customers.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
<label className="block">
<span className="text-xs font-medium">Currency</span>
<select value={form.currency} onChange={(e) => setForm({ currency: e.target.value as Currency })} className={`${field} mt-1`}>
{Object.keys(CURRENCIES).map((c) => (
<option key={c}>{c}</option>
))}
</select>
</label>
<label className="block">
<span className="text-xs font-medium">Issue date</span>
<input type="date" value={form.issueDate} onChange={(e) => setForm({ issueDate: e.target.value })} className={`${field} mt-1`} {...inv("issueDate")} />
<Err k="issueDate" />
</label>
<label className="block">
<span className="text-xs font-medium">Due in (days)</span>
<input type="number" min={0} max={120} value={form.dueInDays} onChange={(e) => setForm({ dueInDays: Number(e.target.value) })} className={`${field} mt-1`} {...inv("dueInDays")} />
<Err k="dueInDays" />
</label>
</div>
<fieldset className="space-y-2">
<legend className="text-xs font-medium">Line items</legend>
<Err k="lines" />
{form.lines.map((l, i) => {
const p = `lines[${i}]`;
return (
<div key={l.uid} className="rounded-md border border-border p-2">
<div className="grid grid-cols-[1fr_auto] gap-2">
<label className="block">
<span className="sr-only">Line {i + 1} description</span>
<input value={l.description} onChange={(e) => setLine(l.uid, { description: e.target.value })} placeholder="Description" className={field} {...inv(`${p}.description`)} />
</label>
<div className="flex items-center gap-1">
<button type="button" onClick={() => moveLine(i, -1)} disabled={i === 0} aria-label={`Move line ${i + 1} up`} className="rounded border border-border px-1.5 py-1 text-xs hover:border-accent disabled:opacity-30">
↑
</button>
<button type="button" onClick={() => moveLine(i, 1)} disabled={i === form.lines.length - 1} aria-label={`Move line ${i + 1} down`} className="rounded border border-border px-1.5 py-1 text-xs hover:border-accent disabled:opacity-30">
↓
</button>
<button type="button" onClick={() => setForm({ lines: form.lines.filter((x) => x.uid !== l.uid) })} aria-label={`Remove line ${i + 1}`} className="rounded border border-border px-1.5 py-1 text-xs hover:border-danger hover:text-danger">
×
</button>
</div>
</div>
<div className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-4">
{(
[
["quantity", "Qty"],
["unitPrice", `Unit (${form.currency})`],
["discountPct", "Disc. %"],
["taxRate", "Tax %"],
] as const
).map(([k, label]) => (
<label key={k} className="block">
<span className="text-[11px] text-fg-muted">{label}</span>
<input inputMode="decimal" value={l[k]} onChange={(e) => setLine(l.uid, { [k]: e.target.value })} className={`${field} font-mono`} {...inv(`${p}.${k}`)} />
</label>
))}
</div>
{(["description", "quantity", "unitPrice", "discountPct", "taxRate"] as const).map((k) => (
<Err key={k} k={`${p}.${k}`} />
))}
{totals?.lines[i] && (
<p className="mt-1 text-right font-mono text-[11px] text-fg-muted">
net {toDecimalString(totals.lines[i]!.net, exp)} · tax {toDecimalString(totals.lines[i]!.tax, exp)}
</p>
)}
</div>
);
})}
<button
type="button"
onClick={() => setState((s) => ({ ...s, nextUid: s.nextUid + 1, form: { ...s.form, lines: [...s.form.lines, { uid: s.nextUid, description: "", quantity: "1", unitPrice: "", taxRate: s.form.lines.at(-1)?.taxRate ?? "0", discountPct: "0" }] } }))}
className={btn}
>
+ add line
</button>
</fieldset>
<div className="grid gap-3 sm:grid-cols-[10rem_1fr]">
<label className="block">
<span className="text-xs font-medium">Invoice discount</span>
<select value={form.discount.type} onChange={(e) => setForm({ discount: { ...form.discount, type: e.target.value as DiscountInput["type"] } })} className={`${field} mt-1`}>
<option value="none">none</option>
<option value="percent">percent</option>
<option value="fixed">fixed amount</option>
</select>
</label>
{form.discount.type !== "none" && (
<label className="block">
<span className="text-xs font-medium">{form.discount.type === "percent" ? "Percent" : `Amount (${form.currency})`}</span>
<input inputMode="decimal" value={form.discount.value} onChange={(e) => setForm({ discount: { ...form.discount, value: e.target.value } })} className={`${field} mt-1 font-mono`} {...inv("discount.value")} />
<Err k="discount.value" />
</label>
)}
</div>
<label className="block">
<span className="text-xs font-medium">Notes</span>
<textarea value={form.notes} onChange={(e) => setForm({ notes: e.target.value })} rows={2} className={`${field} mt-1`} />
</label>
<div className="space-y-2 rounded-md border border-border bg-bg-elev p-3">
<label className="block">
<span className="font-mono text-xs text-fg-muted">Idempotency-Key header</span>
<div className="mt-1 flex gap-2">
<input value={state.idemKey} onChange={(e) => setState((s) => ({ ...s, idemKey: e.target.value }))} className={`${field} font-mono`} spellCheck={false} />
<button type="button" onClick={newKey} className={btn}>
new key
</button>
</div>
</label>
<div className="flex flex-wrap gap-2">
<button type="submit" disabled={busy} className="rounded-md bg-accent px-4 py-1.5 font-mono text-sm font-semibold text-accent-fg hover:opacity-90 disabled:opacity-60">
{busy ? "sending…" : "POST /invoices"}
</button>
<button type="button" onClick={doubleSubmit} disabled={busy} className={btn}>
simulate double-click
</button>
</div>
<p className="text-xs text-fg-muted">Send twice with the same key and the second answer is a replay, not a new invoice. Change a line and reuse the key to get 422.</p>
{touched && !check.ok && (
<p role="alert" className="text-xs text-danger">
The client-side check mirrors the server DTO: fix {Object.keys(errs).length} field(s) or send anyway to see the 400.
</p>
)}
</div>
</form>
<div className="min-w-0 space-y-4 border-t border-border bg-bg-elev p-4 lg:border-t-0 lg:border-l">
{loaded && totals ? (
<section aria-label="Totals" aria-live="polite" className="rounded-md border border-border bg-bg p-3 font-mono text-xs">
<div className="grid grid-cols-[1fr_auto] gap-y-0.5">
<span className="text-fg-muted">subtotal</span>
<span className="text-right">{formatMoney(totals.subtotal, form.currency)}</span>
<span className="text-fg-muted">discount (allocated per line)</span>
<span className="text-right">−{formatMoney(totals.discount, form.currency)}</span>
<span className="text-fg-muted">tax (rounded per line)</span>
<span className="text-right">{formatMoney(totals.tax, form.currency)}</span>
<span className="font-semibold">total</span>
<span className="text-right font-semibold text-accent">{formatMoney(totals.total, form.currency)}</span>
</div>
<p className="mt-2 border-t border-border pt-2 text-fg-muted">
integer minor units: <span className="text-fg">{totals.total.toString()}</span> · same maths with JS floats: <span className="text-warn-fg">{String(naive)}</span>
</p>
</section>
) : (
<p className="text-xs text-fg-muted">Loading totals…</p>
)}
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="font-mono text-xs text-fg-muted">
preview: {stored ? <span className="text-fg">invoice #{stored.id}</span> : "live draft from the form"}
{stored && (
<button type="button" onClick={() => setPreviewId(null)} className="ml-2 underline hover:text-accent">
back to form
</button>
)}
</p>
<button type="button" onClick={() => window.print()} className="rounded-md bg-accent px-3 py-1.5 font-mono text-xs font-semibold text-accent-fg hover:opacity-90">
Download PDF
</button>
</div>
{loaded ? <InvoicePreview inv={preview} customer={db.customers.find((c) => c.id === (stored?.customerId ?? form.customerId))} seller={db.seller} /> : <div className="h-96 animate-pulse rounded-md bg-bg-muted" />}
</div>
</div>
)}
<div className="grid gap-6 border-t border-border p-4 lg:grid-cols-[1.2fr_1fr]">
<section aria-label="Invoices table" className="min-w-0 space-y-3">
<DbTable
name="invoices"
rows={[...db.invoices].sort((a, b) => b.id - a.id)}
rowKey={(r) => r.id}
highlight={(r) => r.id === previewId}
columns={[
{ key: "id", label: "id" },
{ key: "number", label: "number", render: (r) => r.number ?? <span className="text-fg-muted">—</span> },
{ key: "status", label: "status", render: (r) => <span className={STATUS_CLS[r.status]}>{r.status}</span> },
{ key: "customer", label: "customer", render: (r) => db.customers.find((c) => c.id === r.customerId)?.name ?? r.customerId },
{ key: "total", label: "total", render: (r) => (loaded ? `${serialize(db, r).totals.total} ${r.currency}` : "") },
{
key: "actions",
label: "actions",
render: (r) => (
<span className="flex gap-1">
{(
[
["view", null],
["send", "send"],
["pay", "pay"],
["void", "void"],
] as const
).map(([label, action]) => (
<button
key={label}
type="button"
onClick={() => {
if (action) call("POST", `/invoices/${r.id}/${action}`);
else {
setPreviewId(r.id);
setView("builder");
}
}}
aria-label={`${label} invoice ${r.id}`}
className="rounded border border-border px-1.5 font-mono text-[11px] hover:border-accent hover:text-accent"
>
{label}
</button>
))}
</span>
),
},
]}
caption="Send assigns the next INV-YYYY-NNNN number. Paid or void invoices answer 409 to further changes."
/>
<DbTable
name="idempotency_keys"
rows={Object.values(db.idempotency)}
rowKey={(r) => r.key}
empty="No keys yet. POST /invoices stores one per request."
columns={[
{ key: "key", label: "key" },
{ key: "fingerprint", label: "fingerprint" },
{ key: "state", label: "state" },
{ key: "response", label: "status", render: (r) => r.response?.status ?? "—" },
]}
/>
<button type="button" onClick={reset} className={btn}>
reset demo
</button>
</section>
<div className="min-w-0 space-y-3">
{last && (
<section aria-label="Last response" className="rounded-md border border-border bg-[#0b0b0d] p-3">
<div className="mb-2 flex flex-wrap items-center gap-2 font-mono text-[11px] text-[#a1a1aa]">
<StatusBadge status={last.status} />
{last.headers?.["Idempotent-Replayed"] && <span className="text-[#fbbf24]">Idempotent-Replayed: true</span>}
</div>
<JsonCode value={last.body} className="max-h-60" />
</section>
)}
<RequestLog lines={log} onClear={() => setLog([])} className="h-64" />
</div>
</div>
</div>
);
}