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]