writing/tutorial/2026/08
TutorialAug 16, 2026·30 min read

Build and Validate UAE PINT AE E-Invoices in TypeScript

Every article about UAE e-invoicing explains what PINT AE is. None of them show you the code. This tutorial builds the part your Accredited Service Provider will not build for you: a typed mapping from your own order data to PINT AE UBL 2.1, VAT totals that reconcile to the fils, local Schematron validation that fails in CI instead of in production, and a response-leg state machine that survives a rejection three days after you sent the invoice.

There is a specific moment that decides whether a UAE e-invoicing project goes well or badly, and it happens long before go-live. It is the moment someone on the finance side asks the engineering side: "the ASP handles the e-invoicing, right?"

The Accredited Service Provider handles transmission. It runs corner 2 and corner 3 of the Peppol five-corner model, it holds the accreditation, it talks to the Federal Tax Authority. What it does not do is know that your orders table stores VAT as a percentage on the header for legacy customers and per line for everyone since 2023, or that three of your top ten customers were onboarded before you started capturing a TRN, or that your rounding has been off by one fils per line for four years and nobody noticed because nobody ever validated the total against the sum.

That mapping layer is yours. This tutorial builds it.

This is the engineering companion to a strategy piece. If you are still deciding how to scope the project, appoint a provider, or budget it, read UAE E-Invoicing 2026: What Your ASP Won't Do For You first. That article covers the timeline, the phasing, and where the commercial boundary sits. This one assumes those decisions are made and you are writing the code.

What You'll Build

A TypeScript library that takes your internal invoice object and produces a PINT AE compliant UBL 2.1 XML document, with four things that most in-house implementations skip:

  1. A typed domain model that refuses to represent an invoice that cannot be valid — missing TRN, negative quantities on a non-credit document, a VAT category that requires an exemption reason without one.
  2. Integer-fils money arithmetic, so cac:TaxTotal reconciles against the sum of the lines exactly, every time.
  3. A local validation harness running the official XSD and Schematron artefacts as a vitest suite, so a malformed invoice fails on your laptop and in CI rather than as a rejection message from the FTA.
  4. A response-leg state machine for the asynchronous outcome, because a Peppol invoice is not "sent" when your HTTP call returns 200.

By the end you will have buildInvoiceXml(), validateInvoiceXml(), and a persisted document lifecycle that can answer "what is the current legal status of invoice INV-2026-00412" without anyone opening the provider's dashboard.

Prerequisites

  • Node.js 20+ and TypeScript 5.5+
  • Familiarity with XML namespaces — UBL uses four of them and mixing them up is the most common early failure
  • Java 11+ available on the machine and in CI (the reference Schematron tooling is JVM-based; we will wrap it, not rewrite it)
  • Access to your own invoice data model, or a willingness to adapt the example one
  • A sandbox account with your ASP, ideally before you start. If you do not have one yet, everything up to the transmission step still works offline.

You do not need production credentials to follow along. Steps 1 through 7 are entirely local.

Step 0: Pin the specification, do not memorise it

PINT AE is a versioned specification, published by OpenPeppol's Post Award Coordinating Community with UAE-specific rules layered on the international PINT billing model. It has already moved through more than one release, and the exact identifier strings, code lists, and business rules are version-bound.

So the first thing to write is not a builder. It is a single module that holds every spec-derived constant in one place, with the version stamped on it:

// src/spec/pint-ae.ts
 
/**
 * Constants derived from the PINT AE specification.
 * PIN THESE to the release your ASP is certified against, and re-verify
 * against https://docs.peppol.eu/poac/ae/ on every spec bump.
 * Never inline these values into the builder.
 */
export const PINT_AE_RELEASE = "2025-Q2" as const;
 
export const CUSTOMIZATION_ID = "urn:peppol:pint:billing-1@ae-1";
export const PROFILE_ID = "urn:peppol:bis:billing";
export const UBL_VERSION_ID = "2.1";
 
export const NS = {
  inv: "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2",
  cn: "urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2",
  cac: "urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2",
  cbc: "urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2",
  ext: "urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2",
} as const;
 
/** UN/CEFACT 1001 document type codes used in the UAE profile. */
export const DOC_TYPE = {
  taxInvoice: "380",
  creditNote: "381",
  debitNote: "383",
  selfBilledInvoice: "389",
} as const;
 
/** UNCL5305 VAT category codes in scope for the UAE. */
export const VAT_CATEGORY = {
  standard: "S",       // 5%
  zeroRated: "Z",
  exempt: "E",
  reverseCharge: "AE",
  outOfScope: "O",
} as const;
 
