PINT AE in practice: what the UAE e-invoicing format actually requires
Reading the spec is one thing; mapping a live Xero invoice into it is another. Field-level notes from building NAZM's generation layer.
PINT AE is the UAE’s localisation of the Peppol International (PINT) invoice model. If you’ve worked with Peppol BIS in Europe, the shape is familiar - a UBL invoice with a defined set of business terms - but the UAE data dictionary adds its own required fields, its own VAT categories, and its own validation rules. The differences are small enough to be missed and large enough to get an invoice rejected.
The first practical problem is that no mainstream accounting system emits PINT AE natively. A Xero invoice, a QuickBooks invoice, and a Dynamics invoice all carry roughly the same information, but they disagree about where it lives, how tax is broken down, and what a credit note even is. Our answer in NAZM was to never map source-to-format directly: every system maps into a canonical model first, and only the canonical model maps to PINT AE.
Where the mappings bite
Three field groups caused most of the iteration:
- Tax breakdowns. The data dictionary wants tax subtotals grouped by category code. Source systems store tax per line, per rate, or per document - sometimes all three, disagreeing by a rounding step.
- Party identification. A legal entity needs its TRN in the right identifier scheme; the same customer record in a source ledger may hold it in a custom field, a tax-number field, or not at all.
- Rounding. Line-level versus document-level rounding must reconcile exactly. We validate the arithmetic before generation, so the error surfaces as “line 3 doesn’t sum” instead of a schema rejection.
The canonical model keeps all amounts in minor units, so the generation layer is pure translation:
// canonical → pint ae tax subtotal
const subtotal = group(invoice.lines, (l) => l.taxCategory).map((g) => ({
taxableAmount: sumMinor(g.lines, (l) => l.netMinor),
taxAmount: sumMinor(g.lines, (l) => l.taxMinor),
category: g.key, // AE data dictionary code
}));
assertReconciles(subtotal, invoice.totalsMinor);
Validation runs the same rules the network will - data dictionary, VAT logic, schema - before anything leaves the platform. The goal is simple: an error should read like a sentence a bookkeeper can act on, and it should arrive before transmission, not days later as an XML rejection.
If you’re building against the mandate and want to compare notes, we’re easy to reach.