AUTH4D-44 Renewals and dunning

There is no HTTP route. A scheduled job renews paid subscriptions by pay-by-link: each billing period gets one renewal invoice, one pending payment and one Stitch hosted payment link, and the link is emailed to the project’s billing contacts. Unpaid periods move to past_due. When the grace period ends, the subscription is downgraded to the free plan.

  • Job: runRenewals in packages/billing/src/renewals.ts. It is provider-agnostic and uses two ports: PaymentLinkProvider and RenewalNotifier.
  • Worker wiring: apps/api/src/modules/billing/renewals.ts. stitchPaymentLinks adapts CreatePaymentRequest, and outboxRenewalNotifier enqueues billing emails (AUTH4D-43). createScheduledTaskDispatcher in apps/api/src/runtime/services.ts runs it on the billing cron 5-59/10 * * * *, after payment reconciliation. Any other cron expression runs every task. A failure in one task does not skip the others.
  • Storage: infra/cloudflare/d1/migrations/0007_billing_renewals.sql adds billing_renewals and billing_notices.

Timeline

For a monthly subscription whose period ends at E, with the defaults:

Time Effect
E - 7d (leadDays) Renewal invoice for [E, E + 1 month] and a pending payment at the locked price.
same run, or the next run Stitch payment request with expireAt = E + 7d; billing-renewal-due email.
payment completes (webhook) Existing state machine: invoice paid, period extended to E + 1 month.
E, still unpaid active -> past_due; billing-payment-failed email with the still-valid link.
E + 7d (grace), still unpaid past_due -> expired, invoice open -> uncollectible; subscription-downgraded.
E, cancel_at_period_end active -> expired with no renewal; subscription-downgraded (reason canceled).

The 7-day grace (RENEWAL_GRACE_DAYS) equals BILLING_GRACE_PERIOD_DAYS in packages/tenancy/src/entitlements.ts. Entitlements and the job therefore agree on when a tenant falls back to free. A payment that completes during the grace period moves the subscription from past_due to active. One that completes after the downgrade is still applied, through the legal transitions uncollectible -> paid and expired -> active.

Per run

Each phase handles at most batchSize rows. The only cross-tenant queries select (tenant_id, subscription_id) keys. Every later read and write is scoped by tenant.

  1. open: selects active subscriptions with cancel_at_period_end = 0 whose period ends within leadDays and that have no renewal row for that period end. One guarded D1 batch re-checks the subscription. It then inserts the invoice (period_start, period_end, due_at), the pending payment, the billing_renewals row and the audit rows. The renewal primary key (tenant_id, subscription_id, period_start) makes this idempotent: an overlapping run fails its guard or its insert, and the whole batch rolls back. Exactly one payment per period.
  2. link: for a renewal without a provider request whose payment is still pending, the job creates a Stitch payment request that expires at grace_ends_at. The redirect is the console /billing/return URL, validated as in checkout. If the period has not yet ended, attaching the link records the renewal_due notice in the same batch. Stitch errors or an untrusted URL defer the renewal to the next run.
  3. past_due: handles a renewal whose period has started, whose subscription still has that period end and whose invoice is not paid. An active subscription moves to past_due through assertSubscriptionTransition and records payment_failed. A subscription that is already past_due (a failed renewal webhook) only records the notice.
  4. downgrade: handles past_due subscriptions, and still-unrenewed active ones, whose period ended more than 7 days ago. It also handles active subscriptions set to cancel once their period has ended. Each moves to expired, an open renewal invoice becomes uncollectible, and the batch records downgraded.
  5. notify: hands pending notices to the notifier. A notice whose period has moved on, or whose payment already succeeded, is marked skipped (resolved). A renewal_due notice without a live link is also skipped. deferred results stay pending.

Every status change uses the state-machine assertions and writes tenant audit rows in the same batch, with deterministic ids and metadata.source = "renewal": billing.invoice.status_changed (none -> open, open -> uncollectible), billing.payment.status_changed (none -> pending) and billing.subscription.status_changed.

Reconciliation change

reconcilePendingPayments (AUTH4D-42) now looks up billing_renewals. A renewal payment expires only once grace_ends_at has passed, never by the 120-minute checkout TTL. Until then it is still polled, so a missed webhook for a renewal payment is still applied.

Emails

The notifier writes to the encrypted outbox with tenant control-plane, client CONTROL_PLANE_CONSOLE_CLIENT_ID, and a deterministic request id and message id derived from (tenant, notice id, recipient). Retries therefore dedupe. Recipients are the billing account’s billing_email when set. Otherwise they are the verified email identities of active control-plane users who own the project, up to 5.

Notice Template Link
renewal_due billing-renewal-due Stitch payment link
payment_failed billing-payment-failed payment link while valid, else console page
downgraded billing-subscription-downgraded {CONSOLE_ORIGIN}/projects/{tenant}/billing

The email expires 6 days after the notice occurred. Older notices are skipped as stale. The reference shown in failure emails is INV- followed by the uppercased invoice id suffix. If delivery is not configured (no EMAIL_QUEUE, encryption ring or console client id), notices stay pending and are logged as deferred.

Configuration

Var Range Default
BILLING_RENEWAL_LEAD_DAYS 1-14 7
BILLING_RENEWAL_BATCH_SIZE 1-100 25

An invalid value fails the run before any change. When billing is disabled the job does nothing. When billing, the runtime origins or the Stitch endpoints are misconfigured, it logs a skipped_run line.

Logs

event: "billing.renewals". A summary line per run carries counts keyed phase.action. An item line is written for each deferred or failed item, with only the phase, action, fixed reason, tenant id and subscription id. Logs never include secrets, tokens, email addresses or payment URLs. The invocation throws BillingRenewalError when an item fails unexpectedly.

Follow-ups

  • Stitch offers native recurring collection: DebiCheck and debit-order mandates (docs), which are collected through an SFTP file workflow, plus card subscriptions. Pay-by-link renewals are used for now. Moving to mandates needs its own epic: mandate capture in checkout, a collection job, and failed-collection webhooks feeding the same state machine.
  • Stitch documents expireAt on payment requests but no maximum. Confirm with Stitch that 7- to 14-day links are honoured before staging go-live.
  • A renewal payment that settles as failed, canceled or expired at Stitch is not reopened for the same period (one payment per period). The customer is dunned and then downgraded. A “retry payment” action for an open renewal invoice would need a new management route.
  • If the Worker stops after Stitch has created a payment request but before it is attached, the next run creates a second Stitch request for the same payment row. Only the attached one is ever emailed, so Stitch may hold an orphaned request.
  • Pending checkout subscriptions whose payment expired (AUTH4D-42 note) are still not closed.

Source: docs/api-spec/auth4d-44.md