export type VatCategoryCode =
  (typeof VAT_CATEGORY)[keyof typeof VAT_CATEGORY];
 
/** Categories that legally require a stated reason on the document. */
export const REASON_REQUIRED: ReadonlySet<VatCategoryCode> = new Set([
  VAT_CATEGORY.zeroRated,
  VAT_CATEGORY.exempt,
  VAT_CATEGORY.reverseCharge,
  VAT_CATEGORY.outOfScope,
]);
 
export const AED = "AED";
/** AED subdivides into 100 fils. All internal money is integer fils. */
export const MINOR_UNITS = 2;

Why this matters more than it looks. The one prediction I will make with confidence about your project is that the spec version will change at least once between now and your go-live, and probably again after. Teams who inlined urn:peppol:pint:billing-1@ae-1 into a template string across nine files spend a week finding them all. Teams who put it here change one line and re-run the suite.

A note on the identifier strings above. They match the currently published PINT AE binding, but treat them as a starting point rather than gospel. Diff them against the release your ASP is certified against on day one of the project — a CustomizationID mismatch is rejected at corner 2 with an unhelpful error, and it is a five-minute check that saves an afternoon.

Step 1: A domain model that cannot express an invalid invoice

The strongest lever you have is making bad states unrepresentable, so the compiler catches the failures instead of the Schematron. Here is the model, deliberately narrower than UBL:

// src/domain/invoice.ts
 
/** Money is ALWAYS integer minor units (fils). Never a float, never a string. */
export type Fils = number & { readonly __brand: "Fils" };
 
export const fils = (n: number): Fils => {
  if (!Number.isInteger(n)) {
    throw new TypeError(`Money must be integer fils, received ${n}`);
  }
  return n as Fils;
};
 
/** A UAE Tax Registration Number: exactly 15 digits. */
export type Trn = string & { readonly __brand: "Trn" };
 
export const trn = (raw: string): Trn => {
  const cleaned = raw.replace(/[\s-]/g, "");
  if (!/^\d{15}$/.test(cleaned)) {
    throw new TypeError(`Invalid TRN: expected 15 digits, got "${raw}"`);
  }
  return cleaned as Trn;
};
 
export interface LegalIdentifier {
  /** Trade Licence, Emirates ID, Commercial Document, or Passport. */
  readonly scheme: "TL" | "EID" | "CD" | "PAS";
  readonly value: string;
}
 
export interface Party {
  readonly name: string;
  /** Optional for buyers below the registration threshold. */
  readonly trn?: Trn;
  readonly legalId?: LegalIdentifier;
  readonly address: {
    readonly street: string;
    readonly city: string;
    readonly emirate: string;
    readonly countryCode: string; // ISO 3166-1 alpha-2
  };
}
 
interface VatBase {
  readonly rate: number; // percentage, e.g. 5
}
 
export type VatTreatment =
  | ({ readonly category: "S" } & VatBase)
  | { readonly category: "Z" | "E" | "AE" | "O"; readonly rate: 0; readonly reason: string };
 
export interface InvoiceLine {
  readonly id: string;
  readonly description: string;
  readonly quantity: number;
  readonly unitCode: string; // UN/ECE Rec 20, e.g. "EA", "HUR"
  readonly unitPrice: Fils;
  readonly lineExtensionAmount: Fils; // net of VAT
  readonly vat: VatTreatment;
}
 
export interface Invoice {
  readonly number: string;
  readonly issueDate: string; // YYYY-MM-DD
  readonly dueDate?: string;
  readonly documentType: "380" | "381" | "383" | "389";
  readonly currency: "AED";
  readonly seller: Party & { readonly trn: Trn }; // seller TRN is never optional
  readonly buyer: Party;
  readonly lines: readonly InvoiceLine[];
  /** Populated for credit and debit notes only. */
  readonly precedingInvoice?: { readonly number: string; readonly issueDate: string };
}

Read the VatTreatment union again, because it is doing quiet work. A standard-rated line carries a rate and nothing else. Every non-standard category is forced to carry a reason string, and forced to a zero rate, at the type level. The rule that a zero-rated supply must state why it is zero-rated is one of the most common Schematron failures in every jurisdiction that adopts Peppol, and here it is impossible to construct such a line without it.

Same story with Fils and Trn. They are branded types: a plain number will not satisfy Fils without passing through the fils() constructor, and that constructor rejects anything non-integer. Your float rounding bug cannot reach the XML because it cannot reach the domain model.

