Kulcare Docs
DeveloperSubscriptions

Region-Specific Subscription Plans

Last Updated: 2026-04-05

Description

The Kulcare subscription system supports region-specific plans for three markets: India (IN), United States (US), and United Arab Emirates (AE). Each plan is defined with region, currency, and localized pricing so that a facility in a given country sees only plans priced in its local currency. The region is determined by the facility's country_code and is used throughout the stack -- from seed data and database constraints to API filtering and frontend display.

Implementation Status

  • Database schema with region, currency, price columns and unique constraint
  • API endpoint with region filtering (GET /billing/subscription_plans?region=IN)
  • Seed data JSON for all three regions with rake task
  • Frontend plan display filtered by the current facility's region
  • Gateway-to-region mapping via GatewayConfig::MARKET_GATEWAYS

Architecture

Region-Market Model

Each subscription plan row is scoped to a single region. Plans are duplicated per region rather than using currency conversion, giving full control over regional pricing.

Region     Currency     Gateway
------     --------     -------
IN         INR          Cashfree (feature flag: `BILLING_CASHFREE_ENABLED`) — falls back to Razorpay
US         USD          Razorpay (Stripe planned)
AE         AED          Razorpay (Stripe planned)

The mapping is defined in Billing::Gateways::GatewayConfig::MARKET_GATEWAYS and gated by a feature flag for India:

# Static config (defaults):
MARKET_GATEWAYS = {
  'IN' => :cashfree,   # Active when :BILLING_CASHFREE_ENABLED is ON (default)
  'US' => :razorpay,
  'AE' => :razorpay
}.freeze

REGION_CURRENCIES = { 'IN' => 'INR', 'US' => 'USD', 'AE' => 'AED' }.freeze

Feature flag: The gateway_for_market('IN') helper checks the flag at runtime:

  • Flag ON (default after seed) → returns :cashfree for India
  • Flag OFF → falls back to :razorpay instantly (no restart needed)
  • Existing Cashfree vendors/subscriptions are unaffected by the flag (gateway is stored in DB)

Toggle via:

Identity::FeatureFlags.disable(:BILLING_CASHFREE_ENABLED)  # Rollback to Razorpay
Identity::FeatureFlags.clear(:BILLING_CASHFREE_ENABLED)    # Back to the default (ON)

Region Determination Flow

Facility
  |
  +-- country_code (e.g. "IN")
        |
        +-- Used by API: plan.region == facility.country_code
        +-- Used by frontend: useLocale() -> region -> passed to useSubscriptionPlans({ region })
        +-- Used by gateway: GatewayFactory.for_facility(facility) -> country_code -> MARKET_GATEWAYS
  1. When a facility is created, it is assigned a country_code (e.g. "IN", "US", "AE").
  2. The frontend reads the region from useLocale() and passes it as a query parameter when fetching plans.
  3. The backend filters plans using the by_region scope.
  4. During subscription initiation, InitiateService validates that plan.region == facility.country_code.

Database Schema

Table: billing.subscription_plans

CREATE TABLE billing.subscription_plans (
    id uuid DEFAULT public.generate_uuidv7() NOT NULL,
    name character varying(255) NOT NULL,
    description text,
    tier character varying(50) NOT NULL,           -- free | starter | professional | enterprise
    billing_cycle billing.subscription_billing_cycle NOT NULL,  -- monthly | quarterly | semi_annual | annual
    interval integer DEFAULT 1 NOT NULL,
    region character varying(2) DEFAULT 'IN' NOT NULL,
    currency character varying(3) DEFAULT 'INR' NOT NULL,
    price numeric(12,2) NOT NULL,                  -- primary price in the plan's own currency
    price_inr numeric(12,2),                       -- reference price in INR
    price_usd numeric(12,2),                       -- reference price in USD
    price_aed numeric(12,2),                       -- reference price in AED
    features jsonb DEFAULT '{}' NOT NULL,
    limits jsonb DEFAULT '{}',
    trial_days integer,
    gateway_plan_ids jsonb DEFAULT '{}',            -- { "razorpay": "plan_xxx", "stripe": "price_xxx" }
    is_active boolean DEFAULT true NOT NULL,
    created_at timestamp(6) NOT NULL,
    updated_at timestamp(6) NOT NULL
);

Key Indexes

IndexTypePurpose
idx_unique_active_plan_per_region_tier_cycleUNIQUE (partial)Ensures one active plan per region + tier + billing_cycle combination. Only applies where is_active = true.
idx_billing_sub_plans_activeB-treeFast lookup of active plans.
idx_billing_sub_plans_tierB-treeFilter by tier.
index_subscription_plans_on_region_and_is_activeB-treeRegion-based queries on active plans.

