Billing Domain Architecture
Last Updated: 2026-04-05
Description
The domains/billing/ module is the new home for all billing-related functionality in the Kulcare backend, replacing the legacy parts/billing/ architecture. It uses a domain-driven structure with service objects, a gateway abstraction layer, Blueprinter-based serialization, Pundit policies, and a Result monad pattern for service outcomes. The module handles subscriptions, invoicing, payments, commissions, revenue sharing, and financial reporting.
Implementation Status
- Domain directory structure with models, controllers, services, policies, blueprints, jobs
- BaseRecord with automatic
billing.schema prefix - BaseService with Result monad pattern (success/failure)
- BaseBillingPolicy with tenant-scoped authorization
- Gateway abstraction layer (Base, Factory, Config, RazorpayGateway, CashfreeGateway)
- Subscription lifecycle (plans, initiate, verify, cancel, change plan, webhooks)
- Invoicing system (create, issue, PDF generation, email)
- Payment processing (gateway orders, verification, allocations)
- Commission system (rules, calculations, settlements, workflows)
- Revenue sharing (configs, calculations, distributions)
- Report exports (bill reports, consolidated reports)
- Promo code system with atomic usage tracking
- RSpec test suite in
spec/subdirectory
Directory Structure
backend/domains/billing/
|-- blueprints/ # Blueprinter serializers
| |-- commission_*.rb # Commission-related serializers
| |-- invoice_blueprint.rb
| |-- invoice_item_blueprint.rb
| |-- patient_service_blueprint.rb
| |-- payment_blueprint.rb
| |-- payment_allocation_blueprint.rb
| |-- report_export_blueprint.rb
| |-- revenue_share_*.rb # Revenue share serializers
| |-- subscription_blueprint.rb
| |-- subscription_invoice_blueprint.rb
| +-- subscription_plan_blueprint.rb
|
|-- concerns/ # Shared model concerns
|
|-- controllers/
| |-- admin/
| | +-- subscriptions_controller.rb # Admin-only subscription operations
| |-- activity_logs_controller.rb
| |-- commission_*.rb # Commission controllers
| |-- patient_invoices_controller.rb
| |-- patient_payments_controller.rb
| |-- patient_services_controller.rb
| |-- payment_allocations_controller.rb
| |-- payments_controller.rb
| |-- premium_reports_controller.rb
| |-- report_exports_controller.rb
| |-- revenue_share_*.rb # Revenue share controllers
| |-- subscription_plans_controller.rb
| |-- subscriptions_controller.rb
| +-- summary_controller.rb
|
|-- jobs/
| |-- commission_workflow_job.rb
| |-- delete_bill_report_job.rb
| |-- generate_bill_report_job.rb
| |-- invoice_email_job.rb
| |-- invoice_pdf_generation_job.rb
| |-- payment_webhook_processing_job.rb
| |-- report_export_job.rb
| |-- revenue_share_calculation_job.rb
| |-- subscription_expiry_job.rb
| +-- subscription_reminder_job.rb
|
|-- models/
| |-- base_record.rb # Abstract base with billing. schema prefix
| |-- billing_customer.rb
| |-- commission_*.rb # Commission models
| |-- invoice.rb
| |-- invoice_item.rb
| |-- patient_service.rb
| |-- payment.rb
| |-- payment_allocation.rb
| |-- promo_code.rb
| |-- razorpay_webhook.rb
| |-- razorpay_webhook_error.rb
| |-- report_export.rb
| |-- revenue_share_*.rb # Revenue share models
| |-- subscription.rb
| |-- subscription_invoice.rb
| +-- subscription_plan.rb
|
|-- policies/
| |-- base_billing_policy.rb # Base policy with tenant-scoped defaults
| |-- billing_summary_policy.rb
| |-- commission_*.rb # Commission policies
| |-- invoice_item_policy.rb
| |-- patient_invoice_policy.rb
| |-- patient_service_policy.rb
| |-- payment_allocation_policy.rb
| |-- payment_policy.rb
| |-- report_export_policy.rb
| |-- revenue_share_*.rb # Revenue share policies
| |-- subscription_invoice_policy.rb
| |-- subscription_plan_policy.rb
| +-- subscription_policy.rb
|
|-- services/
| |-- base_service.rb # Result monad (success/failure)
| |-- activity_logs/
| |-- billing_addresses/
| |-- commissions/ # Commission calculation, settlement, workflows
| |-- exports/ # Report generation and download
| |-- gateways/ # Payment gateway abstraction layer
| | |-- base.rb # Abstract gateway interface
| | |-- gateway_config.rb # Market-to-gateway mapping
| | |-- gateway_factory.rb # Factory for gateway resolution
| | +-- razorpay_gateway.rb # Razorpay implementation
| |-- invoices/ # Invoice creation, calculation, issuance
| |-- notifications/
| |-- orders/ # Patient payment orders
| |-- patient_invoices/ # Patient-scoped invoice operations
| |-- patient_payments/
| |-- patient_services/
| |-- payment_methods/
| |-- payments/ # Payment processing and gateway service
| |-- promo_codes/
| |-- refund_requests/
| |-- refunds/
| |-- reports/ # Financial reporting
| |-- revenue_share/ # Revenue sharing calculations
| |-- subscriptions/ # Subscription lifecycle services
| | |-- cancel_service.rb
| | |-- change_plan_service.rb
| | |-- create_service.rb
| | |-- current_service.rb
| | |-- initiate_service.rb
| | |-- manual_subscribe_service.rb
| | |-- verify_service.rb
| | +-- webhook_handler_service.rb
| +-- transfers/
|
|-- spec/ # RSpec tests
| |-- models/
| |-- requests/
| +-- services/
|
+-- routes.rb # All billing route definitionsCore Patterns
BaseRecord
File: backend/domains/billing/models/base_record.rb
All billing models inherit from Billing::BaseRecord which sets the PostgreSQL schema prefix to billing.:
class BaseRecord < ApplicationRecord
self.abstract_class = true
self.table_name_prefix = 'billing.'
endThis ensures all table lookups target the billing schema without needing explicit table name declarations (though models can override self.table_name if needed).
BaseService with Result Monad
File: backend/domains/billing/services/base_service.rb
All service objects inherit from Billing::BaseService and return a Result struct:
class BaseService
Result = Struct.new(:success?, :data, :errors, keyword_init: true)
def self.call(...)
new(...).call
end
private
def success(data = {})
Result.new(success?: true, data: data, errors: [])
end
def failure(errors:)
Result.new(success?: false, data: nil, errors: Array(errors))
end
def failure_from_record(record)
failure(errors: record.errors.full_messages)
end
endUsage pattern in controllers:
result = Subscriptions::InitiateService.call(tenant: current_tenant, ...)
if result.success?
render json: { data: result.data[:checkout] }, status: :ok
else
render_validation_errors(result.errors)
endThe handle_service_result helper is available in controllers for the common render pattern:
handle_service_result(result, SubscriptionBlueprint, created: true, view: :with_plan)BaseBillingPolicy
File: backend/domains/billing/policies/base_billing_policy.rb
Provides tenant-scoped authorization defaults for all billing resources:
class BaseBillingPolicy < Shared::BasePolicy
def index?
tenant_member? && has_any_permission?(:view_practice_finance, :manage_practice_finance)
end
def show?
index? && record_in_tenant?
end
def create?
tenant_member? && has_permission?(:manage_practice_finance)
end
def update?
tenant_member? && has_permission?(:manage_practice_finance) && record_in_tenant?
end
def destroy?
tenant_member? && has_permission?(:manage_practice_finance) && record_in_tenant?
end
class Scope < Shared::BasePolicy::Scope
def resolve
if tenant_member? && view_permission?
scope.where(tenant_id: current_tenant.id)
else
scope.none
end
end
end
endKey permissions:
view_practice_finance: Read-only access to practice-finance data (index, show).manage_practice_finance: Full CRUD access to practice finance.
Individual policies override methods for resource-specific rules. For example, SubscriptionPlanPolicy makes index? and show? public (no auth required) since plans are a public catalog.
The three billing permission families
manage_practice_finance used to gate three unrelated things at once, so granting someone
the ability to settle a stakeholder payout also let them raise a patient bill and
cancel the tenant's Kulcare subscription. Three splits separated them
(#2277, #2274). BaseBillingPolicy now means practice finance and nothing else.
| Family | Permissions | Base policy | Covers |
|---|---|---|---|
| Patient bills | view_bills, add_bills, edit_bills | PatientBillingPolicy | The front desk's and the clinician's work: raising a bill, taking a payment, issuing a refund |
| Practice finance | view_practice_finance, manage_practice_finance | BaseBillingPolicy | Commissions, revenue share, payouts, P&L |
| Kulcare subscription | view_subscription, manage_subscription | SubscriptionBillingPolicy | The tenant's own plan, subscription invoices, payment methods, module add-ons |
Which base class to inherit is the decision that matters. Sharing a parent is how the four subscription policies ended up on the practice-finance permission in the first place — the comment above them described subscription management while the gate they inherited covered commissions too. Pick the family by asking whose money is this: the patient's, the practice's, or Kulcare's.
SubscriptionBillingPolicy is deliberately narrower than the other two:
view_practice_finance / manage_practice_finance seed for owner and admin, the subscription pair
for the owner alone. An admin who should be able to change the plan is granted
it at the role level, per tenant — not by virtue of the role name. Note that
has_permission? short-circuits on role_owner?, so an owner holds it with no
row at all.
Two surfaces deliberately do not follow their file name:
BillingAddressPolicystays onBaseBillingPolicy. Despite the name it is the facility's tax identity —GstResolver.gst_registered?is defined as "this facility has a billing address carrying a GSTIN" — so editing it decides whether GST is charged on every patient invoice. Moving it to patient billing would let any practitioner rewrite the clinic's GSTIN.ModuleAddonPolicy#index?is not widened tomanage_practitioners, even though the speciality column on the user-management screen reads it. The blueprint rendersamountandcurrency, andmanage_practitionersseeds for owner, admin and practitioner — so widening it would publish what each speciality costs to every practitioner. The screen degrades on the client instead.
Gateway Abstraction Layer
Overview
The gateway layer provides a polymorphic interface for payment processing. Each market (country) can use a different payment gateway, and the correct one is resolved at runtime.
GatewayFactory.for_facility(facility)
|
+-- facility.country_code -> GatewayConfig.implemented_gateway_for_market('IN', tenant)
| -> :cashfree when BILLING_CASHFREE_ENABLED, else :razorpay
|
+-- tenant.settings['billing']['gateway'] is IGNORED — the per-tenant lever
| is the feature flag, scoped to the tenant
|
+-- GatewayConfig.gateway_class(:razorpay) -> 'Billing::Gateways::RazorpayGateway'
|
+-- RazorpayGateway.newBase (Abstract Interface)
File: backend/domains/billing/services/gateways/base.rb
Defines the contract all gateway implementations must satisfy:
| Method Group | Methods |
|---|---|
| Customers | create_customer, fetch_customer |
| Subscriptions | create_subscription, update_subscription, cancel_subscription, fetch_subscription |
| Orders | create_order, fetch_order |
| Payments | fetch_payment, verify_payment_signature, verify_subscription_signature |
| Keys | publishable_key |
| Refunds | create_refund, fetch_refund |
| Transfers | create_transfer, fetch_transfer |
| Webhooks | verify_webhook_signature |
All methods raise NotImplementedError by default, so missing implementations fail loudly.
GatewayConfig
File: backend/domains/billing/services/gateways/gateway_config.rb
Static configuration mapping markets to gateways. India routes to Cashfree (default) or Razorpay, gated by a feature flag for safe rollout:
# Static defaults (runtime check via Identity::FeatureFlags):
MARKET_GATEWAYS = { # INTENT. Not every entry is implemented.
'IN' => :cashfree, # Gated by :BILLING_CASHFREE_ENABLED (default ON)
'US' => :razorpay,
'AE' => :stripe # intended; no Stripe class in GATEWAY_CLASSES yet
}.freeze
GATEWAY_CLASSES = { # What can actually be BUILT.
razorpay: 'Billing::Gateways::RazorpayGateway',
cashfree: 'Billing::Gateways::CashfreeGateway'
}.freeze
GATEWAY_CURRENCIES = {
razorpay: %w[INR USD AED],
cashfree: %w[INR] # India-only for now
}.freeze
FALLBACK_GATEWAY = :razorpay # broadest currency support of the implemented twoHelper methods:
gateway_for_market(country_code, tenant = nil)-- the market's intended gateway, defaults to:razorpay. For India, checksIdentity::FeatureFlags.enabled?(:BILLING_CASHFREE_ENABLED, tenant)and falls back to:razorpayif disabled (instant rollback, no restart).implemented_gateway_for_market(country_code, tenant = nil)-- the same answer, guaranteed to be a keyGATEWAY_CLASSEScan build. An unimplemented market falls back toFALLBACK_GATEWAYand logs a warning naming the market and the gateway it was meant to use.GatewayFactory.for_facilitycalls this one; callinggateway_for_marketthere raisedArgumentError: Unknown gateway: stripefor every AE facility and blocked subscription, order and care-package checkout for that market.supported_currencies(gateway)-- returns the list of currencies a gateway supports.
Market first, then the flag.
for_facilitypreviously readcountry_codeand then picked onBILLING_CASHFREE_ENABLEDalone, discarding the country — so turning the India rollout on routed US facilities to Cashfree, which supports only INR. It routes throughimplemented_gateway_for_marketnow, so the flag applies to India and every other market keeps its own gateway.The per-tenant lever is that flag, scoped to the tenant.
tenant.settings['billing']['gateway']is deliberately ignored.
gateway_class(gateway_key)-- returns the class name string, raisesArgumentErrorfor unknown keys.available_gateways-- returns all registered gateway keys.
GatewayFactory
File: backend/domains/billing/services/gateways/gateway_factory.rb
Two entry points:
# Resolve by facility (primary usage in services)
gateway = GatewayFactory.for_facility(facility)
# Resolve by explicit key (for webhooks)
gateway = GatewayFactory.for(:razorpay)for_facility checks for a per-tenant gateway override in tenant.settings['billing']['gateway'] JSONB before falling back to the market config.
RazorpayGateway
File: backend/domains/billing/services/gateways/razorpay_gateway.rb
Implements all Base methods using the Razorpay Ruby SDK. Key implementation details:
- Amount conversion: Razorpay expects amounts in paise (smallest currency unit), so
create_orderandcreate_refundmultiply by 100. - Signature verification: Uses
Razorpay::Utility.verify_payment_signatureand catchesSecurityError/Razorpay::Errorto returntrue/false. - Response normalization:
normalize_responseextracts.attributesfrom Razorpay response objects for a consistent hash interface.
CashfreeGateway
Files:
backend/domains/billing/services/gateways/cashfree_gateway.rb— single-merchant SaaS adapterbackend/domains/billing/services/gateways/cashfree/http_client.rb— HTTP client with auth, versioning, error mappingbackend/domains/billing/services/marketplace/cashfree_gateway.rb— marketplace Easy Split adapter
Implements all Base methods via direct HTTP (no Ruby SDK). Key implementation details:
- API version:
2025-01-01(set viax-api-versionheader) - Authentication:
x-client-id+x-client-secretheaders (read fromRails.application.credentials.cashfree) - Webhook signature:
Base64(HMAC-SHA256(timestamp + raw_body, client_secret))— no separator between timestamp and body (per official Cashfree Go SDK; docs notation$timestamp.$payloaduses$as a variable prefix, not a literal dot). - Subscription mandate flow: Cashfree uses UPI mandates — customer authorizes once on Cashfree's hosted page, then Cashfree auto-charges on schedule. Unlike Razorpay's popup-then-verify flow, Cashfree activates the subscription via the
SUBSCRIPTION_STATUS_CHANGED → ACTIVEwebhook. The local subscription record is created aspendingduringInitiateServiceand transitioned totrial/activeby the webhook handler. - Webhook events handled:
SUBSCRIPTION_AUTH_STATUS,SUBSCRIPTION_STATUS_CHANGED,SUBSCRIPTION_PAYMENT_SUCCESS(AUTH + CHARGE),SUBSCRIPTION_PAYMENT_FAILED, plus the one-timePAYMENT_SUCCESS/PAYMENT_FAILEDfor non-subscription orders. - Marketplace: Implements
Billing::Marketplace::GatewayInterfacefor Easy Split vendor onboarding (POST /easy-split/vendors) and split payments (order_splitsat order creation). - Plan pre-creation required: Unlike Razorpay, Cashfree subscription plans must be created in the dashboard (or via
POST /plans) beforePOST /subscriptionscan reference them. Plan IDs in the dashboard must matchgateway_plan_ids.cashfreein our DB.
Key Models
Subscription
File: backend/domains/billing/models/subscription.rb
Tracks a facility's subscription to a plan.
| Column | Type | Description |
|---|---|---|
tenant_id | UUID | Owning tenant (FK to identity.tenants) |
facility_id | UUID | Subscribing facility (FK to identity.facilities) |
plan_id | UUID | The subscription plan (FK to billing.subscription_plans) |
status | enum | pending, trial, active, past_due, cancelled, paused, expired |
billing_cycle | enum | monthly, quarterly, semi_annual, annual |
currency | varchar(3) | ISO currency code |
amount | numeric(12,2) | Amount charged per cycle |
start_date | date | Subscription start |
end_date | date | Current period end |
trial_end_date | date | Trial period end (null if no trial) |
next_billing_date | date | Next charge date |
gateway_subscription_id | varchar | External gateway subscription ID |
gateway | varchar | Gateway name (e.g. "razorpay") |
metadata | JSONB | Additional data (payment IDs, promo code info) |
Status access levels:
trial,active-- full access (full_access?)pending-- mandate not yet authorized (Cashfree flow); customer can retrypast_due-- degraded access with 7-day grace period (in_grace_period?)expired,cancelled,paused-- read-only (read_only?)
The pending row
A pending row is written the moment a Cashfree mandate page opens, before any
payment. It means only that someone started checkout. Two rules follow, and
both have cost real money when broken.
It never grants access, and is never the current subscription. full_access?
and the active_or_trial scope both exclude it (#1641). CurrentService reports
it separately, under meta.pending_checkout — never in data, so no consumer can
mistake it for a subscription.
It must not be retired without asking the gateway. A pending row means either
an abandoned mandate or an authorized one whose webhook is late. Retiring the
second loses the payment: the customer is charged and never activated. Every path
that retires one asks Billing::Subscriptions::MandateState first, which returns
:unauthorized, :authorized, or :unknown and never guesses (#2121).
| Path | Behaviour |
|---|---|
InitiateService retry | Refuses if any pending mandate is authorized or unconfirmable; otherwise expires the superseded row so the retry proceeds at once |
CancelService discard | Expires only a confirmed-unauthorized mandate |
StalePendingCleanupJob | Backstop for rows nobody retries (STALE_AFTER = 24.hours, daily) |
CashfreeWebhookProcessor | Activates pending and expired rows on an ACTIVE mandate — two initiations can race across the unlocked gateway call, and Cashfree saying ACTIVE means money will flow |
Unique constraint: A partial unique index ensures only one active or trial subscription per facility:
CREATE UNIQUE INDEX idx_billing_subs_active_facility
ON billing.subscriptions (tenant_id, facility_id)
WHERE (status IN ('active', 'trial'));SubscriptionPlan
File: backend/domains/billing/models/subscription_plan.rb
Global (not tenant-scoped) plan definitions. See Region-Specific Subscription Plans for full documentation.
PromoCode
File: backend/domains/billing/models/promo_code.rb
Promotional discount codes for subscriptions.
| Column | Type | Description |
|---|---|---|
tenant_id | UUID (nullable) | NULL for global codes, tenant UUID for tenant-specific |
code | varchar(50) | The promo code string |
discount_type | enum | fixed or percentage |
discount_amount | numeric(12,2) | Fixed discount amount (when type is fixed) |
discount_percentage | numeric(5,2) | Percentage discount (when type is percentage) |
plan_id | UUID (nullable) | Restrict to a specific plan, or NULL for any plan |
currency | varchar(3) | Required for fixed discount codes |
expires_at | date | Expiration date (nullable for no expiry) |
max_uses | integer | Maximum redemption count (nullable for unlimited) |
current_uses | integer | Atomic counter incremented on each use |
Key behaviors:
usable?checksis_active, expiry, andmax_uses.calculate_discount(plan_amount)returns the discount, capped at plan amount for fixed codes.record_usage!uses atomic conditional SQL to prevent exceedingmax_usesunder concurrent load.
Key API Endpoints
The complete route map is defined in backend/domains/billing/routes.rb. Below is a summary of the primary endpoint groups:
| Path | Methods | Auth | Description |
|---|---|---|---|
/billing/subscription_plans | GET | Public | Plan catalog with region/tier/cycle filtering |
/billing/subscription_plans/:id | GET | Public | Single plan detail |
/billing/subscriptions | GET, POST | Tenant | List and create subscriptions |
/billing/subscriptions/current | GET | Tenant | Subscription granting access, plus any unfinished checkout under meta |
/billing/subscriptions/initiate | POST | Tenant | Start gateway checkout flow |
/billing/subscriptions/verify | POST | Tenant | Verify payment and create subscription |
/billing/subscriptions/change_plan | POST | Tenant | Upgrade or downgrade plan |
/billing/subscriptions/:id/cancel | POST | Tenant | Cancel a subscription, or discard an unauthorized pending checkout |
/billing/subscriptions/:id/invoices | GET | Tenant | Subscription billing invoices |
/billing/promo_codes/validate | POST | Tenant | Validate a promo code |
/billing/invoices | CRUD | Tenant | Invoice management |
/billing/invoices/:id/issue | POST | Tenant | Transition draft to issued |
/billing/invoices/:id/pdf | GET | Tenant | Download invoice PDF |
/billing/payments | CRUD | Tenant | Payment processing |
/billing/payments/:id/initiate_gateway | POST | Tenant | Start gateway payment flow |
/billing/payments/:id/verify_gateway | POST | Tenant | Verify gateway payment |
/billing/commission_rules | CRUD | Tenant | Commission rule management |
/billing/commission_calculations | GET, PATCH | Tenant | Commission calculations |
/billing/commission_settlements | GET, POST | Tenant | Commission settlement management |
/billing/revenue_share_configs | CRUD | Tenant | Revenue share configurations |
/billing/revenue_share_configs/:id/activate | POST | Tenant | Activate a draft or inactive config (one active per facility, unique-indexed) |
/billing/revenue_share_configs/:id/deactivate | POST | Tenant | Return an active config to inactive |
/billing/revenue_share_calculations | GET, POST | Tenant | Revenue share calculations |
/billing/report_exports | GET, POST | Tenant | Financial report exports |
/billing/facilities/:facility_id/summary | GET | Tenant | Billing dashboard summary |
/billing/webhooks/:gateway | POST | Public | Gateway webhook receiver |
/billing/admin/subscriptions/manual_subscribe | POST | Admin | Admin-only manual subscription |
/billing/admin/subscriptions/:id/extend_trial | POST | Admin | Admin-only trial extension |
/billing/admin/promo_codes | GET, POST | Platform admin | List (filter active) and create promo codes |
/billing/admin/promo_codes/:id | GET, PATCH, PUT | Platform admin | Promo code detail and update |
/billing/admin/promo_codes/:id/deactivate | POST | Platform admin | Set is_active: false; returns the updated code (404 if unknown) |
How to Extend
Adding a Platform-Admin Action
Controllers under backend/domains/billing/controllers/admin/ authorize against a Billing::Admin::*Policy. Most inherit
Shared::PlatformAdminBaseController (SubscriptionOperationsController is the exception, on Billing::BaseController).
The policies inherit either Shared::PlatformAdminApplicationPolicy (PromoCodePolicy, PaymentPolicy, SubscriptionPolicy,
whose CRUD queries return platform_admin?) or Shared::BasePolicy (ModuleAddonPlanPolicy, PlanPricePolicy, whose
undefined CRUD queries return false, so check what the subclass overrides).
A bare authorize record, policy_class: ... makes Pundit infer <action>? from the action name, so a custom action such as
deactivate needs its own query on the policy. If it is missing, the request raises NoMethodError and returns 500, not 403
(#2360). Define the query on the policy, as a delegation to the CRUD query it narrows (def deactivate? = update?), so it can
never be looser than that query. Passing an explicit symbol at the call site (authorize record, :update?, ...) also works at
runtime, but it does not satisfy the route-derived guard in
domains/billing/test/policies/admin_promo_code_policy_test.rb, which requires one query per routed action regardless of how
the controller authorizes. Define the method either way. That guard test is what catches this bug class.
Known limitation: billing.promo_codes is under FORCE RLS whose policy has no platform-admin branch, and
PromoCodesController does not include Shared::Concerns::PlatformAdminRls, so tenant-scoped promo codes are not visible to
the admin promo code endpoints when the app connects as a role without BYPASSRLS: index omits them and show/update/deactivate
return 404. Reads of global codes (tenant_id NULL) are unaffected.
The same predicate is also the policy's WITH CHECK, so writes are blocked too. Creating a promo code with a non-NULL
tenant_id, or setting one on update, is rejected by Postgres under such a role. Widening this means adding a platform-admin
branch to USING, and deciding separately whether WITH CHECK should get one: a WITH CHECK branch grants cross-tenant
promo code writes, which a read fix does not need.
Adding a New Payment Gateway
See the Subscription Checkout Flow guide for the step-by-step process.
Summary:
- Create
backend/domains/billing/services/gateways/new_gateway.rbimplementingBase. - Register in
GatewayConfig::GATEWAY_CLASSESandMARKET_GATEWAYS. - Create
frontend/packages/shared-services/src/checkout/adapters/new_gateway.ts. - Update seed data with the gateway's plan IDs.
Adding a New Subscription Feature
To add a new service (e.g. add-ons, usage-based billing):
- Model: Create a new model in
backend/domains/billing/models/extendingBaseRecord. Add tenant scoping withinclude Shared::Concerns::TenantScopedif the resource is tenant-owned. - Service: Create service objects in
backend/domains/billing/services/your_feature/, extendingBaseService. - Policy: Create a policy in
backend/domains/billing/policies/extendingBaseBillingPolicy. Override methods as needed. - Blueprint: Create a Blueprinter serializer in
backend/domains/billing/blueprints/. - Controller: Create a controller extending
BaseControllerwith standard CRUD actions. - Routes: Add routes in
backend/domains/billing/routes.rbinside thescope :billingblock. - Tests: Add specs in
backend/domains/billing/spec/.
Adding New Commission or Revenue Share Types
The commission and revenue share systems follow the same service pattern:
- Define the calculation logic in a service under
services/commissions/orservices/revenue_share/. - Create background jobs in
jobs/for scheduled calculations. - Add controller actions and routes for management.
- Use the
ActivityLoggableconcern for audit trail on sensitive changes.
Relationship Diagram
SubscriptionPlan (global)
|
+--< Subscription (tenant-scoped)
| |-- belongs_to :facility
| |-- belongs_to :billing_customer
| +--< SubscriptionInvoice
|
+--< PromoCode (global or tenant-scoped)
|-- optional belongs_to :plan
+-- used by InitiateService and VerifyService
BillingCustomer
|-- belongs_to :user
+--< Subscription
Invoice (tenant-scoped)
|-- belongs_to :facility
+--< InvoiceItem
+--< PaymentAllocation --< Payment
CommissionRule (tenant-scoped)
+--< CommissionCalculation
+--< CommissionSettlement
RevenueShareConfig (tenant-scoped)
+--< RevenueShareCalculation
+--< RevenueShareDistributionMigration from Legacy parts/billing/
The domains/billing/ module replaces the legacy parts/billing/ system. Key differences:
| Aspect | Legacy (parts/billing/) | New (domains/billing/) |
|---|---|---|
| Base class | ApplicationRecord | Billing::BaseRecord (auto-schema prefix) |
| Business logic | Interactor pattern | Service object + Result monad |
| Serialization | ActiveModel Serializers | Blueprinter |
| Authorization | Pundit with ad-hoc policies | BaseBillingPolicy with consistent defaults |
| Gateway | Direct Razorpay SDK calls | Gateway abstraction layer (Base, Factory, Config) |
| Multi-region | Single region (IN only) | Region-aware plans, currencies, gateways |
| Schema | public schema tables | billing PostgreSQL schema with RLS |
| Routes | parts/billing/routes.rb | domains/billing/routes.rb |
The new architecture supports multi-gateway, multi-region, and multi-currency operations while maintaining a clean separation between the billing domain and the rest of the application.
Key Files
| File | Purpose |
|---|---|
backend/domains/billing/routes.rb | All billing route definitions |
backend/domains/billing/models/base_record.rb | Abstract base with schema prefix |
backend/domains/billing/services/base_service.rb | Result monad pattern |
backend/domains/billing/policies/base_billing_policy.rb | Tenant-scoped authorization defaults |
backend/domains/billing/services/gateways/base.rb | Abstract gateway interface |
backend/domains/billing/services/gateways/gateway_factory.rb | Gateway resolution logic |
backend/domains/billing/services/gateways/gateway_config.rb | Market-to-gateway mapping |
backend/domains/billing/services/gateways/razorpay_gateway.rb | Razorpay implementation |
backend/domains/billing/models/subscription.rb | Subscription model |
backend/domains/billing/models/subscription_plan.rb | Plan model |
backend/domains/billing/models/promo_code.rb | Promo code model |
backend/domains/billing/controllers/subscriptions_controller.rb | Subscription API |
backend/domains/billing/policies/subscription_policy.rb | Subscription authorization |