Step 2: Money arithmetic that reconciles

Here is a real failure, and it will happen to you if you use floats. Three lines at AED 33.33, VAT 5%:

  • Per-line VAT: 1.6665 each. Rounded, 1.67 each, total 5.01.
  • VAT on the sum: 99.99 times 0.05 equals 4.9995, rounded 5.00.

One fils apart. The Schematron checks that the tax total equals the sum of the tax subtotals, and it does not care which answer you think is more correct — it cares that the document is internally consistent. Most rejections in the first month of any e-invoicing mandate are this, or a cousin of it.

The fix is to fix the order of operations and never deviate:

// src/money.ts
import { fils, type Fils } from "./domain/invoice";
 
export const addFils = (...xs: Fils[]): Fils =>
  fils(xs.reduce((a, b) => a + b, 0));
 
/** Half-up rounding on integers. No floats survive this function. */
export const applyRate = (base: Fils, ratePercent: number): Fils => {
  const numerator = base * Math.round(ratePercent * 100); // rate in basis points
  const scaled = Math.round(numerator / 10_000);
  return fils(scaled);
};
 
/** Fils to the decimal string UBL expects: 12345 becomes "123.45". */
export const toAmountString = (v: Fils): string => {
  const sign = v < 0 ? "-" : "";
  const abs = Math.abs(v);
  return `${sign}${Math.trunc(abs / 100)}.${String(abs % 100).padStart(2, "0")}`;
};

And the totals, computed in exactly one place, grouped by VAT category because that is how UBL wants them:

// src/totals.ts
import { addFils, applyRate } from "./money";
import { fils, type Fils, type Invoice } from "./domain/invoice";
 
export interface TaxSubtotal {
  readonly category: string;
  readonly rate: number;
  readonly taxableAmount: Fils;
  readonly taxAmount: Fils;
  readonly reason?: string;
}
 
export interface Totals {
  readonly lineExtensionAmount: Fils;
  readonly taxExclusiveAmount: Fils;
  readonly taxInclusiveAmount: Fils;
  readonly payableAmount: Fils;
  readonly taxAmount: Fils;
  readonly subtotals: readonly TaxSubtotal[];
}
 
export function computeTotals(invoice: Invoice): Totals {
  const groups = new Map<string, { rate: number; base: Fils; reason?: string }>();
 
  for (const line of invoice.lines) {
    const key = `${line.vat.category}:${line.vat.rate}`;
    const existing = groups.get(key);
    const reason = "reason" in line.vat ? line.vat.reason : undefined;
    groups.set(key, {
      rate: line.vat.rate,
      base: addFils(existing?.base ?? fils(0), line.lineExtensionAmount),
      reason: existing?.reason ?? reason,
    });
  }
 
  // VAT is computed ONCE per category group, on the summed base.
  // Never per line, then summed — that is the one-fils bug.
  const subtotals: TaxSubtotal[] = [...groups.entries()].map(([key, g]) => ({
    category: key.split(":")[0],
    rate: g.rate,
    taxableAmount: g.base,
    taxAmount: applyRate(g.base, g.rate),
    reason: g.reason,
  }));
 
  const lineExtensionAmount = addFils(...invoice.lines.map((l) => l.lineExtensionAmount));
  const taxAmount = addFils(...subtotals.map((s) => s.taxAmount));
  const taxInclusiveAmount = addFils(lineExtensionAmount, taxAmount);
 
  return {
    lineExtensionAmount,
    taxExclusiveAmount: lineExtensionAmount,
    taxInclusiveAmount,
    payableAmount: taxInclusiveAmount,
    taxAmount,
    subtotals,
  };
}

The comment in the middle is the whole lesson. Aggregate the taxable base per category first, then apply the rate once. Every line-level VAT figure you display in your own UI is a presentational convenience; the document's arithmetic runs on the group.

Step 3: Generate the UBL, namespaces and all

Do not build XML with template literals. An unescaped ampersand in a customer name called "Al Futtaim & Sons" will produce a document that fails XSD parsing at the ASP, and the error you get back will be about line 84 of a document you never see. Use a builder that escapes for you:

npm install xmlbuilder2
npm install -D vitest tsx
// src/build/invoice-xml.ts
import { create } from "xmlbuilder2";
import {
  CUSTOMIZATION_ID, PROFILE_ID, UBL_VERSION_ID, NS, AED,
} from "../spec/pint-ae";
import type { Fils, Invoice, Party } from "../domain/invoice";
import { computeTotals } from "../totals";
import { toAmountString } from "../money";
 
