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: reconcilePendingPayments in packages/billing/src/jobs.ts. It is provider-agnostic and calls a PaymentStatusProvider port.
  • Worker wiring: apps/api/src/modules/billing/reconcile.ts adapts the Stitch client (GetPaymentRequestStatus). createScheduledTaskDispatcher in apps/api/src/runtime/services.ts runs 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, including wrangler dev --test-scheduled.

Per run

  1. If BILLING_ENABLED is not true, the run skips with no output. If billing is misconfigured, the run skips and writes a skipped_run log line. It also skips when the Stitch endpoints are unsafe. Checkout and the webhook already fail closed in both cases.
  2. The job selects at most batchSize payments with status = 'pending' that were created at least minAgeMinutes ago. Selection is oldest first. Only this key selection crosses tenants. Every later read and write is scoped by tenant_id.
  3. A cursor stored in lifecycle_cursors (task billing.reconcile_payments) walks the backlog. It wraps after a short page, so payments that stay pending cannot starve newer ones.
  4. 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, or illegal_transition when the outcome differs. Nothing changes, and the subscription period is extended only once.
  • Webhook dedupe on svix-id is 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.ts uses 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.ts mocks 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