Il existe un moment précis qui décide si un projet de facturation électronique aux Émirats se passera bien ou mal, et il arrive bien avant la mise en production. C'est le moment où quelqu'un du côté finance demande au côté ingénierie : « le prestataire s'occupe de la facturation électronique, non ? »
Le prestataire accrédité s'occupe de la transmission. Il opère les coins 2 et 3 du modèle Peppol à cinq coins, il détient l'accréditation, il dialogue avec l'autorité fiscale fédérale. Ce qu'il ne fait pas, c'est savoir que votre table orders stocke la TVA en pourcentage sur l'en-tête pour les clients historiques et par ligne pour tous ceux arrivés depuis 2023, ou que trois de vos dix plus gros clients ont été créés avant que vous ne commenciez à collecter un numéro fiscal, ou que votre arrondi est décalé d'un fils par ligne depuis quatre ans sans que personne ne l'ait remarqué, parce que personne n'a jamais validé le total contre la somme.
Cette couche de mapping vous appartient. Ce tutoriel la construit.
Ceci est le compagnon technique d'un article stratégique. Si vous en êtes encore à cadrer le projet, choisir un prestataire ou établir le budget, lisez d'abord Facturation électronique aux Émirats 2026 : ce que votre prestataire ne fera pas pour vous. Cet article couvre le calendrier, le phasage et la frontière contractuelle. Celui-ci suppose ces décisions prises et vous met au code.
Ce que vous allez construire
Une bibliothèque TypeScript qui prend votre objet facture interne et produit un document UBL 2.1 conforme à PINT AE, avec quatre choses que la plupart des implémentations internes sautent :
- Un modèle de domaine typé qui refuse de représenter une facture qui ne peut pas être valide — numéro fiscal manquant, quantité négative sur un document qui n'est pas un avoir, catégorie de TVA exigeant un motif d'exonération absent.
- Une arithmétique monétaire en fils entiers, pour que
cac:TaxTotalse réconcilie exactement avec la somme des lignes, à chaque fois. - Un harnais de validation local exécutant les artefacts XSD et Schematron officiels sous forme de suite vitest, de sorte qu'une facture malformée échoue sur votre poste et en CI plutôt qu'en message de rejet de l'autorité fiscale.
- Une machine à états pour la voie de réponse, parce qu'une facture Peppol n'est pas « envoyée » quand votre appel HTTP renvoie 200.
À la fin, vous disposerez de buildInvoiceXml(), validateInvoiceXml() et d'un cycle de vie documentaire persisté capable de répondre à « quel est le statut légal actuel de la facture INV-2026-00412 » sans que personne n'ouvre le tableau de bord du prestataire.
Prérequis
- Node.js 20+ et TypeScript 5.5+
- Une familiarité avec les espaces de noms XML — UBL en utilise quatre, et les confondre est la cause d'échec la plus fréquente au démarrage
- Java 11+ disponible sur la machine et en CI (l'outillage Schematron de référence tourne sur la JVM ; nous allons l'encapsuler, pas le réécrire)
- L'accès à votre propre modèle de données de facturation, ou la volonté d'adapter celui de l'exemple
- Un compte bac à sable chez votre prestataire, idéalement avant de commencer. À défaut, tout ce qui précède l'étape de transmission fonctionne hors ligne.
Vous n'avez pas besoin d'identifiants de production pour suivre ce tutoriel. Les étapes 1 à 7 sont entièrement locales.
Étape 0 : épinglez la spécification, ne la mémorisez pas
PINT AE est une spécification versionnée, publiée par la communauté de coordination post-attribution d'OpenPeppol, avec des règles propres aux Émirats posées sur le modèle international de facturation PINT. Elle a déjà connu plus d'une version, et les chaînes d'identifiants, les listes de codes et les règles métier sont liées à la version.
La première chose à écrire n'est donc pas un constructeur. C'est un module unique qui rassemble toutes les constantes issues de la spécification, avec la version estampillée dessus :
// src/spec/pint-ae.ts
/**
* Constantes dérivées de la spécification PINT AE.
* ÉPINGLEZ-LES sur la version pour laquelle votre prestataire est certifié,
* et revérifiez sur https://docs.peppol.eu/poac/ae/ à chaque montée de version.
* N'inlinez jamais ces valeurs dans le constructeur.
*/
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;
/** Codes de type de document UN/CEFACT 1001 utilisés dans le profil émirati. */
export const DOC_TYPE = {
taxInvoice: "380",
creditNote: "381",
debitNote: "383",
selfBilledInvoice: "389",
} as const;
/** Codes de catégorie de TVA UNCL5305 dans le périmètre émirati. */
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];
/** Catégories exigeant légalement un motif énoncé sur le 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";
/** Le dirham se subdivise en 100 fils. Toute monnaie interne est en fils entiers. */
export const MINOR_UNITS = 2;Pourquoi cela compte plus qu'il n'y paraît. La seule prédiction que je fais avec confiance sur votre projet, c'est que la version de la spécification changera au moins une fois d'ici votre mise en production, et probablement encore après. Les équipes qui ont inliné urn:peppol:pint:billing-1@ae-1 dans des littéraux de gabarit répartis sur neuf fichiers passent une semaine à tous les retrouver. Celles qui l'ont mise ici changent une ligne et relancent la suite de tests.
Une note sur les chaînes d'identifiants ci-dessus. Elles correspondent au binding PINT AE actuellement publié, mais traitez-les comme un point de départ, pas comme une vérité révélée. Comparez-les à la version pour laquelle votre prestataire est certifié dès le premier jour du projet — un
CustomizationIDnon concordant est rejeté au coin 2 avec un message d'erreur inutile, et cette vérification de cinq minutes vous épargne un après-midi.
Étape 1 : un modèle de domaine incapable d'exprimer une facture invalide
Le levier le plus puissant dont vous disposez consiste à rendre les mauvais états irreprésentables, pour que le compilateur attrape les défauts à la place du Schematron. Voici le modèle, délibérément plus étroit qu'UBL :
// src/domain/invoice.ts
/** La monnaie est TOUJOURS en unités mineures entières (fils). Jamais un flottant, jamais une chaîne. */
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;
};
/** Un numéro d'enregistrement fiscal émirati : exactement 15 chiffres. */
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 {
/** Licence commerciale, Emirates ID, document commercial ou passeport. */
readonly scheme: "TL" | "EID" | "CD" | "PAS";
readonly value: string;
}
export interface Party {
readonly name: string;
/** Optionnel pour les acheteurs sous le seuil d'enregistrement. */
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; // pourcentage, par exemple 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, par exemple "EA", "HUR"
readonly unitPrice: Fils;
readonly lineExtensionAmount: Fils; // net de TVA
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 }; // le numéro fiscal du vendeur n'est jamais optionnel
readonly buyer: Party;
readonly lines: readonly InvoiceLine[];
/** Renseigné uniquement pour les avoirs et notes de débit. */
readonly precedingInvoice?: { readonly number: string; readonly issueDate: string };
}Relisez l'union VatTreatment, car elle travaille en silence. Une ligne au taux normal porte un taux et rien d'autre. Toute catégorie non standard est contrainte de porter une chaîne reason, et contrainte à un taux nul, au niveau du système de types. La règle voulant qu'une livraison au taux zéro énonce pourquoi elle l'est figure parmi les échecs Schematron les plus courants dans toutes les juridictions ayant adopté Peppol, et ici il est impossible de construire une telle ligne sans elle.
Même histoire avec Fils et Trn. Ce sont des types marqués : un number ordinaire ne satisfera pas Fils sans passer par le constructeur fils(), lequel rejette tout ce qui n'est pas entier. Votre bug d'arrondi en virgule flottante ne peut pas atteindre le XML, parce qu'il ne peut pas atteindre le modèle de domaine.
Étape 2 : une arithmétique monétaire qui se réconcilie
Voici un échec réel, et il vous arrivera si vous utilisez des flottants. Trois lignes à 33,33 AED, TVA 5 % :
- TVA par ligne : 1,6665 chacune. Arrondi : 1,67 chacune, total 5,01.
- TVA sur la somme : 99,99 fois 0,05 égale 4,9995, arrondi 5,00.
Un fils d'écart. Et le Schematron vérifie que le total de taxe égale la somme des sous-totaux ; peu lui importe laquelle des deux réponses vous jugez plus juste — ce qui lui importe, c'est que le document soit cohérent avec lui-même. La majorité des rejets du premier mois de tout mandat de facturation électronique, c'est cela, ou un cousin.
Le correctif consiste à figer l'ordre des opérations et à ne jamais en dévier :
// src/money.ts
import { fils, type Fils } from "./domain/invoice";
export const addFils = (...xs: Fils[]): Fils =>
fils(xs.reduce((a, b) => a + b, 0));
/** Arrondi au demi-supérieur sur des entiers. Aucun flottant ne survit à cette fonction. */
export const applyRate = (base: Fils, ratePercent: number): Fils => {
const numerator = base * Math.round(ratePercent * 100); // taux en points de base
const scaled = Math.round(numerator / 10_000);
return fils(scaled);
};
/** Des fils vers la chaîne décimale attendue par UBL : 12345 devient "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")}`;
};Puis les totaux, calculés en un seul et unique endroit, groupés par catégorie de TVA parce que c'est ainsi qu'UBL les attend :
// 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,
});
}
// La TVA est calculée UNE SEULE fois par groupe de catégorie, sur la base sommée.
// Jamais par ligne puis sommée — c'est le bug du fils unique.
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,
};
}Le commentaire au milieu résume toute la leçon. Agrégez d'abord la base imposable par catégorie, puis appliquez le taux une fois. Chaque montant de TVA par ligne que vous affichez dans votre propre interface est une commodité de présentation ; l'arithmétique du document, elle, tourne sur le groupe.
Étape 3 : générer l'UBL, espaces de noms compris
Ne construisez pas de XML avec des littéraux de gabarit. Une esperluette non échappée dans un nom de client comme « Al Futtaim & Sons » produira un document qui échoue au parsing XSD chez le prestataire, et l'erreur qui vous reviendra parlera de la ligne 84 d'un document que vous ne voyez jamais. Utilisez un constructeur qui échappe pour vous :
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 });
}Observez l'ordre des éléments. Le XSD d'UBL impose une séquence, pas seulement une présence — cbc:IssueDate avant cbc:InvoiceTypeCode, cac:TaxTotal avant cac:LegalMonetaryTotal, cac:LegalMonetaryTotal avant les lignes. Un document contenant tous les champs requis, dans le mauvais ordre, est invalide. C'est la classe d'erreurs la plus pénible à déboguer depuis un message de rejet distant, et la raison d'être de l'étape 4.
Étape 4 : validez en local, avant que quoi que ce soit ne quitte les murs
Les artefacts de validation de référence pour PINT AE sont des schémas XSD plus des jeux de règles Schematron, et l'outillage canonique pour exécuter du Schematron tourne sur la JVM. Réécrire Schematron en TypeScript est un projet, pas une étape ; encapsuler le validateur officiel tient en vingt lignes et reste correct quand les règles changent.
Téléchargez les artefacts de validation Peppol de votre version cible et câblez-les :
// 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) {
// Un code de sortie non nul est la façon dont le validateur signale des
// constats, pas un plantage.
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 ?? "",
})),
);
}Et maintenant la partie qui fait tenir l'ensemble — la validation comme test, pas comme script que quelqu'un pense à lancer :
// 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 lignes à 33,33 AED, TVA 5 % — la divergence classique d'un fils
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);
});
});Mettez ceci en CI dès le premier jour, avec de vraies formes de données clients dans les fixtures. La valeur n'est pas de prouver que la facture d'aujourd'hui est valide — elle est que, quand quelqu'un ajoutera un champ remise en mars, ou une succursale dans un nouvel émirat avec un autre numéro fiscal, la suite le lui dira avant l'autorité fiscale. Les équipes qui ajoutent la validation en fin de projet découvrent leurs problèmes de données pendant la semaine de mise en production, la semaine la plus chère possible pour les découvrir.
Étape 5 : les cinq problèmes de données qui sont réellement les vôtres
Le XML est la moitié facile. Le vrai temps projet part ici, à peu près par ordre de consommation :
La couverture du numéro fiscal acheteur. Toute facture B2B a besoin du numéro fiscal de l'acheteur. Votre CRM l'a pour les clients créés depuis que vous avez commencé à le demander. Faites le compte avant de promettre une 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';C'est le seau malformed qui surprend : des numéros saisis avec des espaces, avec un préfixe TRN-, avec une note à la suite, ou recopiés à 14 chiffres. Normaliser coûte peu ; courir après les manquants est un exercice commercial de plusieurs mois, et c'est pourquoi il doit démarrer au premier mois et non au cinquième.
Les codes d'unité. UBL veut les codes de la recommandation 20 de la CEE-ONU. Votre système contient « each », « hour », « box », « pcs », et une entrée qui dit juste « - ». Chaque valeur distincte a besoin d'un mapping, et ce mapping a besoin d'un propriétaire :
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) {
// Échouez bruyamment au moment de la construction. Un repli silencieux
// vers "EA" est un bug d'intégrité de données qui refait surface des mois
// plus tard, dans un contrôle fiscal.
throw new Error(`Unmapped unit of measure: "${raw}". Add it to UNIT_CODE_MAP.`);
}
return code;
}Lever une exception est délibéré. L'alternative tentante consiste à retomber sur EA, et elle est fausse : vous ne le saurez pas avant qu'un auditeur ne demande pourquoi 4 000 factures ont facturé des heures comme des unités.
Des avoirs qui ne référencent rien. Un avoir doit pointer vers la facture qu'il annule via cac:BillingReference. Si votre système émet des avoirs autonomes — gestes commerciaux, ajustements de solde d'ouverture — ceux-là ont besoin soit d'un document précédent, soit d'un traitement différent. Trouvez-les maintenant :
SELECT COUNT(*) FROM credit_notes WHERE original_invoice_id IS NULL;Les numéros fiscaux multi-établissements. Un groupe avec plusieurs entités licenciées a plusieurs numéros fiscaux, et la facture doit porter celui de l'entité émettrice. Si votre numérotation de factures est globale alors que vos entités juridiques ne le sont pas, ce mapping doit exister quelque part, et « tout le monde sait que la succursale 3 facture sous la licence commerciale » n'est pas un quelque part.
La dérive d'arrondi déjà présente dans le grand livre. Avant de construire quoi que ce soit, vérifiez si vos totaux stockés se réconcilient :
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;Si cela renvoie des lignes, vous avez une décision à prendre sur l'historique avant qu'elle ne devienne une conversation de conformité plutôt qu'une conversation d'ingénierie.
Étape 6 : transmettre au coin 2, de façon idempotente
Votre prestataire expose une API — la forme varie, les préoccupations non. La plus importante : un numéro de facture n'est émis qu'une fois. Une nouvelle tentative après un dépassement de délai ne doit pas créer un second document légal.
// 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> {
// La clé d'idempotence dérive du document lui-même : les mêmes octets
// renvoyés sont le même envoi ; des octets modifiés sont un autre document,
// que le prestataire doit refuser sous un numéro de facture déjà utilisé.
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) {
// Déjà soumis. Résolvez la référence existante au lieu d'échouer.
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";
}
}Ne réessayez que sur les 5xx et les défaillances réseau, avec backoff. Un 4xx signifie que le document est mauvais, et envoyer neuf fois de plus le même mauvais document produit neuf rejets identiques et un ticket de support très perplexe.
Étape 7 : la voie de réponse est une machine à états, pas une valeur de retour
C'est l'étape que les équipes découvrent tard, et celle qui change votre schéma de base de données.
Dans un modèle à cinq coins, votre facture voyage : vous, vers votre prestataire, à travers le réseau Peppol vers le prestataire de l'acheteur, vers l'acheteur — avec une voie de reporting parallèle qui porte les données fiscales vers l'autorité. Les acquittements reviennent de façon asynchrone. Une réponse de niveau message peut arriver quelques minutes plus tard. Une réponse de facture — l'acceptation ou le rejet métier par l'acheteur — peut arriver des jours plus tard, et c'est souvent le cas, parce qu'il s'agit d'un humain cliquant un bouton dans un système de comptabilité fournisseurs.
Donc sent n'est pas un état terminal, et « est-ce que ça a marché » ne se répond pas depuis un code de statut HTTP :
// src/lifecycle/state.ts
export type DocumentState =
| "draft" // construit, pas encore valide
| "validated" // passe le Schematron local
| "submitted" // accepté par notre prestataire au coin 2
| "delivered" // MLR : parvenu au point d'accès de l'acheteur
| "accepted" // IR : l'acheteur l'a accepté
| "rejected" // IR : l'acheteur l'a rejeté — nécessite un avoir
| "failed"; // n'a pas pu être transmis
const TRANSITIONS: Record<DocumentState, readonly DocumentState[]> = {
draft: ["validated", "failed"],
validated: ["submitted", "failed"],
submitted: ["delivered", "failed"],
delivered: ["accepted", "rejected"],
accepted: [],
rejected: [],
failed: ["validated"], // corriger puis réessayer
};
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";
}
}Et le stockage. Notez ce qui est persisté : les octets exacts qui ont été envoyés, pas l'objet à partir duquel ils ont été construits.
CREATE TABLE einvoice_document (
id BIGSERIAL PRIMARY KEY,
invoice_number TEXT NOT NULL UNIQUE,
state TEXT NOT NULL,
-- Les octets transmis, mot pour mot. Reconstruire le XML plus tard ne les
-- reproduira pas une fois la version de spec ou votre mapping modifiés.
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');Deux décisions de conception qui méritent d'être défendues :
Stockez les octets, pas le modèle. L'obligation de conservation porte sur le document qui a été échangé. Dans deux ans, votre constructeur produira une sortie légèrement différente pour la même entrée — montée de version de la spec, correctif de mapping, mise à jour de bibliothèque — et un document régénéré n'est pas le document que vous avez envoyé. xml_sha256 vous permet de le prouver le moment venu.
Estampillez spec_release sur chaque ligne. Quand vous migrerez vers une nouvelle version de PINT AE en cours d'année, vous aurez besoin de savoir quels documents ont été émis sous quelles règles pour répondre à une question les concernant. Rajouter cette colonne après coup relève de la devinette.
L'index partiel est un petit détail qui paie : votre tâche de rapprochement veut exactement les documents en vol, et cet ensemble reste petit pendant que la table dépasse le million de lignes.
Tester votre implémentation
Au-delà de la suite de conformité de l'étape 4, trois contrôles méritent leur place :
Des tests de fichiers de référence. Prenez un instantané du XML de chaque fixture et diffez au changement. Quand une mise à jour de bibliothèque modifie silencieusement l'ordre des attributs ou le style des balises auto-fermantes, vous voulez le voir dans un diff, pas dans un rejet.
Un test de propriété sur la monnaie. Générez des jeux de lignes aléatoires et vérifiez que l'invariant tient pour tous :
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 },
);
});Cinq cents factures aléatoires trouveront la composition que vos trois fixtures écrites à la main ne trouvent pas.
Un harnais de rejeu sur des formes de production. Prenez les vraies factures du mois dernier, passez-les dans le constructeur et le validateur au sein d'une tâche en lecture seule, et comptez les constats fatals par identifiant de règle. Ce seul chiffre — « 4 812 factures, 61 constats fatals, tous AE-R-011 numéro fiscal acheteur manquant » — est le rapport d'avancement le plus utile que vous produirez, et il transforme un risque de conformité abstrait en file de travail.
Dépannage
« Le document n'est pas conforme à la customization » — votre CustomizationID ne correspond pas à la version attendue côté réception. Vérifiez src/spec/pint-ae.ts contre la version certifiée de votre prestataire. C'est l'erreur numéro un du premier jour.
Erreurs de séquence XSD sur un document apparemment complet — c'est l'ordre des éléments, pas leur présence. UBL impose la séquence. Comparez votre sortie à un document d'exemple officiel, élément par élément.
Constats d'écart sur le total de taxe — vous calculez la TVA par ligne puis vous sommez. Retournez à l'étape 2 et agrégez d'abord la base par catégorie.
Des constats qui n'apparaissent qu'en production — presque toujours des données caractère. Du texte arabe, une esperluette, une espace insécable collée depuis Excel, ou un tiret cadratin dans une description. Vos fixtures sont en ASCII ; vos clients ne le sont pas. Ajoutez une fixture avec une raison sociale entièrement en arabe et une avec & < > " ' dans la description d'article.
Le silence après l'envoi — rien n'est revenu, et rien ne reviendra si vous n'avez enregistré aucun point de rappel ni aucune tâche d'interrogation. Les événements de livraison et de réponse sont poussés ou tirés ; ils n'arrivent pas tout seuls. Vérifiez que la tâche de rapprochement de l'étape 7 tourne réellement et que sa requête inclut submitted, pas seulement delivered.
Un rejet qui arrive après que le client a déjà payé — c'est normal, et votre processus comptable doit le gérer. Une facture rejetée ne se dé-émet pas toute seule ; elle appelle un avoir et une réémission, ce qui est un flux métier, pas un bug.
Prochaines étapes
- Branchez la tâche de rapprochement sur une alerte pour les documents bloqués en
submitteddepuis plus de 24 heures — les blocages silencieux sont le mode de défaillance qui coûte le plus et qui se voit le moins. - Traitez l'autofacturation (
389) si un client vous autofacture ; les rôles des parties s'inversent et les règles de validation diffèrent. - Étendez le même constructeur aux avoirs et notes de débit — le document CreditNote utilise un autre élément racine et un autre espace de noms, et
cac:BillingReferencey devient obligatoire. - Si votre ERP est Odoo, la couche de mapping de ce tutoriel se branche derrière son API externe plutôt que dans un module : voir Intégration de l'API externe d'Odoo 17 en TypeScript.
- Vous opérez aussi en Arabie saoudite ? Le tutoriel Intégration ZATCA Phase 2 de la facturation électronique couvre le même problème sous un autre régime — clearance plutôt qu'échange à cinq coins, avec estampillage cryptographique et code QR. Si vous déclarez dans les deux pays, construisez un modèle de domaine et deux sérialiseurs, jamais deux systèmes.
Conclusion
Le mandat émirati de facturation électronique est généralement présenté comme une décision d'achat : choisir un prestataire, signer, terminé. Le prestataire est réellement nécessaire et fait réellement l'ingénierie réseau difficile. Mais la partie qui décide si vos factures seront acceptées est celle qui se trouve à l'intérieur de vos propres systèmes — les numéros fiscaux que vous détenez ou non, l'arrondi que vous pratiquez depuis des années, les codes d'unité que personne n'a normalisés, les avoirs qui ne référencent rien.
Tout ce tutoriel existe pour avancer ces découvertes dans le temps. Un type marqué Fils trouve un bug d'arrondi à la compilation. Une union VatTreatment rend irreprésentable un motif d'exonération manquant. Une suite Schematron en CI transforme un futur rejet en test qui échoue aujourd'hui. Un harnais de rejeu convertit « sommes-nous prêts » d'une opinion en un décompte.
Rien de tout cela n'est exotique. C'est de la discipline d'ingénierie ordinaire appliquée à une échéance qui ne bouge pas.
Vous mappez vos propres données de facturation vers PINT AE et souhaitez un deuxième regard sur les écarts ? Nous faisons de l'intégration entre systèmes ERP et couches de conformité — le mapping, le harnais de validation, la tâche de rapprochement. Une revue courte de votre modèle de données actuel fait généralement remonter les problèmes coûteux en un après-midi. Dites-nous sur quoi vous travaillez.