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,pricecolumns 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' }.freezeFeature flag: The gateway_for_market('IN') helper checks the flag at runtime:
- Flag ON (default after seed) → returns
:cashfreefor India - Flag OFF → falls back to
:razorpayinstantly (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- When a facility is created, it is assigned a
country_code(e.g."IN","US","AE"). - The frontend reads the region from
useLocale()and passes it as a query parameter when fetching plans. - The backend filters plans using the
by_regionscope. - During subscription initiation,
InitiateServicevalidates thatplan.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
| Index | Type | Purpose |
|---|---|---|
idx_unique_active_plan_per_region_tier_cycle | UNIQUE (partial) | Ensures one active plan per region + tier + billing_cycle combination. Only applies where is_active = true. |
idx_billing_sub_plans_active | B-tree | Fast lookup of active plans. |
idx_billing_sub_plans_tier | B-tree | Filter by tier. |
index_subscription_plans_on_region_and_is_active | B-tree | Region-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
endKey design notes:
- The model is not tenant-scoped. Plans are global resources visible to all users.
pricestores the canonical price in the plan's owncurrency.price_inr,price_usd,price_aedare reference prices for cross-currency display (e.g. "Show all plans in USD").gateway_plan_idsis 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:
| Parameter | Type | Required | Description |
|---|---|---|---|
region | string | No | Filter by region code: IN, US, or AE |
tier | string | No | Filter by tier: free, starter, professional, enterprise |
billing_cycle | string | No | Filter 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=monthlyExample 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
limitsJSONB is appended as limit features, with-1displayed 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_plansandrake new_schema:seed_subscription_plansnow stop with a pointer torake 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_plansBehavior:
- Production guard: Aborts if
Rails.env.production?to prevent accidental data loss. Production seeding should use a migration with upsert logic. - Truncates
billing.subscription_plansand resets the identity column. - Loads the appropriate JSON file based on Rails environment.
- Creates a
Billing::SubscriptionPlanfor each entry.
Adding a New Region
To add a new region (e.g. GB):
- Add
"GB"toSubscriptionPlan::REGIONSandREGION_CURRENCIESin the model. - Add the gateway mapping in
GatewayConfig::MARKET_GATEWAYS. - Add a new
price_gbcolumn to the table via migration (optional -- you can use the existingpricecolumn since each plan row is region-specific). - Add plan entries in
subscription_plans.jsonwith"region": "GB"and"currency": "GBP". - Run the seed task in development, or create a migration for production.
- Update the frontend
REGION_CONFIGSin@kulcare/localizationto 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]
}.freezeThe GatewayFactory.for_facility(facility) method resolves the gateway at runtime:
- Reads the facility's
country_code. - Checks for a tenant-level override in
tenant.settings['billing']['gateway']. - Falls back to
MARKET_GATEWAYSfor the country code.
This architecture allows a single facility to override its gateway (e.g. for testing Stripe) without affecting other facilities.
Key Files
| File | Purpose |
|---|---|
backend/domains/billing/models/subscription_plan.rb | Model with regions, tiers, scopes |
backend/domains/billing/controllers/subscription_plans_controller.rb | Public API for plan listing |
backend/domains/billing/blueprints/subscription_plan_blueprint.rb | JSON serialization |
backend/domains/billing/services/gateways/gateway_config.rb | Market-to-gateway mapping |
backend/lib/data/billing/subscription_plans.json | Staging seed data |
backend/lib/data/billing/subscription_plans_live.json | Production seed data |
backend/lib/tasks/seed_subscription_plans.rake | Seed rake task |
frontend/apps/shell/src/pages/account/SubscriptionPage.tsx | Plan display UI |