monk-code-screen

Billing/invoicing/payments backend skeleton. Bun + Hono + bun:sqlite (zero network deps at runtime; TS runs natively, no build step).

START COMMAND (sticky)

bun run dev          # http://localhost:3000, hot reload

Other commands:

bun run start        # no watch
bun run db:reset     # wipe data.db and reseed
bun run test         # e2e API tests (boots a throwaway server + DB, pretty output)
bunx tsc --noEmit    # typecheck

Layout

src/index.ts    Hono app + routes
src/ingest.ts   CSV parser/normalizer + transactional insert (shared by seed and POST /ingest)
src/db.ts       opens data.db, applies schema, seeds from invoices.csv if empty (runs on import)
src/schema.sql  customers / invoices / payments (idempotent)
invoices.csv    seed data — deliberately messy; DO NOT MODIFY

DB is data.db at repo root (WAL, FK on). Boot always re-applies schema, so adding a table = edit schema.sql + restart.

System design (from the tldraw canvas)

Requirements: POST submit invoices · GET return invoices with their status · POST pay open invoices.

Data model

erDiagram
    CUSTOMER ||--o{ INVOICE : has
    INVOICE ||--o{ PAYMENT : "paid by"

    CUSTOMER {
        text id PK
        text customer_name
        text customer_email UK
        text last_updated "Index"
    }
    INVOICE {
        text id PK
        text customer_id FK "Index"
        text status "Status enum"
        text created_at "Index"
        text due_on "Index"
        int quantity
        int unit_price "cents"
        int amount "cents"
        text currency
    }
    PAYMENT {
        text id PK
        text idempotency_key UK "Index"
        text invoice_id FK "Index"
        text status "Index; owned by external PSP"
        text last_updated "Index"
        text completed_at "Index"
        bigint amount "cents"
        text currency
    }

Invoice status

stateDiagram-v2
    state "Partially Paid" as PartiallyPaid
    [*] --> Open : ingest (valid row)
    [*] --> Corrupted : ingest (invalid row)
    Open --> Paid
    Open --> PartiallyPaid
    Open --> Void
    Corrupted --> Open : PUT repair (supply missing fields)

Canvas states are Open → Paid, Partially Paid, Void; Corrupted and its repair edge are our implementation extension for rows that fail ingest validation.

API surface

flowchart LR
    client([Client])
    psp[External PSP]

    subgraph api[REST API]
        ingest["POST /ingest"]
        getInv["GET /invoice/:id"]
        putInv["PUT /invoice/:id"]
        pay["POST /payment/:invoice_id"]
    end

    subgraph db[data.db]
        customers[(customers)]
        invoices[(invoices)]
        payments[(payments)]
    end

    client -->|CSV as request body| ingest
    ingest -->|"normalize rows; negative values / empty currency → status corrupted"| invoices
    ingest -->|dedupe by email| customers
    ingest -->|"status + ingested count + error count"| client
    client --> getInv
    getInv -->|"invoice with status + payment statuses"| client
    client -->|"non-unique fields"| putInv
    putInv -->|"updated invoice JSON"| client
    client -->|"idempotency_key (client-side UUID), amount, currency"| pay
    pay -->|"pending payment (only open / partially paid invoices)"| payments
    psp -.->|owns payment status| payments

Routes

curl localhost:3000/health
curl localhost:3000/customers
curl localhost:3000/customers/<cus_id>/invoices
curl -X PUT localhost:3000/invoice/INV-1011 \
  -H 'content-type: application/json' -d '{"description":"amended","quantity":2}'
curl localhost:3000/invoice/INV-1011                          # by invoice_number or surrogate inv_ id
curl -X POST localhost:3000/ingest --data-binary @invoices.csv
curl -X POST localhost:3000/payment/INV-1011 \
  -H 'content-type: application/json' \
  -d "{\"idempotency_key\":\"$(uuidgen)\",\"amount\":1500000,\"currency\":\"USD\"}"
  • POST /ingest: CSV as raw request body. Rows failing validation (negative values, empty currency, missing/unparseable amount or dates) are still stored, with status corrupted. Returns { status, ingested, errors, rows }. No idempotency — re-posting the same file appends duplicates.
  • GET /invoice/:id: invoice (with status) + its payments (with PSP-owned status). Resolves surrogate id or invoice_number (newest wins on duplicate numbers).
  • PUT /invoice/:id: partial update of non-identity fields (status, due_on, description, quantity, unit_price, amount, currency); immutable/unknown fields → 400. Returns the updated invoice. Repairing a corrupted invoice = supply the missing fields + new status in one PUT (schema CHECK enforces completeness).
  • POST /payment/:invoice_id: zod-validated {idempotency_key: uuid, amount, currency}; only open/partially_paid invoices are payable (409 otherwise — includes corrupted), repeated key replays the original payment (200), else inserts a pending payment (201).

Money is stored as integer minor units (cents): CSV 15015000. Full API contract: openapi.yaml (OpenAPI 3.1).

Seed data

Seeded by ingesting invoices.csv (25 rows → 22 open + 3 corrupted, 21 customers):

  • INV-1006 (line 7): empty currency code
  • INV-1010: negative quantity and amount
  • INV-1020: missing amount

Duplicate invoice numbers (INV-1001 ×2, INV-1006 reused) are ingested as-is — not a validation rule on the canvas. Customers dedupe by email case-insensitively (Acme Corp/ACME CORPORATION/acme corp → one customer); rows without email get a customer keyed by name.

Extension cheatsheet (interview)

  • New route: add to src/index.ts, db.query(...).all()/get(), db.run(sql, [params])
  • Multi-write: db.transaction(() => { ... })()
  • Validation: z.object({...}).safeParse(await c.req.json())
  • New table: append CREATE TABLE IF NOT EXISTS to schema.sql, restart (or db:reset if changing existing tables)
S
Description
No description provided
Readme
867 KiB
Languages
TypeScript 100%