Agent subscriptions & billing
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.
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 toINVOICE_VAT_RATE_PCTenv if the row is unseeded. Rate +deductionTypeIdstamped oninvoice.metadataso historical invoices lock the rate they were issued under. - gap 6:
writeOff()+cancel()emit a negative-amountTaxPayable(taxCode='VAT-18', dueBy=<same period>)in the same$transactionas the status change.UraRemittanceServicesums positive + negative → net zero for the invoice; over- remittance risk eliminated. Idempotent — re-cancelling doesn't create a second reversal. - gap 7:
POST /billing/invoicesandPOST /billing/invoices/:id/paymentsacceptIdempotency-Key;issueIdempotent+recordPaymentIdempotentmemoise for 24 h (scopesinvoice.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 perinvoiceId. Excess → 429. - gap 11:
runDunning()fans out throughNotificationDispatchService.dispatchByTemplateper-agent with templatesbilling.dunning.reminder_{3d,7d,14d}+billing.dunning.final_notice. Resolved channel recorded onDunningEvent.channel; failed dispatch (no user linked, template missing) still records the event withchannel='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.
The credential + retention block — gaps 9, 10, 12 — landed in release subscriptions-p3-20260717154500, closing the module at 12 / 12 gaps.
- gap 9:
Receipt.paymentReferenceis now AES-256-GCM ciphered at rest viaPiiCipher.encrypt; a HMAC-SHA256 hash sibling onReceipt.paymentReferenceHashsupports dedup + uniqueness lookups without the decrypt path.Invoice.paidReferencereceives the same cipher on the paid-flip.GET /billing/invoices/:idreturns the plaintext only for callers holdingreceipt:payment_reference:pii:read(granted tocompliance+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:
RetentionPurgeJobgained two sweeps driven bydata_sharing.invoices_retention_days(default2555= URA §41 7-year floor). Terminal invoices past the window getpaidReference,writtenOffReason, and free-text metadata scrubbed; receipts past the window getpaymentReference+paymentReferenceHashzeroed. Financial-ledger fields (amount,taxAmount,status, timestamps) survive so the URA ledger stays reconstructable. Each pass records a single aggregateinvoice.retention.scrubbed/receipt.retention.scrubbedaudit event with the count + retention window. - gap 12: new
CreditNotemodel (credit_notestable) — reference numberingCN-YYYYMM-NNNNvia the sameTaxInvoiceCountermachinery as invoices so URA reconciliation distinguishes credits from originals.POST /billing/invoices/:id/credit-notes(requiresinvoice:credit_note:write— granted tocompliance+finance_officer) creates the row + a balance-neutral negative-amountTaxPayable(taxCode='VAT-18', deductionTypeCode='INVOICE_VAT_CREDIT')in the same transaction so the monthly VAT return netsinvoice VAT − credit-note VAT. Credit amount is capped at the invoice's outstanding-before-credits value and refused againstcancelled/written_offinvoices.
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:
- AgentSubscriptions —
SubscriptionPlan(catalogue: price, interval days, grace days) +AgentSubscription(per-agent state:pending → active → in_grace → suspended → cancelled | expired).SubscriptionExpiryJobwalks lifecycle transitions nightly at 02:00 UTC. - Billing —
Invoice+Receipt+DunningEvent. Issues invoices per subscription period, records receipts, flips topaidon cumulative-receipts ≥ total.DunningJobruns 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.SubscriptionExpiryJobruns nightly, flipsactive→in_graceatcurrentPeriodEnd, thenin_grace→expiredatcurrentPeriodEnd + graceDays. - Invoice lifecycle:
draft → issued → paid | overdue → written_off | cancelled. Every transition audit-logged. Overdue flip is driven byDunningJobat 06:00 UTC. - Double-entry receipts: cumulative receipts drive the paid flip;
partial payments handled; overpayments rejected with
outstandingmessage. - VAT applied by default: 18 % Uganda rate, skippable via
taxExempt: trueon the DTO. - Dunning ladder: 3 / 7 / 14 / 21 day thresholds, email channel,
DunningEventper 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) vsInvoiceReadOwn(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
- 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.
- 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 aslegacyReference+ generate new sequential ones prefixedINV-2026-. - 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.
- 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.