Skip to main content

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

ConcernTableNotes
Recurring plansubscriptionkind='customer_recurring', next_billing_date, billing_cycle, interval_count, billing_anchor_day
Period idempotencysubscription_invoice_runUNIQUE(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_invoiceentity_type='subscription', entity_id=subscription.id, billing_period set
Charge (immediate path)accounts_receivable_invoiceentity_type='sale' (created by the existing sale->AR listener, unmodified)
Facturasale + FELOn the immediate path, created directly. On on_payment, created later by the deferred-factura sweep and linked via accounts_receivable_invoice.sale_id
Paymentaccounts_receivable_receiptCreated by RecordPaymentService, a thin wrapper — allocation via detail.items[].accountsReceivableInvoiceId
Balance drawdownaccounts_receivable_invoice.balance_dueExisting @OnEvent listener in accounts-receivable-invoices.service.tsno new code path

Idempotency

Two independent guards:

  1. subscription_invoice_run UNIQUE(subscription_id, period) — inserted pending before either path runs. GenerateDueChargesService.processOnePeriod interprets a conflict by looking up the existing row's status:
    • completed → skip charging, still advance next_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 (a failed run is not auto-retried — investigate manually).
  2. A partial unique index on accounts_receivable_invoice (entity_id, billing_period) WHERE entity_type='subscription' — independent backstop for the on_payment path, mirroring platform-billing's uq_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:

  1. GenerateDueChargesService.run() — creates this period's charge for every due subscription.
  2. IssueFacturaOnPaymentService.run() — sweeps PAID on_payment charges and issues their factura.
  3. SendPaymentNoticeService.runCatchUpSweep() — sweeps completed runs with no notice_sent_at and 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's UsageMeter and 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 asynchronousCommunicationsService.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_link table, no public pay page, no general-purpose Stripe service (today's StripeInvoiceAdapter is portal-only and hosted-invoice-only), no webhook to record a payment back automatically. Phase 1 covers deposit/bank-transfer, recorded manually via RecordPaymentService.
  • 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.