Tenant Recurring Subscription Billing
Lets a merchant bill its own customers on a recurring cadence (the
Spotify/Starlink pattern), reusing the existing subscription table and the
accounts-receivable (AR) module rather than a parallel billing schema.
Full spec: specs/046-tenant-recurring-subscriptions/.
The kind discriminator — read this first
subscription already had a live writer before this feature existed:
apps/backend/src/addons/infrastructure/addons-local-gateway.service.ts
creates vendor→merchant addon-billing rows on the same table
(businessId = the seller's business). This feature's rows are
merchant→customer. subscription.kind (addon | customer_recurring)
distinguishes them, and every query in this feature filters on it. Never
remove that filter — it's the only thing preventing the recurring-charge job
from billing an addon subscription's customer.
Two invoicing paths
commercial_invoice_timing (on_payment default, immediate override),
resolved as subscription.commercialInvoiceTiming ?? customer.commercialInvoiceTiming,
decides how the factura (FEL) is issued:
sequenceDiagram
participant Job as Daily job
participant Run as subscription_invoice_run
participant AR as accounts_receivable_invoice
participant Sale as sale
participant Rcpt as accounts_receivable_receipt
rect rgb(240, 240, 255)
Note over Job,Rcpt: on_payment (default)
Job->>Run: insert pending (period guard)
Job->>AR: create charge directly (entity_type='subscription')
Note over AR: this AR row IS the payment slip
Rcpt->>AR: receipt posted -> balance_due draws down (existing listener)
AR->>Sale: once PAID, sweep issues factura, links sale_id back
end
rect rgb(255, 245, 235)
Note over Job,Rcpt: immediate (exception)
Job->>Run: insert pending (period guard)
Job->>Sale: create submitted sale on credit
Sale->>Sale: existing sale->FEL listener certifies factura
Sale->>AR: existing sale->AR listener creates the charge
Rcpt->>AR: receipt posted -> balance_due draws down (existing listener)
end
The invariant carried over from the legacy system: exactly one of
{AR-direct, sale-first} ever creates the AR entry for a given period — never
both. The immediate path writes almost no new code — it's the same "sale
on credit" flow the POS already runs; the existing OnCreateSaleEvent/
OnUpdateSaleEvent listeners in fel/ and accounts-receivable-invoices/
do the rest.
Ledger map
| Concern | Table | Notes |
|---|---|---|
| Recurring plan | subscription | kind='customer_recurring', next_billing_date, billing_cycle, interval_count, billing_anchor_day |
| Period idempotency | subscription_invoice_run | UNIQUE(subscription_id, period); ON DELETE RESTRICT on subscription_id — a subscription can't be deleted out from under its billing history |
| Charge (on_payment path) | accounts_receivable_invoice | entity_type='subscription', entity_id=subscription.id, billing_period set |
| Charge (immediate path) | accounts_receivable_invoice | entity_type='sale' (created by the existing sale->AR listener, unmodified) |
| Factura | sale + FEL | On the immediate path, created directly. On on_payment, created later by the deferred-factura sweep and linked via accounts_receivable_invoice.sale_id |
| Payment | accounts_receivable_receipt | Created by RecordPaymentService, a thin wrapper — allocation via detail.items[].accountsReceivableInvoiceId |
| Balance drawdown | accounts_receivable_invoice.balance_due | Existing @OnEvent listener in accounts-receivable-invoices.service.ts — no new code path |
Idempotency
Two independent guards:
subscription_invoice_runUNIQUE(subscription_id, period) — insertedpendingbefore either path runs.GenerateDueChargesService.processOnePeriodinterprets a conflict by looking up the existing row's status:completed→ skip charging, still advancenext_billing_date(recovers a crash between "charge created" and "next_billing_date persisted").pending/failed→ stop this subscription for the rest of this run, don't advance (afailedrun is not auto-retried — investigate manually).
- A partial unique index on
accounts_receivable_invoice (entity_id, billing_period) WHERE entity_type='subscription'— independent backstop for theon_paymentpath, mirroringplatform-billing'suq_ar_invoice_platform_period.
Job ops
One BullMQ repeatable job (SUBSCRIPTION_BILLING_QUEUE, daily at 03:00 server
time), registered the same way as activity-log's archive job. Three passes,
in order, per run:
GenerateDueChargesService.run()— creates this period's charge for every due subscription.IssueFacturaOnPaymentService.run()— sweepsPAIDon_paymentcharges and issues their factura.SendPaymentNoticeService.runCatchUpSweep()— sweeps completed runs with nonotice_sent_atand queues the notice (email only — see below).
Manual trigger for ops/tests: POST /subscriptions/jobs/generate-due (runs
pass 1 only — currently reachable by any authenticated user; a dedicated
ops/role restriction is a known follow-up, see spec.md open questions).
Notices
- Automatic sweep: email only. SMS/WhatsApp are swept by
platform-billing'sUsageMeterand billed back to the merchant as metered platform usage — an automatic default must not silently opt every subscriber into a billable channel. - Manual "Send notice" (PWA charges card): merchant picks email or SMS explicitly.
- Sends are asynchronous —
CommunicationsService.send()queues a row, drained by the existing 30-second poller. Every response here says "queued", not "sent". - WhatsApp templates are seeded but inert: out-of-session sends need a
Meta-approved
providerTemplateId, which isn't provisioned yet.
What's deferred
- Card payment link — nothing to build on in this codebase yet: no
payment_linktable, no public pay page, no general-purpose Stripe service (today'sStripeInvoiceAdapteris portal-only and hosted-invoice-only), no webhook to record a payment back automatically. Phase 1 covers deposit/bank-transfer, recorded manually viaRecordPaymentService. - Dunning/reminders driven by
reminder_days_before. - Partial-payment facturas (phase 1 issues on full payment only).
- MRR/ARR Metabase cards.
Module layout
apps/backend/src/subscriptions/
├── subscriptions.module.ts # BullMQ queue + OnModuleInit job registration
├── subscriptions.constants.ts # queue/job names, cron
├── domain/
│ ├── billing-schedule.util.ts # pure date math — no Nest/Kysely
│ ├── subscriptions-repository.domain.ts # + findDueForBilling
│ └── subscription-invoice-run-repository.domain.ts
├── application/
│ ├── subscriptions.service.ts # CRUD + getSubscriptionByIdForSystemUse
│ ├── generate-due-charges.service.ts # both paths
│ ├── issue-factura-on-payment.service.ts # deferred-factura sweep
│ ├── send-payment-notice.service.ts
│ ├── record-payment.service.ts
│ └── events/on-subscription-charged.event.ts
├── infrastructure/
│ ├── subscriptions.repository.ts
│ ├── subscription-invoice-run.repository.ts
│ └── subscription-billing.processor.ts # the BullMQ @Process handler
└── interfaces/
├── subscriptions.controller.ts
└── dtos/
PWA: apps/frontend-pwa/src/pages/subscriptions/ — list + detail pages under
/billing/subscriptions, composition mirrors the platform-billing detail page
(header → summary cards → status card → plan lines → charges card), reusing
the existing tenant InvoiceStatusBadge rather than platform-billing's
module-private status constants.