Invoicing & expenses API for small businesses
A multi-tenant REST API covering organizations, roles, customers, invoices with a status machine, idempotent payments, expenses and reports. It is production-style: typed end to end, validated with zod, documented with an OpenAPI 3.1 spec generated from the code, and covered by 33 integration and unit tests.
What's inside
Multi-tenant by design
Every query is scoped to the organization in the JWT. Other tenants' records return 404, never 403, so their existence isn't leaked.
Auth that holds up
bcrypt hashing; 15-minute access JWTs; refresh tokens that rotate, are stored hashed, and revoke their whole family when reuse is detected; timing-safe login.
Correct money
Integer cents, per-line tax with half-up rounding, a server-side total, and a status machine (draft → sent → partially_paid → paid / void). Overpayments are rejected.
Safe retries
POST /payments requires an Idempotency-Key. Retries replay the stored response; reusing a key with a different body returns 422. Concurrent retries are serialized.
Operable
Request IDs, pino structured logs with secrets redacted, /health and /ready probes, rate limits, graceful shutdown, Docker and Compose files, CI.
Documented from code
The same zod schemas validate requests, serialize responses and generate this OpenAPI 3.1 document, so the docs can't drift from the code.
Try it MOCK
Pick a call and press Send. Nothing hits a server: the response was recorded from the real API during npm run docs. That run registers an organization, invites a member, creates customers and invoices, records idempotent payments and expenses, and triggers some errors on purpose.
Endpoints
| Method | Path | Summary | Tag |
|---|---|---|---|
| GET | /health | Liveness probe | Health |
| GET | /ready | Readiness probe | Health |
| POST | /v1/auth/register | Create an account and its organization | Auth |
| POST | /v1/auth/login | Exchange credentials for tokens | Auth |
| POST | /v1/auth/refresh | Rotate a refresh token | Auth |
| POST | /v1/auth/logout | Revoke a refresh token (and its family) | Auth |
| GET | /v1/auth/me | Current user, organization and role | Auth |
| GET | /v1/members/ | List organization members | Members |
| POST | /v1/members/ | Add a member (owner only) | Members |
| PATCH | /v1/members/{userId} | Change a member's role (owner only) | Members |
| DELETE | /v1/members/{userId} | Remove a member (owner only) | Members |
| GET | /v1/customers/ | List customers | Customers |
| POST | /v1/customers/ | Create a customer | Customers |
| GET | /v1/customers/{id} | Get a customer | Customers |
| PATCH | /v1/customers/{id} | Update a customer | Customers |
| DELETE | /v1/customers/{id} | Delete a customer (owner only) | Customers |
| GET | /v1/invoices/ | List invoices | Invoices |
| POST | /v1/invoices/ | Create a draft invoice | Invoices |
| GET | /v1/invoices/{id} | Get an invoice with lines and payments | Invoices |
| PATCH | /v1/invoices/{id} | Update a draft invoice | Invoices |
| DELETE | /v1/invoices/{id} | Delete a draft invoice (owner only) | Invoices |
| POST | /v1/invoices/{id}/send | Send (finalize) a draft invoice | Invoices |
| POST | /v1/invoices/{id}/void | Void an invoice | Invoices |
| POST | /v1/payments/ | Record a payment (idempotent) | Payments |
| GET | /v1/payments/ | List payments | Payments |
| GET | /v1/expenses/ | List expenses | Expenses |
| POST | /v1/expenses/ | Record an expense | Expenses |
| DELETE | /v1/expenses/{id} | Delete an expense | Expenses |
| GET | /v1/reports/summary | Financial summary for a period | Reports |
Tests & coverage
33/33 tests passing. Integration tests call the real HTTP layer through app.inject, and every test file gets a fresh, migrated SQLite database.
tests/auth.test.ts 11 tests
- ✓ auth › registers an owner with a new organization and returns tokens
- ✓ auth › rejects duplicate emails with 409 problem+json
- ✓ auth › returns field-level validation errors for weak passwords and bad emails
- ✓ auth › uses the same 401 for unknown email and wrong password (no user enumeration)
- ✓ auth › rotates refresh tokens and revokes the whole family when an old token is replayed
- ✓ auth › logout revokes the refresh token
- ✓ auth › rejects missing, malformed and tampered access tokens
- ✓ platform › exposes liveness and readiness probes
- ✓ platform › propagates a valid X-Request-Id and generates one otherwise
- ✓ platform › answers unknown routes and malformed JSON with problem+json
- ✓ platform › rate-limits the auth endpoints
tests/invoices-payments.test.ts 10 tests
- ✓ invoices › computes per-line tax with half-up rounding and server-side totals
- ✓ invoices › validates the body (lines required, dates ordered, positive quantities)
- ✓ invoices › enforces the status machine: edit only drafts, send once, void only unpaid
- ✓ invoices › deletes drafts only and never reuses invoice numbers
- ✓ invoices › paginates, filters and sorts the invoice list
- ✓ payments › requires an Idempotency-Key header
- ✓ payments › replays the original response for a retried request and never double-charges
- ✓ payments › rejects reusing a key with a different body (422)
- ✓ payments › moves invoices to partially_paid then paid, and blocks overpayment and draft payments
- ✓ payments › summarizes invoiced, collected, expenses and aging in the report
tests/rbac-tenancy.test.ts 6 tests
- ✓ RBAC › members can work with customers but only owners can delete them
- ✓ RBAC › only owners manage members, and the last owner cannot be demoted or removed
- ✓ RBAC › members may delete only their own expenses
- ✓ tenant isolation › another organization's resources are invisible (404, never 403)
- ✓ tenant isolation › cannot reference another tenant's customer or pay another tenant's invoice
- ✓ tenant isolation › invoice numbers are sequential per organization
tests/unit-openapi.test.ts 6 tests
- ✓ domain logic (unit) › rounds half-up robustly and totals invoices line by line
- ✓ domain logic (unit) › encodes the invoice state machine
- ✓ domain logic (unit) › buckets receivables by days past due
- ✓ domain logic (unit) › fingerprints request bodies independent of key order
- ✓ domain logic (unit) › refuses to boot in production with the development JWT secret
- ✓ OpenAPI › generates an OpenAPI 3.1 document from the route schemas
API reference
The full OpenAPI 3.1 reference (schemas, parameters, responses) opens on its own page, rendered with Redoc bundled locally (no CDN). Keeping it separate keeps this page light and responsive.