AUTH4D-42 Scheduled payment reconciliation
There is no HTTP route. A cron-triggered job polls Stitch for payments that are still pending and applies the outcome. It covers webhooks that were missed or never succeeded, and it expires payments that stay pending for too long.
- Job:
reconcilePendingPaymentsinpackages/billing/src/jobs.ts. It is provider-agnostic and calls aPaymentStatusProviderport. - Worker wiring:
apps/api/src/modules/billing/reconcile.tsadapts the Stitch client (GetPaymentRequestStatus).createScheduledTaskDispatcherinapps/api/src/runtime/services.tsruns the job. - Trigger:
5-59/10 * * * *in staging and production (apps/api/wrangler.jsonc). This is five minutes after the lifecycle cron*/10 * * * *. The dispatcher picks the task from the cron expression. Any other expression runs every task, includingwrangler dev --test-scheduled.
Per run
- If
BILLING_ENABLEDis nottrue, the run skips with no output. If billing is misconfigured, the run skips and writes askipped_runlog line. It also skips when the Stitch endpoints are unsafe. Checkout and the webhook already fail closed in both cases. - The job selects at most
batchSizepayments withstatus = 'pending'that were created at leastminAgeMinutesago. Selection is oldest first. Only this key selection crosses tenants. Every later read and write is scoped bytenant_id. - A cursor stored in
lifecycle_cursors(taskbilling.reconcile_payments) walks the backlog. It wraps after a short page, so payments that stay pending cannot starve newer ones. - Each payment is handled as follows:
| Situation | Action |
|---|---|
| No longer pending when re-read | skipped |
| No Stitch id attached, but the checkout ledger has one | Attach it, then poll |
| Stitch reports completed, failed, cancelled or expired | Apply that outcome (applied) |
The link’s merchantReference is not this payment |
rejected, no write |
| Stitch reports pending or does not know the link, within the TTL | unchanged |
Same as above, past paymentTtlMinutes |
Expire the payment (applied, expired) |
| No Stitch link was ever created, past the TTL | Expire it without calling Stitch |
| Stitch unavailable (auth, network or upstream error) | deferred, never expired |
| Concurrent update conflict | deferred, retried next run |
| Unexpected error | failed; the batch continues |
When at least one payment ends as failed, the scheduled invocation throws
BillingReconcileError afterwards, so the failure appears in Workers observability.
Idempotency and webhook concurrency
Every change goes through repository.applyPaymentOutcome. That is the same guarded state-machine
batch the webhook uses. The provider event id is deterministic:
reconcile:{paymentId}:{outcome}. As a result:
- Overlapping runs record a single event. The first run applies it and the other sees
duplicate. - If a webhook settles the payment first, the job’s apply is
already_applied, orillegal_transitionwhen the outcome differs. Nothing changes, and the subscription period is extended only once. - Webhook dedupe on
svix-idis unaffected.
The recorded event type is the Stitch link status (for example PAID)
or reconcile.ttl_expired. The payload hash covers the job’s own inputs, never a provider body.
Audit
Applied changes emit the same tenant audit events as the webhook:
billing.payment.status_changed, billing.invoice.status_changed and
billing.subscription.status_changed. Each row has metadata.source = "reconciliation",
provider_event_id, provider_state, from and to. Event ids are deterministic
(INSERT OR IGNORE), and all rows from one run share one req_reconcile… request id.
Configuration
Optional Worker vars. An invalid value fails the run before anything is selected or changed.
| Var | Default | Range |
|---|---|---|
BILLING_RECONCILE_MIN_AGE_MINUTES |
10 | 1–1440 |
BILLING_PAYMENT_TTL_MINUTES |
120 | 60–10080, above the min age |
BILLING_RECONCILE_BATCH_SIZE |
25 | 1–100 |
Stitch payment requests expire after 30 minutes (checkout expiresAt). The TTL floor of 60
minutes therefore keeps the job from expiring a payment whose hosted page can still be paid.
Logs
The job writes one billing.reconcile summary line per run, with scanned, per-action counts
and durationMs. It also writes one line per rejected, deferred or failed payment, with
fixed reason codes only. Secrets, tokens and provider messages are never logged.
Not covered
Expiring a checkout payment leaves its invoice open and its subscription pending. Neither
grants entitlements, and a new checkout is still allowed. Closing them belongs to the subscription
lifecycle work.
Tests
packages/billing/src/jobs.test.tsuses a fake provider port. It covers the missed webhook, failed and cancelled outcomes, young payments, pending within the TTL, TTL expiry (pending, unknown and never attached), completed past the TTL, provider outage, reference mismatch, ledger attach, a race with a webhook, overlapping runs, batch bound and cursor, error isolation, and policy parsing.tests/integration/auth4d-42.billing-reconcile.test.tsmocks Stitch over HTTP and runs the Worker scheduled dispatcher. It covers cron routing, the missed webhook, expiry, the disabled and misconfigured skip, and that no secret appears in logs.
Source: docs/api-spec/auth4d-42.md