feat(api): add OpenAPI spec and end‑to‑end tests for billing API
This commit is contained in:
+420
@@ -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]
|
||||
Reference in New Issue
Block a user