421 lines
13 KiB
YAML
421 lines
13 KiB
YAML
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]
|