Subscription Checkout Flow
Last Updated: 2026-04-05
Description
The subscription checkout flow handles the full lifecycle of purchasing a subscription plan: from initiating a gateway session, through the client-side payment dialog, to verifying the payment and creating a local subscription record. The flow is gateway-agnostic -- the backend selects the correct gateway per facility region, and the frontend lazy-loads the matching payment adapter.
Implementation Status
- Initiate endpoint creates gateway subscription, returns checkout data
- Frontend lazy-loads correct gateway adapter (Razorpay implemented, Stripe placeholder)
- Verify endpoint validates payment signature and creates local subscription
- Promo code support at both initiate and verify stages
- Advisory lock prevents concurrent initiation for the same facility
- Orphaned gateway subscription cleanup on verification failure
- Settled guard prevents double-resolving the checkout promise
Architecture
Checkout Sequence
Frontend Backend Payment Gateway
-------- ------- ---------------
| | |
| POST /billing/subscriptions/initiate |
| { plan_id, facility_id } | |
| -----------------------------> | |
| | validate plan/region |
| | advisory lock on facility |
| | find_or_create billing_customer |
| | gateway.create_subscription() |
| | --------------------------------> |
| | <-- { id, short_url, ... } |
| <-- { checkout data } | |
| | |
| openCheckout(data) | |
| (lazy-load adapter) | |
| -------------------------------------------------------------> |
| <-- payment callback { payment_id, subscription_id, signature } |
| | |
| POST /billing/subscriptions/verify |
| { payment_id, subscription_id, signature, plan_id, facility_id } |
| -----------------------------> | |
| | verify_payment_signature() |
| | validate no active subscription |
| | create Subscription record |
| | apply promo code usage |
| <-- { subscription } | |Design Decisions
- No local record on initiate: The subscription is only persisted after successful payment verification. This avoids orphaned records from abandoned checkouts.
- Advisory lock: A PostgreSQL advisory lock (
pg_advisory_xact_lock) on(tenant_id, facility_id)prevents race conditions where two tabs/users initiate simultaneously. - Gateway-agnostic frontend: The
openCheckout()function dispatches to the correct adapter based on thegatewayfield from the initiate response. - Lazy-loaded adapters: Each gateway adapter (Razorpay, Stripe) is code-split into a separate chunk, loaded only when needed.
API Reference
POST /billing/subscriptions/initiate
Creates a gateway subscription session and returns checkout data for the frontend. Requires authentication and manage_subscription permission (#2274; manage_billing, as it was then named, until the subscription was split out of practice finance — see Billing domain architecture).
File: backend/domains/billing/controllers/subscriptions_controller.rb (action: initiate)
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
plan_id | string (UUID) | Yes | ID of the subscription plan |
facility_id | string (UUID) | Yes | ID of the facility subscribing |
promo_code | string | No | Promotional code for discount |
Example Request:
POST /billing/subscriptions/initiate
{
"plan_id": "019...",
"facility_id": "019...",
"promo_code": "LAUNCH50"
}Success Response (200):
{
"data": {
"gateway": "razorpay",
"gateway_subscription_id": "sub_QA1b2c3d4e5f6g",
"gateway_key": "rzp_test_xxxxxxxxxxxx",
"amount": 249.5,
"currency": "INR",
"plan_id": "019...",
"plan_name": "Deluxe",
"billing_cycle": "monthly",
"trial_days": 14,
"customer_id": "019...",
"promo_code": "LAUNCH50"
}
}Error Responses:
| Status | Error | Cause |
|---|---|---|
| 422 | "This plan is not available for your facility's region" | Plan region does not match facility country |
| 422 | "Facility already has an active subscription" | Active or trial subscription exists |
| 422 | "Cannot initiate checkout for a free plan" | Free-tier plans do not use gateway checkout |
| 422 | "Invalid or expired promo code: LAUNCH50" | Promo code not found, expired, or exhausted |
POST /billing/subscriptions/verify
Verifies the payment signature from the gateway callback and creates the local subscription record. Requires authentication and manage_subscription permission.
File: backend/domains/billing/controllers/subscriptions_controller.rb (action: verify)
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
plan_id | string (UUID) | Yes | ID of the subscription plan |
facility_id | string (UUID) | Yes | ID of the facility |
payment_id | string | Yes | Gateway payment ID (e.g. pay_QA1b2c3d4e5f6g) |
subscription_id | string | Yes | Gateway subscription ID |
signature | string | Yes | HMAC signature from gateway callback |
promo_code | string | No | Promo code applied during initiation |
customer_id | string (UUID) | No | Billing customer ID from initiate response |
Example Request:
POST /billing/subscriptions/verify
{
"plan_id": "019...",
"facility_id": "019...",
"payment_id": "pay_QA1b2c3d4e5f6g",
"subscription_id": "sub_QA1b2c3d4e5f6g",
"signature": "a1b2c3d4e5f6g7h8i9j0...",
"promo_code": "LAUNCH50",
"customer_id": "019..."
}Success Response (201):
{
"data": {
"id": "019...",
"tenant_id": "019...",
"facility_id": "019...",
"plan_id": "019...",
"status": "trial",
"billing_cycle": "monthly",
"currency": "INR",
"amount": "249.50",
"start_date": "2026-04-05",
"end_date": "2026-05-05",
"trial_end_date": "2026-04-19",
"next_billing_date": "2026-04-19",
"gateway_subscription_id": "sub_QA1b2c3d4e5f6g",
"gateway": "razorpay",
"plan": {
"id": "019...",
"name": "Deluxe",
"tier": "professional"
}
}
}Error Responses:
| Status | Error | Cause |
|---|---|---|
| 422 | "Payment verification failed -- signature mismatch" | HMAC signature check failed |
| 422 | "Plan is not available for this facility's region" | Region mismatch |
| 422 | "Facility already has an active subscription" | Duplicate subscription attempt |
Backend Services
InitiateService
File: backend/domains/billing/services/subscriptions/initiate_service.rb
Responsibilities:
- Validates plan region matches facility country code.
- Acquires advisory lock on
(tenant_id, facility_id)usingpg_advisory_xact_lockto prevent concurrent initiations. - Checks no active or trial subscription exists for the facility.
- Resolves promo code (if provided) and calculates the discounted amount.
- Finds or creates a billing customer on the gateway (e.g. Razorpay customer).
- Creates a gateway subscription session via the gateway abstraction layer.
- Returns checkout data without creating any local subscription record.
result = Subscriptions::InitiateService.call(
tenant: current_tenant,
facility: facility,
plan: plan,
user: current_user,
promo_code: "LAUNCH50"
)
# result.data[:checkout] contains the checkout response hashVerifyService
File: backend/domains/billing/services/subscriptions/verify_service.rb
Responsibilities:
- Validates plan region matches facility.
- Verifies payment signature via the gateway's
verify_subscription_signature()method. - Resolves promo code again (with
FOR UPDATElock to prevent race conditions on usage count). - Validates no active subscription exists (double-check inside transaction).
- Creates the
Billing::Subscriptionrecord with status:trialor:activebased ontrial_days. - Records promo code usage atomically.
- On any error, cancels the gateway subscription to prevent orphaned subscriptions that would charge the customer.
result = Subscriptions::VerifyService.call(
tenant: current_tenant,
facility: facility,
user: current_user,
plan: plan,
payment_id: "pay_xxx",
subscription_id: "sub_xxx",
signature: "sig_xxx",
promo_code: "LAUNCH50",
customer_id: "019..."
)
# result.data[:subscription] is the created Billing::SubscriptionFrontend Checkout Architecture
Entry Point
File: frontend/packages/shared-services/src/checkout/index.ts
The openCheckout() function is the single entry point. It receives the checkout data from the initiate response and dispatches to the correct adapter:
import { openCheckout } from '@kulcare/shared-services'
import type { GatewayCheckoutData, CheckoutResult } from '@kulcare/shared-services'
const result: CheckoutResult = await openCheckout(checkoutData, {
onDismiss: () => toast.info('Payment cancelled'),
theme: { color: '#2563eb' },
})
if (result.success) {
// result.payment_id, result.subscription_id, result.signature available
await verifyMutation.mutateAsync({ ... })
}The adapter is selected via a switch statement on data.gateway and dynamically imported:
switch (data.gateway) {
case 'razorpay': {
const { openRazorpayCheckout } = await import('./adapters/razorpay')
return openRazorpayCheckout(data, options)
}
case 'stripe': {
const { openStripeCheckout } = await import('./adapters/stripe')
return openStripeCheckout(data, options)
}
default:
return { success: false, error: `Unsupported payment gateway: ${data.gateway}` }
}Types
File: frontend/packages/shared-services/src/checkout/types.ts
interface GatewayCheckoutData {
gateway: 'razorpay' | 'stripe'
gateway_subscription_id: string
gateway_key: string
amount: number
currency: string
plan_id: string
plan_name: string
billing_cycle: string
trial_days: number | null
customer_id: string
promo_code?: string | null
}
interface CheckoutResult {
success: boolean
payment_id?: string
subscription_id?: string
signature?: string
error?: string
}
interface CheckoutOptions {
onDismiss?: () => void
theme?: { color?: string }
}Razorpay Adapter
File: frontend/packages/shared-services/src/checkout/adapters/razorpay.ts
The Razorpay adapter:
- Dynamically loads the Razorpay checkout script (
https://checkout.razorpay.com/v1/checkout.js) if not already present. - Opens the payment dialog with the
subscription_idandgateway_keyfrom the initiate response. - Resolves a Promise with the payment result using a settled guard pattern to prevent double-resolution.
Key implementation detail -- the settled guard:
let settled = false
const settleOnce = (result: CheckoutResult) => {
if (settled) return
settled = true
resolve(result)
}This prevents the promise from being resolved twice if both handler (success) and modal.ondismiss fire in quick succession (a known Razorpay edge case).
Stripe Adapter (Placeholder)
File: frontend/packages/shared-services/src/checkout/adapters/stripe.ts
Currently returns an error message. To be implemented when Stripe is added for US/AE regions.
Full Frontend Flow
The SubscriptionPage.tsx component orchestrates the end-to-end flow:
// Step 1: Initiate - get checkout data from backend
const checkoutData = await initiateMutation.mutateAsync({
plan_id: selectedPlan.id,
facility_id: facilityId,
promo_code: promoCode || undefined,
})
// Step 2: Open gateway checkout (lazy-loads correct adapter)
const result = await openCheckout(checkoutData, {
onDismiss: () => toast.info('Payment cancelled'),
})
if (!result.success) return
// Step 3: Verify payment and create subscription
await verifyMutation.mutateAsync({
payment_id: result.payment_id!,
subscription_id: result.subscription_id!,
signature: result.signature!,
plan_id: selectedPlan.id,
facility_id: facilityId,
promo_code: promoCode || undefined,
customer_id: checkoutData.customer_id,
})
// Step 4: Refresh subscription data
refetchSubscription()How to Add a New Payment Gateway
Adding a new payment gateway (e.g. Stripe) requires changes in three layers:
1. Backend: Implement the Gateway Class
Create a new file at backend/domains/billing/services/gateways/stripe_gateway.rb:
module Billing
module Gateways
class StripeGateway < Base
def gateway_name
:stripe
end
def publishable_key
Rails.application.credentials.dig(:stripe, :publishable_key)
end
def create_subscription(plan_id:, customer_id:, **opts)
# Stripe SDK call
end
def verify_subscription_signature(subscription_id:, payment_id:, signature:)
# Stripe webhook signature verification
end
# Implement all other Base methods...
end
end
end2. Backend: Register in GatewayConfig
Update backend/domains/billing/services/gateways/gateway_config.rb:
MARKET_GATEWAYS = {
'IN' => :razorpay,
'US' => :stripe, # Switch US to Stripe
'AE' => :stripe # Switch AE to Stripe
}.freeze
GATEWAY_CLASSES = {
razorpay: 'Billing::Gateways::RazorpayGateway',
stripe: 'Billing::Gateways::StripeGateway' # Add Stripe class
}.freeze
GATEWAY_CURRENCIES = {
razorpay: %w[INR USD AED],
stripe: %w[USD AED GBP EUR] # Add Stripe currencies
}.freeze3. Frontend: Create the Adapter
Create frontend/packages/shared-services/src/checkout/adapters/stripe.ts:
export async function openStripeCheckout(
data: GatewayCheckoutData,
options?: CheckoutOptions
): Promise<CheckoutResult> {
// Load Stripe.js, mount payment element, handle result
}No changes needed in index.ts -- the case 'stripe' branch already exists.
4. Seed Data: Add Gateway Plan IDs
Update plan entries in the seed JSON with the Stripe price IDs:
{
"gateway_plan_ids": {
"razorpay": "plan_KFnwQRXVlmTD4L",
"stripe": "price_1XXXXXXXXXXXXXXXXX"
}
}Error Handling
Orphaned Gateway Subscriptions
If verification fails (signature mismatch, duplicate subscription, database error), the VerifyService attempts to cancel the gateway subscription to prevent the customer from being charged:
def cancel_gateway_subscription_safely
return unless @gateway_subscription_id.present?
resolve_gateway.cancel_subscription(subscription_id: @gateway_subscription_id, at_cycle_end: false)
rescue StandardError => e
Rails.logger.error("ORPHANED GATEWAY SUBSCRIPTION: #{@gateway_subscription_id} - cancel failed: #{e.message}")
endIf cancellation also fails (network issue, gateway down), the error is logged with the ORPHANED GATEWAY SUBSCRIPTION prefix. These should be monitored and resolved manually.
Promo Code Race Conditions
The VerifyService re-resolves the promo code with a FOR UPDATE lock to prevent concurrent requests from exceeding max_uses:
@promo_code = Billing::PromoCode.lock('FOR UPDATE')
.available
.for_plan(@plan.id)
.find_by('LOWER(code) = ?', @promo_code_str.downcase)The record_usage! method uses an atomic conditional update:
rows = self.class.where(id: id)
.where('max_uses IS NULL OR current_uses < max_uses')
.update_all('current_uses = current_uses + 1')
raise 'Promo code usage limit exceeded' if rows.zero? && max_uses.present?Security Considerations
| Concern | Mitigation |
|---|---|
| Concurrent initiation | PostgreSQL advisory lock on (tenant_id, facility_id) |
| IDOR (plan/facility) | Plan is fetched from SubscriptionPlan.active.find(), facility from current_tenant.facilities.find() |
| Payment signature tampering | HMAC signature verified via gateway SDK before creating subscription |
| Double subscription | Unique partial index idx_billing_subs_active_facility on (tenant_id, facility_id) WHERE status IN ('active', 'trial') |
| Promo code exhaustion | Row-level FOR UPDATE lock + atomic conditional increment |
| Orphaned charges | Gateway subscription cancelled on verification failure |
| Double promise resolution | Settled guard in frontend prevents checkout promise from resolving twice |
| Authorization | SubscriptionPolicy#create? requires manage_subscription permission on tenant |
Key Files
| File | Purpose |
|---|---|
backend/domains/billing/services/subscriptions/initiate_service.rb | Gateway session creation |
backend/domains/billing/services/subscriptions/verify_service.rb | Payment verification and subscription creation |
backend/domains/billing/controllers/subscriptions_controller.rb | API endpoints for initiate, verify, and lifecycle |
frontend/packages/shared-services/src/checkout/index.ts | Gateway-agnostic checkout entry point |
frontend/packages/shared-services/src/checkout/types.ts | Shared TypeScript types |
frontend/packages/shared-services/src/checkout/adapters/razorpay.ts | Razorpay checkout adapter |
frontend/packages/shared-services/src/checkout/adapters/stripe.ts | Stripe checkout adapter (placeholder) |
frontend/apps/shell/src/pages/account/SubscriptionPage.tsx | Full checkout UI integration |