Skip to main content

Agent subscriptions & billing

Phase 1 shipped (2026-07-17)

The four criticals — gaps 1 (paired TaxPayable(taxCode='VAT-18') row created atomically with each invoice; UraRemittanceService picks up VAT items via the existing envelope), 2 (new TaxInvoiceCounter per (year, month, taxCode); invoice reference now INV-<YYYYMM>-<sequence> per Tax Procedures Act §39), 3 (new POST /agent-subscriptions/:id/cancel-pending cooling-off endpoint honouring SUBSCRIPTIONS_COOLING_OFF_HOURS — default 168 h / 7 days — cancels open invoices and flags paid ones for finance refund), and 4 (InvoicesService.recordPayment calls SanctionsService.screenForPayout('agent', invoice.agentId) before creating the Receipt; blocked → 403 + audit event) — landed in release subscriptions-p1-20260717143500.

Schema migration 20260717150000_subscriptions_phase1 adds the TaxInvoiceCounter table + TaxPayable.invoiceId FK/index.

New billing-phase1.spec locks all 4 contracts (7 tests: sequential numbering across two issues, VAT TaxPayable created + skipped on taxExempt, sanctions blocks recordPayment, cooling-off cancels inside window / rejects outside / flags refund on paid invoice). 50/50 suites, 363/363 tests green.

Phase 2 shipped (2026-07-17)

The operational-hygiene block — gaps 5, 6, 7, 8, 11 — landed in release subscriptions-p2-20260717150900:

  • gap 5: VAT rate now read from DeductionType(code='VAT-18', isActive=true). Falls back to INVOICE_VAT_RATE_PCT env if the row is unseeded. Rate + deductionTypeId stamped on invoice.metadata so historical invoices lock the rate they were issued under.
  • gap 6: writeOff() + cancel() emit a negative-amount TaxPayable(taxCode='VAT-18', dueBy=<same period>) in the same $transaction as the status change. UraRemittanceService sums positive + negative → net zero for the invoice; over- remittance risk eliminated. Idempotent — re-cancelling doesn't create a second reversal.
  • gap 7: POST /billing/invoices and POST /billing/invoices/:id/payments accept Idempotency-Key; issueIdempotent + recordPaymentIdempotent memoise for 24 h (scopes invoice.issue, invoice.record_payment). Body mismatch → IDEMPOTENCY_CONFLICT.
  • gap 8: two-layer throttle on record-payment — @Throttle({ ttl:60_000, limit:60 }) per-source-IP + in-service 20/min sliding window per invoiceId. Excess → 429.
  • gap 11: runDunning() fans out through NotificationDispatchService.dispatchByTemplate per-agent with templates billing.dunning.reminder_{3d,7d,14d} + billing.dunning.final_notice. Resolved channel recorded on DunningEvent.channel; failed dispatch (no user linked, template missing) still records the event with channel='queued' so the ladder stays idempotent.

New billing-phase2.spec locks all 5 contracts (8 tests). 51/51 suites, 371/371 tests green.

Phase 3 (gaps 9, 10, 12) remains open — cipher Receipt.paymentReference, retention sweep on invoices + receipts, new CreditNote model for partial adjustments.

Phase 3 shipped (2026-07-17)

The credential + retention block — gaps 9, 10, 12 — landed in release subscriptions-p3-20260717154500, closing the module at 12 / 12 gaps.

  • gap 9: Receipt.paymentReference is now AES-256-GCM ciphered at rest via PiiCipher.encrypt; a HMAC-SHA256 hash sibling on Receipt.paymentReferenceHash supports dedup + uniqueness lookups without the decrypt path. Invoice.paidReference receives the same cipher on the paid-flip. GET /billing/invoices/:id returns the plaintext only for callers holding receipt:payment_reference:pii:read (granted to compliance + finance_officer); every other caller sees ***MASKED***. Legacy plaintext rows round-trip via a decrypt-fallback so a rolling migration doesn't have to backfill.
  • gap 10: RetentionPurgeJob gained two sweeps driven by data_sharing.invoices_retention_days (default 2555 = URA §41 7-year floor). Terminal invoices past the window get paidReference, writtenOffReason, and free-text metadata scrubbed; receipts past the window get paymentReference + paymentReferenceHash zeroed. Financial-ledger fields (amount, taxAmount, status, timestamps) survive so the URA ledger stays reconstructable. Each pass records a single aggregate invoice.retention.scrubbed / receipt.retention.scrubbed audit event with the count + retention window.
  • gap 12: new CreditNote model (credit_notes table) — reference numbering CN-YYYYMM-NNNN via the same TaxInvoiceCounter machinery as invoices so URA reconciliation distinguishes credits from originals. POST /billing/invoices/:id/credit-notes (requires invoice:credit_note:write — granted to compliance + finance_officer) creates the row + a balance-neutral negative-amount TaxPayable(taxCode='VAT-18', deductionTypeCode='INVOICE_VAT_CREDIT') in the same transaction so the monthly VAT return nets invoice VAT − credit-note VAT. Credit amount is capped at the invoice's outstanding-before-credits value and refused against cancelled / written_off invoices.

New billing-phase3.spec locks all 3 contracts (6 tests). 52/52 suites, 378/378 tests green.