Unique Constraint Detail

The partial unique index prevents two active plans from sharing the same (region, tier, billing_cycle):

CREATE UNIQUE INDEX idx_unique_active_plan_per_region_tier_cycle
  ON billing.subscription_plans (region, tier, billing_cycle)
  WHERE (is_active = true);

This allows deactivated plans to exist alongside active ones without conflict.

Model

File: backend/domains/billing/models/subscription_plan.rb

class SubscriptionPlan < BaseRecord
  self.table_name = 'billing.subscription_plans'

  TIERS = %w[free starter professional enterprise].freeze
  REGIONS = %w[IN US AE].freeze
  REGION_CURRENCIES = { 'IN' => 'INR', 'US' => 'USD', 'AE' => 'AED' }.freeze

  enum :billing_cycle, {
    monthly: 'monthly', quarterly: 'quarterly',
    semi_annual: 'semi_annual', annual: 'annual'
  }

  scope :active,    -> { where(is_active: true) }
  scope :by_region, ->(region) { where(region: region.to_s.upcase) }
  scope :by_tier,   ->(tier) { where(tier: tier) }
  scope :by_cycle,  ->(cycle) { where(billing_cycle: cycle) }
  scope :ordered,   -> { order(price: :asc) }

  def price_for_currency(currency_code)
    return price if currency_code.to_s.upcase == currency
    case currency_code.to_s.upcase
    when 'INR' then price_inr
    when 'USD' then price_usd
    when 'AED' then price_aed
    end
  end

  def gateway_plan_id_for(gateway_name)
    gateway_plan_ids&.dig(gateway_name.to_s)
  end
end

Key design notes:

  • The model is not tenant-scoped. Plans are global resources visible to all users.
  • price stores the canonical price in the plan's own currency.
  • price_inr, price_usd, price_aed are reference prices for cross-currency display (e.g. "Show all plans in USD").
  • gateway_plan_ids is a JSONB hash mapping gateway names to their plan IDs (e.g. { "razorpay": "plan_KFnwQRXVlmTD4L" }).

API Reference

GET /billing/subscription_plans

Returns active subscription plans. Public endpoint -- no authentication required.

File: backend/domains/billing/controllers/subscription_plans_controller.rb

Query Parameters:

ParameterTypeRequiredDescription
regionstringNoFilter by region code: IN, US, or AE
tierstringNoFilter by tier: free, starter, professional, enterprise
billing_cyclestringNoFilter by cycle: monthly, quarterly, semi_annual, annual

Caching: Response is cached for 30 minutes (expires_in 30.minutes, public: true).

Example Request:

GET /billing/subscription_plans?region=IN&billing_cycle=monthly

Example Response:

{
  "data": [
    {
      "id": "019...",
      "name": "Standard",
      "tier": "starter",
      "billing_cycle": "monthly",
      "is_active": true,
      "region": "IN",
      "currency": "INR",
      "price": "0.0",
      "price_inr": "0.0",
      "price_usd": "0",
      "price_aed": "0",
      "trial_days": 14,
      "features": {
        "patients_per_day": 10,
        "queue_appointments": true,
        "billing": true
      },
      "limits": {
        "monthly_appointments": 300,
        "locations": 1,
        "doctors": 3,
        "staff": 2
      }
    },
    {
      "id": "019...",
      "name": "Deluxe",
      "tier": "professional",
      "billing_cycle": "monthly",
      "is_active": true,
      "region": "IN",
      "currency": "INR",
      "price": "499.0",
      "price_inr": "499.0",
      "price_usd": "6",
      "price_aed": "22",
      "trial_days": 14,
      "features": { "..." },
      "limits": {}
    }
  ]
}

GET /billing/subscription_plans/:id

Returns a single plan with full detail including gateway_plan_ids. Public endpoint.

Example Response (detailed view adds gateway_plan_ids, created_at, updated_at):

{
  "data": {
    "id": "019...",
    "name": "Deluxe",
    "gateway_plan_ids": {
      "razorpay": "plan_KFnwQRXVlmTD4L"
    },
    "created_at": "2026-03-16T10:00:00.000Z",
    "updated_at": "2026-03-16T10:00:00.000Z"
  }
}

Frontend Integration

File: frontend/apps/shell/src/pages/account/SubscriptionPage.tsx

The frontend uses the useLocale() hook from @kulcare/localization to determine the current region. Plans are fetched with the region as a query parameter.

