Design Decisions

Key design constraints, architectural rationale, and extensibility guide for the Order Split feature

👤 Sachin Sharma, Nilesh Bhusari📅 Updated: May 2, 2026🏷️ feature🏷️ billing🏷️ architecture

Design Decisions & Architecture


Key Design Decisions & Constraints

Hybrid Shift + Clone as the data movement model

Every dependent table is classified into one of four actions before any code is written. This eliminates ad-hoc migration logic and makes each table's behavior explicit.

ActionWhen to useExamples
Shift FKRow travels with the moved tests; no longer belongs to the parentBillingInfo, LabReportRelation, test-level ICD/modifier rows
Clone / CreateRow must exist independently on both bills after splitBillApprovalAction, LabMissingDetails, bill-level ICD/modifier rows, consent forms
No changeIdentity preserved via LRR continuity, or log-onlyreportValues, ReflexTestDetails, Notification
Block by guardTable is unhandled; its presence prevents the splitInsuranceClaim, HomeCollection, PaymentGatewayTransactions

Report identity preservation (no LRR recreation)

LabReportRelation rows are shifted by FK update, never recreated. Recreating them would change primary keys, breaking report values, attachments, Elasticsearch documents, and integrations that store labReportId. Preserving IDs means no remediation of dependent data is needed after the split.

Single atomic transaction with post-commit sync

All DB write steps run inside one transaction.atomic() — any failure rolls back the entire split, leaving no partial state. Post-commit steps (ES sync, Redis, Fusion ledger, activity logs) are outside the transaction by design: a sync failure should not reverse a committed, financially consistent split.

Guard-table blocking over silent migration

Unhandled tables block the split when rows exist rather than being silently left on the parent. Blocking and surfacing the gap is safer than producing a split bill with missing linked data.

3-step stepper modal with progressive API modes

The UI uses a 3-step modal (Test Selection → Data Confirmation → Payment Summary) instead of a single-screen confirm. The same endpoint supports three modes (is_validate, is_calculate, execute), so each step surfaces errors and projections progressively without extra endpoints or premature writes.


Architectural Rationale

Four-action classification as an extensibility contract

The Shift / Clone / No change / Block model is the specification for every dependent table. When a new table enters the domain, the split code only needs to answer: "which of the four actions does this belong to?" The existing migration infrastructure handles it without structural changes.

Proportional financial distribution without rounding loss

Financials are computed as split_proportion = split_base_amount / original_base_total, then applied to VAT, TDS, and additional charges. The parent's values are reduced by the split-side values — not independently recomputed — so both bills always sum back to the original totals.

orderNumber lineage without a linking table

The split bill's orderNumber is derived from the parent by appending ~{step} (e.g. ORD-1234ORD-1234~1), providing traceability in existing order-number surfaces without schema changes. The ActivityLog with log_context = BILL_SPLIT serves as the full audit record.

AOE responses and consent records are migrated atomically with the financial and relational data. Moving them to a post-commit step would create a window where tests have moved but form data hasn't — breaking consent history on the split bill.

Fusion ledger entry is conditional on org type

The ledger webhook sends a negative-amount entry with note_entry = true for PREPAID orgs and false for POSTPAID. Running it outside the transaction is intentional — ledger reconciliation is eventually consistent and a webhook failure should not reverse the split.


Why Not Full Recreate or Full FK-shift?

Full recreate was rejected because:

  1. Recreating LabReportRelation rows changes their PKs, breaking report values, attachments, and Elasticsearch documents indexed by labReportId.
  2. Every dependent table would need its own recreation strategy — high write volume and ongoing maintenance cost.

Full FK-shift was rejected because:

  1. Bill-level 1:1 entities (BillApprovalAction, LabMissingDetails, consent forms) must stay on the parent after split; shifting them leaves the parent broken.
  2. It treats "rows tied to a specific test" and "rows tied to the bill as a whole" identically — a distinction that downstream features (approval, claims, missing details) depend on.

Guidance for future enhancements

What you want to doHow
Add a new dependent tableClassify into one of the four actions; implement in bill_split_manager.py
Add a new guard-table blockerAdd the model to GUARD_TABLE_MODELS; the check runs automatically in validate_split()
Support a new split variant (e.g. paid bills)Add a new validated path with its own eligibility checks; do not relax existing guard conditions
Support multi-bill splitorderNumber ~{step} already supports it; main work is financial distribution and transaction ordering
Add a post-commit sync stepAdd after create_activity_log() in do_split(); keep outside transaction.atomic()
Migrate a currently blocked tableRemove from GUARD_TABLE_MODELS, classify its action, implement in the transaction, add test coverage

On this page