Canonical reference for how billing, subscriptions, and credits actually
work after the 8-phase hardening pass. Sits alongside architecture.md
(higher-level product blueprint) and razorpay-setup-guide.md (provider
setup). Read this when you need to: understand why a specific state
transition happens, add a new webhook event, or trace a credit movement.
1. Runtime Shape
┌───────────────────────────────────────────────────────────────────┐
│ Cloudflare Workers │
│ │
│ seller-dashboard (Next.js) │
│ │ │
│ ▼ │
│ apps/gateway (auth + JWT → headers) │
│ │ │
│ ▼ │
│ apps/manager │
│ • routes/billing.ts ← user-facing billing HTTP │
│ • services/credits.ts ← bucket-aware credit operations │
│ • services/escalation.ts ← past-due + trial state machine │
│ • services/reconciliation ← async retry for Razorpay failures │
│ • services/integrity-audit ← daily invariant checker │
│ • lib/logger.ts / alerts.ts← observability primitives │
│ │
│ Razorpay webhooks ──▶ /billing/webhook │
│ │
│ Service apps (blog, content, brain) ──▶ @repo/core-billing │
│ CreditLedger class │
│ │
│ All persistence: @repo/core-database on Neon Postgres │
└───────────────────────────────────────────────────────────────────┘apps/manager is the state-machine authority. CreditLedger in
@repo/core-billing is the only sanctioned path for service apps to
debit credits.
2. Subscription State Machine
┌────────────────────────────────────────────────────────┐
│ │
▼ │
┌───────────────┐ │
│ free / active │ │
└───────┬───────┘ │
│ /checkout → /payment/verify │
▼ │
┌──────────────────────┐ │
│ paid / trialing │── trial ends ──┐ │
└──────┬───────────────┘ │ │
│ ▼ │
│ ┌───────────────┐ │
│ │ paid / active ├── cancel ────┐ │
│ └───┬───────┬───┘ │ │
│ │ │ │ │
│ downgrade ◀──┘ └─▶ upgrade │ │
│ scheduled │ │
│ │ │ │
│ ▼ │ │
│ ┌─────────────────┐ │ │
│ │ active + │ │ │
│ │ pendingPlanId │ │ │
│ └────────┬────────┘ │ │
│ │ next subscription.charged │ │
│ ▼ │ │
│ ┌─────────────────┐ │ │
│ │ promoted │ │ │
│ └─────────────────┘ │ │
│ │ │
└── payment.failed ─▶ past_due ── day-14 cron ──────┐ │ │
▼ ▼ │
┌──────────────────┐ │
│ canceled + free │───┘
└──────────────────┘Key transitions
| From → To | Trigger | Code |
|---|---|---|
free → trialing |
/checkout + /payment/verify |
billing.ts |
trialing → active |
subscription.charged webhook |
billing.ts webhook case |
active → active+pendingPlanId (upgrade) |
/change-plan upgrade |
writes intent only |
active+pendingPlanId → promoted |
/payment/verify matching pendingRazorpaySubscriptionId |
promotion path |
active → scheduled downgrade |
/change-plan downgrade |
Razorpay updateSubscriptionPlan + pendingPlanId |
scheduled downgrade → promoted |
subscription.charged at cycle end |
webhook promotion |
active → past_due |
payment.failed webhook |
sets pastDueSince |
past_due → active |
subscription.charged |
clears pastDueSince |
past_due → canceled/free |
14-day escalation cron | auto-downgrade |
Pending-change protocol (upgrades + cycle switches)
Never mutate the live subscription row until the user pays. Server records:
pendingPlanId— target planpendingBillingCycle— target cyclependingRazorpaySubscriptionId— new Razorpay sub created by/checkout
/payment/verify detects a payload matching pendingRazorpaySubscriptionId
and promotes atomically: flips planId/billingCycle/razorpaySubscriptionId,
cancels the old Razorpay sub (best-effort with reconciliation-queue fallback),
clears pending fields, dispenses new plan's credits.
If the user abandons mid-flow: no state changes. /pending-change/cancel
explicitly clears pending state + cancels the unpaid Razorpay sub.
Downgrade via Razorpay scheduled plan change
Downgrades use Razorpay's native updateSubscriptionPlan with
schedule_change_at: "cycle_end". Local pendingPlanId is set, and the
next subscription.charged webhook (billing cycle end) detects the shape
pendingPlanId IS NOT NULL AND pendingRazorpaySubscriptionId IS NULL =
downgrade, and promotes along with the credit dispense.
3. Credit Bucket Model
tenant_credits
├─ subscription_balance ┐
├─ subscription_expires_at │ "Use it or lose it" — expires at
│ ┘ end of billing cycle.
│
├─ permanent_balance Never expires. Credit packs, admin
│ grants, refunds land here.
│
└─ balance (legacy mirror) = effective_sub + permanent
where effective_sub = 0 if expiredReads apply lazy expiry: subscription bucket treated as 0 if expiry
elapsed, even if stored value wasn't zeroed yet. getCreditBalance and
/billing/current both use this.
Writes actually zero the expired bucket before applying the new
change (expireElapsedSubscription helper), with a subscription_expired
ledger entry for audit.
Debits drain subscription bucket first, then permanent — expiring credits get spent before never-expiring ones.
Refunds land in the permanent bucket — a refund of an originally- expiring credit shouldn't silently re-expire.
Two entry points, one ledger
apps/manager/src/services/credits.ts— transactional path (Drizzledb.transaction+ SAVEPOINTs). Used by manager endpoints.packages/core-billing/src/ledger.ts— single-statement CAS path (works under neon-http which doesn't support transactions). Used by service apps.
Both maintain the invariant balance = effective_sub + permanent.
runIntegrityAudit checks this nightly.
Idempotency
Every credit mutation accepts an idempotencyKey. Enforced by the
partial unique index credit_tx_idempotency_idx on
(tenant_id, idempotency_key) where key is non-null. Replays return
current balance without double-moving money.
Conventions:
- Razorpay-driven:
rzp_pay_${paymentId} - Admin adjustments:
admin_adjust_${actorUserId}_${timestamp} - Addon renewals:
${addonRecordId}
4. Webhook Event Handling
| Event | Handler | State change | Credit effect |
|---|---|---|---|
subscription.activated |
✅ | status=active, clear pastDueSince |
rebuildEntitlements |
subscription.charged |
✅ | status=active, update currentPeriodEnd, promote pendingPlanId if downgrade |
Dispense baseCredits × (1 monthly / 12 yearly) to subscription bucket |
subscription.cancelled |
✅ | status=canceled, planId=free, rebuildEntitlements |
— |
subscription.halted |
✅ | status=canceled, planId=free, clear pending_*, rebuildEntitlements |
— |
subscription.completed |
✅ | status=canceled, planId=free, rebuildEntitlements |
— |
subscription.updated |
✅ | No-op (promotion happens on .charged) |
— |
subscription.authenticated |
✅ | No-op | — |
payment.failed |
✅ | status=past_due, stamp pastDueSince (preserve on retries) |
— |
payment.captured |
✅ | Credit pack → permanent bucket | manual_top_up |
refund.processed |
✅ | Log only — manual reconciliation for now | — |
Idempotency: each event writes to processed_payment_events keyed on
rzp_${eventType}_${eventId}. eventId prefers payment.entity.id
(unique per charge), falling back to refund.entity.id then
subscription.entity.id for lifecycle-only events.
5. Reconciliation Queue
Some Razorpay operations fail transiently in the synchronous request
path. Instead of retrying inline or losing work, we enqueue a
billing_reconciliation_queue row.
Enqueued from:
/payment/verifyupgrade promotion → old sub cancel fails/cancelwith pending upgrade → pending sub cancel fails/pending-change/cancelupgrade path → pending sub cancel fails- Auto-downgrade at day 14 → Razorpay cancel fails
POST /billing/internal/reconcile (cron, every 5 min)
├─ pulls pending rows (next_attempt_at ≤ NOW())
├─ retries with backoff: 1m → 5m → 15m → 1h → 2h → 4h → 8h → 12h → 1d → 2d
├─ success → mark completed
├─ failure → next_attempt_at += backoff
└─ 10 attempts exhausted → abandoned + critical alertAbandoned tasks each fire a critical alert — potentially a customer being double-billed until manual intervention.
6. Grace Period Escalation
payment.failed → pastDueSince stamped
│
│ /billing/internal/escalation (hourly cron)
▼
day 1 day 3 day 7 day 14
╷ ╷ ╷ ╷
▼ ▼ ▼ ▼
reminder_1 reminder_2 reminder_3 auto_downgrade
(final warning) ├─ cancel Razorpay sub
├─ planId = free
├─ status = canceled
└─ critical alert firesEach step writes a billing_notifications row keyed on
(tenant_id, kind, cycle_ref). cycle_ref = ISO of pastDueSince so
a tenant who recovers then past-dues again gets fresh reminders.
billing_notifications is the delivery-agnostic event log — a downstream
comms worker subscribes to these to send emails.
Auto-downgrade claims its notification FIRST (before the expensive Razorpay cancel) so concurrent cron runs can't double-downgrade.
7. Observability
Structured logger (apps/manager/src/lib/logger.ts) emits JSON per
event: { timestamp, severity, event, ...context }. Event names are
the primary analytics dimension — keep them stable.
Alerts (apps/manager/src/lib/alerts.ts) POST to
BILLING_ALERT_WEBHOOK_URL (Slack-compatible). If unset, falls back to
structured error logs.
Critical alerts fire on:
| Signal | Source |
|---|---|
| Reconciliation task abandoned | /reconcile |
| Integrity audit balance / ledger mismatch | /audit-integrity |
| Signature-failure spike (10/hr per tenant) | /payment/verify + /webhook |
| Auto-downgrade executed | /escalation |
Warning alerts fire on:
| Signal | Source |
|---|---|
| Integrity audit stale pending upgrade (> 72h) | /audit-integrity |
8. Security
-
HMAC verification on every Razorpay signature (
/payment/verify,/webhook). Rejected signatures logged tobilling_signature_failuresfor forensics. -
Permission checks (
system:ownerorbilling:account.manage) on every mutating user-facing billing endpoint. -
platform:adminrequired for cross-tenant admin endpoints (/admin/adjust-credits,/admin/signature-failures). -
Rate limits (DB-backed fixed window) on all mutating endpoints:
Endpoint Limit /checkout10 / min /credits/buy10 / min /addons/buy10 / min /change-plan5 / min /switch-cycle5 / min /pending-change/cancel5 / min /cancel3 / min -
Gateway secret (
x-gateway-key) on all/internal/*cron endpoints.
9. Data Model Cheat Sheet
Hot tables:
| Table | Purpose | Written by |
|---|---|---|
subscriptions |
Live sub + pending upgrade/downgrade intent | /checkout, /change-plan, /payment/verify, webhooks |
tenant_credits |
Credit wallet, two buckets + legacy mirror | services/credits.ts, core-billing/ledger.ts |
credit_transactions |
Append-only ledger | Every credit mutation |
plans + plan_service_limits |
Plan catalog | Seeded; rarely changed |
tenant_services |
Active entitlements per tenant | rebuildEntitlements |
Support tables:
| Table | Purpose |
|---|---|
processed_payment_events |
Webhook dedup |
rate_limit_buckets |
Fixed-window rate limiter |
billing_reconciliation_queue |
Async-retry queue for flaky Razorpay ops |
billing_signature_failures |
Forensics for rejected HMACs |
billing_integrity_reports |
Invariant audit findings |
billing_notifications |
Escalation step dedup |
10. Where to Look for X
| Task | File |
|---|---|
| Add a new plan | apps/manager/src/seed.ts + Razorpay dashboard |
| Change credit cost of an action | packages/core-types/src/credit-costs.ts |
| Add a webhook event handler | apps/manager/src/routes/billing.ts (/webhook case block) |
| Add a credit movement reason | packages/core-billing/src/reasons.ts |
| Add an alert channel | apps/manager/src/lib/alerts.ts |
| Add an integrity check | apps/manager/src/services/integrity-audit.ts |
| Change backoff schedule | apps/manager/src/services/reconciliation.ts |
| Change past-due thresholds | apps/manager/src/services/escalation.ts (exported constants) |
11. Cron Schedule
| Endpoint | Frequency | Purpose |
|---|---|---|
POST /billing/internal/reconcile |
every 5 min | Retry flaky Razorpay calls |
POST /billing/internal/escalation |
hourly | Past-due reminders + day-14 auto-downgrade |
POST /billing/internal/addon-renewals |
hourly | Debit recurring addons |
POST /billing/internal/cleanup |
hourly | Prune old rate-limit + completed-reconciliation rows |
POST /billing/internal/audit-integrity |
daily (3 AM UTC) | Invariant audit |
All require x-gateway-key header matching GATEWAY_SECRET.
12. Environment Variables
| Name | Required | Purpose |
|---|---|---|
DATABASE_URL |
yes | Neon connection string |
RAZORPAY_KEY_ID |
yes | Razorpay API auth |
RAZORPAY_KEY_SECRET |
yes | Razorpay API auth + signature verification |
RAZORPAY_WEBHOOK_SECRET |
yes | Webhook HMAC verification |
GATEWAY_SECRET |
yes | Protects /internal/* endpoints |
BILLING_ALERT_WEBHOOK_URL |
no | Slack Incoming Webhook for critical alerts |