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.
-`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).
-`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).
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.