logicspike/docs

Billing

Billing System — Operational Reference

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 plan
  • pendingBillingCycle — target cycle
  • pendingRazorpaySubscriptionId — 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 expired

Reads 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 (Drizzle db.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/verify upgrade promotion → old sub cancel fails
  • /cancel with pending upgrade → pending sub cancel fails
  • /pending-change/cancel upgrade 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 alert

Abandoned 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 fires

Each 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 to billing_signature_failures for forensics.

  • Permission checks (system:owner or billing:account.manage) on every mutating user-facing billing endpoint.

  • platform:admin required for cross-tenant admin endpoints (/admin/adjust-credits, /admin/signature-failures).

  • Rate limits (DB-backed fixed window) on all mutating endpoints:

    Endpoint Limit
    /checkout 10 / min
    /credits/buy 10 / min
    /addons/buy 10 / min
    /change-plan 5 / min
    /switch-cycle 5 / min
    /pending-change/cancel 5 / min
    /cancel 3 / 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
Billing