Multi-entity finance teams running AI agents have a pair of problems that single-entity teams don’t:
- Defensible FX rates. Each transaction in a foreign currency needs a USD-equivalent for materiality checks and consolidation. The rate has to be defensible — your auditor will ask the source.
- Intercompany matching. A US-parent expense booked to “intercompany receivable from JP-sub” needs to match the corresponding JP-sub entry booked to “intercompany payable to US-parent.” Across entities, across currencies, often across timezones.
Both problems benefit from the same pattern: Protocol + registry adapters. Define a typing.Protocol; ship reference implementations; let teams register their own.
This article walks both adapters and the runtime mechanics.
The FX adapter Protocol
from typing import Protocol, runtime_checkable
from datetime import date
from decimal import Decimal
@runtime_checkable
class FxRateAdapter(Protocol):
name: str
def rate(self, base: str, quote: str, as_of: date) -> FxRate:
"""Return the rate from base to quote on the given date."""
...
FxRate carries the rate value plus source attribution:
@dataclass(frozen=True)
class FxRate:
rate: Decimal
source: str # adapter name (e.g. "ecb_daily", "bloomberg-fx")
as_of: date # actual date the rate was sourced for
The Protocol is read-only and deterministic. The adapter doesn’t write to closegate’s stores; the audit log records the rate’s source attribution on every cross-currency event.
Reference adapters
Three implementations ship:
FixedRateAdapter (default for demos + tests)
class FixedRateAdapter:
name = "fixed"
rates = {
("EUR", "USD"): Decimal("1.08"),
("GBP", "USD"): Decimal("1.27"),
("JPY", "USD"): Decimal("0.0067"),
# ...
}
def rate(self, base, quote, as_of):
return FxRate(rate=self.rates[(base, quote)], source=self.name, as_of=as_of)
Static table. Use for demos and tests; raises an alarm in production via CLOSEGATE_FX_ADAPTER_PROD_GUARD.
EcbDailyRatesAdapter (free ECB feed)
Pulls the European Central Bank daily reference rates from https://www.ecb.europa.eu/stats/eurofxref/. Free, public, defensible source for non-USD-anchored teams. Caches locally; refreshes daily.
OpenExchangeRatesAdapter (paid)
Paid tier of openexchangerates.org. Required for hourly granularity or for rates against non-major currencies. ~$50/mo.
Registering your own
from closegate_policy.fx import FxRateAdapter, register_fx_adapter
class BloombergFxAdapter:
name = "bloomberg-fx"
def __init__(self, api_token: str):
self._token = api_token
def rate(self, base, quote, as_of):
# Bloomberg API call
...
return FxRate(rate=fetched_rate, source=self.name, as_of=as_of)
register_fx_adapter("bloomberg", BloombergFxAdapter(api_token=os.environ["BLOOMBERG_API_TOKEN"]))
Then set CLOSEGATE_FX_ADAPTER=bloomberg in env. The policy gate looks up the configured adapter on every cross-currency calculation.
The intercompany matcher Protocol
@runtime_checkable
class IntercompanyMatcher(Protocol):
name: str
def match(self,
gl_entries: list[Entry],
sl_entries: list[Entry]
) -> list[IntercompanyMatch]:
"""Find candidate intercompany pairs."""
...
IntercompanyMatch is a typed pairing:
@dataclass(frozen=True)
class IntercompanyMatch:
gl_entry_id: str
sl_entry_id: str
confidence: float # 0..1
rule_applied: str # which rule fired
notes: str | None = None
Reference intercompany matchers
Three implementations:
NoOpMatcher
For deployments without intercompany flows. Returns empty list. Default.
AccountCodePairMatcher
The most common pattern. Matches by chart-of-accounts convention:
- US-parent books to
1500-INTERCO-JP-RECEIVABLE - JP-sub books to
2500-INTERCO-US-PAYABLE - Both entries have the same amount + opposite signs
The matcher recognizes the 1500-X ↔ 2500-X pattern (configurable via account_pairs in policy.yaml). Returns high-confidence matches when both legs are found.
JsonRulesMatcher
Declarative rules in policy.yaml:
intercompany_rules:
- name: "us-parent-to-jp-sub"
gl_account_pattern: "1500-JP-*"
sl_account_pattern: "2500-US-*"
tolerance_pct: 0.5 # FX rate slippage tolerance
require_same_period: true
- name: "us-parent-to-uk-sub"
gl_account_pattern: "1500-UK-*"
sl_account_pattern: "2500-US-*"
tolerance_pct: 0.5
require_same_period: true
The JsonRulesMatcher reads these rules and applies them. Useful for teams with non-standard chart-of-accounts conventions.
How it fits into the recon pipeline
The matching pipeline runs in order:
- Exact matcher (deterministic, single-entity, amount + reference)
- Multi-to-one matcher (deterministic, one GL entry to multiple SL entries)
- Intercompany matcher (cross-entity, FX-aware)
- Fuzzy matcher (LLM-assisted, near-misses)
The intercompany matcher runs after exact + multi-to-one because intercompany pairs are usually higher-confidence than fuzzy matches but need to be checked after the deterministic single-entity passes have cleared their candidates.
When a pair matches, the FX adapter is called for the cross-currency check. If the JP-sub entry is ¥10,000,000 and the US-parent entry is $66,840, the matcher confirms (¥10M × 0.006684 USD/¥ = $66,840). Tolerance pct in the rule handles small FX slippage.
Audit-log evidence
Every intercompany match writes an audit event with:
- The two entry IDs
- The FX rate used + the rate’s source + the as_of date
- The matcher rule that fired (
account_code_pairorjson_rules:us-parent-to-jp-sub) - The confidence score
Your auditor samples these and asks: “where did this FX rate come from?” The answer is the audit-event row + the FX adapter config in policy.yaml.
Real-world deployment shape
A US-headquartered holdco with US-parent + UK-sub + JP-sub + IN-sub:
fx_adapter: ecb_daily # or bloomberg in production
intercompany_matcher: account_code_pair
intercompany_account_pairs:
- { left: "1500-UK-*", right: "2500-US-*", tolerance_pct: 0.5 }
- { left: "1500-JP-*", right: "2500-US-*", tolerance_pct: 0.5 }
- { left: "1500-IN-*", right: "2500-US-*", tolerance_pct: 0.5 }
entity_materiality_overrides:
us-parent: 10000 # USD
uk-sub: 8000 # GBP
jp-sub: 1000000 # JPY
in-sub: 500000 # INR
The per-entity materiality overrides combine with the intercompany matcher to give: “below-materiality intercompany pairs auto-confirm; above-materiality always route to HITL with full FX rate + source on the audit event.”
What this gives you
- A defensible FX-rate-source story your auditor accepts
- Multi-entity intercompany matching that survives multi-currency arithmetic
- Per-entity materiality overrides that respect local-currency norms
- Configurable rules without forking closegate; just register your adapter
What to read next
- The policy gate — what consumes the FX + intercompany results
- Materiality thresholds for AI — per-entity overrides in detail
- For architects — Protocol + registry adapter pattern
- How it works — the full pipeline architecture