Les implémentations de facturation électronique sont jugées deux fois. Au lancement, elles sont jugées sur le générateur de factures — pouvez-vous produire un document PINT AE valide et le faire passer par votre prestataire de services accrédité (ASP). En production, elles sont jugées sur les corrections — et c'est là que les implémentations émiraties échoueront réellement, car le premier retour client, la première remise trimestrielle sur volume et la première ligne mal tarifée arrivent tous dans les semaines qui suivent le lancement, et chacun exige un document qui n'est pas celui que vous avez construit.
Sous le mandat émirati, une correction n'est pas un PDF de courtoisie. Un avoir fiscal doit être émis comme document structuré, transmis via votre ASP comme n'importe quelle facture, lié au document qu'il corrige, et émis dans les 14 jours suivant l'événement d'ajustement. Le déploiement annoncé est progressif — janvier 2027 pour les grandes entreprises, juillet 2027 pour les autres entreprises assujetties à la TVA, et octobre 2027 pour les entités gouvernementales — avec la réserve habituelle que les dates se précisent au fil des annonces du ministère des Finances et de la FTA ; confirmez donc le calendrier actuel auprès de votre ASP plutôt que via un article de blog, y compris celui-ci.
Ce tutoriel construit la couche de corrections en TypeScript. C'est le compagnon d'implémentation que notre tutoriel sur la facture PINT AE avait explicitement reporté : cet article-là couvre la construction et la validation de la facture fiscale 380 — l'arithmétique en fils, le module d'épinglage de la spécification, le Schematron local en CI — et rien n'en est répété ici. Celui-ci couvre tout ce qui fait de l'avoir un animal différent : un type de document distinct avec des noms d'éléments distincts, un lien obligatoire vers la facture précédente, un code de motif avec exactement une exception, un garde-fou anti-sur-crédit que votre auditeur vous demandera, et un profil d'autofacturation que presque tout le monde décrit incorrectement.
Chaque fait de spécification ci-dessous a été lu dans les spécifications PINT AE publiées (version 2025-Q2 au moment de la rédaction) sur docs.peppol.eu. Votre ASP est certifié sur une version précise ; considérez ses notes de version comme l'arbitre.
Prérequis
Avant de commencer, assurez-vous d'avoir :
- Node.js 20+ et TypeScript 5+ avec
strict,noUncheckedIndexedAccessetexactOptionalPropertyTypesactivés — chaque extrait ci-dessous compile sous ces drapeaux - Le tutoriel sur la facture PINT AE — cet article suppose son type monétaire entier
Filset sa discipline d'épinglage de la spécification - Une compréhension pratique de la structure des éléments UBL (inutile de l'avoir mémorisée ; les différences qui comptent sont tabulées ci-dessous)
Ce que vous allez construire
Un moteur de corrections en six parties :
- Un module de spécification épinglé contenant les identifiants et codes de type sur lesquels votre ASP peut vous contredire
- Une union discriminée qui rend irreprésentable un avoir sans référence
- Un allocateur au prorata qui transforme « créditer 1 des 3 unités de la ligne 1 » en fils positifs corrects
- Un recalcul de TVA qui s'aligne sur la facture d'origine au lieu de dériver d'un fils
- Un registre de corrections qui fait du sur-crédit une erreur levée et rend les rejeux idempotents
- Un sérialiseur capable d'émettre les deux encodages publiés d'un avoir sur le fil, car celui qu'attend votre ASP est un fait de configuration, pas une vérité universelle
Étape 1 : épinglez la spécification — et notez ce qui n'y figure pas
La couche de corrections a son propre jeu de constantes, et l'une d'elles corrige un mythe qui s'est déjà répandu dans les FAQ des éditeurs.
// src/spec/pint-ae.ts — every constant your ASP can disagree with lives here.
export const PINT_AE_RELEASE = '2025-Q2';
export const CUSTOMIZATION_ID = {
billing: 'urn:peppol:pint:billing-1@ae-1',
self_billing: 'urn:peppol:pint:selfbilling-1@ae-1',
} as const;
export const PROFILE_ID = {
billing: 'urn:peppol:bis:billing',
self_billing: 'urn:peppol:bis:selfbilling',
} as const;
// UAE document type codes. Note what is NOT here: 389 and 361.
export const DOC_TYPE = {
taxInvoice: '380',
taxCreditNote: '381',
outOfScopeInvoice: '480',
outOfScopeCreditNote: '81',
} as const;
export const CREDIT_NOTE_ISSUANCE_DAYS = 14;Quatre codes de type de document couvrent l'intégralité du modèle émirati : 380 pour une facture fiscale, 381 pour un avoir fiscal, 480 pour une facture hors du champ de la taxe, et 81 pour un avoir portant sur des biens ou services hors champ.
Le mythe 389/361. Plusieurs guides de facturation électronique émiratie affirment que les factures autofacturées utilisent le code de type 389 et les avoirs autofacturés le 361. La spécification d'autofacturation PINT AE publiée ne fait pas cela — elle utilise les quatre mêmes codes de type que le profil de facturation. L'autofacturation est signalée par l'identifiant de spécification (
urn:peppol:pint:selfbilling-1@ae-1) et le profil d'autofacturation, pas par un code de type spécial. La confusion est importée du Peppol BIS européen, où 389 et 261 existent comme codes autofacturés. Intégrez l'hypothèse européenne dans un système émirati et vos documents « autofacturés » porteront un code de type que les règles de validation AE ne reconnaissent pas.
Étape 2 : modélisez les quatre documents — et rendez la référence manquante impossible
Le rejet d'avoir le plus courant est une référence manquante ou malformée à la facture précédente. PINT AE exige la référence à la facture précédente sur un avoir — sauf quand le crédit est une remise sur volume, auquel cas le code de motif (le terme métier propre aux Émirats BTAE-03) vaut VD et la référence peut être omise, car un rabais trimestriel ne corrige aucune facture en particulier.
Cette règle « obligatoire, sauf » est exactement ce pour quoi existent les unions discriminées. Modélisez le motif pour que le compilateur impose l'exception :
// src/documents.ts
import type { Fils } from './money';
export type VatCategory = 'S' | 'Z' | 'E' | 'AE' | 'O';
export interface DocumentLine {
id: string;
itemName: string;
quantity: number;
/** Line net amount in integer fils. Always positive on the wire. */
netAmount: Fils;
vatCategory: VatCategory;
/** Percentage, e.g. 5 for the UAE standard rate. */
vatRate: number;
}
export interface PrecedingInvoiceRef {
invoiceNumber: string;
issueDate: string; // YYYY-MM-DD
}
/**
* BTAE-03 drives this union. Volume discounts ('VD') are the one reason
* that waives the preceding invoice reference — every other reason
* cannot be constructed without one.
*/
export type CreditReason =
| { kind: 'volume_discount' }
| { kind: 'return'; preceding: PrecedingInvoiceRef }
| { kind: 'post_invoice_adjustment'; preceding: PrecedingInvoiceRef }
| { kind: 'invoice_error'; preceding: PrecedingInvoiceRef };
export type PintAeDocument =
| { docType: '380'; kind: 'tax_invoice'; id: string; issueDate: string; lines: DocumentLine[] }
| { docType: '381'; kind: 'tax_credit_note'; id: string; issueDate: string; reason: CreditReason; lines: DocumentLine[] }
| { docType: '480'; kind: 'out_of_scope_invoice'; id: string; issueDate: string; lines: DocumentLine[] }
| { docType: '81'; kind: 'out_of_scope_credit_note'; id: string; issueDate: string; reason: CreditReason; lines: DocumentLine[] };
export type BillingProfile = 'billing' | 'self_billing';Un tax_credit_note sans reason ne compile pas. Un return sans référence preceding ne compile pas. Le seul cas légitime sans référence — la remise sur volume — est une variante délibérée et visible plutôt qu'un champ optionnel que quelqu'un oublie de remplir. Quand le message de rejet de la FTA arrive après trois niveaux d'indirection via votre ASP, « le compilateur ne m'aurait pas laissé construire ce document » est un bien meilleur point de départ de débogage que « le champ est optionnel dans notre modèle ».
Étape 3 : la règle d'interdiction des factures négatives
Le PINT générique — le modèle international — permet deux façons d'annuler une facture : émettre un avoir, ou émettre une facture négative. La liaison émiratie supprime le choix. La spécification l'énonce clairement : aux Émirats, annuler une facture émise et reçue ne peut se faire qu'en émettant un avoir.
Cela a une conséquence structurelle sur votre modèle interne. Les systèmes comptables adorent les nombres signés — un retour est une ligne négative, et la somme de la colonne donne la position nette. Gardez cela, en interne. Mais le document sur le fil est différent : un avoir PINT AE porte des montants positifs, et c'est le type de document qui porte la direction. Le mappage entre votre grand livre signé et le fil non signé appartient à exactement un endroit — la frontière — et tout arrondi d'un intermédiaire fractionnaire doit arrondir la magnitude puis réappliquer le signe, sinon le comportement de Math.round à la frontière .5 égarera un fils sur les annulations :
// src/money.ts
declare const filsBrand: unique symbol;
export type Fils = number & { readonly [filsBrand]: true };
export function fils(n: number): Fils {
if (!Number.isSafeInteger(n)) {
throw new Error(`amounts are integer fils; got ${n}`);
}
return n as Fils;
}
/** Round a fractional fils value: round the magnitude, then reapply the sign. */
export function roundFils(value: number): Fils {
const sign = value < 0 ? -1 : 1;
return fils(sign * Math.round(Math.abs(value)));
}Si un montant négatif atteint un jour votre sérialiseur, ce n'est pas un problème de formatage à masquer avec Math.abs — c'est un bug en amont (généralement un retour traité comme facture négative par un ERP configuré pour une autre juridiction), et le sérialiseur doit lever une erreur plutôt que de le blanchir.
Étape 4 : l'horloge des 14 jours
L'avoir doit être émis dans les 14 jours suivant l'événement d'ajustement. C'est assez court pour que « la finance balaie les retours chaque semaine et l'ERP groupe les avoirs chaque mois » — un processus parfaitement normal avant le mandat — soit structurellement non conforme. Il vous faut l'échéance comme valeur calculée et surveillée, pas comme savoir tribal :
// src/deadline.ts
import { CREDIT_NOTE_ISSUANCE_DAYS } from './spec/pint-ae';
function assertIsoDate(date: string): void {
const parsed = new Date(`${date}T00:00:00.000Z`);
if (parsed.toISOString().slice(0, 10) !== date) {
throw new Error(`not a real calendar date: ${date}`);
}
}
/** Last day a credit note may be issued for an adjustment event. */
export function creditNoteDeadline(adjustmentDate: string): string {
assertIsoDate(adjustmentDate);
const d = new Date(`${adjustmentDate}T00:00:00.000Z`);
d.setUTCDate(d.getUTCDate() + CREDIT_NOTE_ISSUANCE_DAYS);
return d.toISOString().slice(0, 10);
}
export function daysRemaining(adjustmentDate: string, asOf: string): number {
assertIsoDate(asOf);
const deadline = new Date(`${creditNoteDeadline(adjustmentDate)}T00:00:00.000Z`);
const now = new Date(`${asOf}T00:00:00.000Z`);
return Math.floor((deadline.getTime() - now.getTime()) / 86_400_000);
}Deux détails portent la charge. D'abord, assertIsoDate existe parce que le Date de JavaScript ne rejette pas les dates impossibles — new Date('2027-02-30T00:00:00.000Z') bascule silencieusement au 2 mars, et une date d'ajustement basculée décale silencieusement une échéance légale. Seul le contrôle aller-retour l'attrape. Ensuite, asOf est un paramètre, jamais un new Date() dans la fonction — la même règle que notre tutoriel de réconciliation des règlements applique au rapprochement, et pour la même raison : un rapport d'échéances que vous ne pouvez pas rejouer pour mardi dernier est un rapport d'échéances que vous ne pouvez pas déboguer.
Branchez daysRemaining sur l'alerting que vous exécutez déjà, et alertez à 2 jours restants, pas à la violation. Une alerte qui se déclenche quand l'échéance est déjà manquée est un rapport d'incident, pas une alerte.
Étape 5 : avoirs partiels — allocation et la TVA qui doit coïncider
L'annulation intégrale d'une facture est le cas facile. Le cas courant est partiel : créditer 1 unité sur 3, créditer une ligne sur dix, créditer une réduction de prix de 10 %. Deux règles gardent les avoirs partiels honnêtes.
Règle un : un crédit intégral copie, un crédit partiel calcule. Si la quantité demandée égale la quantité facturée, copiez le montant d'origine exactement — faire passer original × 1.0 par la virgule flottante pour revenir au point de départ est une invitation à un écart d'un fils sur le seul type de document dont les écarts sont audités.
// src/allocation.ts
import type { DocumentLine } from './documents';
import { fils, roundFils } from './money';
export interface CreditRequest {
lineId: string;
/** Quantity being credited; must not exceed the original quantity. */
quantity: number;
}
/**
* Build credit-note lines from original invoice lines, pro-rata by quantity.
* Amounts stay positive — the document type carries the direction.
*/
export function allocateCredit(
originalLines: readonly DocumentLine[],
requests: readonly CreditRequest[],
): DocumentLine[] {
const byId = new Map(originalLines.map((l) => [l.id, l]));
return requests.map((request) => {
const original = byId.get(request.lineId);
if (!original) {
throw new Error(`no such line on the original invoice: ${request.lineId}`);
}
if (request.quantity <= 0 || request.quantity > original.quantity) {
throw new Error(
`credited quantity ${request.quantity} out of range for line ${request.lineId} (invoiced ${original.quantity})`,
);
}
const ratio = request.quantity / original.quantity;
return {
...original,
quantity: request.quantity,
netAmount: request.quantity === original.quantity
? fils(original.netAmount) // full credit: copy exactly, no arithmetic
: roundFils(original.netAmount * ratio),
};
});
}Règle deux : la TVA se calcule une fois par groupe de catégorie sur la base sommée — sur l'avoir exactement comme sur la facture. L'exemple chiffré du tutoriel facture s'applique ici avec plus de force : trois lignes de 33,33 AED à 5 % donnent 5,01 AED si vous arrondissez par ligne puis sommez, et 5,00 AED si vous sommez la base et arrondissez une fois. Sur une facture, ce fils est une question de cohérence Schematron. Sur un avoir, c'est pire : un crédit intégral dont la TVA n'égale pas celle de la facture d'origine laisse une position TVA fantôme d'un fils sur une transaction qui n'existe plus, et elle reste dans votre déclaration de TVA jusqu'à ce que quelqu'un l'explique.
// src/vat.ts
import type { DocumentLine } from './documents';
import { Fils, fils, roundFils } from './money';
/**
* VAT is computed once per category group on the summed base —
* never per line and then summed. Same rule as on the invoice side.
*/
export function vatTotals(lines: DocumentLine[]): Map<string, Fils> {
const bases = new Map<string, { base: number; rate: number }>();
for (const line of lines) {
const key = `${line.vatCategory}:${line.vatRate}`;
const group = bases.get(key) ?? { base: 0, rate: line.vatRate };
group.base += line.netAmount;
bases.set(key, group);
}
const totals = new Map<string, Fils>();
for (const [key, group] of bases) {
totals.set(key, roundFils((group.base * group.rate) / 100));
}
return totals;
}
export function documentVat(lines: DocumentLine[]): Fils {
let sum = fils(0);
for (const amount of vatTotals(lines).values()) {
sum = fils(sum + amount);
}
return sum;
}Étape 6 : le garde-fou anti-sur-crédit
Rien dans le schéma XML ne vous empêche de créditer 12 000 AED contre une facture de 10 000 AED — via trois avoirs séparés, chacun individuellement plausible. Schematron valide un document à la fois ; le sur-crédit est un invariant inter-documents, donc il ne peut vivre que dans votre système, sous forme de registre :
// src/ledger.ts
import type { DocumentLine } from './documents';
import { Fils, fils } from './money';
export class OverCreditError extends Error {
constructor(lineId: string, attempted: number, available: number) {
super(
`over-credit on line ${lineId}: attempted ${attempted} fils, only ${available} fils remain creditable`,
);
this.name = 'OverCreditError';
}
}
interface LinePosition {
invoiced: Fils;
credited: Fils;
}
/**
* Tracks, per original invoice line, how much has already been credited.
* Claims are recorded per credit-note id so reprocessing the same
* credit note is idempotent rather than double-counted.
*/
export class CorrectionLedger {
private readonly positions = new Map<string, LinePosition>();
private readonly applied = new Set<string>();
registerInvoice(invoiceId: string, lines: readonly DocumentLine[]): void {
for (const line of lines) {
this.positions.set(`${invoiceId}:${line.id}`, {
invoiced: line.netAmount,
credited: fils(0),
});
}
}
claim(creditNoteId: string, invoiceId: string, lines: readonly DocumentLine[]): void {
if (this.applied.has(creditNoteId)) return; // idempotent replay
// Validate everything before mutating anything.
for (const line of lines) {
const position = this.positions.get(`${invoiceId}:${line.id}`);
if (!position) {
throw new Error(`credit references unknown line ${line.id} on ${invoiceId}`);
}
const available = position.invoiced - position.credited;
if (line.netAmount > available) {
throw new OverCreditError(line.id, line.netAmount, available);
}
}
for (const line of lines) {
const key = `${invoiceId}:${line.id}`;
const position = this.positions.get(key);
if (!position) continue;
this.positions.set(key, {
invoiced: position.invoiced,
credited: fils(position.credited + line.netAmount),
});
}
this.applied.add(creditNoteId);
}
remaining(invoiceId: string, lineId: string): Fils {
const position = this.positions.get(`${invoiceId}:${lineId}`);
if (!position) throw new Error(`unknown line ${lineId} on ${invoiceId}`);
return fils(position.invoiced - position.credited);
}
}Trois décisions de conception comptent plus que la structure de données. Les réclamations sont indexées par l'identifiant de l'avoir, donc un message rejoué par une file ou un webhook réémis par votre ASP est un no-op, pas un double débit. La validation se termine avant toute mutation, donc un avoir qui sur-crédite sa deuxième ligne ne laisse pas sa première ligne à moitié appliquée. Et le garde-fou lève une erreur — un sur-crédit n'est jamais un avertissement à logger, car la personne qui lirait le log est la même qui vient de se tromper dans la quantité retournée. En production, cette classe enveloppe une table de base de données avec les deux mêmes colonnes ; la version en mémoire existe pour que l'invariant soit testable en CI.
Étape 7 : un modèle sémantique, deux encodages sur le fil
Voici le fait que ce tutoriel existe pour rendre sans ambiguïté : PINT AE publie des liaisons de syntaxe pour les deux encodages d'un avoir. Il y a un document ubl:Invoice portant cbc:InvoiceTypeCode 381 — l'exemple de remise sur volume de la spécification elle-même est encodé ainsi — et il y a un document ubl:CreditNote complet avec son propre arbre syntaxique, portant cbc:CreditNoteTypeCode 381. Ils expriment la même sémantique avec des noms d'éléments différents :
| Sémantique | Encodage Invoice | Encodage CreditNote |
|---|---|---|
| Élément racine | Invoice | CreditNote |
| Élément du code de type | cbc:InvoiceTypeCode | cbc:CreditNoteTypeCode |
| Conteneur de lignes | cac:InvoiceLine | cac:CreditNoteLine |
| Quantité | cbc:InvoicedQuantity | cbc:CreditedQuantity |
| Facture précédente | cac:BillingReference | cac:BillingReference |
Lequel voyage sur le fil est décidé par la version certifiée de votre ASP et sa documentation d'onboarding — c'est un fait de configuration de votre intégration, pas quelque chose à coder en dur dans cent points d'appel. Le sérialiseur prend donc la liaison en paramètre explicite, et le reste du système ne mentionne jamais les noms d'éléments :
// src/serialize.ts
import type { CreditReason, DocumentLine, PintAeDocument, BillingProfile } from './documents';
import { CUSTOMIZATION_ID, PROFILE_ID } from './spec/pint-ae';
/**
* PINT AE publishes syntax bindings for BOTH encodings of a credit note.
* Your ASP's certified release decides which one travels.
* Pin it in configuration; never hardcode it at call sites.
*/
export type WireBinding = 'invoice-381' | 'creditnote-root';
const esc = (s: string) =>
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
function precedingRefXml(reason: CreditReason): string {
if (reason.kind === 'volume_discount') return '';
return [
' <cac:BillingReference>',
' <cac:InvoiceDocumentReference>',
` <cbc:ID>${esc(reason.preceding.invoiceNumber)}</cbc:ID>`,
` <cbc:IssueDate>${reason.preceding.issueDate}</cbc:IssueDate>`,
' </cac:InvoiceDocumentReference>',
' </cac:BillingReference>',
].join('\n');
}
function lineXml(line: DocumentLine, binding: WireBinding): string {
const lineEl = binding === 'creditnote-root' ? 'cac:CreditNoteLine' : 'cac:InvoiceLine';
const qtyEl = binding === 'creditnote-root' ? 'cbc:CreditedQuantity' : 'cbc:InvoicedQuantity';
return [
` <${lineEl}>`,
` <cbc:ID>${esc(line.id)}</cbc:ID>`,
` <${qtyEl}>${line.quantity}</${qtyEl}>`,
` <cbc:LineExtensionAmount currencyID="AED">${(line.netAmount / 100).toFixed(2)}</cbc:LineExtensionAmount>`,
` </${lineEl}>`,
].join('\n');
}
export function serializeCreditNote(
doc: Extract<PintAeDocument, { kind: 'tax_credit_note' | 'out_of_scope_credit_note' }>,
binding: WireBinding,
profile: BillingProfile,
): string {
const root = binding === 'creditnote-root' ? 'CreditNote' : 'Invoice';
const typeCodeEl =
binding === 'creditnote-root' ? 'cbc:CreditNoteTypeCode' : 'cbc:InvoiceTypeCode';
return [
`<${root}>`,
` <cbc:CustomizationID>${CUSTOMIZATION_ID[profile]}</cbc:CustomizationID>`,
` <cbc:ProfileID>${PROFILE_ID[profile]}</cbc:ProfileID>`,
` <cbc:ID>${esc(doc.id)}</cbc:ID>`,
` <cbc:IssueDate>${doc.issueDate}</cbc:IssueDate>`,
` <${typeCodeEl}>${doc.docType}</${typeCodeEl}>`,
precedingRefXml(doc.reason),
...doc.lines.map((line) => lineXml(line, binding)),
`</${root}>`,
].filter(Boolean).join('\n');
}L'extrait montre le squelette — identifiants, code de type, référence précédente, lignes — car ce sont les parties qui diffèrent entre les deux encodages. Le jeu complet de champs PINT AE (blocs des parties, les termes d'extension émiratis BTAE tels que le montant de TVA en AED et le montant à payer, sous-totaux de taxe, totaux du document) est exactement le constructeur que vous avez assemblé dans le tutoriel facture ; un avoir porte les mêmes blocs, et votre discipline existante de séquence d'éléments s'applique inchangée, car le XSD d'UBL impose l'ordre des éléments sur les documents CreditNote aussi strictement que sur les documents Invoice.
Posez à votre ASP une question écrite avant de construire cette étape : « Pour les avoirs fiscaux, votre version certifiée de PINT AE attend-elle la syntaxe Invoice avec le code de type 381, ou la syntaxe CreditNote ? » C'est une réponse d'une ligne qui vous économise un sprint de re-sérialisation, et l'avoir par écrit clôt le débat quand un rejet apparaît six mois plus tard.
Étape 8 : l'autofacturation est un profil, pas un code de type
L'autofacturation — le client émet la facture et l'envoie au fournisseur, typique des places de marché, des dépôts-ventes et des règlements de commissions — a sa propre spécification PINT AE. Trois faits la gardent droite :
- Les identifiants changent. Customization ID
urn:peppol:pint:selfbilling-1@ae-1, profilurn:peppol:bis:selfbilling. C'est l'intégralité du signal qu'un document est autofacturé. - Les codes de type ne changent pas. Une facture autofacturée reste un 380 ; un avoir autofacturé reste un 381 (avec 480 et 81 pour le hors-champ). Pas de 389, pas de 361 — voir l'étape 1.
- Les rôles des parties ne s'échangent pas. Le fournisseur — la partie qui réalise la livraison taxable — reste dans le bloc fournisseur, et l'acheteur reste dans le bloc acheteur, même si c'est l'acheteur qui a rédigé le document. Un refactor « serviable » qui échange les blocs des parties parce que « l'acheteur est l'émetteur » produit un document affirmant que l'acheteur s'est livré des biens à lui-même ; c'est le faux pas le plus tentant d'une implémentation d'autofacturation.
Dans cette architecture, l'autofacturation coûte un paramètre. serializeCreditNote(doc, binding, 'self_billing') échange les identifiants, et tout le reste — l'union des motifs, l'allocation, le registre, l'horloge d'échéance — est identique, ce qui est précisément l'argument pour faire du profil une valeur plutôt qu'un second chemin de code.
Une note de frontière : savoir si vous pouvez autofacturer une relation fournisseur donnée est une question juridique — les arrangements d'autofacturation ont des conditions d'accord et d'éligibilité sous les règles de TVA émiraties qui vivent hors du format de document. La spécification n'en encode rien. Faites confirmer l'arrangement par votre conseiller fiscal avant que le premier document autofacturé ne quitte votre système ; la validité du XML ne prouve rien quant à la licéité de l'arrangement.
Tester votre implémentation
Chaque extrait de ce tutoriel a été extrait dans un projet et vérifié avant publication : tsc --noEmit passe sans erreur sous strict, noUncheckedIndexedAccess et exactOptionalPropertyTypes, et la suite d'assertions ci-dessous est au vert. Ce sont les assertions qui attrapent de vraies régressions :
// test.ts (excerpts — the assertions that matter)
import assert from 'node:assert/strict';
// The 33.33 case: per-line rounding drifts, summed-base rounding ties.
const thirds = [1, 2, 3].map((i) => ({
id: String(i), itemName: 'x', quantity: 1,
netAmount: fils(3333), vatCategory: 'S' as const, vatRate: 5,
}));
assert.equal(documentVat(thirds), 500); // AED 5.00 — correct
assert.equal(
thirds.map((l) => Math.round(l.netAmount * 0.05)).reduce((a, b) => a + b, 0),
501, // AED 5.01 — the drift you must not ship
);
// Over-credit throws, and a replayed credit note is a no-op.
const ledger = new CorrectionLedger();
ledger.registerInvoice('INV-100', lines);
ledger.claim('CN-1', 'INV-100', allocateCredit(lines, [{ lineId: '1', quantity: 2 }]));
ledger.claim('CN-1', 'INV-100', allocateCredit(lines, [{ lineId: '1', quantity: 2 }]));
assert.equal(ledger.remaining('INV-100', '1'), 3333); // counted once
assert.throws(
() => ledger.claim('CN-2', 'INV-100', allocateCredit(lines, [{ lineId: '1', quantity: 2 }])),
OverCreditError,
);
assert.equal(ledger.remaining('INV-100', '1'), 3333); // failed claim applied nothing
// Impossible dates must not roll over into wrong legal deadlines.
assert.equal(creditNoteDeadline('2027-01-20'), '2027-02-03');
assert.throws(() => creditNoteDeadline('2027-02-30'));
// Both wire bindings carry 381; volume discounts omit the reference.
assert.match(serializeCreditNote(cn, 'invoice-381', 'billing'), /<cbc:InvoiceTypeCode>381</);
assert.match(serializeCreditNote(cn, 'creditnote-root', 'billing'), /<cbc:CreditNoteTypeCode>381</);
assert.doesNotMatch(serializeCreditNote(vd, 'invoice-381', 'billing'), /BillingReference/);Les deux assertions qui suivent la levée du sur-crédit sont celles que les équipes sautent : le rejeu qui est un no-op (votre ASP finira par relivrer un callback) et la réclamation échouée qui laisse le registre intact (l'application partielle est la façon dont un avoir rejeté corrompt silencieusement le solde restant créditable).
Dépannage
Votre avoir est rejeté pour référence de facture précédente manquante. Le motif est autre chose qu'une remise sur volume, et le bloc BillingReference est absent ou son numéro de facture ne correspond à aucun document transmis. Si votre modèle vous a laissé construire ce document, resserrez le modèle — c'est l'union de l'étape 2 qui fait son travail.
Votre avoir passe la validation locale mais votre ASP rejette la structure du document d'emblée. Vous émettez presque certainement la mauvaise liaison — une racine CreditNote vers un endpoint certifié pour Invoice-avec-381, ou l'inverse. Cela échoue avant l'évaluation de toute règle métier, donc le message d'erreur est généralement une erreur de schéma peu parlante. Vérifiez la liaison d'abord, pas le contenu des champs.
Un crédit intégral laisse un résidu de TVA d'un fils contre la facture d'origine. Un arrondi de TVA par ligne quelque part dans le pipeline — généralement un export ERP calculant la TVA de ligne avant même que votre code ne s'exécute. Recalculez la TVA par groupe de catégorie sur la base sommée au moment de la sérialisation et traitez les chiffres par ligne entrants comme des valeurs d'affichage.
Un avoir apparaît deux fois dans votre registre après un retry de l'ASP. Les réclamations sont indexées par quelque chose de non unique (horodatage, id de ligne) au lieu de l'identifiant du document d'avoir. L'idempotence doit s'indexer sur l'identifiant métier.
Les documents autofacturés sont rejetés sous le profil de facturation. Le document porte urn:peppol:pint:billing-1@ae-1 avec une sémantique autofacturée, ou les blocs des parties ont été échangés. Relisez l'étape 8 ; transmettez sous le customization ID d'autofacturation avec les rôles des parties non échangés.
Prochaines étapes
- Construisez d'abord le côté facture si ce n'est pas fait : Construction et validation du XML de facture PINT AE des Émirats en TypeScript — le type fils, le module de spécification et le Schematron local sur lesquels ce tutoriel s'appuie.
- Choisir un prestataire de services accrédité et séquencer le travail ERP est une question de phase de décision — notre guide d'intégration ASP et ERP pour la facturation électronique des Émirats couvre le calendrier, la frontière de l'ASP et la conversation budgétaire.
- Vous travaillez aussi le côté saoudien ? Les mécaniques de correction équivalentes sous ZATCA — avec un chaînage cryptographique qui les rend plus dures — sont dans le tutoriel ZATCA Phase 2.
- La discipline du registre de l'étape 6 se généralise à tout mouvement d'argent ; le tutoriel de réconciliation des règlements l'applique aux fichiers de versement des acquéreurs.
Conclusion
La couche de corrections est plus petite que le générateur de factures — peut-être un cinquième du code — et elle porte plus que sa part du risque d'audit, car les avoirs sont là où l'argent recule et où chaque raccourci arithmétique devient une position visible dans une déclaration de TVA. Le fil conducteur de ce tutoriel est que chaque règle propre aux Émirats est devenue une propriété structurelle : la référence obligatoire est une variante d'union, la règle anti-négatifs est une frontière du sérialiseur, l'invariant anti-sur-crédit est un registre qui lève, le double encodage est un paramètre de configuration, et le profil d'autofacturation est une valeur. Aucune de ces propriétés ne peut être supprimée par une modification hâtive sans que le compilateur ou la suite de tests n'objecte — et c'est le seul type de conformité qui survit à la rotation des équipes d'ici au mandat.
Si vous cadrez la facturation électronique des Émirats face à une échéance 2027 — en cartographiant lesquels de vos flux de documents produisent des avoirs, quelles relations exigent l'autofacturation, et où les données de correction de votre ERP ne sont pas encore assez propres pour être sérialisées — nous faisons ce travail d'intégration pour vivre. Parlez-nous-en et apportez votre scénario de correction le plus tordu ; c'est le moyen le plus rapide de découvrir où est le vrai travail.