diff --git a/README.md b/README.md index 0319894..adbe296 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Other commands: ```sh 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 ``` @@ -28,12 +29,102 @@ 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 + +```mermaid +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 + +```mermaid +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 + +```mermaid +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 ```sh curl localhost:3000/health curl localhost:3000/customers curl localhost:3000/customers//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 \ @@ -43,9 +134,10 @@ curl -X POST localhost:3000/payment/INV-1011 \ - `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 `150` → `15000`. +Money is stored as integer minor units (cents): CSV `150` → `15000`. Full API contract: `openapi.yaml` (OpenAPI 3.1). ## Seed data