Skip to main content
BlogERP

Multi-Company ERP: Intercompany Transactions and Elimination

How to design multi-company support into a custom ERP: shared master data, intercompany invoicing, automatic elimination entries, and consolidated reporting.

Matthews Wong

Software Engineer

July 202610 min read
Multi-Company ERP: Intercompany Transactions and Elimination

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.

The shared master data problem

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.

  • Shared across all companies: chart of accounts structure, item master, unit of measure tables, and the employee directory, these should have one source of truth with a company-agnostic identifier.
  • Local to each company: tax codes, statutory reporting formats, functional currency, and the subset of the chart of accounts actually enabled for posting, a PT entity in Indonesia and a Pte Ltd sibling in Singapore rarely share a tax setup.
  • Shared identity, local behavior: customers and vendors need one master record so the intercompany counterpart resolves correctly, but each company keeps its own credit terms, price list, and open-item ledger against that same customer.

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.

Designing the company dimension

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.

  • Add an intercompany pair table that records which two companies are allowed to trade with each other and which customer or vendor code represents the counterpart entity on each side.
  • Give every intercompany-eligible account a flag rather than hardcoding account numbers into the elimination job, accounts change per client, the flag does not.
  • Store the functional currency and exchange rate source per company, since intercompany invoices routinely cross currency boundaries even inside one group.

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.

The intercompany invoicing workflow

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.

  1. Look up the intercompany pair for the two companies involved to resolve which customer code represents the buyer and which vendor code represents the seller.
  2. Post the AR invoice on the selling entity in its own functional currency, tagged with the intercompany pair id.
  3. Convert the invoice amount using the exchange rate in effect on the posting date and post the mirrored AP bill on the buying entity, tagged with the same intercompany pair id.
  4. Link both documents to each other so a single lookup returns the full pair, this link, not account number matching, is what the elimination job relies on.

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.

Automatic elimination entries

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 lineAccountDebit (IDR)Credit (IDR)
Consolidation eliminationIntercompany sales revenue100,000,000—
Consolidation eliminationCost of goods sold (intercompany)—100,000,000
Consolidation eliminationIntercompany accounts payable—100,000,000
Consolidation eliminationIntercompany accounts receivable100,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.

Consolidated reporting: currency, ownership, and minority interest

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.

  • Translate each subsidiary's trial balance into the group's presentation currency using period-end rates for balance sheet accounts and average rates for income statement accounts, keeping the translation adjustment in a separate equity account rather than forcing it through profit and loss.
  • Carry an ownership percentage per company relationship so the consolidation engine knows how much of a partially owned subsidiary's equity and net income belongs to the group versus to minority interest.
  • Keep the elimination and translation steps separate and independently re-runnable, so correcting a currency rate does not require re-deriving every elimination entry from scratch.

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.

Practical pitfalls when building this yourself

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.

  • Treat the intercompany pair link as the source of truth for elimination, not account number matching, account numbers can be reused for unrelated postings, but a pair id cannot.
  • Never let a period close on one entity while its intercompany counterpart is still open, close dates must be coordinated across the pair, or the elimination job will find a one-sided entry.
  • Build the elimination job to be idempotent and re-runnable for a given period, because someone will eventually restate a prior month and need to run it again without doubling up prior elimination entries.
  • Log every elimination journal with a reference back to the source AR and AP pair so an auditor can trace a consolidated number all the way down to the two entity-level postings that produced it.

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.

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

What is an intercompany transaction in ERP?

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.

Why do intercompany transactions need to be eliminated?

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.

How do you automate elimination entries in a custom ERP?

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.

Can multiple companies share the same customer or vendor master data?

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.

What happens to consolidated reporting when a parent owns less than 100 percent of a subsidiary?

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.