const amt = (v: Fils) => ({ "@currencyID": AED, "#": toAmountString(v) });
 
function partyNode(p: Party, endpointScheme: string) {
  const node: Record<string, unknown> = {};
 
  if (p.trn) {
    node["cbc:EndpointID"] = { "@schemeID": endpointScheme, "#": p.trn };
  }
 
  node["cac:PostalAddress"] = {
    "cbc:StreetName": p.address.street,
    "cbc:CityName": p.address.city,
    "cbc:CountrySubentity": p.address.emirate,
    "cac:Country": { "cbc:IdentificationCode": p.address.countryCode },
  };
 
  if (p.trn) {
    node["cac:PartyTaxScheme"] = {
      "cbc:CompanyID": p.trn,
      "cac:TaxScheme": { "cbc:ID": "VAT" },
    };
  }
 
  node["cac:PartyLegalEntity"] = {
    "cbc:RegistrationName": p.name,
    ...(p.legalId
      ? { "cbc:CompanyID": { "@schemeAgencyID": p.legalId.scheme, "#": p.legalId.value } }
      : {}),
  };
 
  return node;
}
 
export function buildInvoiceXml(invoice: Invoice, endpointScheme: string): string {
  const t = computeTotals(invoice);
 
  const doc = create({ version: "1.0", encoding: "UTF-8" }).ele("Invoice", {
    xmlns: NS.inv,
    "xmlns:cac": NS.cac,
    "xmlns:cbc": NS.cbc,
    "xmlns:ext": NS.ext,
  });
 
  doc.ele("cbc:UBLVersionID").txt(UBL_VERSION_ID);
  doc.ele("cbc:CustomizationID").txt(CUSTOMIZATION_ID);
  doc.ele("cbc:ProfileID").txt(PROFILE_ID);
  doc.ele("cbc:ID").txt(invoice.number);
  doc.ele("cbc:IssueDate").txt(invoice.issueDate);
  if (invoice.dueDate) doc.ele("cbc:DueDate").txt(invoice.dueDate);
  doc.ele("cbc:InvoiceTypeCode").txt(invoice.documentType);
  doc.ele("cbc:DocumentCurrencyCode").txt(AED);
 
  if (invoice.precedingInvoice) {
    doc.ele("cac:BillingReference").ele("cac:InvoiceDocumentReference").ele({
      "cbc:ID": invoice.precedingInvoice.number,
      "cbc:IssueDate": invoice.precedingInvoice.issueDate,
    });
  }
 
  doc.ele("cac:AccountingSupplierParty").ele({
    "cac:Party": partyNode(invoice.seller, endpointScheme),
  });
  doc.ele("cac:AccountingCustomerParty").ele({
    "cac:Party": partyNode(invoice.buyer, endpointScheme),
  });
 
  const taxTotal = doc.ele("cac:TaxTotal");
  taxTotal.ele("cbc:TaxAmount", { currencyID: AED }).txt(toAmountString(t.taxAmount));
 
  for (const s of t.subtotals) {
    const sub = taxTotal.ele("cac:TaxSubtotal");
    sub.ele("cbc:TaxableAmount", { currencyID: AED }).txt(toAmountString(s.taxableAmount));
    sub.ele("cbc:TaxAmount", { currencyID: AED }).txt(toAmountString(s.taxAmount));
    const cat = sub.ele("cac:TaxCategory");
    cat.ele("cbc:ID").txt(s.category);
    cat.ele("cbc:Percent").txt(s.rate.toFixed(2));
    if (s.reason) cat.ele("cbc:TaxExemptionReason").txt(s.reason);
    cat.ele("cac:TaxScheme").ele("cbc:ID").txt("VAT");
  }
 
  doc.ele("cac:LegalMonetaryTotal").ele({
    "cbc:LineExtensionAmount": amt(t.lineExtensionAmount),
    "cbc:TaxExclusiveAmount": amt(t.taxExclusiveAmount),
    "cbc:TaxInclusiveAmount": amt(t.taxInclusiveAmount),
    "cbc:PayableAmount": amt(t.payableAmount),
  });
 
  for (const line of invoice.lines) {
    const l = doc.ele("cac:InvoiceLine");
    l.ele("cbc:ID").txt(line.id);
    l.ele("cbc:InvoicedQuantity", { unitCode: line.unitCode }).txt(String(line.quantity));
    l.ele("cbc:LineExtensionAmount", { currencyID: AED })
      .txt(toAmountString(line.lineExtensionAmount));
    l.ele("cac:Item").ele({
      "cbc:Name": line.description,
      "cac:ClassifiedTaxCategory": {
        "cbc:ID": line.vat.category,
        "cbc:Percent": line.vat.rate.toFixed(2),
        "cac:TaxScheme": { "cbc:ID": "VAT" },
      },
    });
    l.ele("cac:Price").ele("cbc:PriceAmount", { currencyID: AED })
      .txt(toAmountString(line.unitPrice));
  }
 
  return doc.end({ prettyPrint: true });
}

