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:
runRenewalsinpackages/billing/src/renewals.ts. It is provider-agnostic and uses two ports:PaymentLinkProviderandRenewalNotifier. - Worker wiring:
apps/api/src/modules/billing/renewals.ts.stitchPaymentLinksadaptsCreatePaymentRequest, andoutboxRenewalNotifierenqueues billing emails (AUTH4D-43).createScheduledTaskDispatcherinapps/api/src/runtime/services.tsruns it on the billing cron5-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.sqladdsbilling_renewalsandbilling_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.
- open: selects active subscriptions with
cancel_at_period_end = 0whose period ends withinleadDaysand 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, thebilling_renewalsrow 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. - 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/returnURL, validated as in checkout. If the period has not yet ended, attaching the link records therenewal_duenotice in the same batch. Stitch errors or an untrusted URL defer the renewal to the next run. - 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_duethroughassertSubscriptionTransitionand recordspayment_failed. A subscription that is alreadypast_due(a failed renewal webhook) only records the notice. - downgrade: handles
past_duesubscriptions, 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 toexpired, an open renewal invoice becomesuncollectible, and the batch recordsdowngraded. - notify: hands pending notices to the notifier. A notice whose period has moved on, or whose
payment already succeeded, is marked
skipped(resolved). Arenewal_duenotice without a live link is also skipped.deferredresults 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
expireAton 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