Skip to content

ADR-0002 · Subscription & billing system of record

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; the existing webhook Edge Function pattern (HMAC signature + timestamp window + constant-time compare + idempotency via UNIQUE event id, already established in the _shared kit) 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 exact HMAC + idempotency pattern already used elsewhere in the kit.

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 existing _shared Webhook kit; the specifics below (from an API study, 2026-07-20) are decisions, not just notes.

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 — exactly the kit's existing 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) · pendinggrace 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 environmentRAZORPAY_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 pp11% (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.