writing/tutorial/2026/08
TutorialAug 21, 2026·26 min read

UAE PINT AE Credit Notes and Self-Billing in TypeScript

Build the corrections layer for UAE e-invoicing in TypeScript: PINT AE tax credit notes (381), the no-negative-invoices rule, preceding invoice references and the volume-discount exception, partial-credit VAT in integer fils, an over-credit ledger, and the separate self-billing profile.

E-invoicing implementations are judged twice. At go-live, they are judged on the invoice generator — can you produce a valid PINT AE document and get it through your Accredited Service Provider. In production, they are judged on corrections — and corrections are where UAE implementations will actually fail, because the first customer return, the first quarterly volume rebate, and the first mispriced line all arrive within weeks of go-live, and each one demands a document that is not the one you built.

Under the UAE mandate, a correction is not a courtesy PDF. A tax credit note must be issued as a structured document, transmitted through your ASP like any invoice, linked to the document it corrects, and issued within 14 days of the adjustment event. The announced rollout is phased — January 2027 for larger businesses, July 2027 for the remaining VAT-registered businesses, and October 2027 for government entities — with the usual caveat that dates firm up through Ministry of Finance and FTA announcements, so confirm the current timeline with your ASP rather than a blog post, including this one.

This tutorial builds the corrections layer in TypeScript. It is the implementation-stage companion that our PINT AE invoice tutorial explicitly deferred: that piece covers building and validating the 380 tax invoice — the fils arithmetic, the spec-pinning module, local Schematron in CI — and none of it is repeated here. This one covers everything that makes a credit note a different animal: a different document type with different element names, a mandatory link back to the preceding invoice, a reason code with exactly one exception, an over-credit guard your auditor will ask about, and a self-billing profile that almost everyone describes incorrectly.

Every spec fact below was read from the published PINT AE specifications (release 2025-Q2 at the time of writing) on docs.peppol.eu. Your ASP certifies against a specific release; treat their release notes as the tiebreaker.

Prerequisites

Before starting, ensure you have:

  • Node.js 20+ and TypeScript 5+ with strict, noUncheckedIndexedAccess and exactOptionalPropertyTypes enabled — every snippet here compiles under those flags
  • The PINT AE invoice tutorial — this article assumes its Fils integer-money type and its pin-the-spec discipline
  • A working understanding of UBL element structure (you do not need to have memorised it; the differences that matter are tabulated below)

What You'll Build

A corrections engine with six parts:

  1. A pinned spec module holding the identifiers and type codes your ASP can disagree with
  2. A discriminated union that makes an unreferenced credit note unrepresentable
  3. A pro-rata allocator that turns "credit 1 of the 3 units on line 1" into correct positive fils
  4. A VAT recomputation that ties to the original invoice instead of drifting by a fils
  5. A correction ledger that makes over-crediting a thrown error and replays idempotent
  6. A serializer that can emit both published wire encodings of a credit note, because which one your ASP expects is a configuration fact, not a universal truth

Step 1: Pin the Spec — and Notice What Is Not in It

The corrections layer has its own set of constants, and one of them corrects a myth that has already spread through vendor FAQs.

// 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;

Four document type codes cover the entire UAE model: 380 for a tax invoice, 381 for a tax credit note, 480 for an invoice out of scope of tax, and 81 for a credit note related to out-of-scope goods or services.

The 389/361 myth. Several UAE e-invoicing explainers state that self-billed invoices use type code 389 and self-billed credit notes use 361. The published PINT AE self-billing specification does not do this — it uses the same four type codes as the billing profile. Self-billing is signalled by the specification identifier (urn:peppol:pint:selfbilling-1@ae-1) and the self-billing profile, not by a special type code. The confusion is imported from European Peppol BIS, where 389 and 261 exist as self-billed codes. Build the EU assumption into a UAE system and your "self-billed" documents will carry a type code the AE validation rules do not recognise.

Step 2: Model the Four Documents — and Make the Missing Reference Impossible

The single most common credit-note rejection is a missing or malformed reference to the preceding invoice. PINT AE requires the preceding invoice reference on a credit note — except when the credit is a volume discount, in which case the reason code (the UAE-specific business term BTAE-03) is set to VD and the reference may be omitted, because a quarterly rebate does not correct any single invoice.

That "required, except" rule is exactly what discriminated unions are for. Model the reason so that the compiler enforces the 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';

A tax_credit_note without a reason does not compile. A return without a preceding reference does not compile. The one legitimate no-reference case — the volume discount — is a deliberate, visible variant rather than an optional field someone forgets to fill. When the FTA rejection message arrives at three levels of indirection through your ASP, "the compiler would not have let me build that document" is a much better place to debug from than "the field is optional in our model."

Step 3: The No-Negative-Invoices Rule

