Billing Summary API - Data Calculation
Last Updated: 2026-04-01
What We Fixed
1. Date Filter Not Working
Problem: Date filter was not returning any data despite having invoices in the date range.
| Before | After |
|---|---|
@start_time..@end_time (DateTime) | @start_time.to_date..@end_time.to_date (Date) |
Why: PostgreSQL DATE column doesn't match DateTime objects.
2. Daywise Revenue Showing ₹0
Problem: All daily revenue values were showing ₹0.
| Before | After |
|---|---|
data[day.to_s] (String key only) | data[day] + data[day.to_s] (both keys) |
Why: Rails group().sum() returns Date objects as keys, not strings.
3. Amount Received Showing ₹0
Problem: amount_received was always ₹0.
| Before | After |
|---|---|
payments.sum(&:amount) from PaymentAllocation | invoices.sum(&:amount_paid) from Invoice |
Why: PaymentAllocation table is empty (no data migrated). Invoice has amount_paid field with actual values.
How Each Field is Calculated
Response: bills
Collected cash grouped by payment mode.
Source: billing.payments directly (completed + active, by payment_date)
Logic: group(:payment_method).sum(:amount), mapped to legacy mode namesSingle collected source (RD-1): bills, earnings.amount_received, and
billing_summary.paid_amount all read Billing::Payment (not the invoice
ledger / payment_allocations), so every "collected" figure agrees.
Response: services
Services provided to patients
Source: billing.invoice_items
Logic: Group by description → sum(quantity), sum(line_total)
Example Output:
[
{ "service_name": "Consultation", "total_count": 5, "amount": 2500.00 },
{ "service_name": "Medicine", "total_count": 10, "amount": 500.00 }
]Response: billing_summary
| Field | Source | Logic |
|---|---|---|
billed_amount | invoice.total_amount | Sum of revenue invoices (excludes NON_REVENUE_STATUSES: draft/cancelled/refunded/written_off) |
unbilled_amount | Always 0 | In new model, invoices ARE the billing |
paid_amount | billing.payments | Cash collected (single source, RD-1) |
unpaid_amount | invoice.balance_due | Sum where status = 'issued', 'partially_paid', or 'overdue' |
teleconsultation | Always 0 | Placeholder |
Response: visit_types
Billed revenue grouped by the linked encounter's FHIR class, mapped to UI buckets.
| Bucket | Source encounter_class |
|---|---|
Walkin | ambulatory |
Virtual | virtual |
Fixed | inpatient / emergency / home / observation / short_stay |
Unspecified | invoice has no linked encounter (only emitted when non-zero) |
Excludes NON_REVENUE_STATUSES so Σ(visit_types) == billing_summary.billed_amount.
Response: earnings
| Field | Source | Logic |
|---|---|---|
total_revenue | invoice.total_amount | Revenue invoices (= billed_amount) |
billed_amount | invoice.total_amount | Revenue invoices |
amount_received | billing.payments | Cash collected (single source, RD-1) |
amount_pending | invoice.balance_due | Σ balance_due of unpaid (accrual outstanding, ≥ 0 — not billed − collected, which mixes bases) |
Response: by_practitioner, by_service_type, gst (Phase 2)
Billed-revenue breakdowns over the revenue invoice_items, with line_total
signed (discount/credit lines subtract) so each reconciles to billed_amount.
| Field | Source | Logic |
|---|---|---|
by_practitioner | invoice_items → patient_services.practitioner_id (LEFT JOIN) | Group by doctor; null → "Unattributed / Facility". [{ practitioner_id, name, amount, count }] |
by_service_type | patient_services.service_type | Group by service type; no patient_service → "unclassified". [{ service_type, amount, count }] |
gst | invoice_items.tax_amount by tax_percent | Only for GST-applicable facilities (GstResolver.applicable?), else []. taxable_value = line_total − tax_amount; credit lines signed negative. [{ tax_percent, gst_collected, taxable_value }] |
This
gstfield is the reporting view only. For when and where GST is actually charged (the facility IN + GSTIN gate and per-catalog-itemis_taxable/gst_rate), see per-service-payment-tracking-and-gst.md.
Response: ar_aging (Phase 3)
Outstanding receivables aged as of today (facility-wide, point-in-time — NOT
period-filtered). Invoice.ar_aging buckets balance_due of unpaid invoices;
Σ(buckets) == Σ outstanding.
{ "not_due": 0.0, "0_30": 0.0, "31_60": 0.0, "61_90": 0.0, "90_plus": 0.0 }Null / not-yet-due due_date → not_due (so nothing vanishes). Requires the
#1859-maintained invoice.balance_due.
Response: daywise_total_revenue
Daily revenue time series
Source: billing.invoices
Query: WHERE issue_date BETWEEN ? AND ?
Group: DATE(issue_date)
Sum: total_amount
Output: [{ "date_ts": "2026-03-01", "value": 1500.00 }, ...]Response: daywise_total_patients
Daily patient count
Source: billing.invoices
Query: WHERE issue_date BETWEEN ? AND ?
Group: DATE(issue_date)
Count: DISTINCT patient_id
Output: [{ "date_ts": "2026-03-01", "value": 5 }, ...]Response: prev_month_*
Same logic as daywise but for previous month date range.
Data Tables Used
| Table | Data | Status |
|---|---|---|
billing.invoices | Invoice records + AR-aging | ✅ Has data |
billing.invoice_items | Line items + breakdowns + GST | ✅ Has data |
billing.payments | Collected cash (the collected source) | ✅ Has data (#1859) |
billing.patient_services | Doctor / service-type attribution | ✅ Has data |
clinical.encounters | Visit-type (encounter_class) | ✅ Has data |
billing.payment_allocations | Payment↔invoice linkage | Read only via invoice.balance_due; the summary reads payments directly |
Files Changed
backend/domains/billing/services/summary_service.rb- Fixed 3 issues