feat(billing): finish PMS-33 story (invoices, payments, gateways, tax rates) #25

Closed
David wants to merge 0 commits from feat/billing-story-pms-33 into main
Owner

Implements YouTrack story PMS-33 (billing). 8 commits, one per sub-task. Targets main directly - no dependency on the auth / tickets / portal PRs.

Sub-tasks

  • PMS-34 scaffold: src/modules/billing/{mod.rs, models.rs, routes.rs, service.rs}, wired into create_api_router via merge (replaces the /invoices and /payments stubs).
  • PMS-35 (F7) GET /api/v1/invoices paginated + filterable (company / status / contract / q).
  • PMS-36 GET /api/v1/invoices/:id returns the invoice + every invoice_lines row, two round-trips.
  • PMS-37 POST /api/v1/invoices atomically increments invoice_sequences (per tenant), writes header + lines in one transaction, computes subtotal / total / balance_due.
  • PMS-38 PUT /api/v1/invoices/:id rejects edits on frozen statuses (sent / paid / partially_paid / void / written_off), recomputes totals on tax/discount/line changes, stamps sent_at on first transition into sent.
  • PMS-39 payments: GET / POST / DELETE /api/v1/payments. Post bumps invoices.amount_paid / balance_due / status and stamps paid_at in the same transaction; delete reverses.
  • PMS-40 payment gateways CRUD with AES-256-GCM encryption at rest using the host ENCRYPTION_KEY. create_api_router grows an encryption_key parameter; main.rs parses it from AppConfig::encryption_key.
  • PMS-41 tax rates CRUD + GET /api/v1/tax-rates/lookup?name=... (treats tax_rates.name as jurisdiction key; falls back to tenant default).

Behaviour-visible changes

  • /api/v1/invoices*, /api/v1/payments*, /api/v1/payment-gateways*, /api/v1/tax-rates* move from 501 to real reads/writes.
  • All endpoints gated on RequireFinance (super_admin / admin / finance roles).
  • create_api_router signature grows an encryption_key: [u8; 32] parameter - any downstream caller of the function needs to pass it.

Test plan

  • cargo check --bin mokosh-server clean (verified locally).
  • Smoke: POST /api/v1/invoices returns an invoice with a fresh INV-000001-style number; GET /api/v1/invoices lists it; GET /api/v1/invoices/:id returns it with lines.
  • Smoke: POST /api/v1/payments against a draft invoice flips its status appropriately.
  • Smoke: PUT /api/v1/payment-gateways {"provider":"stripe", "config": {...}} round-trips through GET /api/v1/payment-gateways with the same config back (decrypted).
  • Smoke: GET /api/v1/tax-rates/lookup?name=US-CA after seeding a tax_rates row returns the right rate.

Closes #PMS-33

Implements YouTrack story PMS-33 (billing). 8 commits, one per sub-task. Targets `main` directly - no dependency on the auth / tickets / portal PRs. ## Sub-tasks - PMS-34 scaffold: `src/modules/billing/{mod.rs, models.rs, routes.rs, service.rs}`, wired into `create_api_router` via `merge` (replaces the `/invoices` and `/payments` stubs). - PMS-35 (F7) `GET /api/v1/invoices` paginated + filterable (company / status / contract / q). - PMS-36 `GET /api/v1/invoices/:id` returns the invoice + every `invoice_lines` row, two round-trips. - PMS-37 `POST /api/v1/invoices` atomically increments `invoice_sequences` (per tenant), writes header + lines in one transaction, computes `subtotal` / `total` / `balance_due`. - PMS-38 `PUT /api/v1/invoices/:id` rejects edits on frozen statuses (sent / paid / partially_paid / void / written_off), recomputes totals on tax/discount/line changes, stamps `sent_at` on first transition into `sent`. - PMS-39 payments: GET / POST / DELETE `/api/v1/payments`. Post bumps `invoices.amount_paid` / `balance_due` / `status` and stamps `paid_at` in the same transaction; delete reverses. - PMS-40 payment gateways CRUD with AES-256-GCM encryption at rest using the host `ENCRYPTION_KEY`. `create_api_router` grows an `encryption_key` parameter; `main.rs` parses it from `AppConfig::encryption_key`. - PMS-41 tax rates CRUD + `GET /api/v1/tax-rates/lookup?name=...` (treats `tax_rates.name` as jurisdiction key; falls back to tenant default). ## Behaviour-visible changes - `/api/v1/invoices*`, `/api/v1/payments*`, `/api/v1/payment-gateways*`, `/api/v1/tax-rates*` move from 501 to real reads/writes. - All endpoints gated on `RequireFinance` (super_admin / admin / finance roles). - `create_api_router` signature grows an `encryption_key: [u8; 32]` parameter - any downstream caller of the function needs to pass it. ## Test plan - [ ] `cargo check --bin mokosh-server` clean (verified locally). - [ ] Smoke: `POST /api/v1/invoices` returns an invoice with a fresh `INV-000001`-style number; `GET /api/v1/invoices` lists it; `GET /api/v1/invoices/:id` returns it with lines. - [ ] Smoke: `POST /api/v1/payments` against a draft invoice flips its status appropriately. - [ ] Smoke: `PUT /api/v1/payment-gateways {"provider":"stripe", "config": {...}}` round-trips through `GET /api/v1/payment-gateways` with the same config back (decrypted). - [ ] Smoke: `GET /api/v1/tax-rates/lookup?name=US-CA` after seeding a `tax_rates` row returns the right rate. Closes #PMS-33
Creates `mokosh-server::modules::billing` with `models.rs`, `service.rs`, and `routes.rs`. The module exposes the wire-shaped DTOs for invoices, payments, payment-gateway configs, and tax rates - all four tables already exist in `001_initial_schema.sql`, so the data layer just needs an HTTP face.