Generic PINT — the international model — permits two ways to revert an invoice: issue a credit note, or issue a negative invoice. The UAE binding removes the choice. The specification states it plainly: in the UAE, reverting an invoice that has been issued and received can be achieved only by issuing a credit note.

This has a structural consequence for your internal model. Accounting systems love signed numbers — a return is a negative row, and summing the column gives the net position. Keep that, internally. But the wire document is different: a PINT AE credit note carries positive amounts, and the document type carries the direction. The mapping between your signed ledger and the unsigned wire belongs in exactly one place — the edge — and rounding of any fractional intermediate must round the magnitude and reapply the sign, or Math.round's behaviour at the .5 boundary will strand a fils on reversals:

// 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)));
}

If a negative amount ever reaches your serializer, that is not a formatting problem to paper over with Math.abs — it is an upstream bug (usually a return processed as a negative invoice by an ERP configured for a different jurisdiction), and the serializer should throw rather than launder it.

Step 4: The 14-Day Clock

The credit note must be issued within 14 days of the adjustment event. That is short enough that "finance sweeps returns weekly and the ERP batches credit notes monthly" — a completely normal pre-mandate process — is structurally non-compliant. You need the deadline as a computed, monitored value, not tribal knowledge:

// 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);
}

Two details are load-bearing. First, assertIsoDate exists because JavaScript's Date does not reject impossible dates — new Date('2027-02-30T00:00:00.000Z') silently rolls over to March 2nd, and a rolled-over adjustment date silently shifts a legal deadline. Only the round-trip check catches it. Second, asOf is a parameter, never new Date() inside the function — the same rule our settlement reconciliation tutorial applies to matching, and for the same reason: a deadline report you cannot re-run for last Tuesday is a deadline report you cannot debug.

Wire daysRemaining into whatever alerting you already run, and alert on 2 days remaining, not on breach. An alert that fires when the deadline is already missed is an incident report, not an alert.

Step 5: Partial Credits — Allocation and the VAT That Must Tie

Full-invoice reversals are the easy case. The common case is partial: credit 1 unit of 3, credit one line of ten, credit a 10% price reduction. Two rules keep partial credits honest.

Rule one: a full credit copies, a partial credit computes. If the requested quantity equals the invoiced quantity, copy the original amount exactly — running original × 1.0 through floating point to arrive back where you started is an invitation for a one-fils discrepancy on the one document type where discrepancies get audited.

// 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),
    };
  });
}

Rule two: VAT is computed once per category group on the summed base — on the credit note exactly as on the invoice. The invoice tutorial's worked example applies with more force here: three lines of AED 33.33 at 5% give AED 5.01 if you round per line and sum, and AED 5.00 if you sum the base and round once. On an invoice that one fils is a Schematron consistency question. On a credit note it is worse: a full credit whose VAT does not equal the original invoice's VAT leaves a phantom one-fils VAT position on a transaction that no longer exists, and it sits in your VAT return until someone explains it.

// 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;
}

Step 6: The Over-Credit Guard

Nothing in the XML schema stops you crediting AED 12,000 against an AED 10,000 invoice — across three separate credit notes, each individually plausible. Schematron validates one document at a time; over-crediting is a cross-document invariant, so it can only live in your system, as a ledger:

// 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);
  }
}

Three design decisions matter more than the data structure. Claims are keyed by credit-note id, so a message replayed by a queue or a webhook retried by your ASP is a no-op, not a double debit. Validation completes before any mutation begins, so a credit note that over-credits its second line does not leave its first line half-applied. And the guard throws — an over-credit is never a warning to log, because the person who would read the log is the same person who just fat-fingered the return quantity. In production this class wraps a database table with the same two columns; the in-memory version exists so the invariant is testable in CI.

Step 7: One Semantic Model, Two Wire Encodings

Here is the fact that this tutorial exists to make unambiguous: PINT AE publishes syntax bindings for both encodings of a credit note. There is a ubl:Invoice document carrying cbc:InvoiceTypeCode 381 — the specification's own volume-discount example is encoded this way — and there is a full ubl:CreditNote document with its own syntax tree, carrying cbc:CreditNoteTypeCode 381. They express the same semantics with different element names:

SemanticInvoice encodingCreditNote encoding
Root elementInvoiceCreditNote
Type code elementcbc:InvoiceTypeCodecbc:CreditNoteTypeCode
Line containercac:InvoiceLinecac:CreditNoteLine
Quantitycbc:InvoicedQuantitycbc:CreditedQuantity
Preceding invoicecac:BillingReferencecac:BillingReference

Which one travels on the wire is decided by your ASP's certified release and their onboarding documentation — it is a configuration fact about your integration, not something to hardcode into a hundred call sites. So the serializer takes the binding as an explicit parameter, and the rest of the system never mentions element names:

// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
 
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');
}