Note the element order. UBL's XSD enforces sequence, not just presence — cbc:IssueDate before cbc:InvoiceTypeCode, cac:TaxTotal before cac:LegalMonetaryTotal, cac:LegalMonetaryTotal before the lines. A document with every required field present, in the wrong order, is invalid. This is the single most annoying class of error to debug from a remote rejection message, and the reason Step 4 exists.

Step 4: Validate locally, before anything leaves the building

The reference validation artefacts for PINT AE are XSD schemas plus Schematron rule sets, and the canonical tooling to run Schematron is JVM-based. Rewriting Schematron in TypeScript is a project, not a step; wrapping the official validator is twenty lines and stays correct when the rules change.

Download the Peppol validation artefacts for your target release and wire them up:

// src/validate/schematron.ts
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { writeFile, mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
 
const run = promisify(execFile);
 
export interface ValidationFinding {
  readonly severity: "fatal" | "warning";
  readonly ruleId: string;
  readonly location: string;
  readonly message: string;
}
 
const JAR = process.env.PEPPOL_VALIDATOR_JAR ?? "./tools/phive-cli.jar";
const RULESET = process.env.PEPPOL_RULESET ?? "eu.peppol.pint.ae:invoice:latest";
 
export async function validateInvoiceXml(xml: string): Promise<ValidationFinding[]> {
  const dir = await mkdtemp(join(tmpdir(), "pint-ae-"));
  const file = join(dir, "invoice.xml");
  try {
    await writeFile(file, xml, "utf8");
    const { stdout } = await run("java", [
      "-jar", JAR,
      "--vesid", RULESET,
      "--mode", "json",
      file,
    ]);
    return parseFindings(stdout);
  } catch (err) {
    // A non-zero exit is how the validator reports findings, not a crash.
    const stdout = (err as { stdout?: string }).stdout;
    if (stdout) return parseFindings(stdout);
    throw new Error(
      `Validator failed to run. Is Java on PATH and ${JAR} present? ${String(err)}`,
    );
  } finally {
    await rm(dir, { recursive: true, force: true });
  }
}
 
function parseFindings(stdout: string): ValidationFinding[] {
  const report = JSON.parse(stdout) as {
    results?: Array<{ items?: Array<Record<string, string>> }>;
  };
  return (report.results ?? []).flatMap((r) =>
    (r.items ?? []).map((i) => ({
      severity: i.errorLevel === "ERROR" ? ("fatal" as const) : ("warning" as const),
      ruleId: i.errorID ?? "unknown",
      location: i.errorLocation ?? "",
      message: i.errorText ?? "",
    })),
  );
}

Now the part that makes it stick — validation as a test, not as a script someone remembers to run:

// src/__tests__/invoice.spec.ts
import { describe, expect, it } from "vitest";
import { buildInvoiceXml } from "../build/invoice-xml";
import { validateInvoiceXml } from "../validate/schematron";
import { computeTotals } from "../totals";
import { standardInvoice, mixedRateInvoice, reverseChargeInvoice } from "./fixtures";
 
describe("PINT AE conformance", () => {
  for (const [name, fixture] of Object.entries({
    standardInvoice, mixedRateInvoice, reverseChargeInvoice,
  })) {
    it(`${name} produces zero fatal findings`, async () => {
      const findings = await validateInvoiceXml(buildInvoiceXml(fixture, "0235"));
      const fatal = findings.filter((f) => f.severity === "fatal");
      expect(fatal, JSON.stringify(fatal, null, 2)).toHaveLength(0);
    }, 30_000);
  }
});
 
describe("monetary reconciliation", () => {
  it("tax total equals the sum of subtotals for awkward thirds", () => {
    // 3 lines at 33.33 AED, 5% VAT — the classic one-fils divergence
    const t = computeTotals(mixedRateInvoice);
    const summed = t.subtotals.reduce((a, s) => a + s.taxAmount, 0);
    expect(t.taxAmount).toBe(summed);
  });
 
  it("inclusive total equals exclusive plus tax", () => {
    const t = computeTotals(standardInvoice);
    expect(t.taxInclusiveAmount).toBe(t.taxExclusiveAmount + t.taxAmount);
  });
});

Put this in CI on day one, with real customer data shapes in the fixtures. The value is not in proving today's invoice is valid — it is that when someone adds a discount field in March, or a new emirate branch with a different TRN, the suite tells them before the FTA does. Teams who add validation at the end of the project discover their data problems during the go-live week, which is the most expensive possible week to discover them.

Step 5: The five data problems that are actually yours

The XML is the easy half. Here is where the real project time goes, in rough order of how much of it they consume:

Buyer TRN coverage. Every B2B invoice needs the buyer's TRN. Your CRM has it for the customers onboarded since you started asking. Run the count before you promise a date:

SELECT
  COUNT(*) FILTER (WHERE trn IS NULL OR trn = '')                    AS missing,
  COUNT(*) FILTER (WHERE trn ~ '^[0-9]{15}$')                        AS well_formed,
  COUNT(*) FILTER (WHERE trn IS NOT NULL AND trn !~ '^[0-9]{15}$')   AS malformed
FROM customers
WHERE status = 'active' AND customer_type = 'business';

The malformed bucket is the one that surprises people — TRNs entered with spaces, with a TRN- prefix, with a trailing note, or transcribed at 14 digits. Normalising is cheap; chasing the missing ones is a months-long commercial exercise, which is why it must start in month one and not month five.

Unit codes. UBL wants UN/ECE Recommendation 20 codes. Your system has "each", "hour", "box", "pcs", and one entry that just says "-". Every distinct value needs a mapping, and the mapping needs an owner:

const UNIT_CODE_MAP: Record<string, string> = {
  each: "EA", pcs: "EA", unit: "EA", item: "EA",
  hour: "HUR", hr: "HUR", hours: "HUR",
  day: "DAY", month: "MON",
  kg: "KGM", km: "KMT", litre: "LTR", l: "LTR",
};
 
export function toUnitCode(raw: string): string {
  const code = UNIT_CODE_MAP[raw.trim().toLowerCase()];
  if (!code) {
    // Fail loudly at build time. A silent fallback to "EA" is a data
    // integrity bug that surfaces months later in a VAT audit.
    throw new Error(`Unmapped unit of measure: "${raw}". Add it to UNIT_CODE_MAP.`);
  }
  return code;
}

Throwing is deliberate. The tempting alternative is defaulting to EA, and it is wrong: you will not find out until an auditor asks why 4,000 invoices billed hours as units.

Credit notes that reference nothing. A credit note must point at the invoice it reverses via cac:BillingReference. If your system issues standalone credits — goodwill gestures, opening-balance adjustments — those need either a preceding document or a different treatment. Find them now:

SELECT COUNT(*) FROM credit_notes WHERE original_invoice_id IS NULL;

Multi-branch TRNs. A group with several licensed entities has several TRNs, and the invoice must carry the one belonging to the issuing entity. If your invoice numbering is global but your legal entities are not, that mapping has to exist somewhere, and "everyone knows branch 3 bills under the trading licence" is not somewhere.

Rounding drift already in the ledger. Before you build anything, check whether your existing stored totals reconcile:

SELECT id, total_vat, computed_vat, total_vat - computed_vat AS drift
FROM (
  SELECT i.id, i.total_vat,
         ROUND(SUM(l.net_amount) * 0.05, 2) AS computed_vat
  FROM invoices i JOIN invoice_lines l ON l.invoice_id = i.id
  WHERE i.issue_date >= DATE '2026-01-01'
  GROUP BY i.id, i.total_vat
) x
WHERE ABS(total_vat - computed_vat) > 0.001
ORDER BY ABS(total_vat - computed_vat) DESC
LIMIT 50;

If that returns rows, you have a decision to make about historical data before it becomes a compliance conversation rather than an engineering one.

Step 6: Transmit to corner 2, idempotently

Your ASP exposes an API — the shape varies, but the concerns do not. The one that matters most: an invoice number is issued once. A retry after a timeout must not create a second legal document.

// src/transmit/send.ts
import { createHash } from "node:crypto";
 
export interface SubmissionResult {
  readonly providerRef: string;
  readonly acceptedAt: string;
}
 
export async function submit(
  invoiceNumber: string,
  xml: string,
  deps: { fetch: typeof fetch; baseUrl: string; token: string },
): Promise<SubmissionResult> {
  // Idempotency key derives from the document itself: the same bytes retried
  // are the same submission; changed bytes are a different document and the
  // provider must reject them under an already-used invoice number.
  const idempotencyKey = createHash("sha256")
    .update(`${invoiceNumber}:${xml}`)
    .digest("hex");
 
  const res = await deps.fetch(`${deps.baseUrl}/documents`, {
    method: "POST",
    headers: {
      "Content-Type": "application/xml",
      Authorization: `Bearer ${deps.token}`,
      "Idempotency-Key": idempotencyKey,
    },
    body: xml,
  });
 
  if (res.status === 409) {
    // Already submitted. Resolve the existing reference instead of failing.
    const existing = await res.json();
    return { providerRef: existing.documentId, acceptedAt: existing.receivedAt };
  }
 
  if (!res.ok) {
    throw new TransmissionError(res.status, await res.text());
  }
 
  const body = await res.json();
  return { providerRef: body.documentId, acceptedAt: body.receivedAt };
}
 
export class TransmissionError extends Error {
  constructor(readonly status: number, readonly body: string) {
    super(`Corner 2 rejected submission: HTTP ${status}`);
    this.name = "TransmissionError";
  }
}

Retry only on 5xx and network faults, with backoff. A 4xx means the document is wrong, and sending the same wrong document nine more times produces nine more identical rejections and one very confused support ticket.

Step 7: The response leg is a state machine, not a return value

This is the step teams discover late, and it is the one that changes your database schema.

In a five-corner model your invoice travels: you, to your ASP, across the Peppol network to the buyer's ASP, to the buyer — with a parallel reporting leg carrying tax data to the authority. Acknowledgements come back asynchronously. A Message Level Response can arrive minutes later. An Invoice Response — the buyer's business-level accept or reject — can arrive days later, and often does, because it is a human clicking a button in an AP system.

So sent is not a terminal state, and "did it work" is not answerable from an HTTP status code:

// src/lifecycle/state.ts
export type DocumentState =
  | "draft"           // built, not yet valid
  | "validated"       // passes local Schematron
  | "submitted"       // accepted by our ASP at corner 2
  | "delivered"       // MLR: reached the buyer's access point
  | "accepted"        // IR: the buyer accepted it
  | "rejected"        // IR: the buyer rejected it — needs a credit note
  | "failed";         // could not be transmitted
 
const TRANSITIONS: Record<DocumentState, readonly DocumentState[]> = {
  draft: ["validated", "failed"],
  validated: ["submitted", "failed"],
  submitted: ["delivered", "failed"],
  delivered: ["accepted", "rejected"],
  accepted: [],
  rejected: [],
  failed: ["validated"], // fix and retry
};
 
export function canTransition(from: DocumentState, to: DocumentState): boolean {
  return TRANSITIONS[from].includes(to);
}
 
export class IllegalTransition extends Error {
  constructor(from: DocumentState, to: DocumentState) {
    super(`Illegal document transition: ${from} to ${to}`);
    this.name = "IllegalTransition";
  }
}

And the storage. Note what is persisted: the exact bytes that were sent, not the object they were built from.

CREATE TABLE einvoice_document (
  id                BIGSERIAL PRIMARY KEY,
  invoice_number    TEXT NOT NULL UNIQUE,
  state             TEXT NOT NULL,
  -- The transmitted bytes, verbatim. Rebuilding the XML later will not
  -- reproduce them once the spec version or your mapping changes.
  xml_payload       BYTEA NOT NULL,
  xml_sha256        TEXT NOT NULL,
  spec_release      TEXT NOT NULL,
  provider_ref      TEXT,
  submitted_at      TIMESTAMPTZ,
  delivered_at      TIMESTAMPTZ,
  responded_at      TIMESTAMPTZ,
  rejection_reason  TEXT,
  created_at        TIMESTAMPTZ NOT NULL DEFAULT now()
);
 
CREATE TABLE einvoice_event (
  id            BIGSERIAL PRIMARY KEY,
  document_id   BIGINT NOT NULL REFERENCES einvoice_document(id),
  from_state    TEXT NOT NULL,
  to_state      TEXT NOT NULL,
  payload       JSONB,
  occurred_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);
 
CREATE INDEX ON einvoice_document (state) WHERE state IN ('submitted', 'delivered');

Two design decisions worth defending:

Store the bytes, not the model. The retention obligation is on the document that was exchanged. In two years your builder will produce slightly different output for the same input — a spec bump, a mapping fix, a library upgrade — and a regenerated document is not the document you sent. xml_sha256 lets you prove that later.

Stamp spec_release on every row. When you migrate to a new PINT AE version mid-year, you will need to know which documents were issued under which rules to answer a query about them. Retrofitting that column is guesswork.

The partial index is a small thing that pays off: your reconciliation job wants exactly the documents that are in flight, and that set stays small while the table grows past a million rows.

Testing Your Implementation

Beyond the conformance suite in Step 4, three checks earn their keep:

Golden-file tests. Snapshot the XML for each fixture and diff on change. When a library upgrade silently changes attribute ordering or self-closing tag style, you want to see it in a diff, not in a rejection.

A property test on money. Generate random line sets and assert the invariant holds for all of them:

import fc from "fast-check";
 
it("totals always reconcile regardless of line composition", () => {
  fc.assert(
    fc.property(
      fc.array(fc.integer({ min: 1, max: 5_000_00 }), { minLength: 1, maxLength: 60 }),
      (amounts) => {
        const inv = invoiceWithLineAmounts(amounts);
        const t = computeTotals(inv);
        const summed = t.subtotals.reduce((a, s) => a + s.taxAmount, 0);
        return t.taxAmount === summed
          && t.taxInclusiveAmount === t.taxExclusiveAmount + t.taxAmount;
      },
    ),
    { numRuns: 500 },
  );
});

Five hundred random invoices will find the composition your three handwritten fixtures do not.

A replay harness against production shapes. Take last month's real invoices, run them through the builder and validator in a read-only job, and count fatal findings by rule ID. That single number — "4,812 invoices, 61 fatal findings, all AE-R-011 missing buyer TRN" — is the most useful project status report you will produce, and it turns an abstract compliance risk into a work queue.

Troubleshooting

"Document does not conform to the customization" — your CustomizationID does not match the release the receiving side expects. Check src/spec/pint-ae.ts against your ASP's certified version. This is the number one first-day error.

XSD sequence errors on an apparently complete document — element order, not element presence. UBL enforces sequence. Compare your output against an official sample document element by element.

Tax total mismatch findings — you are computing VAT per line and summing. Go back to Step 2 and aggregate the base per category first.

Findings that appear only in production — almost always character data. Arabic text, an ampersand, a non-breaking space pasted from Excel, or an em dash in a description. Your fixtures are ASCII; your customers are not. Add a fixture with a full Arabic trading name and one with & < > " ' in the item description.

Silence after submission — nothing came back, and nothing will, if you have no callback endpoint registered or no polling job. Delivery and response events are pushed or pulled; they do not arrive by themselves. Check that the reconciliation job in Step 7 is actually running and that its query includes submitted, not only delivered.

A rejection arriving after the customer already paid — this is normal and your accounting process must handle it. A rejected invoice does not un-issue itself; it needs a credit note and a reissue, which is a business workflow, not a bug.

Next Steps

  • Wire the reconciliation job to alert on documents stuck in submitted for more than 24 hours — silent stalls are the failure mode that costs the most and shows up the least.
  • Handle self-billing (389) if any customer self-bills you; the party roles invert and the validation rules differ.
  • Extend the same builder to credit and debit notes — the CreditNote document uses a different root element and namespace, and cac:BillingReference becomes mandatory.
  • If your ERP is Odoo, the mapping layer in this tutorial slots in behind its external API rather than inside a module: see Odoo 17 External API Integration with TypeScript.
  • Already operating in Saudi Arabia? The ZATCA Phase 2 e-invoicing integration tutorial covers the same problem under a different regime — clearance rather than five-corner exchange, with cryptographic stamping and a QR code. If you file in both countries, build one domain model and two serialisers, never two systems.

Conclusion

The UAE e-invoicing mandate is usually presented as a procurement decision: choose an ASP, sign, done. The ASP is genuinely necessary and genuinely does the hard network engineering. But the part that decides whether your invoices are accepted is the part inside your own systems — the TRNs you do or do not hold, the rounding you have been doing for years, the unit codes nobody standardised, the credit notes that reference nothing.

Everything in this tutorial exists to move those discoveries earlier. A branded Fils type finds a rounding bug at compile time. A VatTreatment union makes a missing exemption reason unrepresentable. A Schematron suite in CI turns a future rejection into today's failing test. A replay harness converts "are we ready" from an opinion into a count.

None of it is exotic. It is ordinary engineering discipline applied to a deadline that does not move.


Mapping your own invoice data to PINT AE and want a second pair of eyes on the gaps? We do integration work between ERP systems and compliance layers — the mapping, the validation harness, the reconciliation job. A short review of your current data model usually surfaces the expensive problems in an afternoon. Tell us what you are working with.