API Reference
Endpoint contract, request and response format, operation modes, and validation gates for the Order Split API.
Request flow
Endpoint
POST /api-v3/finance/bill/split/{bill_id}Request body
| Key | Type | Required | Description |
|---|---|---|---|
test_ids | number[] | Yes | BillingInfo.id values to move to the split bill |
new_source | string | null | No | Destination source for the split bill. Accepted values: "Org Pay", "Self Pay" |
is_validate | boolean | No | Dry-run validation: runs all checks and returns result without writing any data |
is_calculate | boolean | No | Dry-run calculation: returns financial projections for both bills without writing any data |
Operation modes
The same endpoint serves three modes. This lets callers check eligibility and preview financials before committing, without needing separate endpoints.
Mode 1 — Validate only (is_validate=true)
Runs the full validation pipeline and returns the result. Always returns HTTP 200, regardless of whether validation passed or failed — the caller must inspect status and failed_validation to determine outcome.
Validation passed:
{
"status": "success",
"failed_validation": [],
"message": "Validation results only"
}Validation failed:
{
"status": "failed",
"failed_validation": [
{
"type": "INVALID_BILL_STATE",
"message": "Bills which are Locked, Cancelled, Written Off, Complete or Refunded are not allowed to split"
}
],
"message": "Bill split validation failed"
}Mode 2 — Calculate only (is_calculate=true)
Computes financial values for both bills in memory and returns them. No data is written.
{
"status": "success",
"message": "Billing calculations",
"bill": {
"vat": 18.0,
"TDSAmount": 0.0,
"vat_percent": 1.5,
"billAdvance": 0.0,
"co_pay_amount": 0.0,
"billConcession": 0.0,
"billTotalAmount": 1200.0,
"deductible_amount": 0.0,
"billAdditionalAmount": 50.0,
"patientPayableAmount": 0.0
},
"split_bill": {
"vat": 6.0,
"TDSAmount": 0.0,
"vat_percent": 1.5,
"billAdditionalAmount": 16.67,
"billTotalAmount": 400.0,
"billConcession": 0.0,
"billAdvance": 0.0,
"co_pay_amount": 0.0,
"deductible_amount": 0.0,
"patientPayableAmount": 0.0
}
}bill contains updated values for the parent bill after split. split_bill contains projected values for the new bill.
Mode 3 — Execute (default, no flags)
Runs the full atomic split transaction.
Success (HTTP 200):
{
"status": "success",
"message": "Bill split successfully",
"data": {
"original_bill": { "id": 101, "labBillId": 4501, "total_amount": 1200.0 },
"new_bill": { "id": 102, "labBillId": 4502, "total_amount": 400.0 }
}
}Validation failure (HTTP 400):
{
"status": "failed",
"failed_validation": [
{ "type": "RELATION_BLOCKED", "message": "finance_insurance_claim" }
],
"message": "Bill split validation failed"
}HTTP status code summary
| Scenario | HTTP status |
|---|---|
is_validate=true — any result (pass or fail) | 200 |
is_calculate=true — success | 200 |
| Execute — success | 200 |
| Execute — validation failed | 400 |
Payload validation error (lab_id, bill_id, test_ids shape) | 400 (raises ValidationError) |
Validation flow
Key behaviours:
- A fails fast — raises immediately, no manager instantiated, no DB read
- B and C always run — for all three modes; errors accumulate into
failed_validation, not raised - D and E only run on execute — skipped entirely for
is_validateandis_calculate is_validatealways returns HTTP 200 — caller must inspectstatusandfailed_validation- Execute returns HTTP 400 when any error exists in
failed_validationafter D+E
Validation gates
A) Request payload (view layer)
Validated before the manager is instantiated:
1. lab_id must exist in session
2. bill_id must be a numeric value
3. test_ids must be a non-empty list or tuple
4. test_ids must contain only unique entries
5. every entry in test_ids must be an integerB) Bill-level eligibility (manager validate_bill)
Runs during manager __init__. Errors accumulate into failed_validation.
| Error type | Condition |
|---|---|
BILL_NOT_FOUND | No Billing record for (lab_id, bill_id) |
INVALID_BILL_SOURCE | bill.source is not insurance |
INVALID_BILL_STATE | Bill is locked, cancelled, refunded, written off, complete, or invoiced |
BILL_IS_ABDM_LINKED | A LinkedBills row exists for this bill |
C) BillingInfo-level eligibility (manager validate_to_split_test_ids)
Runs during manager __init__. Note: profile children are auto-included via a subquery — if a parent profile is in test_ids, its child tests are automatically resolved.
| Error type | Condition |
|---|---|
TO_SPLIT_TESTS_NOT_FOUND | No BillingInfo rows found for the given IDs on this bill |
TO_SPLIT_TESTS_NOT_FROM_SAME_LAB | Any selected row belongs to a different lab |
TO_SPLIT_TESTS_DISMISSED_OR_REFUNDED | Any selected row is dismissed or refunded |
INVALID_TO_SPLIT_TEST | A child test-of-profile ID was submitted without its parent profile ID |
D) Split eligibility (manager validate_split)
Called explicitly after __init__. Adds to the same failed_validation list.
| Error type | Condition |
|---|---|
BILL_ADVANCE_NOT_ALLOWED | bill.billAdvance != 0 |
ZERO_TEST_COUNT_AFTER_SPLIT | All non-dismissed, non-profile-child rows would move, leaving parent empty |
UNSUPPORTED_ATTACHMENTS_FOUND | Active attachments exist outside SmartReport or Image categories |
RELATION_BLOCKED | Rows exist in a guarded table (message contains the DB table name) |
E) Guarded tables
These models are checked as blockers. RELATION_BLOCKED is returned with the table's DB name.
| Model | DB table (approx.) |
|---|---|
InsuranceClaim | finance_insurance_claim |
BillClaimRelation | finance_bill_claim_relation |
ClaimTestRelation | finance_claim_test_relation |
ClaimProviderInfo | finance_claim_provider_info |
HomeCollection | patient_home_collection |
EmrAppointments | patient_emr_appointments |
PaymentGatewayTransactions | payments_payment_gateway_transactions |
Kit | crm_kit |
ShippingDetails | integration_shipping_details |
LinkedBill | patient_linked_bill |
ProcessedFile | account_processed_file |
B2BPatient | patient_b2b_patient |
TestClinicalInfo | integration_test_clinical_info |
BillClassifierTags | finance_bill_classifier_tags |