Skip to content

ADR-0002 · Subscription & billing system of record ​

STALE — read current architecture state first (2026-08-08)

Razorpay as system of record stands. What changes: the billing SUBJECT is now billing_accounts(subject_kind: user | organization) and seats license USERS, not workspaces (QRS-397) — a dealership with 8 showrooms and 30 agents has 39 cards and 30 seats. profiles.subscription_tier is retired; plan features are grants at scope_kind='plan' (ADR-0021), not subscription_tiers.features.

Status: 🟢 Accepted — PSP model + Razorpay confirmed 2026-07-20 · Depends on: ADR-0001 · Drives: ADR-0007, ADR-0008

Context ​

The real schema has subscription_tiers, user_subscriptions, subscription_usage_logs, and user_feature_permissions (see Database & RLS) — a flat, per-profile entitlement model. No payment-provider integration exists yet: there is no payment/billing Edge Function in the EF index today.

The prototype's Subscriptions.dc.html (reviewed, see Subscriptions review) models proration, dunning, and refunds as custom, in-house logic — the admin UI computes and displays these directly rather than reflecting state owned by a payment processor. It also has a Custom Deals tab with org/seat pricing that depends on ADR-0001's organizations entity.

This matters because payment/subscription lifecycle correctness (proration math, dunning cadence, refund handling, tax-compliant invoicing) is exactly the kind of surface where QRSETU's own non-negotiable standards (security-by-design, reliability, idempotent mutations) argue for not reinventing what a mature payment processor already solves — and getting it wrong has real financial and compliance consequences, not just UX ones.

Options considered ​

A — In-house billing engine (as prototyped) ​

QRSETU owns proration, dunning, invoice generation, and refund logic. Still needs some payment gateway for card capture (storing raw card numbers in-house would pull QRSETU into PCI-DSS scope), so this becomes "custom billing logic layered on top of a raw payment-gateway charge API."

  • For: full control over UX and pricing logic; no per-transaction platform fee beyond the raw gateway's.
  • Against: QRSETU now owns dunning-email cadence, proration edge cases, tax-compliant invoice generation, and refund/chargeback handling as first-party code — a large, correctness-critical surface with no differentiation benefit for QRSETU's actual product.

B — Payment-service-provider (PSP) as the billing system of record ​

A subscription-billing product (e.g. Razorpay Subscriptions, Stripe Billing) owns the full lifecycle: proration, dunning emails, invoice PDF generation, refunds. Supabase holds only entitlement state (user_subscriptions.status/plan/current_period_end) synced via webhook.

  • For: proration/dunning/refunds/tax-invoicing become "someone else's mature, audited system," not QRSETU's code; a webhook Edge Function pattern (HMAC signature + constant-time compare + idempotency via UNIQUE event id — see the correction below on "timestamp window") is exactly the right shape for consuming PSP subscription-lifecycle events — no new pattern needs inventing.
  • Against: less bespoke control over exact proration/pricing edge cases; introduces a hard external dependency in the billing path.

C — Hybrid: PSP for capture only, QRSETU still owns plan/proration/dunning logic ​

PSP handles the raw charge; QRSETU computes proration/dunning/plan-state itself from raw charge events.

  • For: slightly more pricing-logic flexibility than B.
  • Against: keeps the highest-risk logic (proration correctness, dunning cadence, refund handling) in-house anyway — closer to A's risk profile than B's, for only a modest flexibility gain.

Recommendation ​

B. This is the option that actually matches CLAUDE.md's own locked standards: security-by-design and reliability argue for using a PSP's mature, audited subscription engine rather than reimplementing dunning and proration as first-party logic. Given the SMB customer base implied throughout the prototype (cafe/salon/ clinic/retail domains, rupee-denominated examples), Razorpay Subscriptions is the natural default; Stripe Billing is the fallback if/when international expansion happens — which ties directly to CONTRACTS.md's deliberately-deferred i18n/multi-currency item, so this decision doesn't need to be made until that one is.

