Multi-Company ERP: Intercompany Transactions and Elimination

Photo by daveynin on flickr
An intercompany transaction is a sale, purchase, loan, or transfer between two legal entities that belong to the same corporate group, for example one subsidiary billing another for goods or services. Each entity records its own side of the transaction in its own books, but because both parties are part of the same group, the transaction has to be removed when preparing consolidated financial statements so the group is not shown selling to itself.
A group cannot recognize profit or revenue from trading with itself, so accounting standards such as IFRS 10 require removing intercompany sales, cost of goods sold, and AR and AP balances before producing consolidated financial statements. Without elimination, the group's revenue and balance sheet would appear inflated by internal activity that has no effect on outside parties.
Flag which accounts are eligible for elimination, link the two sides of every intercompany posting through a shared pair id at the moment they are created, and run a period-end job that walks those linked pairs and posts reversing journal entries in a separate consolidation entity. Keeping the job idempotent and re-runnable lets you correct a prior period without duplicating elimination entries.
Yes, and they generally should share one master record so the intercompany counterpart resolves consistently across entities. Each company still keeps its own credit terms, price list, and open-item ledger against that shared customer or vendor, so the shared identity does not remove per-entity flexibility.
The consolidation engine needs an ownership percentage on the company relationship so it can split the partially owned subsidiary's equity and net income between the group and minority interest, sometimes called non-controlling interest. This split is calculated after elimination and currency translation, and it is reported as a separate line in the consolidated equity and income statement.

Photo by daveynin on flickr
Key Takeaway
This guide shows how to add multi-company support to a custom ERP: share master data like the chart of accounts and item master across entities, scope tax and currency settings per company, link intercompany invoices with a pair id, and generate automatic elimination entries so consolidated reports net to zero without manual reconciliation.
Most ERP systems are built for one company. The moment a business incorporates a second legal entity, a trading arm, a manufacturing subsidiary, a regional branch, the assumptions baked into a single-company data model start to break: shared customers duplicate across databases, intercompany sales look like real revenue, and the finance team ends up reconciling entities by hand in spreadsheets at every month end.
This post walks through how to design multi-company support into a custom ERP: what master data to share versus keep local per entity, how to model intercompany invoicing so both legs of a transaction stay linked, how to generate elimination entries automatically instead of by hand, and what changes when you roll everything up into a single consolidated report.
Before writing a single intercompany posting rule, decide what data lives above the company level and what stays scoped to one entity. Get this wrong and you either duplicate the same customer five times with five different codes, or let one entity's local tax setup leak into another entity's books.
The practical fix is a company dimension on every transactional table, plus a small number of master tables kept intentionally without that dimension. Reporting and posting logic then filters by company; master data lookups don't.
Once shared master data is settled, the schema question is how a company scopes everything else. The cleanest approach is a first-class Company entity with its own base currency, fiscal calendar, and enabled chart of accounts subset, referenced by a company id column on every ledger, invoice, and journal table.
This is also where multi-company support pays for itself operationally: onboarding a new subsidiary becomes inserting one Company row and a handful of intercompany pair records, not a schema migration.
If you're retrofitting multi-company support onto an existing single-company schema, add the company id column with a default value equal to the existing single company's id first, backfill it, then make it required. That keeps the migration reversible if something breaks halfway through.
An intercompany sale has to produce two independent, balanced transactions, an AR invoice on the selling entity and an AP bill on the buying entity, that stay linked so the elimination job can find both sides later. Treat it as one business operation with two ledger effects, not two unrelated postings that happen to reference each other.
The code below shows the shape of that operation: one function call that creates both legs atomically, so a failure midway never leaves one company with a posted invoice and the other with nothing.
// Posting an intercompany sale: one call creates both legs
async function postIntercompanySale(input: {
sellingCompanyId: string;
buyingCompanyId: string;
itemId: string;
qty: number;
unitPrice: number; // in sellingCompany's currency
postingDate: string;
}) {
const icPair = await getIntercompanyPair(
input.sellingCompanyId,
input.buyingCompanyId
);
// 1. AR invoice on the selling entity
const arInvoice = await createInvoice({
companyId: input.sellingCompanyId,
customerId: icPair.counterpartyCustomerId, // maps to buying company
lines: [{ itemId: input.itemId, qty: input.qty, price: input.unitPrice }],
intercompanyPairId: icPair.id,
postingDate: input.postingDate,
});
// 2. AP bill on the buying entity, converted to its base currency
const fxRate = await getRate(icPair.sellCurrency, icPair.buyCurrency, input.postingDate);
const apBill = await createBill({
companyId: input.buyingCompanyId,
vendorId: icPair.counterpartySupplierId, // maps to selling company
lines: [{
itemId: input.itemId,
qty: input.qty,
price: round(input.unitPrice * fxRate, 2),
}],
intercompanyPairId: icPair.id,
postingDate: input.postingDate,
});
// 3. Link both legs so the elimination job can find the pair later
await linkIntercompanyDocuments(arInvoice.id, apBill.id);
return { arInvoice, apBill };
}Wrap the whole function in a database transaction. Partial intercompany postings are one of the most time-consuming reconciliation problems to unwind after the fact, because by the time someone notices, a period may already be closed on one side.
At period close, every intercompany AR and AP pair, and every intercompany revenue and cost of goods sold pair, needs to be reversed for the consolidated view, while staying untouched in each entity's own books. The elimination job walks the intercompany pair links created during posting, groups them by consolidation group, and generates one elimination journal per pair.
| Elimination line | Account | Debit (IDR) | Credit (IDR) |
|---|---|---|---|
| Consolidation elimination | Intercompany sales revenue | 100,000,000 | — |
| Consolidation elimination | Cost of goods sold (intercompany) | — | 100,000,000 |
| Consolidation elimination | Intercompany accounts payable | — | 100,000,000 |
| Consolidation elimination | Intercompany accounts receivable | 100,000,000 | — |
Two elimination lines reverse the sales-versus-cost pair recorded in the revenue and cost of goods sold accounts; the other two reverse the AR and AP pair recorded on the balance sheet. None of this ever touches Company A's or Company B's own ledgers, the elimination journal posts only in the consolidation entity, so audit trails at the entity level stay exactly as originally recorded.
Elimination only works if debit and credit amounts match exactly, including after currency conversion. A one-day gap between when the selling entity posts and when the buying entity posts, or a stale exchange rate on one side, produces an elimination that does not net to zero, reconcile posting dates and rates before running the job, not after.
Elimination handles the intercompany noise, but rolling multiple entities into one consolidated report raises two more questions: what currency do you report in, and what happens when a parent owns less than one hundred percent of a subsidiary.
This is where the company dimension from the earlier design section earns its keep again: the consolidated report is really just a filtered, translated, and netted view over the same transactional tables every other report already reads.
Teams that build multi-company support from scratch tend to hit the same handful of problems. Plan for these before they show up in production.
None of this requires an exotic architecture. A company dimension, an intercompany pair table, a flag on eliminable accounts, and a re-runnable elimination job cover the vast majority of real-world multi-entity groups, including the currency and partial-ownership cases most custom ERPs eventually run into.