The snippet shows the skeleton — identifiers, type code, preceding reference, lines — because those are the parts that differ between the two encodings. The full PINT AE field set (party blocks, the BTAE UAE extension terms such as the AED VAT amount and amount payable, tax subtotals, document totals) is exactly the builder you assembled in the invoice tutorial; a credit note carries the same blocks, and your existing element-sequence discipline applies unchanged, because UBL's XSD enforces element order on CreditNote documents just as strictly as on Invoice documents.

Ask your ASP one written question before you build this step: "For tax credit notes, does your certified PINT AE release expect the Invoice syntax with type code 381, or the CreditNote syntax?" It is a one-line answer that saves a re-serialization sprint, and having it in writing settles the argument when a rejection appears six months later.

Step 8: Self-Billing Is a Profile, Not a Type Code

Self-billing — the customer issues the invoice and sends it to the supplier, typical for marketplaces, consignment arrangements, and commission settlements — has its own PINT AE specification. Three facts keep it straight:

  1. The identifiers change. Customization ID urn:peppol:pint:selfbilling-1@ae-1, profile urn:peppol:bis:selfbilling. That is the entire signal that a document is self-billed.
  2. The type codes do not change. A self-billed invoice is still a 380; a self-billed credit note is still a 381 (with 480 and 81 for out-of-scope). No 389, no 361 — see Step 1.
  3. The party roles do not swap. The supplier — the party making the taxable supply — remains the supplier block, and the buyer remains the buyer block, even though the buyer authored the document. A "helpful" refactor that swaps the party blocks because "the buyer is the issuer" produces a document claiming the buyer supplied goods to themselves; it is the single most tempting wrong move in a self-billing implementation.

In this architecture, self-billing costs one parameter. serializeCreditNote(doc, binding, 'self_billing') swaps the identifiers, and everything else — the reason union, the allocation, the ledger, the deadline clock — is identical, which is precisely the argument for making the profile a value instead of a second code path.

One boundary note: whether you may self-bill a given supplier relationship is a legal question — self-billing arrangements have agreement and eligibility conditions under UAE VAT rules that live outside the document format. The specification encodes none of that. Get the arrangement confirmed by your tax adviser before the first self-billed document leaves your system; the XML being valid proves nothing about the arrangement being permitted.

Testing Your Implementation

Every snippet in this tutorial was extracted into a project and verified before publication: tsc --noEmit passes with zero errors under strict, noUncheckedIndexedAccess and exactOptionalPropertyTypes, and the assertion suite below runs green. These are the assertions that catch real regressions:

// 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/);

The two assertions after the over-credit throw are the ones teams skip: the replay being a no-op (your ASP will redeliver a callback eventually) and the failed claim leaving the ledger untouched (partial application is how a rejected credit note silently corrupts the remaining-creditable balance).

Troubleshooting

Your credit note is rejected for a missing preceding invoice reference. The reason is anything other than a volume discount, and the BillingReference block is absent or its invoice number does not match a transmitted document. If your model allowed you to construct that document, tighten the model — this is Step 2's union doing its job.

Your credit note validates locally but your ASP rejects the document structure outright. You are almost certainly emitting the wrong binding — a CreditNote root to an endpoint certified for Invoice-with-381, or vice versa. This fails before any business rule is evaluated, so the error message is usually an unhelpful schema-level one. Check the binding first, not the field contents.

A full credit leaves a one-fils VAT residue against the original invoice. Per-line VAT rounding somewhere in the pipeline — usually an ERP export computing line VAT before your code ever runs. Recompute VAT per category group on the summed base at serialization time and treat the incoming per-line figures as display values.

A credit note appears twice in your ledger after an ASP retry. Claims are being keyed by something non-unique (timestamp, row id) instead of the credit-note document id. Idempotency must key on the business identifier.

Self-billed documents are rejected under the billing profile. The document carries urn:peppol:pint:billing-1@ae-1 with self-billed semantics, or the party blocks were swapped. Re-read Step 8; transmit under the self-billing customization ID with the party roles unswapped.

Next Steps

Conclusion

The corrections layer is smaller than the invoice generator — perhaps a fifth of the code — and it carries more than its share of the audit risk, because credit notes are where money moves backwards and where every arithmetic shortcut becomes a visible position in a VAT return. The through-line of this tutorial is that each UAE-specific rule became a structural property: the mandatory reference is a union variant, the no-negatives rule is a serializer boundary, the over-credit invariant is a throwing ledger, the dual encoding is a configuration parameter, and the self-billing profile is a value. None of those properties can be deleted by a hurried edit without the compiler or the test suite objecting — which is the only kind of compliance that survives staff turnover between now and the mandate.

If you are scoping UAE e-invoicing against a 2027 deadline — mapping which of your document flows produce credit notes, which relationships need self-billing, and where your ERP's correction data is not yet clean enough to serialize — we do this integration work for a living. Talk to us and bring your messiest correction scenario; it is the fastest way to find out where the real work is.