diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..d82b266 --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,420 @@ +openapi: 3.1.0 +info: + title: monk-code-screen billing API + version: 1.0.0 + description: | + Billing/invoicing/payments backend. Invoices are ingested from CSV; rows failing + validation (negative values, empty currency, missing/unparseable amount or dates) + are stored with status `corrupted` rather than rejected. + + Money fields (`unit_price`, `amount`) are integer minor units (cents): CSV `150` → `15000`. +servers: + - url: http://localhost:3000 +# Explicitly unauthenticated: local interview skeleton, no auth layer +security: [] + +paths: + /health: + get: + summary: Liveness check + operationId: getHealth + responses: + "200": + description: Service is up + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + const: true + required: [ok] + + /customers: + get: + summary: List customers + description: Ordered by `last_updated`. Customers are deduplicated by email (case-insensitive); rows without an email are keyed by name. + operationId: listCustomers + responses: + "200": + description: All customers + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Customer" + + /customers/{id}/invoices: + get: + summary: List a customer's invoices + description: Newest first. Unknown customer ids return an empty list. + operationId: listCustomerInvoices + parameters: + - name: id + in: path + required: true + schema: + type: string + example: cus_1a2b3c4d + responses: + "200": + description: The customer's invoices (empty array when the customer is unknown) + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Invoice" + + /ingest: + post: + summary: Ingest invoices from CSV + description: | + CSV attached as the raw request body. Header must be exactly: + `invoice_number,customer_name,customer_email,invoice_date,due_date,description,quantity,unit_price,amount,currency` + + Messy-but-parseable values are normalized (`1/12/26`, `Jan 16 2026`, `"$12,000.00"`, `"€1,850.00"`). + Rows failing validation are still inserted with status `corrupted`. + Not idempotent: re-posting the same file appends duplicate invoices (customers are deduplicated). + operationId: ingestCsv + requestBody: + required: true + content: + text/csv: + schema: + type: string + example: | + invoice_number,customer_name,customer_email,invoice_date,due_date,description,quantity,unit_price,amount,currency + INV-1001,Acme Corp,billing@acme.com,2026-01-05,2026-02-04,Consulting services,10,150,1500,USD + responses: + "200": + description: Ingest summary with per-row results + content: + application/json: + schema: + $ref: "#/components/schemas/IngestResult" + "400": + description: Empty body or unexpected CSV header + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /invoice/{id}: + parameters: + - name: id + in: path + required: true + description: Surrogate id (`inv_...`) or invoice number (`INV-1001`). Duplicate invoice numbers resolve to the newest invoice. + schema: + type: string + example: INV-1001 + get: + summary: Get an invoice with its payments + operationId: getInvoice + responses: + "200": + description: The invoice (with status) and its payments (status owned by the external PSP) + content: + application/json: + schema: + type: object + properties: + invoice: + $ref: "#/components/schemas/Invoice" + payments: + type: array + items: + $ref: "#/components/schemas/Payment" + required: [invoice, payments] + "404": + description: Invoice not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + put: + summary: Update an invoice + description: | + Partial update of non-identity fields. `id`, `invoice_number`, `customer_id`, and + `created_at` are immutable and rejected. At least one field is required. + + A `corrupted` invoice can be repaired by supplying the missing fields together with a + new `status` in the same request; moving off `corrupted` while money/date fields are + still missing fails the database CHECK and returns 400. + operationId: updateInvoice + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/InvoiceUpdate" + responses: + "200": + description: The updated invoice + content: + application/json: + schema: + $ref: "#/components/schemas/Invoice" + "400": + description: Validation error, unknown/immutable field, or integrity CHECK failure + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "404": + description: Invoice not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /payment/{invoice_id}: + post: + summary: Pay an invoice + description: | + Only `open` and `partially_paid` invoices are payable. Payments start as `pending`; + status transitions are owned by the external PSP. `idempotency_key` is generated + client-side: repeating a key replays the original payment instead of double-charging. + operationId: createPayment + parameters: + - name: invoice_id + in: path + required: true + description: Surrogate id (`inv_...`) or invoice number (`INV-1001`). + schema: + type: string + example: INV-1011 + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PaymentCreate" + responses: + "201": + description: Payment created (status `pending`) + content: + application/json: + schema: + $ref: "#/components/schemas/PaymentView" + "200": + description: Idempotent replay — the original payment for this key + content: + application/json: + schema: + $ref: "#/components/schemas/PaymentView" + "400": + description: Validation error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "404": + description: Invoice not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "409": + description: Invoice is not payable (paid, void, or corrupted) + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + +components: + schemas: + Customer: + type: object + properties: + id: + type: string + example: cus_1a2b3c4d + customer_name: + type: string + example: Acme Corp + customer_email: + type: [string, "null"] + description: Unique when present; null for rows ingested without an email. + example: billing@acme.com + last_updated: + type: string + example: "2026-08-10 14:33:55" + required: [id, customer_name, customer_email, last_updated] + + Invoice: + type: object + description: Money fields are integer minor units (cents). Nullable fields are only null on `corrupted` invoices. + properties: + id: + type: string + example: inv_f726dd40 + invoice_number: + type: string + description: Not unique — duplicate numbers in source data are ingested as-is. + example: INV-1011 + customer_id: + type: [string, "null"] + example: cus_1a2b3c4d + status: + $ref: "#/components/schemas/InvoiceStatus" + created_at: + type: string + example: "2026-01-22" + due_on: + type: [string, "null"] + example: "2026-02-21" + description: + type: [string, "null"] + example: Security audit + quantity: + type: [integer, "null"] + example: 1 + unit_price: + type: [integer, "null"] + description: Minor units (cents). + example: 1500000 + amount: + type: [integer, "null"] + description: Minor units (cents). + example: 1500000 + currency: + type: [string, "null"] + example: USD + required: + [id, invoice_number, customer_id, status, created_at, due_on, description, quantity, unit_price, amount, currency] + + InvoiceStatus: + type: string + enum: [open, partially_paid, paid, void, corrupted] + + InvoiceUpdate: + type: object + description: All fields optional, at least one required. Unknown or immutable fields are rejected. + additionalProperties: false + minProperties: 1 + properties: + status: + $ref: "#/components/schemas/InvoiceStatus" + due_on: + type: string + format: date + example: "2026-03-01" + description: + type: [string, "null"] + quantity: + type: integer + minimum: 1 + unit_price: + type: integer + minimum: 0 + description: Minor units (cents). + amount: + type: integer + minimum: 0 + description: Minor units (cents). + currency: + type: string + minLength: 1 + description: Normalized to uppercase. + example: USD + + Payment: + type: object + properties: + id: + type: string + example: pay_4696225e + idempotency_key: + type: string + format: uuid + invoice_id: + type: string + example: inv_f726dd40 + status: + type: string + description: Owned by the external PSP; starts as `pending`. + example: pending + last_updated: + type: string + example: "2026-08-10 14:34:11" + completed_at: + type: [string, "null"] + amount: + type: integer + description: Minor units (cents). + example: 1500000 + currency: + type: string + example: USD + required: [id, idempotency_key, invoice_id, status, last_updated, completed_at, amount, currency] + + PaymentCreate: + type: object + properties: + idempotency_key: + type: string + format: uuid + description: Generated client-side to prevent duplicate charges. + amount: + type: integer + minimum: 1 + description: Minor units (cents). + currency: + type: string + minLength: 1 + required: [idempotency_key, amount, currency] + + PaymentView: + type: object + properties: + payment: + $ref: "#/components/schemas/Payment" + required: [payment] + + IngestResult: + type: object + properties: + status: + type: string + const: ok + ingested: + type: integer + description: Total rows inserted, including corrupted ones. + errors: + type: integer + description: Count of rows stored with status `corrupted`. + rows: + type: array + items: + type: object + properties: + line: + type: integer + description: 1-based CSV line number (header is line 1). + id: + type: string + invoice_number: + type: string + status: + type: string + enum: [open, corrupted] + errors: + type: array + items: + type: string + required: [line, id, invoice_number, status, errors] + required: [status, ingested, errors, rows] + + Error: + type: object + properties: + error: + description: Message string, or a zod error tree for validation failures. + oneOf: + - type: string + - type: object + required: [error] diff --git a/scripts/test.ts b/scripts/test.ts new file mode 100644 index 0000000..450f918 --- /dev/null +++ b/scripts/test.ts @@ -0,0 +1,331 @@ +// End-to-end API tests. Boots the real server against a throwaway DB (seeded +// from invoices.csv on first boot) and exercises every endpoint, including all +// the edge cases deliberately planted in the CSV. +// +// bun run test + +import { rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { z } from "zod"; + +const ROOT = join(import.meta.dir, ".."); +const PORT = 3900 + Math.floor(Math.random() * 1000); +const BASE = `http://localhost:${PORT}`; +const DB_PATH = join(tmpdir(), `monk-test-${Date.now()}.db`); + +const BOLD = "\x1b[1m"; +const DIM = "\x1b[2m"; +const GREEN = "\x1b[32m"; +const RED = "\x1b[31m"; +const CYAN = "\x1b[36m"; +const RESET = "\x1b[0m"; + +// --- Response schemas: network JSON is untrusted, parse it once ------------- + +const Customer = z.object({ + id: z.string(), + customer_name: z.string(), + customer_email: z.string().nullable(), + last_updated: z.string(), +}); + +const Invoice = z.object({ + id: z.string(), + invoice_number: z.string(), + customer_id: z.string().nullable(), + status: z.string(), + created_at: z.string(), + due_on: z.string().nullable(), + description: z.string().nullable(), + quantity: z.number().nullable(), + unit_price: z.number().nullable(), + amount: z.number().nullable(), + currency: z.string().nullable(), +}); + +const Payment = z.object({ + id: z.string(), + idempotency_key: z.string(), + invoice_id: z.string(), + status: z.string(), + last_updated: z.string(), + completed_at: z.string().nullable(), + amount: z.number(), + currency: z.string(), +}); + +const InvoiceView = z.object({ invoice: Invoice, payments: z.array(Payment) }); +const PaymentView = z.object({ payment: Payment }); +const IngestOk = z.object({ + status: z.literal("ok"), + ingested: z.number(), + errors: z.number(), + rows: z.array( + z.object({ + line: z.number(), + id: z.string(), + invoice_number: z.string(), + status: z.enum(["open", "corrupted"]), + errors: z.array(z.string()), + }), + ), +}); + +// --- Harness ----------------------------------------------------------------- + +let passed = 0; +let failed = 0; + +const section = (title: string) => console.log(`\n${BOLD}${CYAN}${title}${RESET}`); + +const check = (name: string, ok: boolean, detail?: string) => { + if (ok) { + passed++; + console.log(` ${GREEN}✓${RESET} ${name}`); + } else { + failed++; + console.log(` ${RED}✗ ${name}${RESET}${detail ? `\n ${DIM}${detail}${RESET}` : ""}`); + } +}; + +const eq = (name: string, actual: unknown, expected: unknown) => + check(name, actual === expected, `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + +type ApiResponse = { status: number; body: unknown }; + +const api = async (method: string, path: string, body?: unknown, raw?: string): Promise => { + const res = await fetch(`${BASE}${path}`, { + method, + headers: body !== undefined ? { "content-type": "application/json" } : undefined, + body: body !== undefined ? JSON.stringify(body) : raw, + }); + return { status: res.status, body: await res.json().catch(() => null) }; +}; + +const getInvoice = async (key: string) => InvoiceView.parse((await api("GET", `/invoice/${key}`)).body).invoice; + +const payment = (invoice: string, overrides?: Record) => + api("POST", `/payment/${invoice}`, { + idempotency_key: crypto.randomUUID(), + amount: 1000, + currency: "USD", + ...overrides, + }); + +// --- Boot server against a throwaway DB ------------------------------------- + +const server = Bun.spawn(["bun", "src/index.ts"], { + cwd: ROOT, + env: { ...process.env, PORT: String(PORT), DB_PATH }, + stdout: "ignore", + stderr: "inherit", +}); + +let up = false; +for (let i = 0; i < 50 && !up; i++) { + up = await fetch(`${BASE}/health`).then((r) => r.ok).catch(() => false); + if (!up) await Bun.sleep(100); +} +if (!up) { + console.error(`${RED}server failed to boot on :${PORT}${RESET}`); + server.kill(); + process.exit(1); +} + +try { + console.log(`${DIM}server :${PORT} db ${DB_PATH}${RESET}`); + + // --- GET /health ----------------------------------------------------------- + section("GET /health"); + const health = await api("GET", "/health"); + eq("responds 200", health.status, 200); + eq("body is { ok: true }", z.object({ ok: z.boolean() }).parse(health.body).ok, true); + + // --- GET /customers: identity edge cases ----------------------------------- + section("GET /customers — customer identity edge cases"); + const customers = z.array(Customer).parse((await api("GET", "/customers")).body); + eq("25 CSV rows dedupe to 21 customers", customers.length, 21); + + const acmes = customers.filter((c) => c.customer_email === "billing@acme.com"); + eq("Acme Corp / ACME CORPORATION / acme corp → one customer (email dedup)", acmes.length, 1); + eq("first-seen name wins for deduped customer", acmes[0]?.customer_name, "Acme Corp"); + + const noEmail = customers.filter((c) => c.customer_email === null).map((c) => c.customer_name); + eq("rows with empty email (Initech, Wonka) still get customers", noEmail.length, 2); + check( + "email-less customers keyed by name", + noEmail.includes("Initech") && noEmail.includes("Wonka Industries"), + `got ${JSON.stringify(noEmail)}`, + ); + + // --- GET /customers/:id/invoices -------------------------------------------- + section("GET /customers/:id/invoices"); + const acmeInvoices = z.array(Invoice).parse((await api("GET", `/customers/${acmes[0]!.id}/invoices`)).body); + eq("acme has 4 invoices across 3 name spellings", acmeInvoices.length, 4); + eq( + "exact duplicate row (INV-1001 ×2, line 9) is ingested as-is", + acmeInvoices.filter((i) => i.invoice_number === "INV-1001").length, + 2, + ); + eq( + "unknown customer returns empty list", + z.array(Invoice).parse((await api("GET", "/customers/cus_nope/invoices")).body).length, + 0, + ); + + // --- GET /invoice/:id: normalization edge cases ------------------------------ + section("GET /invoice/:id — messy-but-parseable rows normalize"); + + const i1004 = await getInvoice("INV-1004"); + eq("INV-1004: M/D/YY date '1/12/26' → 2026-01-12", i1004.created_at, "2026-01-12"); + eq("INV-1004: quoted '$12,000.00' → 1200000 cents", i1004.amount, 1200000); + eq("INV-1004: status open (messy ≠ corrupted)", i1004.status, "open"); + + eq("INV-1017: M/D/YYYY date '2/1/2026' → 2026-02-01", (await getInvoice("INV-1017")).created_at, "2026-02-01"); + + const i1007 = await getInvoice("INV-1007"); + eq("INV-1007: text date 'Jan 16 2026' → 2026-01-16", i1007.created_at, "2026-01-16"); + eq("INV-1007: text due date 'Feb 15 2026' → 2026-02-15", i1007.due_on, "2026-02-15"); + + const i1005 = await getInvoice("INV-1005"); + eq("INV-1005: fractional unit_price 0.02 → 2 cents", i1005.unit_price, 2); + eq("INV-1005: quantity 50000 preserved", i1005.quantity, 50000); + + eq("INV-1009: decimal amount 847.32 → 84732 cents", (await getInvoice("INV-1009")).amount, 84732); + eq("INV-1012: decimal unit_price 49.99 → 4999 cents", (await getInvoice("INV-1012")).unit_price, 4999); + eq("INV-1014: bare quoted '9,999.99' → 999999 cents", (await getInvoice("INV-1014")).amount, 999999); + + const i1018 = await getInvoice("INV-1018"); + eq("INV-1018: '€1,850.00' → 185000 cents", i1018.amount, 185000); + eq("INV-1018: non-USD currency preserved (EUR)", i1018.currency, "EUR"); + + eq("INV-1022: far-future dates are valid (open)", (await getInvoice("INV-1022")).status, "open"); + eq("INV-1003: missing email doesn't corrupt the invoice", (await getInvoice("INV-1003")).status, "open"); + + // --- Corrupted rows ----------------------------------------------------------- + section("Corrupted rows — stored, status 'corrupted'"); + const i1010 = await getInvoice("INV-1010"); + eq("INV-1010: negative values → corrupted", i1010.status, "corrupted"); + eq("INV-1010: negative quantity stored as-is (-2)", i1010.quantity, -2); + eq("INV-1010: negative amount stored as-is (-100000 cents)", i1010.amount, -100000); + + const i1020 = await getInvoice("INV-1020"); + eq("INV-1020: missing amount → corrupted", i1020.status, "corrupted"); + eq("INV-1020: amount stored as null", i1020.amount, null); + eq("INV-1020: parseable fields kept (unit_price 480000)", i1020.unit_price, 480000); + + // duplicate number INV-1006: line 7 corrupted (empty currency), line 18 open revision + const i1006 = await getInvoice("INV-1006"); + eq("INV-1006 by number resolves to newest (revised, open)", i1006.status, "open"); + eq("INV-1006 newest is the 5200.00 revision", i1006.amount, 520000); + + // --- GET /invoice/:id resolution ---------------------------------------------- + section("GET /invoice/:id — resolution"); + const i1002 = await getInvoice("INV-1002"); + eq("surrogate inv_ id resolves to the same invoice", (await getInvoice(i1002.id)).invoice_number, "INV-1002"); + eq("unknown invoice → 404", (await api("GET", "/invoice/INV-9999")).status, 404); + + // --- POST /payment/:invoice_id --------------------------------------------------- + section("POST /payment/:invoice_id"); + const key = crypto.randomUUID(); + const pay1 = await payment("INV-1011", { idempotency_key: key, amount: 1500000 }); + eq("open invoice → 201", pay1.status, 201); + const created = PaymentView.parse(pay1.body).payment; + eq("payment starts pending (PSP owns transitions)", created.status, "pending"); + + const replay = await payment("INV-1011", { idempotency_key: key, amount: 1500000 }); + eq("same idempotency_key replays → 200", replay.status, 200); + eq("replay returns the original payment, no double-charge", PaymentView.parse(replay.body).payment.id, created.id); + + const shown = InvoiceView.parse((await api("GET", "/invoice/INV-1011")).body); + eq("payment visible on GET /invoice", shown.payments[0]?.id, created.id); + + eq("corrupted invoice (INV-1010) not payable → 409", (await payment("INV-1010")).status, 409); + eq("corrupted invoice (INV-1020) not payable → 409", (await payment("INV-1020")).status, 409); + eq("unknown invoice → 404", (await payment("INV-9999")).status, 404); + eq("non-uuid idempotency_key → 400", (await payment("INV-1011", { idempotency_key: "nope" })).status, 400); + eq("negative amount → 400", (await payment("INV-1011", { amount: -5 })).status, 400); + eq("malformed JSON body → 400", (await api("POST", "/payment/INV-1011", undefined, "not json")).status, 400); + + // --- PUT /invoice/:id ------------------------------------------------------------- + section("PUT /invoice/:id"); + const put = (key: string, body: unknown) => api("PUT", `/invoice/${key}`, body); + + const updated = await put("INV-1002", { description: "Platform subscription Q1 (amended)", quantity: 2 }); + eq("partial update → 200", updated.status, 200); + const u1002 = Invoice.parse(updated.body); + eq("returns the updated invoice (description)", u1002.description, "Platform subscription Q1 (amended)"); + eq("returns the updated invoice (quantity)", u1002.quantity, 2); + eq("untouched fields unchanged (amount)", u1002.amount, 240000); + + eq("currency normalized to uppercase", Invoice.parse((await put("INV-1002", { currency: "eur" })).body).currency, "EUR"); + eq("description can be cleared with null", Invoice.parse((await put("INV-1002", { description: null })).body).description, null); + + eq("empty body {} → 400", (await put("INV-1002", {})).status, 400); + eq("immutable field (invoice_number) → 400", (await put("INV-1002", { invoice_number: "INV-X" })).status, 400); + eq("immutable field (customer_id) → 400", (await put("INV-1002", { customer_id: "cus_x" })).status, 400); + eq("negative amount → 400", (await put("INV-1002", { amount: -1 })).status, 400); + eq("malformed due_on → 400", (await put("INV-1002", { due_on: "Jan 16 2026" })).status, 400); + eq("unknown invoice → 404", (await put("INV-9999", { quantity: 1 })).status, 404); + + // repair flow for the corrupted INV-1020 (missing amount) + eq("corrupted → open without supplying missing amount → 400 (CHECK)", (await put("INV-1020", { status: "open" })).status, 400); + const repaired = await put("INV-1020", { status: "open", amount: 480000 }); + eq("corrupted → open with missing amount supplied → 200", repaired.status, 200); + eq("repaired invoice is open", Invoice.parse(repaired.body).status, "open"); + eq("repaired invoice is now payable → 201", (await payment("INV-1020")).status, 201); + + // --- POST /ingest (runs last: appends rows) --------------------------------------- + section("POST /ingest"); + eq("empty body → 400", (await api("POST", "/ingest", undefined, " ")).status, 400); + eq("wrong header → 400", (await api("POST", "/ingest", undefined, "foo,bar\n1,2")).status, 400); + + const csv = await Bun.file(join(ROOT, "invoices.csv")).text(); + const ingest = IngestOk.parse((await api("POST", "/ingest", undefined, csv)).body); + eq("re-ingesting invoices.csv → status ok", ingest.status, "ok"); + eq("ingested count = 25 rows", ingest.ingested, 25); + eq("error count = 3 corrupted rows", ingest.errors, 3); + + const corrupted = ingest.rows.filter((r) => r.status === "corrupted"); + eq("corrupted rows are exactly CSV lines 7, 12, 23", corrupted.map((r) => r.line).join(","), "7,12,23"); + check( + "INV-1006 (line 7) flagged for empty currency code", + corrupted[0]?.errors.includes("empty currency code") ?? false, + JSON.stringify(corrupted[0]?.errors), + ); + check( + "INV-1010 (line 12) flagged for negative quantity and amount", + (corrupted[1]?.errors.includes("negative quantity") && corrupted[1]?.errors.includes("negative amount")) ?? false, + JSON.stringify(corrupted[1]?.errors), + ); + check( + "INV-1020 (line 23) flagged for missing amount", + corrupted[2]?.errors.includes("missing or invalid amount") ?? false, + JSON.stringify(corrupted[2]?.errors), + ); + + // the corrupted line-7 copy is addressable by its returned surrogate id + const corrupt1006 = await getInvoice(corrupted[0]!.id); + eq("line-7 INV-1006 copy: quoted '4,500.50 USD' amount still parsed", corrupt1006.amount, 450050); + eq("line-7 INV-1006 copy: empty currency stored as null", corrupt1006.currency, null); + + eq( + "re-ingest creates no duplicate customers (still 21)", + z.array(Customer).parse((await api("GET", "/customers")).body).length, + 21, + ); +} catch (err) { + failed++; + console.error(`\n${RED}aborted: ${err instanceof Error ? err.message : String(err)}${RESET}`); +} finally { + server.kill(); + rmSync(DB_PATH, { force: true }); + rmSync(`${DB_PATH}-wal`, { force: true }); + rmSync(`${DB_PATH}-shm`, { force: true }); +} + +console.log( + `\n${BOLD}${failed === 0 ? GREEN : RED}${passed} passed, ${failed} failed${RESET} ${DIM}(${passed + failed} checks)${RESET}`, +); +process.exit(failed === 0 ? 0 : 1);