const { region } = useLocale()
const { data: plansData, isLoading } = useSubscriptionPlans({ region })

Plans are displayed in tabs grouped by billing_cycle (monthly, quarterly, semi-annual, annual). The currency symbol is resolved from the REGION_CONFIGS mapping:

<PlanCard
  price={plan.price}
  currency={plan.currency || REGION_CONFIGS[region].currency}
  interval={plan.interval}
  features={plan.features}
/>

Features and Limits Display

The features JSONB field is transformed into human-readable strings:

  • Boolean values (true) are converted to title-cased feature names.
  • Numeric values are prefixed to the feature name (e.g. "10 Patients Per Day").
  • The limits JSONB is appended as limit features, with -1 displayed as "Unlimited".

Seed Data

JSON Structure

Seed data lives in backend/lib/data/billing/subscription_plans.json (staging) and subscription_plans_live.json (production). Each entry contains:

{
  "name": "Deluxe",
  "description": "For growing clinics",
  "tier": "professional",
  "billing_cycle": "monthly",
  "interval": 1,
  "region": "IN",
  "currency": "INR",
  "price": 499.0,
  "price_inr": 499.0,
  "price_usd": 6,
  "price_aed": 22,
  "features": {
    "patients_per_day": null,
    "queue_appointments": true,
    "billing": true,
    "video_consultations": true,
    "digital_prescriptions": true,
    "clinic_website": true,
    "google_my_business": true
  },
  "limits": {},
  "trial_days": 14,
  "gateway_plan_ids": {
    "razorpay": "plan_KFnwQRXVlmTD4L"
  },
  "is_active": false
}

The features hash uses null to indicate "unlimited" for numeric features (e.g. patients_per_day: null means no limit), true/false for boolean features.

Rake Task

File: backend/lib/tasks/seed_subscription_plans.rake

Retired. rake db:seed_subscription_plans and rake new_schema:seed_subscription_plans now stop with a pointer to rake billing:seed_go_forward_plans, which seeds the two plans Kulcare sells (Essential and Premium) and their Cashfree plan IDs. The text below records how the old task worked.

bundle exec rake db:seed_subscription_plans

Behavior:

  1. Production guard: Aborts if Rails.env.production? to prevent accidental data loss. Production seeding should use a migration with upsert logic.
  2. Truncates billing.subscription_plans and resets the identity column.
  3. Loads the appropriate JSON file based on Rails environment.
  4. Creates a Billing::SubscriptionPlan for each entry.

Adding a New Region

To add a new region (e.g. GB):

  1. Add "GB" to SubscriptionPlan::REGIONS and REGION_CURRENCIES in the model.
  2. Add the gateway mapping in GatewayConfig::MARKET_GATEWAYS.
  3. Add a new price_gb column to the table via migration (optional -- you can use the existing price column since each plan row is region-specific).
  4. Add plan entries in subscription_plans.json with "region": "GB" and "currency": "GBP".
  5. Run the seed task in development, or create a migration for production.
  6. Update the frontend REGION_CONFIGS in @kulcare/localization to include the new region.

Gateway-to-Region Mapping

The GatewayConfig class controls which payment gateway processes payments for each market:

# backend/domains/billing/services/gateways/gateway_config.rb

MARKET_GATEWAYS = {
  'IN' => :razorpay,
  'US' => :razorpay,   # Stripe planned
  'AE' => :razorpay    # Stripe planned
}.freeze

GATEWAY_CURRENCIES = {
  razorpay: %w[INR USD AED]
}.freeze

The GatewayFactory.for_facility(facility) method resolves the gateway at runtime:

  1. Reads the facility's country_code.
  2. Checks for a tenant-level override in tenant.settings['billing']['gateway'].
  3. Falls back to MARKET_GATEWAYS for the country code.

This architecture allows a single facility to override its gateway (e.g. for testing Stripe) without affecting other facilities.

Key Files

FilePurpose
backend/domains/billing/models/subscription_plan.rbModel with regions, tiers, scopes
backend/domains/billing/controllers/subscription_plans_controller.rbPublic API for plan listing
backend/domains/billing/blueprints/subscription_plan_blueprint.rbJSON serialization
backend/domains/billing/services/gateways/gateway_config.rbMarket-to-gateway mapping
backend/lib/data/billing/subscription_plans.jsonStaging seed data
backend/lib/data/billing/subscription_plans_live.jsonProduction seed data
backend/lib/tasks/seed_subscription_plans.rakeSeed rake task
frontend/apps/shell/src/pages/account/SubscriptionPage.tsxPlan display UI

On this page