Appearance
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
UNIQUEevent id, already established in the_sharedkit) 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
organizationstable, with seat count synced to the PSP subscription's quantity/line-items. - A new webhook Edge Function (
billing-webhookor similar, Type "Webhook" perEDGE_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 theX-Razorpay-Signatureheader (readawait req.text(), verify, thenJSON.parse— never parse first); idempotency via the uniquex-razorpay-event-idwith aUNIQUEconstraint — exactly the kit's existing webhook pattern. - Carry
user_id/tenant_idin the subscription/ordernotes(Razorpay's key/value metadata) so every webhook payload joins back to our row. Store the Razorpaycustomer_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
plansas versioned, immutable rows keyed by Razorpayplan_id; a price change or experiment mints a newplan_idwith grandfathering. One logical tier ↔ many historicalplan_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, thenhalted. We react (grace UX onpending, revoke onhalted); we don't configure the cadence. cancelledis terminal — no restart; preferpause/resumefor 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_SECRETas 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)
| Channel | Commission | On a ₹1,000/mo plan |
|---|---|---|
| Razorpay (web) | ~2% + 18% GST on the fee ≈ 2.36% | ≈ ₹24 |
| Apple IAP | 15% (Small Business Program <$1M/yr, and auto-renew subs after year 1) / else 30% | ₹150 – ₹300 |
| Google Play IAP | 15% (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 entitlement | US 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.
Related decisions
- 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-webhooksetsuser_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.
Related
- Tracker: "In-house billing engine designed instead of a payment-provider integration"
- Subscriptions review
- Edge Function standards (webhook HMAC + idempotency pattern)