Design Decisions
Key design constraints, architectural rationale, and extensibility guide for the Order Split feature
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.
| Action | When to use | Examples |
|---|---|---|
| Shift FK | Row travels with the moved tests; no longer belongs to the parent | BillingInfo, LabReportRelation, test-level ICD/modifier rows |
| Clone / Create | Row must exist independently on both bills after split | BillApprovalAction, LabMissingDetails, bill-level ICD/modifier rows, consent forms |
| No change | Identity preserved via LRR continuity, or log-only | reportValues, ReflexTestDetails, Notification |
| Block by guard | Table is unhandled; its presence prevents the split | InsuranceClaim, 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-1234 → ORD-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.
Consent and AOE migrated inside the transaction
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:
- Recreating
LabReportRelationrows changes their PKs, breaking report values, attachments, and Elasticsearch documents indexed bylabReportId. - Every dependent table would need its own recreation strategy — high write volume and ongoing maintenance cost.
Full FK-shift was rejected because:
- Bill-level 1:1 entities (
BillApprovalAction,LabMissingDetails, consent forms) must stay on the parent after split; shifting them leaves the parent broken. - 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 do | How |
|---|---|
| Add a new dependent table | Classify into one of the four actions; implement in bill_split_manager.py |
| Add a new guard-table blocker | Add 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 split | orderNumber ~{step} already supports it; main work is financial distribution and transaction ordering |
| Add a post-commit sync step | Add after create_activity_log() in do_split(); keep outside transaction.atomic() |
| Migrate a currently blocked table | Remove from GUARD_TABLE_MODELS, classify its action, implement in the transaction, add test coverage |