Kulcare Docs
DeveloperBilling

Per-Service Payment Tracking + GST (#1840)

Two billing subsystems added on top of per-doctor pricing (#1838) and procedures-in-packages (#1839): tracking a payment down to individual service lines, and resolving GST per item for Indian facilities.

1. Per-service payment tracking

Problem

Previously a payment was allocated only at the invoice level (billing.payment_allocations → invoice.amount_paid). Staff could not tell which line items on a bill were paid, or how much a specific doctor's service had been settled.

The ledger

billing.service_payment_allocations — a dedicated ledger recording how a payment is distributed across individual lines:

columnmeaning
payment_idthe payment being distributed
patient_service_idthe settled service line (nullable)
patient_package_subscription_idthe settled package subscription (nullable) — reserved for the deferred package-settlement phase
amountamount applied (> 0)

Invariants (DB-enforced): CHECK num_nonnulls(patient_service_id, patient_package_subscription_id) = 1 (exactly one target), amount > 0, FORCE row-level security + tenant_isolation, and composite FKs on (tenant_id, payment_id) / (tenant_id, patient_service_id).

It is deliberately NOT a reuse of billing.payment_allocations. That table is a payment→invoice ledger guarded by a check_allocation_limit trigger (SUM(amount) per payment ≤ payment.amount) and summed by invoice.update_amount_paid!; a second service-grain row there would be rejected by the trigger and double-count the invoice.

Projection, not accumulator

Billing::PatientService#recompute_paid_amount! sets paid_amount = SUM(service_payment_allocations) (a reconstructable projection, recomputed — never +=) and the derived is_paid. This makes distribution idempotent: re-running yields the identical result. paid? / the paid/unpaid scopes read paid_amount >= total_amount (source of truth); a line with recorded payment cannot be deleted (before_destroy guard, protecting report reconciliation). paid_amount/is_paid are never client-writable.

FIFO distribution

Billing::Payments::ServiceDistributionService runs inside ProcessService's transaction, after invoice allocation. It takes its own FOR UPDATE on the touched invoices (self-contained serialization — two payments on the same encounter serialize here), computes remaining = payment.amount − SUM(allocations for this payment), and settles service lines with outstanding > 0 in FIFO order (by invoice_item.created_at). The outstanding > 0 filter naturally skips fully-covered lines (total_amount 0) and resumes partially-paid ones. Excess remains as unallocated on the payment. All money is BigDecimal.

Deferred

  • Package settlement (Phase 1): must charge the invoiced snapshot amount (the package invoice line's line_total), not the live catalog effective_price — a package-price edit between invoicing and payment would otherwise over/under-settle. Lands with the endpoint wiring below. subscription#mark_paid! + the is_paid/paid_at columns are in place for it.
  • Endpoint wiring: payments_controller#create passes no invoice_ids, so neither the existing invoice-grain allocation nor this distribution runs from the standard payment endpoint — a pre-existing gap; supplying invoice_ids activates both.

2. GST (per-item, India-gated)

Model

Indian healthcare is GST-exempt by default (Notif. 12/2017); only specific items are taxable (OPD pharmacy/goods, cosmetic/non-therapeutic procedures 18%; IPD room rent >₹5,000/day 5% — not billed yet). A flat invoice-wide rate would wrongly tax exempt clinical services, so taxability lives per catalog item:

  • clinical.facility_services / clinical.facility_procedures: is_taxable (default false) + gst_rate (default 0, CHECK 0..100). Exempt by default → nothing is wrongly taxed; an admin marks the taxable ones.

Resolution + gate

Billing::GstResolver:

  • applicable?(facility) — GST applies only when facility.country_code == 'IN' (the NOT-NULL region column; FacilityLocation has no country_code) AND the facility is GST-registered (a GSTIN on billing.billing_addresses). Fail-safe: nil facility / non-IN / unregistered → exempt.
  • line_rate(facility:, facility_service:, facility_procedure:) — is_taxable ? gst_rate : 0, else 0 (manual/non-catalog lines → 0).

CreateService resolves tax_percent server-side and snaps it onto the patient_service (client-supplied tax is ignored — same posture as unit_price in #1838). The rate is snapshotted: editing the catalog item's gst_rate afterward does not retroactively change already-billed lines. GST base is net-payable (post-discount, post-benefit), so a fully package-covered line is 0 GST.

Invoice aggregation

GST is embedded per line (each line's tax_percent/tax_amount), not as separate item_type: 'tax' rows. Invoice#recalculate_totals! aggregates tax_amount from the charge lines with a pre-tax subtotal (matching Invoices::CalculationService), so invoice.tax_amount reflects the real GST collected (not 0).

Deferred (minimal-correct scope)

SAC/HSN codes, Bill-of-Supply vs Tax-Invoice document types, IGST/inter-state, the IPD room-rent conditional (IPD not billed), and the therapeutic-vs-cosmetic override. CGST/SGST split (rate/2 each, intra-state) is a display/report concern.

Key files

  • Ledger: backend/domains/billing/models/service_payment_allocation.rb, migrations 20260713100001..100003
  • Distribution: backend/domains/billing/services/payments/service_distribution_service.rb, wired in payments/process_service.rb
  • Paid projection + guards: backend/domains/billing/models/patient_service.rb
  • GST: backend/domains/billing/services/gst_resolver.rb, patient_services/create_service.rb, migration 20260713100004, clinical/models/facility_service.rb + facility_procedure.rb
  • Invoice totals: backend/domains/billing/models/invoice.rb

References

  • Issue #1840; plan docs/plans/2026-07-13-feat-billing-payment-tracking-gst-reports-plan.md
  • GST rules: Notification 12/2017-Central Tax (Rate) Entry 74; hospital room-rent 5% (18 Jul 2022)
  • Related: #1837 (billing panel), #1838 (per-doctor pricing), #1839 (procedures in packages)

On this page