`billing_routes` is wired into `create_api_router` via `merge` (not `nest`), replacing the prior `nest("/invoices", stub_routes())` and `nest("/payments", stub_routes())` placeholders. The router is empty in this commit; subsequent commits in the PMS-33 story add the endpoint families:
- PMS-35..38 invoices CRUD
- PMS-39 payments
- PMS-40 payment gateway configs (encrypted at rest)
- PMS-41 tax rates

#PMS-34 State Done
Read-only first pass. `InvoiceFilter` supports `company_id`, `status`, `contract_id`, and free-text `q` (matches against `invoice_number` and `po_number`). Pagination piggy-backs on the shared `utils::pagination::PaginationParams` and `PaginatedResponse::from_params` so the wire envelope matches the rest of the API.

Gated on `RequireFinance` (super_admin / admin / finance) since invoice listings are financial data; non-finance roles get a 403. `lines` is `None` on list rollups - the per-row GET that lands in PMS-36 returns the full line items.

#PMS-35 State Done
Reads the invoice row + every `invoice_lines` row joined under it in two round-trips. `InvoiceResponse.lines` switches from `None` (list view) to `Some(Vec<InvoiceLineResponse>)` on this endpoint so callers can branch on presence. Returns 404 when the id is outside the tenant.

The computed totals (subtotal, tax, total, balance_due) are read straight from the columns - PMS-37 and PMS-38 (create / update) own keeping them in sync.

#PMS-36 State Done
Wraps the whole create in a transaction: row-locked UPDATE on `invoice_sequences` (per tenant) hands out the next dense human-readable number (`INV-000001` etc), the `invoices` row goes in with computed `subtotal = sum(line.total)` / `total = subtotal + tax - discount` / `balance_due = total`, then every `invoice_lines` row writes under it. Commits or rolls back together so the sequence and the row are never out of sync.

First invoice per tenant seeds the `invoice_sequences` row on the fly (handles tenants created before the billing module existed). Gated on `RequireFinance`.

#PMS-37 State Done
`InvoiceStatus::is_frozen` returns true for sent / paid / partially_paid / void / written_off. Update rejects with a `Conflict` when the current invoice is in any of those states; the customer can already quote the totals back at you, so correction belongs in a credit note (out of scope).

Otherwise: the whole edit runs in one transaction. If `lines` is `Some`, the existing rows are deleted and the new set is inserted; `subtotal` is recomputed from `sum(line.total)`. Header fields use `COALESCE` so omitted keys keep their existing values. `balance_due` is re-derived from `total - amount_paid` so a tax/discount tweak immediately reflects what the customer owes. `sent_at` stamps on the first transition into `sent`.

#PMS-38 State Done
`GET /api/v1/payments` (paginated + filterable by invoice / company), `POST /api/v1/payments` (records the payment and, when `invoice_id` is set, rebumps the linked invoice's `amount_paid` / `balance_due` and transitions status to `paid` / `partially_paid` / `sent` in the same transaction), and `DELETE /api/v1/payments/:id` (reverses the bump and recomputes status). `paid_at` stamps on the first transition into `paid` and clears when delete undoes it.

Hard delete is the right call for unposted payments; once a payment lands in accounting the correct workflow is a credit note (out of scope here). Gated on `RequireFinance`.

#PMS-39 State Done
`GET /api/v1/payment-gateways` lists per-tenant configs with the secret config blob decrypted, so a finance admin can confirm what's wired without a separate "reveal" round-trip. `PUT /api/v1/payment-gateways` upserts (the schema's `UNIQUE(tenant_id, provider)` makes `ON CONFLICT DO UPDATE` the natural choice). `DELETE /api/v1/payment-gateways/:provider` removes a config.

Secrets are encrypted with AES-256-GCM (existing `utils::crypto::encrypt` / `decrypt`) using the host-wide `ENCRYPTION_KEY`. `create_api_router` grows an `encryption_key: [u8; 32]` parameter that `main.rs` parses out of `AppConfig::encryption_key` at boot; misconfigured keys hard-fail at startup rather than silently storing cleartext.

#PMS-40 State Done
feat(billing): tax rates CRUD + jurisdiction lookup
All checks were successful
Create release / Create release from merged PR (pull_request) Has been skipped
f31778bef7
Standard tenant-scoped CRUD plus `GET /api/v1/tax-rates/lookup?name=...` for invoice-line generation. The schema doesn't have a dedicated jurisdiction column, so `tax_rates.name` is treated as the jurisdiction key (e.g. `"US-CA"`, `"EU-DE"`); lookup returns the active rate by exact name match, or the tenant's `is_default = TRUE` rate as a fallback, or 404 if neither exists.

Setting `is_default = true` on create or update demotes any prior default in the same transaction so only one rate is the tenant default at a time. Gated on `RequireFinance`.

#PMS-41 State Done
vas2000-work closed this pull request 2026-05-21 02:42:55 +02:00
Some checks are pending
Create release / Create release from merged PR (pull_request) Has been skipped
Check / * (pull_request)
Required
E2E / * (pull_request)
Required

Pull request closed

Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/mokosh-server!25
No description provided.