Kulcare Docs
DeveloperBilling

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.

BeforeAfter
@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.

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

BeforeAfter
payments.sum(&:amount) from PaymentAllocationinvoices.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 names

Single 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

FieldSourceLogic
billed_amountinvoice.total_amountSum of revenue invoices (excludes NON_REVENUE_STATUSES: draft/cancelled/refunded/written_off)
unbilled_amountAlways 0In new model, invoices ARE the billing
paid_amountbilling.paymentsCash collected (single source, RD-1)
unpaid_amountinvoice.balance_dueSum where status = 'issued', 'partially_paid', or 'overdue'
teleconsultationAlways 0Placeholder

Response: visit_types

Billed revenue grouped by the linked encounter's FHIR class, mapped to UI buckets.

BucketSource encounter_class
Walkinambulatory
Virtualvirtual
Fixedinpatient / emergency / home / observation / short_stay
Unspecifiedinvoice has no linked encounter (only emitted when non-zero)

Excludes NON_REVENUE_STATUSES so Σ(visit_types) == billing_summary.billed_amount.


Response: earnings

FieldSourceLogic
total_revenueinvoice.total_amountRevenue invoices (= billed_amount)
billed_amountinvoice.total_amountRevenue invoices
amount_receivedbilling.paymentsCash collected (single source, RD-1)
amount_pendinginvoice.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.

FieldSourceLogic
by_practitionerinvoice_items → patient_services.practitioner_id (LEFT JOIN)Group by doctor; null → "Unattributed / Facility". [{ practitioner_id, name, amount, count }]
by_service_typepatient_services.service_typeGroup by service type; no patient_service → "unclassified". [{ service_type, amount, count }]
gstinvoice_items.tax_amount by tax_percentOnly for GST-applicable facilities (GstResolver.applicable?), else []. taxable_value = line_total − tax_amount; credit lines signed negative. [{ tax_percent, gst_collected, taxable_value }]

This gst field is the reporting view only. For when and where GST is actually charged (the facility IN + GSTIN gate and per-catalog-item is_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

TableDataStatus
billing.invoicesInvoice records + AR-aging✅ Has data
billing.invoice_itemsLine items + breakdowns + GST✅ Has data
billing.paymentsCollected cash (the collected source)✅ Has data (#1859)
billing.patient_servicesDoctor / service-type attribution✅ Has data
clinical.encountersVisit-type (encounter_class)✅ Has data
billing.payment_allocationsPayment↔invoice linkageRead only via invoice.balance_due; the summary reads payments directly

Files Changed

  • backend/domains/billing/services/summary_service.rb - Fixed 3 issues

On this page