Kulcare Docs
DeveloperBilling

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 definitions

Core 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.'
end

This 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
end

Usage 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)
end

The 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
end

Key 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.

FamilyPermissionsBase policyCovers
Patient billsview_bills, add_bills, edit_billsPatientBillingPolicyThe front desk's and the clinician's work: raising a bill, taking a payment, issuing a refund
Practice financeview_practice_finance, manage_practice_financeBaseBillingPolicyCommissions, revenue share, payouts, P&L
Kulcare subscriptionview_subscription, manage_subscriptionSubscriptionBillingPolicyThe 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:

  • BillingAddressPolicy stays on BaseBillingPolicy. 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 to manage_practitioners, even though the speciality column on the user-management screen reads it. The blueprint renders amount and currency, and manage_practitioners seeds 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.new

Base (Abstract Interface)

File: backend/domains/billing/services/gateways/base.rb

Defines the contract all gateway implementations must satisfy:

Method GroupMethods
Customerscreate_customer, fetch_customer
Subscriptionscreate_subscription, update_subscription, cancel_subscription, fetch_subscription
Orderscreate_order, fetch_order
Paymentsfetch_payment, verify_payment_signature, verify_subscription_signature
Keyspublishable_key
Refundscreate_refund, fetch_refund
Transferscreate_transfer, fetch_transfer
Webhooksverify_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 two

Helper methods:

  • gateway_for_market(country_code, tenant = nil) -- the market's intended gateway, defaults to :razorpay. For India, checks Identity::FeatureFlags.enabled?(:BILLING_CASHFREE_ENABLED, tenant) and falls back to :razorpay if disabled (instant rollback, no restart).
  • implemented_gateway_for_market(country_code, tenant = nil) -- the same answer, guaranteed to be a key GATEWAY_CLASSES can build. An unimplemented market falls back to FALLBACK_GATEWAY and logs a warning naming the market and the gateway it was meant to use. GatewayFactory.for_facility calls this one; calling gateway_for_market there raised ArgumentError: Unknown gateway: stripe for 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_facility previously read country_code and then picked on BILLING_CASHFREE_ENABLED alone, discarding the country — so turning the India rollout on routed US facilities to Cashfree, which supports only INR. It routes through implemented_gateway_for_market now, 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, raises ArgumentError for 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_order and create_refund multiply by 100.
  • Signature verification: Uses Razorpay::Utility.verify_payment_signature and catches SecurityError/Razorpay::Error to return true/false.
  • Response normalization: normalize_response extracts .attributes from Razorpay response objects for a consistent hash interface.

CashfreeGateway

Files:

  • backend/domains/billing/services/gateways/cashfree_gateway.rb — single-merchant SaaS adapter
  • backend/domains/billing/services/gateways/cashfree/http_client.rb — HTTP client with auth, versioning, error mapping
  • backend/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 via x-api-version header)
  • Authentication: x-client-id + x-client-secret headers (read from Rails.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.$payload uses $ 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 → ACTIVE webhook. The local subscription record is created as pending during InitiateService and transitioned to trial/active by the webhook handler.
  • Webhook events handled: SUBSCRIPTION_AUTH_STATUS, SUBSCRIPTION_STATUS_CHANGED, SUBSCRIPTION_PAYMENT_SUCCESS (AUTH + CHARGE), SUBSCRIPTION_PAYMENT_FAILED, plus the one-time PAYMENT_SUCCESS/PAYMENT_FAILED for non-subscription orders.
  • Marketplace: Implements Billing::Marketplace::GatewayInterface for Easy Split vendor onboarding (POST /easy-split/vendors) and split payments (order_splits at order creation).
  • Plan pre-creation required: Unlike Razorpay, Cashfree subscription plans must be created in the dashboard (or via POST /plans) before POST /subscriptions can reference them. Plan IDs in the dashboard must match gateway_plan_ids.cashfree in our DB.

Key Models

Subscription

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

Tracks a facility's subscription to a plan.

ColumnTypeDescription
tenant_idUUIDOwning tenant (FK to identity.tenants)
facility_idUUIDSubscribing facility (FK to identity.facilities)
plan_idUUIDThe subscription plan (FK to billing.subscription_plans)
statusenumpending, trial, active, past_due, cancelled, paused, expired
billing_cycleenummonthly, quarterly, semi_annual, annual
currencyvarchar(3)ISO currency code
amountnumeric(12,2)Amount charged per cycle
start_datedateSubscription start
end_datedateCurrent period end
trial_end_datedateTrial period end (null if no trial)
next_billing_datedateNext charge date
gateway_subscription_idvarcharExternal gateway subscription ID
gatewayvarcharGateway name (e.g. "razorpay")
metadataJSONBAdditional data (payment IDs, promo code info)

Status access levels:

  • trial, active -- full access (full_access?)
  • pending -- mandate not yet authorized (Cashfree flow); customer can retry
  • past_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).