Consequences ​

  • Subscriptions.dc.html's proration/dunning/refund UI should be revalidated once this is accepted — most of that screen becomes display of PSP-computed state, not logic QRSETU implements. This shrinks the admin panel's real implementation scope, which is the intended effect, not a loss of functionality.
  • The Custom Deals org/seat pricing tab is now unblocked by ADR-0001's organizations table, with seat count synced to the PSP subscription's quantity/line-items.
  • A new webhook Edge Function (billing-webhook or similar, Type "Webhook" per EDGE_FUNCTION_GUIDELINES.md) becomes required, following the HMAC + idempotency pattern in _shared/webhook.ts (QRS-311).

Decision (2026-07-20) — Razorpay confirmed, with the integration architecture ​

Product confirmed Razorpay as the PSP (India-first; Stripe remains the deferred international fallback tied to the CONTRACTS.md i18n item). Razorpay's model maps cleanly onto the _shared webhook kit; the specifics below (from an API study, 2026-07-20) are decisions, not just notes.

Correction, 2026-08-03 (QRS-311) — this ADR's own webhook-kit claims were false when written

This ADR asserted, in three places, that "the HMAC signature + timestamp window + constant-time compare + idempotency" pattern was "already established in the _shared kit." It was not. Verified by grepping the whole tree for hmac/timingSafeEqual/crypto.subtle on 2026-08-03 and finding zero hits. EDGE_FUNCTION_GUIDELINES.md §4.6 had the honest version all along: "No current QRSETU function is a webhook receiver; this applies to the next one that is." This ADR's decision text disagreed with the guidelines it cites, and the guidelines were right.

