feat(billing): generate recurring invoices from contract items (PMS-64 AC5) #119

Merged
vas2000-work merged 3 commits from feat/pms64-recurring-invoicing into main 2026-06-05 23:10:58 +02:00
Owner

Closes PMS-64 AC5: contracts exposed recurring items + billing_cycle/amount, but billing never turned them into invoices. This adds a recurring billing run.

Design

  • New ledger migrations/031_contract_invoice_runs.sql with UNIQUE (tenant_id, contract_id, period_start). Per contract+period in one transaction: mint invoice -> insert header -> INSERT ... ON CONFLICT DO NOTHING RETURNING id; if the period was already billed (prior/concurrent run), the whole tx rolls back (invoice + sequence bump). At most one invoice per period, robust to double-runs/restarts.
  • BillingService::generate_due_recurring_invoices(tenant_id, now) finds active, non-one_time contracts whose current period is due and have recurring items, builds lines from recurring_service/retainer items, and creates the invoice via the existing create_invoice machinery (sequence lock, totals, audit) - not a parallel writer.
  • current_billing_period(cycle, start_date, today) tiles fixed periods (1/3/12 months) forward from start_date.
  • RecurringInvoicingWorker (scheduler Job, daily-ish, cross-tenant), registered in main.rs like ContractLifecycleWorker. Service method is independently callable (tests drive it with injected now).

Tests (tests/recurring_invoicing.rs, #[sqlx::test])

Monthly -> one invoice/2 lines/total; rerun same period -> no dupe; one_time + expired -> nothing; no recurring items -> nothing; advance a month -> distinct invoice, rerun idempotent.

Decisions to confirm (human review)

  1. Migration 031 (030 is the open notifications-seed PR; renumber if it collides).
  2. Invoice total = SUM of recurring contract_items (NOT the contract-level billing_amount), per the AC wording. Confirm source.
  3. invoice_date = run-day, due = +30d/net30/USD (mirrors the time-entry path). Confirm vs invoice_date = period_start.
  4. Contracts with zero recurring items write no ledger row (so a later-added item bills next run).
  5. New ledger table is outside RLS (created post-024), consistent with internal bookkeeping.

No HTTP trigger added (AC is a scheduled run + callable service method). NOT compiler-checked - needs just check + the test (with tests/ mount).

Closes PMS-64 AC5: contracts exposed recurring items + billing_cycle/amount, but billing never turned them into invoices. This adds a recurring billing run. ## Design - New ledger `migrations/031_contract_invoice_runs.sql` with `UNIQUE (tenant_id, contract_id, period_start)`. Per contract+period in one transaction: mint invoice -> insert header -> `INSERT ... ON CONFLICT DO NOTHING RETURNING id`; if the period was already billed (prior/concurrent run), the whole tx rolls back (invoice + sequence bump). At most one invoice per period, robust to double-runs/restarts. - `BillingService::generate_due_recurring_invoices(tenant_id, now)` finds active, non-one_time contracts whose current period is due and have recurring items, builds lines from `recurring_service`/`retainer` items, and creates the invoice via the existing `create_invoice` machinery (sequence lock, totals, audit) - not a parallel writer. - `current_billing_period(cycle, start_date, today)` tiles fixed periods (1/3/12 months) forward from start_date. - `RecurringInvoicingWorker` (scheduler Job, daily-ish, cross-tenant), registered in main.rs like ContractLifecycleWorker. Service method is independently callable (tests drive it with injected `now`). ## Tests (tests/recurring_invoicing.rs, #[sqlx::test]) Monthly -> one invoice/2 lines/total; rerun same period -> no dupe; one_time + expired -> nothing; no recurring items -> nothing; advance a month -> distinct invoice, rerun idempotent. ## Decisions to confirm (human review) 1. Migration 031 (030 is the open notifications-seed PR; renumber if it collides). 2. Invoice total = SUM of recurring contract_items (NOT the contract-level billing_amount), per the AC wording. Confirm source. 3. invoice_date = run-day, due = +30d/net30/USD (mirrors the time-entry path). Confirm vs invoice_date = period_start. 4. Contracts with zero recurring items write no ledger row (so a later-added item bills next run). 5. New ledger table is outside RLS (created post-024), consistent with internal bookkeeping. No HTTP trigger added (AC is a scheduled run + callable service method). NOT compiler-checked - needs `just check` + the test (with tests/ mount).
feat(billing): generate recurring invoices from contracts (PMS-64 AC5)
Some checks failed
Check / fmt + clippy + compile + tests (pull_request) Failing after 13s
E2E (staging) / Playwright against staging (pull_request) Successful in 26s
Build OCI container / Build and push mokosh-api image (push) Has been cancelled
e266dcac22
Close the gap where contracts carry recurring line items and a billing cycle but billing never turns them into invoices. Add a billing run that, for each active recurring contract due for its current billing period, creates a draft invoice from its recurring items (recurring_service / retainer), idempotently per period.

Idempotency uses a dedicated ledger table contract_invoice_runs with UNIQUE (tenant_id, contract_id, period_start) (migration 031). The per-contract transaction inserts the invoice header, then INSERT ... ON CONFLICT DO NOTHING into the ledger: on conflict the whole transaction rolls back (invoice header + sequence bump included), so a monthly contract yields exactly one invoice per month even across double-runs and process restarts. A dedicated ledger keeps billing-run bookkeeping separate from the invoice lifecycle, so manually-created, edited, or voided invoices never confuse the dedupe.

The due period is computed from billing_cycle + start_date by tiling fixed-length periods forward (monthly/quarterly/annually) to the period containing now; one_time, non-active, not-yet-started, past-end_date, and no-recurring-items contracts are skipped. now is injected so the scheduler and tests drive periods deterministically.

Invoice creation reuses the same gapless invoice_sequences row-lock, invoice + invoice_lines write path, and in-transaction audit row as create_invoice; tax/discount left at 0 to match the other invoice paths. A RecurringInvoicingWorker Job runs the cross-tenant driver on the shared scheduler at an hourly cadence (the ledger makes extra ticks no-ops), registered in main.rs alongside ContractLifecycleWorker. The per-tenant generate_due_recurring_invoices stays independently callable for tests.

Integration tests cover: a monthly contract with recurring items generates one invoice with the right lines + total; a second run for the same period creates nothing; one_time / expired / no-recurring-item contracts generate nothing; advancing now into the next month generates a distinct new invoice and re-running stays idempotent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
style: apply cargo fmt
All checks were successful
E2E (staging) / Playwright against staging (pull_request) Successful in 27s
Check / fmt + clippy + compile + tests (pull_request) Successful in 2m9s
Build OCI container / Build and push mokosh-api image (push) Successful in 3m35s
0f837beac6
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Merge branch 'main' into feat/pms64-recurring-invoicing
All checks were successful
Create release / Create release from merged PR (pull_request) Has been skipped
Check / fmt + clippy + compile + tests (pull_request) Successful in 2m23s
E2E (staging) / Playwright against staging (pull_request) Successful in 22s
Build OCI container / Build and push mokosh-api image (push) Successful in 3m37s
a58c1dbea4
vas2000-work deleted branch feat/pms64-recurring-invoicing 2026-06-05 23:10:58 +02:00
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!119
No description provided.