PathBehaviour
InitiateService retryRefuses if any pending mandate is authorized or unconfirmable; otherwise expires the superseded row so the retry proceeds at once
CancelService discardExpires only a confirmed-unauthorized mandate
StalePendingCleanupJobBackstop for rows nobody retries (STALE_AFTER = 24.hours, daily)
CashfreeWebhookProcessorActivates 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.

ColumnTypeDescription
tenant_idUUID (nullable)NULL for global codes, tenant UUID for tenant-specific
codevarchar(50)The promo code string
discount_typeenumfixed or percentage
discount_amountnumeric(12,2)Fixed discount amount (when type is fixed)
discount_percentagenumeric(5,2)Percentage discount (when type is percentage)
plan_idUUID (nullable)Restrict to a specific plan, or NULL for any plan
currencyvarchar(3)Required for fixed discount codes
expires_atdateExpiration date (nullable for no expiry)
max_usesintegerMaximum redemption count (nullable for unlimited)
current_usesintegerAtomic counter incremented on each use

Key behaviors:

  • usable? checks is_active, expiry, and max_uses.
  • calculate_discount(plan_amount) returns the discount, capped at plan amount for fixed codes.
  • record_usage! uses atomic conditional SQL to prevent exceeding max_uses under 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:

PathMethodsAuthDescription
/billing/subscription_plansGETPublicPlan catalog with region/tier/cycle filtering
/billing/subscription_plans/:idGETPublicSingle plan detail
/billing/subscriptionsGET, POSTTenantList and create subscriptions
/billing/subscriptions/currentGETTenantSubscription granting access, plus any unfinished checkout under meta
/billing/subscriptions/initiatePOSTTenantStart gateway checkout flow
/billing/subscriptions/verifyPOSTTenantVerify payment and create subscription
/billing/subscriptions/change_planPOSTTenantUpgrade or downgrade plan
/billing/subscriptions/:id/cancelPOSTTenantCancel a subscription, or discard an unauthorized pending checkout
/billing/subscriptions/:id/invoicesGETTenantSubscription billing invoices
/billing/promo_codes/validatePOSTTenantValidate a promo code
/billing/invoicesCRUDTenantInvoice management
/billing/invoices/:id/issuePOSTTenantTransition draft to issued
/billing/invoices/:id/pdfGETTenantDownload invoice PDF
/billing/paymentsCRUDTenantPayment processing
/billing/payments/:id/initiate_gatewayPOSTTenantStart gateway payment flow
/billing/payments/:id/verify_gatewayPOSTTenantVerify gateway payment
/billing/commission_rulesCRUDTenantCommission rule management
/billing/commission_calculationsGET, PATCHTenantCommission calculations
/billing/commission_settlementsGET, POSTTenantCommission settlement management
/billing/revenue_share_configsCRUDTenantRevenue share configurations
/billing/revenue_share_configs/:id/activatePOSTTenantActivate a draft or inactive config (one active per facility, unique-indexed)
/billing/revenue_share_configs/:id/deactivatePOSTTenantReturn an active config to inactive
/billing/revenue_share_calculationsGET, POSTTenantRevenue share calculations
/billing/report_exportsGET, POSTTenantFinancial report exports
/billing/facilities/:facility_id/summaryGETTenantBilling dashboard summary
/billing/webhooks/:gatewayPOSTPublicGateway webhook receiver
/billing/admin/subscriptions/manual_subscribePOSTAdminAdmin-only manual subscription
/billing/admin/subscriptions/:id/extend_trialPOSTAdminAdmin-only trial extension
/billing/admin/promo_codesGET, POSTPlatform adminList (filter active) and create promo codes
/billing/admin/promo_codes/:idGET, PATCH, PUTPlatform adminPromo code detail and update
/billing/admin/promo_codes/:id/deactivatePOSTPlatform adminSet 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:

  1. Create backend/domains/billing/services/gateways/new_gateway.rb implementing Base.
  2. Register in GatewayConfig::GATEWAY_CLASSES and MARKET_GATEWAYS.
  3. Create frontend/packages/shared-services/src/checkout/adapters/new_gateway.ts.
  4. 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):

  1. Model: Create a new model in backend/domains/billing/models/ extending BaseRecord. Add tenant scoping with include Shared::Concerns::TenantScoped if the resource is tenant-owned.
  2. Service: Create service objects in backend/domains/billing/services/your_feature/, extending BaseService.
  3. Policy: Create a policy in backend/domains/billing/policies/ extending BaseBillingPolicy. Override methods as needed.
  4. Blueprint: Create a Blueprinter serializer in backend/domains/billing/blueprints/.
  5. Controller: Create a controller extending BaseController with standard CRUD actions.
  6. Routes: Add routes in backend/domains/billing/routes.rb inside the scope :billing block.
  7. 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:

  1. Define the calculation logic in a service under services/commissions/ or services/revenue_share/.
  2. Create background jobs in jobs/ for scheduled calculations.
  3. Add controller actions and routes for management.
  4. Use the ActivityLoggable concern 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
       +--< RevenueShareDistribution