All 12 / 12 gaps closed for the Subscriptions & Billing module.

Scope

Two modules that together form the agent-facing SaaS billing surface:

  1. AgentSubscriptionsSubscriptionPlan (catalogue: price, interval days, grace days) + AgentSubscription (per-agent state: pending → active → in_grace → suspended → cancelled | expired). SubscriptionExpiryJob walks lifecycle transitions nightly at 02:00 UTC.
  2. BillingInvoice + Receipt + DunningEvent. Issues invoices per subscription period, records receipts, flips to paid on cumulative-receipts ≥ total. DunningJob runs daily at 06:00 UTC — flips overdue + fires the 3 / 7 / 14 / 21 day dunning ladder.

The surface accepts UGX / USD; VAT is applied at 18 % (Uganda default) unless the invoice is issued taxExempt. Payments recorded via bank_transfer, mobile_money, card, or cash.

Compliance envelope

  • URA Income Tax Act §80 — VAT collection + monthly remittance by the 15th of the following month; VAT return (Form URA-04).
  • Tax Procedures Act 2014 §39 — every VAT invoice must carry a sequential number within the tax period; skipped numbers must be reconciled.
  • Consumer Protection Act 2011 §37 — 7-day cooling-off window on consumer financial contracts (subscription is arguably one).
  • AML Act 2013 §6 — payment received from a sanctioned agent is handling proceeds; screening obligation is symmetric between payer and payee.
  • DPPA 2019 §21 — agent financial data (Invoice amounts, Receipt references) at rest; §33 breach notification within 72 h.
  • BOU Cybersecurity Guidelines §5.4 — financial-ledger integrity; every transition audit-logged.
  • PCI-DSS SAQ-A — only relevant if we ever process card details directly (current model delegates to an aggregator).

Current state (2026-07-17)

Module footprint

src/modules/agent-subscriptions/agent-subscriptions.module.ts — 233 LOC
src/modules/subscription-plans/subscription-plans.module.ts — 136 LOC
src/modules/billing/billing.module.ts — 458 LOC
src/modules/scheduled-jobs/subscription-expiry.job.ts — grace + expire cron
src/modules/scheduled-jobs/(billing DunningJob is co-located with billing.module.ts)

Prisma models: SubscriptionPlan, AgentSubscription, Invoice, Receipt, DunningEvent, TaxPayable, TaxRemittance.

What works today

  • Subscription lifecycle: pending → active → in_grace → suspended → cancelled | expired. SubscriptionExpiryJob runs nightly, flips activein_grace at currentPeriodEnd, then in_graceexpired at currentPeriodEnd + graceDays.
  • Invoice lifecycle: draft → issued → paid | overdue → written_off | cancelled. Every transition audit-logged. Overdue flip is driven by DunningJob at 06:00 UTC.
  • Double-entry receipts: cumulative receipts drive the paid flip; partial payments handled; overpayments rejected with outstanding message.
  • VAT applied by default: 18 % Uganda rate, skippable via taxExempt: true on the DTO.
  • Dunning ladder: 3 / 7 / 14 / 21 day thresholds, email channel, DunningEvent per stage per invoice (idempotent on (invoiceId, stage)).
  • Write-off + cancel: distinct paths, both require a reason string, both audit-logged.
  • Permission scoping: InvoiceRead (cross-agent) vs InvoiceReadOwn (agent-scoped); write actions each have a dedicated permission (InvoiceIssue, InvoiceRecordPayment, InvoiceWriteOff, InvoiceCancel).

Gaps

All 12 gaps closed across Phase 1, Phase 2, and Phase 3 (all shipped 2026-07-17). See the shipped-notes above for the exact scope of each phase; every gap is now enforced by locked spec coverage in billing-phase1.spec / billing-phase2.spec / billing-phase3.spec.


Open questions

  1. VAT-inclusive vs VAT-exclusive plans. Are SubscriptionPlan prices VAT-exclusive (invoice adds 18 %) or VAT-inclusive (invoice splits the amount)? Today the code assumes exclusive. Confirm with finance which one matches published pricing.
  2. Retro-numbering existing invoices. Backfilling sequential numbers on existing invoices could break agent-visible references (agents may have quoted the old INV-<random> in support tickets). Suggest: keep the old reference as legacyReference + generate new sequential ones prefixed INV-2026-.
  3. Cooling-off refund path. For agents who paid via mobile money inside the window, refund goes back to the same MSISDN. Do we call the payout provider directly or credit their wallet? Wallet is simpler; suggest that.
  4. Aggregate-agent VAT return vs per-agent. URA VAT is per-taxpayer, not per-customer — ZFA files ONE return. TaxPayable envelope already groups by (taxCode, period) so the current shape supports this.

What this surface does not cover

  • Insurer-side commission bordereau — that's the Bordereau module.
  • Card-present payment terminals — no in-scope EMV / PIN entry; card acceptance stays behind an aggregator.
  • Multi-tenant billing — a single ZFA instance bills all its agents; multi-ZFA-tenant is out of scope.
  • Foreign-exchange invoice — USD plans are supported (currency field) but no FX-rate lock semantics for USD-invoiced agents paying in UGX.