Inter-Company Transaction Mirroring
A design reference for auto-generating the counterpart transaction when two Legal Entities inside the same system trade with each other
1. Requirements
1.1 Functional requirements
- When one Legal Entity sells to another Legal Entity that is also modeled in the same system, creating a Sales Order/Sales Invoice/Delivery Note on the selling side must be able to auto-generate the matching Purchase Order/Purchase Invoice/Purchase Receipt on the buying side (and the reverse direction, buy-side to sell-side) without re-keying line items, parties, or addresses.
- A Customer or Supplier record can be flagged as internal — a stand-in inside one Legal Entity’s books for another Legal Entity that also transacts in this system — and tagged with which Legal Entity it represents.
- Only one internal Customer may represent a given Legal Entity, and only one internal Supplier may represent a given Legal Entity — the representation is meant to be a single, unambiguous stand-in, not one of several.
- An internal party can optionally be restricted to an explicit allowlist of Legal Entities it is permitted to transact with; outside that allowlist, the counterpart-resolution step must fail rather than guess.
- Generating the counterpart must invert the roles correctly: the selling Legal Entity’s own address becomes the buying side’s supplier address, the buying Legal Entity’s shipping address becomes the selling side’s customer-facing address, and so on for every address slot on the document.
- Line items already fulfilled by a prior counterpart document (partial mirrors) must be excluded or reduced on the next mirror, so re-running the mirror action after a partial delivery does not duplicate quantity.
- Each created document must retain a reference back to the document it mirrors, and that reference must be nulled out again if either side is cancelled.
- A Journal Entry can also be paired across the two Legal Entities’ books, tagged with a dedicated voucher type, and cross-checked so the totals recorded on each side agree.
- Quantities recorded on the receiving side (Purchase Receipt / Purchase Invoice with a stock update) must not exceed what the mirrored source document (Delivery Note / Sales Invoice) actually transferred, within a configurable over-receipt allowance.
- Optionally, the rate charged on the buy-side document must match the rate on the mirrored sell-side line, with a configurable warn-or-block policy.
- The stock valuation the receiving Legal Entity books for a transferred item must trace back to the sending Legal Entity’s own recorded cost, not be independently re-derived.
1.2 Non-functional requirements
- Idempotent linkage: re-invoking the mirror-creation action against a document that already has an unfulfilled counterpart should not silently create a second, orphaned counterpart for quantity already mirrored.
- No cross-entity leakage of accounting: every generated document still posts into its own Legal Entity’s own chart of accounts and currency; mirroring only pre-fills data entry, it never merges two entities’ books into one.
- Auditability of the pairing: given either document in a mirrored pair, it must be possible to navigate directly to the other one via a stored reference, not by name-matching or date-matching heuristics.
- Graceful partial fulfillment: an order-level mirror followed by several partial delivery-level and invoice-level mirrors must all resolve to the same counterpart party without re-asking which internal party to use each time.
1.3 Constraints
- Mirroring is entirely intra-system: both Legal Entities must be provisioned inside the same deployment. There is no external message format, queue, or API boundary — the “producer” and “consumer” sides of a mirrored transaction are two records in the same database, created synchronously in one request.
- The two Legal Entities must share the same base currency for most mirror paths; a currency mismatch on the transaction is treated as an error, not as an occasion to apply an exchange rate.
- Mirroring reuses each document type’s own price list, address, and tax-resolution logic rather than defining a parallel, mirror-specific version of any of them.
2. High-Level Design
2.1 Component diagram
2.2 Data flow — creating a counterpart (annotated walkthrough)
This is a short procedure with a few real branches, so it is written as steps rather than forced into a second diagram:
- User invokes the mirror action from a submitted (or, for orders, even a draft) source document — a Sales Order, Sales Invoice, Purchase Order, Purchase Invoice, Delivery Note, or Purchase Receipt.
- Cross-entity checks run first. The buying and selling price lists must both have their “usable for buying” and “usable for selling” flags set, and the two Legal Entities’ base currencies must match — with an explicit exception for a same-entity internal stock transfer, where a single Legal Entity moves stock between its own warehouses and no currency conversion question arises at all.
- Resolve the counterpart party. The system looks up the internal Customer (or Supplier) whose Represented Legal Entity field equals the source document’s own Legal Entity, filtered to non-disabled records. If more than one candidate internal party exists, the source document’s address is used to disambiguate — the address record’s own link back to a specific party breaks the tie; if that still resolves nothing, the first candidate found is used.
- Invert the transaction. The target document’s Legal Entity becomes the counterpart party’s Represented Legal Entity; the target’s party becomes the resolved internal party; every address slot is inverted (the source’s own company-facing address becomes the target’s party-facing address, and vice versa) but only when the address is genuinely linked to the correct party or Legal Entity — an address that doesn’t check out is skipped rather than copied blindly.
- Exclude already-mirrored quantity. Before mapping line items across, the system sums how much of each source line has already been carried into a prior counterpart document (by reference field, not by date), and only maps the remainder. A source document whose lines are now fully mirrored produces an explicit error rather than an empty counterpart.
- Re-price and re-tax on the target side, using the target Legal Entity’s own tax template and price list — mirroring never copies the source’s tax amounts verbatim, only the rate and quantity feed back into the target’s own tax computation.
- Store the mirror reference on both sides so either document can be navigated to the other, and so step 5 has something to sum against on the next partial mirror.
2.3 Two independent engines, not one
Both engines call the same validation and party-resolution helpers, but each implements its own field-mapping logic independently — the Order/Invoice pair and the Dispatch/Receipt pair do not share a mapping engine, only the checks that gate it. The Journal Entry path is smaller still: it does not map fields at all, it only creates an empty shell pointed at its counterpart and lets the user fill in the accounts by hand — see §3.3.
3. Deep Dive
3.1 Data model
Internal Customer / Internal Supplier flag A boolean on the Customer or Supplier record. Setting it exposes a Represented Legal Entity field; clearing it wipes that field back to empty. A save-time check enforces that at most one internal Customer (and, independently, at most one internal Supplier) can represent any given Legal Entity — the representation is meant to be unique per entity per role.
Represented Legal Entity field Points at the Legal Entity that this Customer/Supplier is really a stand-in for. Counterpart resolution is driven entirely by this field plus the internal flag — nothing about representation is inferred from name matching, tax identifiers, or any other master-data similarity.
Approved transacting-entities allowlist An optional per-party child list of Legal Entities the internal party is allowed to be used against. Where present, it acts as a hard gate: transacting outside the list raises an error asking the operator to either change the Legal Entity or extend the list. Where absent (empty list), no restriction applies at all — the field is opt-in, not a default deny-list.
Mirror reference field Every mirror-capable document type carries a field that stores the name of its counterpart. This is where the first real irregularity in the design shows up: it is not one field name reused everywhere. The Order pair and the Invoice pair each get their own distinctly-named reference field, and the Dispatch/Receipt pair gets a third, differently-named field again — three names for one conceptual role. A document type belonging to more than one pair (a Purchase Invoice, for instance, sitting both in the Invoice pair and adjacent to stock-update logic shared with the Dispatch/Receipt pair) ends up with attempts to populate a reference field that its own schema does not even declare; that particular assignment is a harmless no-op, but it is a real seam in the design, not a hypothetical one — see §5.
Per-entity price list validity Both the buying and selling price lists involved must be flagged usable for both buying and selling transactions (a single price list servicing both sides) unless the transaction is a same-entity internal stock transfer, which is exempted from this check entirely.
Reconciliation quantity Tracked implicitly, not as a stored running total: whenever a guard needs “how much has already crossed from the source document to the counterpart,” it re-sums the counterpart’s own line items filtered by the mirror reference field, rather than reading a maintained counter. This makes the guard self-correcting after a cancellation (a cancelled counterpart’s lines simply stop being summed) but means every check re-scans the counterpart table rather than reading a cached figure.
3.2 The mirror-creation algorithm
The two field-mapping engines (Order/Invoice, and Dispatch/Receipt) both follow the same shape even though they are separate implementations:
create_counterpart(source_document):
validate_price_lists_and_currency(source_document) # skipped for same-entity transfers
counterpart_party, counterpart_entity = resolve_internal_party(source_document)
already_mirrored = sum_prior_counterpart_lines(source_document, by=mirror_reference_field)
target = new_document(counterpart_type)
target.legal_entity = counterpart_entity
target.party = counterpart_party
invert_addresses(source_document, target) # each slot individually validated, not blind-copied
for line in source_document.lines:
remaining = line.qty - already_mirrored.get(line, 0)
if remaining <= 0:
continue # fully mirrored already; skip, don't zero-row it
target.add_line(line, qty=remaining)
if target.has_no_lines():
raise "everything already mirrored"
re_price_and_re_tax(target) # target's own price list + tax template, not copied amounts
target.mirror_reference = source_document.name
return target # returned unsaved; operator reviews and submits it themselves
The counterpart is not auto-submitted. It is handed back to the operator as an editable draft; the operator is expected to review and submit it, which is also the moment the reciprocal reference gets written back onto the source document (so that navigation works in both directions only once both sides genuinely exist as submitted records for the order/invoice pair — cancelling either side clears the reference on both).
3.3 The Journal Entry pairing path — a structurally different, smaller mechanism
Unlike every other pair above, pairing two Journal Entries across Legal Entities does not go through field mapping, address inversion, or line-item exclusion at all. The creation step produces an empty new Journal Entry, sets its voucher type to the dedicated inter-company variant, stamps today’s date, and stores the mirror reference — nothing about the originating entry’s account lines is copied across. The operator fills in the accounts themselves. The only automated check is a save-time cross-total comparison: the new entry’s total debit must equal the referenced entry’s total credit and vice versa — but only when the two Legal Entities share the same base currency. If they don’t, the comparison is skipped outright and nothing enforces that the two sides even roughly balance against each other. This is a genuinely different design point on the spectrum from full field-mapping — closer to “a linked shell” than “a mirror.”
3.4 Error handling
- No matching internal party: counterpart resolution fails loudly, naming the Legal Entity it could not find a representative for, rather than falling back to any generic party.
- Party/entity mismatch on manual entry: if an operator manually sets the mirror reference field on a document (rather than using the action), the party actually chosen is cross-checked against what the reference implies — the party must genuinely represent the referenced document’s Legal Entity, and the referenced document’s own party must represent the current document’s Legal Entity. Either direction failing throws.
- Transacting-entity allowlist violation: a party restricted to specific Legal Entities is blocked from transacting outside that list with a message naming which section of the party record to edit.
- Over-receipt: a receiving document with a stock update is blocked from recording more quantity, per item, than the mirrored dispatch actually transferred — after applying a configurable over-delivery/over-receipt allowance percentage. This check re-derives both the “transferred” and “already received” totals from the live table rather than trusting a cached figure on either side.
- Rate drift: when a global setting to enforce matching rates across the mirrored pair is active, a line whose rate differs from its mirrored counterpart line either raises a hard stop (with a role-based override) or a soft warning, depending on configuration.
- Fully-mirrored source: attempting to mirror a source document with no remaining unmirrored quantity throws rather than producing a zero-line counterpart.
- Cancellation unwind: cancelling either side of a pair clears the mirror reference on both records; a subsequent mirror action against the still-open side then treats the relationship as fresh.
- Cross-currency Journal Entry pairs: as noted in §3.3, the total-matching guard is silently skipped when the two Legal Entities’ base currencies differ — an operator pairing two entries across a currency boundary gets no automated balance check at all.
3.5 Valuation continuity across the mirror
For a stock-affecting pair (Dispatch Record into Goods Receipt), the receiving Legal Entity does not independently value the incoming stock. The incoming rate is read back from the sending side’s own recorded incoming/outgoing rate on the matching dispatch line (plus any landed-cost amount already apportioned to that line), so the transferred item’s cost basis is carried across the entity boundary rather than re-derived from the receiving entity’s own valuation method. This is deliberate: it prevents the same physical transfer from being valued two different ways depending on which entity’s valuation method happens to run, at the cost of the receiving entity’s stock value being dependent on bookkeeping quality on the sending side.
4. Scale and Reliability
- Load pattern: mirror creation is a low-frequency, operator-triggered, synchronous action — one document creates one counterpart per invocation, inside the same request that renders the “review before submit” screen. It does not run as a background job and carries no queue of its own.
- No distributed transaction: because both sides live in the same database, there is no two-phase commit problem to solve — the counterpart is either fully created (as an unsaved draft returned to the browser) or the whole action throws before anything is written. The risk window is narrower than a cross-service mirror would face, precisely because this is not a cross-service design.
- Re-entrancy via re-summing, not locking: the quantity-exclusion and over-receipt checks recompute their totals from the current row set on every invocation rather than maintaining a running counter, so two operators mirroring the same source document back-to-back will each see an accurate “remaining” figure at the moment they act — at the cost of a heavier query on every mirror action as the number of partial counterparts against one source grows.
- No dedicated retry or replay path: because the whole operation is synchronous and interactive (the counterpart is returned as a draft, not silently auto-submitted), a failed attempt simply produces an error to the operator; there is no queued external call to retry, unlike a cross-system integration.
- Failure isolation between the two engines: because the Order/Invoice engine and the Dispatch/Receipt engine are independent implementations, a defect in one cannot corrupt state in the other — but by the same token, a fix applied to one does not automatically apply to the other, which is the cost side of that isolation (see §5).
5. Trade-off Analysis
| Decision | Trade-off |
|---|---|
| Shared validation/party-resolution helpers live inside one document type’s module (the Invoice side) rather than a neutral shared location | Every other mirror-capable document type imports from that one module, so there is a single place to read the counterpart-resolution rule — but it also means the Invoice module’s boundaries are not what they appear to be: its file is a dependency for document types that have nothing to do with invoicing, and a change there risks the Order, Dispatch, and Receipt paths simultaneously. |
Each create-counterpart function lives in the module of its source document type, named for the target type it produces |
Intuitive at the call site (you’re looking at a Purchase Order, and the action to create its Sales Order counterpart is right there) — but it means there is no single module that “owns” inter-company mirroring; six creation functions are scattered across six different document types’ modules, each a thin wrapper delegating to shared logic elsewhere. |
| Two independent field-mapping engines (Order/Invoice vs. Dispatch/Receipt) instead of one generic mapper | Each engine is shaped correctly for its own document type’s fields (stock/warehouse handling for the physical pair, price-list/tax handling for the financial pair) — but the two engines have quietly diverged in small ways (see the mirror-reference field-name inconsistency in §3.1), and a bug fixed in one is not automatically fixed in the other. |
| Three separately-named mirror-reference fields for one conceptual role | Each document type’s schema stays self-describing (the field name tells you what it is without needing the pair context) — but it makes “find the counterpart” logic document-type-specific rather than generic, and produced at least one confirmed inert cross-assignment where a value is written to a field a document type never declared. |
| The Journal Entry pair is an empty linked shell, not a field-mapped mirror | Appropriate for a document type whose whole purpose is manual account selection — auto-mapping accounts across two different charts of accounts would be actively wrong — but it means the “mirroring” story is inconsistent across document types: five pairs copy content, one pair copies almost nothing but a pointer. |
| Reconciliation quantities are re-summed live rather than maintained as running counters | Self-healing after cancellation with no extra bookkeeping to keep in sync — but every guard re-scans the counterpart table, which is a real cost once a source document accumulates many partial counterparts. |
| Cross-currency Journal Entry pairs skip the total-matching guard entirely | Avoids the harder problem of picking and applying an exchange rate inside a manual, unmapped shell — but it means the one safety check this path has is silently absent exactly when the two entities’ currencies differ, which is also the case most likely to hide a genuine entry error. |
6. What to Revisit as the System Grows
- Unify the mirror-reference field name, or at minimum centralize the “given this document, find its counterpart” lookup behind one function per document type so the three-field-name irregularity stops leaking into every caller that needs to walk a pair.
- Relocate the shared validation/party-resolution helpers out of one document type’s module into a module with no document-type ownership of its own, so a change scoped to invoicing cannot accidentally reach the Order and Dispatch/Receipt paths, and so the dependency direction matches what the code actually does.
- Converge the two field-mapping engines, or at least extract their common skeleton (validate → resolve party → exclude already-mirrored quantity → map → re-price) into one shared routine that each engine specializes, so a correctness fix to one pair’s exclusion logic is not silently absent from the other.
- Extend the total-matching guard on Journal Entry pairs to the cross-currency case, even if that only means requiring an explicit exchange rate on save rather than fully automating it, so pairing across a currency boundary is not the one path with no automated balance check at all.
- Consider a maintained running total for already-mirrored quantity once source documents routinely accumulate many partial counterparts, trading a small amount of write-side bookkeeping for a cheaper read on every subsequent mirror and over-receipt check.
- Make the “unlink on cancel” behavior symmetric across all three pairs explicitly, rather than trusting that each of the three separate implementations continues to clear the reference on both sides identically as the schema evolves.