Now built, for the Razorpay Route payment webhook (26.0.1, not this ADR's billing-webhook, which remains future work): supabase/functions/_shared/webhook.ts — verifyHmacSignature (raw-body HMAC-SHA256 hex compare, constant-time) + isWithinTimestampWindow. One further correction while building it: Razorpay's own docs (verified 2026-08-03) show the signature is computed purely over the raw body — there is no timestamp bound into the signed string (contrast Stripe's t=…,v1=… scheme, where tampering with delay is cryptographically detectable). So "timestamp window" is not part of HMAC verification for Razorpay; it is a separate, unsigned defense-in-depth check against Razorpay's own created_at payload field, and isWithinTimestampWindow is deliberately a distinct function for exactly that reason. Idempotency is deliberately not in webhook.ts — each receiver's own UNIQUE event_id ledger table owns that, since the two receivers built so far (payment_events, a future payout_account_events) differ too much in shape for one generic helper to own without pretending to know a schema it doesn't.

Sync architecture ​

  • Razorpay = money system of record; user_subscriptions = entitlement state, reconciled by a Type-B webhook Edge Function (billing-webhook, --no-verify-jwt). Verify HMAC-SHA256 over the raw request body against the X-Razorpay-Signature header (read await req.text(), verify, then JSON.parse — never parse first); idempotency via the unique x-razorpay-event-id with a UNIQUE constraint — using _shared/webhook.ts's webhook pattern.
  • Carry user_id/tenant_id in the subscription/order notes (Razorpay's key/value metadata) so every webhook payload joins back to our row. Store the Razorpay customer_id (auto-created at auth) on the user.
  • Handlers must be idempotent and set-state-from-payload — Razorpay does not guarantee event ordering. Derive tier from the payload's status/current_end/plan, not from event arrival order.

Entitlement state machine (→ user_subscriptions) ​

subscription.activated/charged → grant/extend (set paid-through) · pending → grace period, don't revoke · halted → revoke/downgrade to free (dunning exhausted) · cancelled/completed → downgrade at period end · updated → recompute tier. Also wire payment.captured/failed, invoice.paid, and refund events (for one-time purchases and proration Credit Notes).

Constraints that shape our data model (policy, not plumbing) ​

  • Plans are immutable — amount/period/interval can never be edited. Model plans as versioned, immutable rows keyed by Razorpay plan_id; a price change or experiment mints a new plan_id with grandfathering. One logical tier ↔ many historical plan_ids. (This is the biggest architectural constraint.)
  • Proration is automatic and Razorpay-owned — upgrades invoice the difference; downgrades issue Credit-Note refunds (min diff ₹0.50). Reconcile the resulting events; do not reimplement proration math (this validates Recommendation B — the prototype's in-house proration UI becomes display-of-PSP-state).
  • Dunning is fixed/opaque — failed charge → pending, retries ~T+1/T+2/T+3, then halted. We react (grace UX on pending, revoke on halted); we don't configure the cadence.
  • cancelled is terminal — no restart; prefer pause/resume for temporary stops; "reactivate" = new subscription + new mandate.
  • ₹15,000 auto-debit ceiling on cards + UPI Autopay (regular MCCs) — keep recurring tier prices ≤ ₹15,000/cycle to avoid per-cycle OTP/PIN friction (churn risk). UPI Autopay is the mass-market default; e-NACH only for high-value/enterprise.
  • GST-compliant tax invoices are NOT available via the API (Dashboard-only, INR-only). Razorpay's API invoices are payment receipts; QRSETU must generate GST-compliant invoices itself (or via a separate tool) — a real gap to own, logged in the tracker.
  • Keys per environment — RAZORPAY_KEY_ID / RAZORPAY_KEY_SECRET / RAZORPAY_WEBHOOK_SECRET as EF secrets, test keys on Dev / live on Prod (mirrors the two-project split). During webhook-secret rotation, keep the old secret valid through the overlap (retried events are signed with the old one).

To confirm in a Razorpay sandbox before build ​

Exact webhook (not payment) retry counts/backoff, and the addons deprecation vs the still-accepted Create-Subscription addons field (treat mid-cycle add-ons as unavailable; only rely on addons for an upfront charge at creation).

App-store billing compliance (mobile) — decision 2026-07-20 ​

R2 ships native apps (ADR-0011). Apple and Google require their billing for in-app digital-subscription purchases, at a commission far above Razorpay's. The question: how do we keep Razorpay as the system of record and stay compliant on both stores? Figures verified 2026-07-20 (policy is volatile — reverify at R2 build):

What the stores charge (and what it costs us) ​

ChannelCommissionOn a ₹1,000/mo plan
Razorpay (web)~2% + 18% GST on the fee ≈ 2.36%≈ ₹24
Apple IAP15% (Small Business Program <$1M/yr, and auto-renew subs after year 1) / else 30%₹150 – ₹300
Google Play IAP15% (first $1M/yr + all subscriptions) / else 30%₹110 – ₹150
Google User Choice Billing (India, Razorpay in-app)store fee −4 pp → 11% (subs) / 26% (above $1M)₹110
Apple external-link entitlementUS 0% currently (post-Epic injunction, Apr 30 2025, pending court-set rate); other regions 12% (SBP) / 27%varies

Store IAP costs ~6–12× the Razorpay fee. Two India-timing facts help: India is Google's "Rest of World" group, so its current fee structure holds until 30 Sep 2027 (R2 is under today's rules), and QRSETU will be under $1M/yr initially (the 15%/SBP tiers, not 30%).

Chosen model — "free companion app" (Apple guideline 3.1.3(d)) + web-first purchase via Razorpay ​

The QRSETU mobile apps are free companions to the paid web-based SaaS. All subscription purchase/upgrade happens on the web via Razorpay; the native app carries no in-app purchase UI and no purchase call-to-action. Apple explicitly exempts "free stand-alone apps that act as companions to paid web-based tools (VOIP, cloud storage, email, web hosting)… provided there is no purchasing inside the app or calls to action for purchase outside the app" — QRSETU sits squarely in that family. This also avoids Google Play Billing's in-app-digital-goods trigger. Result: 0% store commission, 100% through Razorpay, fully compliant.

  • Implementation (composes with ADR-0011's universal codebase): the purchase/upgrade surface renders on web only (Platform.OS === 'web') and is hidden on native iOS/Android — one codebase, platform-conditional purchase UI, exactly what the universal architecture makes trivial. Users subscribe/upgrade on the (mobile or desktop) web; the native app signs them in to use what they bought.

Driving upgrades on native (the conversion path — honest trade-off) ​

The companion model does give up the in-app "Upgrade" button; that is the price of 0% commission, and it must not be waved away. The key judgment: for QRSETU this trade is favorable, because a plan upgrade here is a considered business decision (an SMB owner deciding to pay for more listings/features), not an impulse tap like a game coin. The entire B2B-SaaS industry runs web-first for exactly this reason (Netflix, Spotify, Notion). What we lose in impulse conversion is small for this category; what we keep is 13–30 points of margin.

What the native app CAN do (compliant, and this is where upgrades are actually driven):

  • Show value and feature-gating — locked features, usage meters ("8 / 10 listings used"), "Pro unlocks unlimited listings." Informational paywalls are allowed; only in-app purchase and (outside the US) purchase CTAs are restricted. This creates the upgrade intent in-app.
  • Convert through channels the stores don't police — this is the real engine: email (ZeptoMail, ADR-0008) ("you've hit your free limit — upgrade here"), WhatsApp, and in-app notifications, all pointing to web checkout. Targeted lifecycle email typically converts upgrades better than a passive in-app button.
  • Seamless handoff to mobile web — because the mobile web is the same universal app (ADR-0011), "manage plan" opens the browser already-authenticated (or via magic link), completes Razorpay in a couple of taps, and returns to the native app with the tier unlocked. In the US (post-Epic), a direct external link is allowed, shortening this further.

Per-platform escalation — only if data shows native conversion is genuinely hurt:

  • Android → Google India User Choice Billing — add a real in-app Razorpay upgrade button; Google takes ~11% (15% − 4 pp) + Razorpay ~2% ≈ ~13% all-in on those transactions. Worth it only if in-app checkout lifts conversion enough to beat losing 13 points.
  • iOS → multiplatform IAP (3.1.3(b)) — offer Apple IAP in-app (pay 15% on iOS-originated upgrades only) while web stays 0%. A paid-convenience option, not the default.

Net: default to web-first (0%); treat the native funnel as intent in-app → completion via email/web; revisit the per-platform in-app options with conversion data, not upfront. This is a reversible decision, so we start at 0% and buy conversion later only if the numbers justify it.

Fallbacks (only if in-app purchase convenience is later justified by conversion data) ​

  • Google India User Choice Billing — offer Razorpay in-app on Android, pay Google 4 pp less (11% subs). Still worse than web-first's 0%, so adopt only if data shows the in-app-checkout convenience lifts conversion enough to beat the 11%.
  • Apple external-purchase-link entitlement — US currently 0%; other regions 12%/27%. Only relevant if we add in-app purchase; the companion model avoids needing it.

Risks / challenges to know before packaging ​

  • App-review is reviewer-dependent — a subscription-gated app with no in-app way to pay can draw guideline-3.1.1 scrutiny; mitigate by positioning clearly as a companion to the web platform (the exemption family above).
  • Anti-steering — historically the native app couldn't even tell users where to subscribe. The 2025 US Epic ruling relaxed this in the US (can link out, 0% commission); outside the US, keep the native app free of purchase CTAs to stay safe until the local rules are confirmed.
  • Policy volatility — DMA (EU), the Epic rate-setting, and India CCI rulings are all in motion; these figures are a 2026-07-20 planning snapshot. Reverify at R2 build (India's Sep-2027 stability means R2 is under current rules).
  • India tax — GST (and any TDS) on Razorpay settlements apply regardless of channel.
  • Feature entitlements are governed separately by ADR-0007: Razorpay owns what tier a user is on; ADR-0007 owns what a tier unlocks. The billing-webhook sets user_subscriptions.tier; the entitlement resolver reads it.
  • Billing emails (receipts, dunning, payment-failed) send via the transactional path in ADR-0008 (ZeptoMail), not Razorpay's own emails, to keep one branded transactional stream.