Migration from Legacy parts/billing/

The domains/billing/ module replaces the legacy parts/billing/ system. Key differences:

AspectLegacy (parts/billing/)New (domains/billing/)
Base classApplicationRecordBilling::BaseRecord (auto-schema prefix)
Business logicInteractor patternService object + Result monad
SerializationActiveModel SerializersBlueprinter
AuthorizationPundit with ad-hoc policiesBaseBillingPolicy with consistent defaults
GatewayDirect Razorpay SDK callsGateway abstraction layer (Base, Factory, Config)
Multi-regionSingle region (IN only)Region-aware plans, currencies, gateways
Schemapublic schema tablesbilling PostgreSQL schema with RLS
Routesparts/billing/routes.rbdomains/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

FilePurpose
backend/domains/billing/routes.rbAll billing route definitions
backend/domains/billing/models/base_record.rbAbstract base with schema prefix
backend/domains/billing/services/base_service.rbResult monad pattern
backend/domains/billing/policies/base_billing_policy.rbTenant-scoped authorization defaults
backend/domains/billing/services/gateways/base.rbAbstract gateway interface
backend/domains/billing/services/gateways/gateway_factory.rbGateway resolution logic
backend/domains/billing/services/gateways/gateway_config.rbMarket-to-gateway mapping
backend/domains/billing/services/gateways/razorpay_gateway.rbRazorpay implementation
backend/domains/billing/models/subscription.rbSubscription model
backend/domains/billing/models/subscription_plan.rbPlan model
backend/domains/billing/models/promo_code.rbPromo code model
backend/domains/billing/controllers/subscriptions_controller.rbSubscription API
backend/domains/billing/policies/subscription_policy.rbSubscription authorization

On this page