We built Invocie's compliance engine to handle MENA clearance, EU Peppol, and global post-audit out of one codebase. The architecture took three iterations to settle. These are the design choices that survived.
1. Strategy Pattern at the country level, not the region level
Region is too coarse. Saudi (clearance, ZATCA, 15%) and the UAE (interoperable, FTA, 5%) are both "MENA" but architecturally very different. We dispatch by ISO country code first, region second, with a Default fallback.
function pickStrategy(t: { country_code: string; tax_region: string }) {
if (MENA.includes(t.country_code)) return new MENAStrategy();
if (EU.includes(t.country_code)) return new EUStrategy();
if (t.tax_region === "MENA") return new MENAStrategy();
if (t.tax_region === "EU") return new EUStrategy();
return new DefaultStrategy();
}2. Decimal everywhere, never floats
We use BigInt with fixed-point scaling (10000x for 4-decimal precision) in performance paths and decimal.js where the SDK is friendlier. The only forbidden type for monetary values is JavaScript's number — we caught three rounding bugs in pre-production tracing back to a stray Number().
3. Async-by-default, sync-by-exception
The HTTP route that creates an invoice never blocks on the government API. It writes to the database, enqueues a job, and returns 201. The worker handles the gov submission with retries (exponential backoff, max 6 attempts, jitter). This insulates the user-facing API from gov-side latency, and it makes retries safe even when ZATCA has a bad afternoon.
4. Separate the canonical model from the wire format
Our internal Invoice type is the same regardless of country. Each strategy's buildArtifacts() turns it into the country-specific wire format (TLV QR, UBL 2.1 XML, or just a Default JSON). The canonical model never knows about XML. The XML never escapes the strategy. This makes adding India IRP later a one-file change.
5. Logging is part of the contract
Every gov interaction writes a ComplianceLog row with prev_hash → hash chaining. When auditors come — and they will come — you don't want to be reconstructing what happened from app logs. The DB row is the receipt.