Kulcare Docs
DeveloperSubscriptions

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 the gateway field 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:

FieldTypeRequiredDescription
plan_idstring (UUID)YesID of the subscription plan
facility_idstring (UUID)YesID of the facility subscribing
promo_codestringNoPromotional 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:

StatusErrorCause
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:

FieldTypeRequiredDescription
plan_idstring (UUID)YesID of the subscription plan
facility_idstring (UUID)YesID of the facility
payment_idstringYesGateway payment ID (e.g. pay_QA1b2c3d4e5f6g)
subscription_idstringYesGateway subscription ID
signaturestringYesHMAC signature from gateway callback
promo_codestringNoPromo code applied during initiation
customer_idstring (UUID)NoBilling 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:

StatusErrorCause
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:

  1. Validates plan region matches facility country code.
  2. Acquires advisory lock on (tenant_id, facility_id) using pg_advisory_xact_lock to prevent concurrent initiations.
  3. Checks no active or trial subscription exists for the facility.
  4. Resolves promo code (if provided) and calculates the discounted amount.
  5. Finds or creates a billing customer on the gateway (e.g. Razorpay customer).
  6. Creates a gateway subscription session via the gateway abstraction layer.
  7. 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 hash

VerifyService

File: backend/domains/billing/services/subscriptions/verify_service.rb

Responsibilities:

  1. Validates plan region matches facility.
  2. Verifies payment signature via the gateway's verify_subscription_signature() method.
  3. Resolves promo code again (with FOR UPDATE lock to prevent race conditions on usage count).
  4. Validates no active subscription exists (double-check inside transaction).
  5. Creates the Billing::Subscription record with status :trial or :active based on trial_days.
  6. Records promo code usage atomically.
  7. 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::Subscription

Frontend 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:

  1. Dynamically loads the Razorpay checkout script (https://checkout.razorpay.com/v1/checkout.js) if not already present.
  2. Opens the payment dialog with the subscription_id and gateway_key from the initiate response.
  3. 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
end

2. 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
}.freeze

3. 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}")
end

If 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

ConcernMitigation
Concurrent initiationPostgreSQL 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 tamperingHMAC signature verified via gateway SDK before creating subscription
Double subscriptionUnique partial index idx_billing_subs_active_facility on (tenant_id, facility_id) WHERE status IN ('active', 'trial')
Promo code exhaustionRow-level FOR UPDATE lock + atomic conditional increment
Orphaned chargesGateway subscription cancelled on verification failure
Double promise resolutionSettled guard in frontend prevents checkout promise from resolving twice
AuthorizationSubscriptionPolicy#create? requires manage_subscription permission on tenant

Key Files

FilePurpose
backend/domains/billing/services/subscriptions/initiate_service.rbGateway session creation
backend/domains/billing/services/subscriptions/verify_service.rbPayment verification and subscription creation
backend/domains/billing/controllers/subscriptions_controller.rbAPI endpoints for initiate, verify, and lifecycle
frontend/packages/shared-services/src/checkout/index.tsGateway-agnostic checkout entry point
frontend/packages/shared-services/src/checkout/types.tsShared TypeScript types
frontend/packages/shared-services/src/checkout/adapters/razorpay.tsRazorpay checkout adapter
frontend/packages/shared-services/src/checkout/adapters/stripe.tsStripe checkout adapter (placeholder)
frontend/apps/shell/src/pages/account/SubscriptionPage.tsxFull checkout UI integration

On this page