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:
| column | meaning |
|---|---|
payment_id | the payment being distributed |
patient_service_id | the settled service line (nullable) |
patient_package_subscription_id | the settled package subscription (nullable) — reserved for the deferred package-settlement phase |
amount | amount 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 catalogeffective_price— a package-price edit between invoicing and payment would otherwise over/under-settle. Lands with the endpoint wiring below.subscription#mark_paid!+ theis_paid/paid_atcolumns are in place for it. - Endpoint wiring:
payments_controller#createpasses noinvoice_ids, so neither the existing invoice-grain allocation nor this distribution runs from the standard payment endpoint — a pre-existing gap; supplyinginvoice_idsactivates 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(defaultfalse) +gst_rate(default0,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 whenfacility.country_code == 'IN'(the NOT-NULL region column;FacilityLocationhas nocountry_code) AND the facility is GST-registered (a GSTIN onbilling.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, migrations20260713100001..100003 - Distribution:
backend/domains/billing/services/payments/service_distribution_service.rb, wired inpayments/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, migration20260713100004,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)