writing/tutorial/2026/10
● TutorialOct 26, 2026·24 min read

Saudi Supplier VAT Number Gate in TypeScript for Payables

Build the check that belongs inside accounts payable, not in a quarterly spreadsheet: ZATCA's BR-KSA-40 format rule at supplier onboarding, a dated registration lookup before each return, the QR code on every received invoice decoded and reconciled, and an input VAT gate that holds a doubtful invoice instead of losing it, because Article 49(8) leaves five years to deduct it.

Every finance team in Saudi Arabia checks supplier VAT numbers. Almost none of them check at the right moment. The usual pattern is a person, a browser tab with ZATCA's lookup service, and a sample of invoices pulled a week before the return is due. By then the invoices are posted, the input VAT is in the ledger, and a wrong number means reopening entries rather than fixing a field.

The rule behind the cost is short. Article 49(7) of the VAT Implementing Regulations allows input VAT to be deducted only when the taxable person holds the evidence the GCC agreement requires, which in practice is a valid tax invoice. Article 53(5) lists what that invoice must carry, starting with the supplier's tax identification number and the names and addresses of both parties. A number that is wrong, or that belongs to someone who is not registered, takes the deduction with it.

This tutorial builds the check where it belongs: inside the accounts-payable flow, in four small TypeScript modules with tests. It does not replace ZATCA's lookup. Nothing can, because only ZATCA knows who is registered today. It makes sure that lookup happens on every supplier, before every return, and that what it found is recorded with a date.

Prerequisites

  • Node.js 20 or later, and TypeScript in strict mode
  • tsx to run the tests (npm i -D tsx typescript @types/node)
  • An accounts-payable system you can hook into: a supplier master, an invoice inbox, and a VAT return step. The Saudi VAT return engine is the return step this gate feeds
  • Basic familiarity with ZATCA e-invoicing. If you issue invoices, the Phase 2 integration tutorial covers the other side of the QR code built here

What You'll Build

Four modules, each answering one question:

  1. vatNumber.ts: is this string even shaped like a Saudi VAT number? The rule is ZATCA's BR-KSA-40, and nothing more.
  2. supplier.ts: is this supplier registered, according to whom, and when was that last checked? It also produces the list of numbers to paste into ZATCA's batch lookup.
  3. qr.ts: what does the QR code on a received invoice actually say? It decodes the TLV payload defined in ZATCA's Security Features Implementation Standards.
  4. gate.ts: given all of that, does this invoice's input VAT go into the return, wait, or never go in?

The output of the gate is one of three decisions: deduct, hold or no_deduction. The difference between the last two is the main idea of this tutorial, and Step 6 explains why it matters.

Step 1: The only published rule

ZATCA's Electronic Invoice XML Implementation Standard (version 1.2, May 2023) states the format rule for the seller's number in BR-KSA-40: it "must contain 15 digits. The first and the last digits are '3'". BR-KSA-44 applies the same rule to the buyer's number when it is present and the invoice is not an export.

That is all ZATCA publishes about the format. You will find articles describing a check digit and internal segments. ZATCA does not publish a checksum algorithm, so code that claims to validate one is guessing. The validator below enforces BR-KSA-40 and stops there.

It does two things before testing the rule, because they are where real input fails. It converts Arabic-Indic digits, which arrive whenever someone types the number on an Arabic keyboard. And it names a ten-digit input separately: a ten-digit number in the VAT field is almost always the supplier's commercial registration number, and the person who typed it should be told exactly that.

// src/vatNumber.ts
// BR-KSA-40 (XML Implementation Standard v1.2): the seller VAT number
// "must contain 15 digits. The first and the last digits are '3'".
// That is the whole published rule. Nothing here checks more than that.
 
export type VatNumberResult =
  | { ok: true; value: string }
  | { ok: false; reason: VatNumberError; value: string };
 
export type VatNumberError =
  | "EMPTY"
  | "NOT_DIGITS"
  | "LOOKS_LIKE_CR" // 10 digits: almost always a commercial registration number
  | "WRONG_LENGTH"
  | "FIRST_OR_LAST_NOT_3";
 
const ARABIC_INDIC = "٠١٢٣٤٥٦٧٨٩";
const EXTENDED_ARABIC_INDIC = "۰۱۲۳۴۵۶۷۸۹";
 
export function normalizeVatNumber(raw: string): string {
  let out = "";
  for (const ch of raw.trim()) {
    const a = ARABIC_INDIC.indexOf(ch);
    const e = EXTENDED_ARABIC_INDIC.indexOf(ch);
    if (a >= 0) out += String(a);
    else if (e >= 0) out += String(e);
    else if (ch === " " || ch === "-" || ch === " ") continue;
    else out += ch;
  }
  return out;
}
 
export function checkVatNumber(raw: string): VatNumberResult {
  const value = normalizeVatNumber(raw);
  if (value === "") return { ok: false, reason: "EMPTY", value };
  if (!/^[0-9]+$/.test(value)) return { ok: false, reason: "NOT_DIGITS", value };
  if (value.length === 10) return { ok: false, reason: "LOOKS_LIKE_CR", value };
  if (value.length !== 15) return { ok: false, reason: "WRONG_LENGTH", value };
  if (value[0] !== "3" || value[14] !== "3") {
    return { ok: false, reason: "FIRST_OR_LAST_NOT_3", value };
  }
  return { ok: true, value };
}

LOOKS_LIKE_CR is a separate error code because it calls for a separate message. "Wrong length" sends someone back to recount digits. "This looks like a commercial registration number; ask the supplier for the 15-digit VAT number on their registration certificate" gets the right number on the first reply.

You can try the same rule by hand in the free VAT number format checker. It runs in the browser and sends the number nowhere.

Step 2: The supplier master refuses bad numbers

The cheapest place to stop a wrong number is the form that creates the supplier. Once a number is saved, every invoice from that supplier inherits it, and every later check compares against it.

// src/supplier.ts
import { checkVatNumber, VatNumberError } from "./vatNumber";
 
// Only ZATCA's registration lookup can say a number is registered.
// The supplier record keeps what was checked, when, and how.
 
export type Registration =
  | { status: "unchecked" }
  | { status: "registered"; checkedOn: string; evidence: string }
  | { status: "not_registered"; checkedOn: string; evidence: string };
 
export interface Supplier {
  id: string;
  registeredName: string; // as shown by the ZATCA lookup
  vatNumber: string; // normalised, passed checkVatNumber
  registration: Registration;
}
 
export type CreateResult =
  | { ok: true; supplier: Supplier }
  | { ok: false; reason: VatNumberError };
 
// The gate at onboarding: a malformed number is never saved.
export function createSupplier(id: string, registeredName: string, rawVat: string): CreateResult {
  const vat = checkVatNumber(rawVat);
  if (!vat.ok) return { ok: false, reason: vat.reason };
  return {
    ok: true,
    supplier: { id, registeredName, vatNumber: vat.value, registration: { status: "unchecked" } },
  };
}
 
// Our policy, not a ZATCA rule: a registration check is fresh for the
// period being filed only if it was made on or after the period start.
export function needsLookup(s: Supplier, periodStart: string): boolean {
  return s.registration.status === "unchecked" || s.registration.checkedOn < periodStart;
}
 
// One column of numbers, to paste into the template ZATCA's lookup
// service offers for checking several establishments at once.
export function lookupBatch(suppliers: Supplier[], periodStart: string): string[] {
  const out = new Set<string>();
  for (const s of suppliers) if (needsLookup(s, periodStart)) out.add(s.vatNumber);
  return [...out].sort();
}
 
export interface LookupRow {
  vatNumber: string;
  registered: boolean;
}
 
export function applyLookup(
  suppliers: Supplier[],
  rows: LookupRow[],
  checkedOn: string,
  evidence: string,
): Supplier[] {
  const byNumber = new Map(rows.map((r) => [r.vatNumber, r.registered]));
  return suppliers.map((s) => {
    const registered = byNumber.get(s.vatNumber);
    if (registered === undefined) return s;
    return {
      ...s,
      registration: registered
        ? { status: "registered", checkedOn, evidence }
        : { status: "not_registered", checkedOn, evidence },
    };
  });
}

Three design choices are worth stating:

  • createSupplier returns an error rather than a supplier. The type system then forces the onboarding screen to handle the failure. There is no path where a malformed number is saved "to fix later".
  • registration is evidence, not a flag. A boolean isRegistered tells an auditor nothing. A status with checkedOn and evidence (the file name of the lookup result, or a reference to a saved screenshot) tells them what was checked, when, and how.
  • Freshness is our policy, not a ZATCA rule. needsLookup treats a check as current only if it was made on or after the start of the period being filed. Registrations can be cancelled, so a check from last year proves nothing about this quarter. If your volume is small, you can loosen this. Write down whatever you choose.

Step 3: Registration is a lookup with a date

ZATCA's "Verify VAT registration" service accepts three kinds of search: the VAT number, the VAT certificate number, or the unified commercial registration number. For several establishments at once, the same page offers an xlsx template to download, fill in and upload.

lookupBatch produces the column of numbers for that template, covering only the suppliers not yet checked this period. We do not reproduce the template's layout here. ZATCA owns it and can change it, so take the current template from the service page every time.

When the results come back, applyLookup records them against each supplier with the date and the name of the evidence file. A supplier whose number is not in the results keeps its previous state. Absence from a batch is not proof of anything.

For a team that receives invoices from forty suppliers a month, this turns a sample check into a complete one, and the pre-return run becomes one upload instead of forty tabs.

Step 4: Reading the QR code on what you receive

Every Saudi e-invoice carries a QR code. ZATCA's Security Features Implementation Standards (section 4) define it: a Base64 string of Tag-Length-Value records, where the tag is one byte, the length is one byte, and the value is the UTF-8 bytes of the field. The table in that section fixes the tags:

TagFieldRequired from
1Seller's name4 December 2021
2VAT registration number of the seller4 December 2021
3Invoice timestamp, ISO 86014 December 2021
4Invoice total with VAT4 December 2021
5VAT total4 December 2021
6Hash of the XML invoice1 January 2023
7ECDSA signature of the hash1 January 2023
8ECDSA public key1 January 2023
9ZATCA stamp signature (simplified invoices and their notes)1 January 2023

On a standard tax invoice, the e-invoicing Detailed Guidelines say the QR string is generated by ZATCA at clearance. That makes it the best single source of what the seller actually submitted: if the header says one VAT number and the QR says another, the QR is the one ZATCA saw.

// src/qr.ts
// Reads the QR payload of a Saudi e-invoice (Security Features
// Implementation Standards, section 4): Base64 of TLV records, one byte
// for the tag, one byte for the length, then the value.
// Tags 1-5 are UTF-8 text. Tags 6-9 (hash, signature, public key, ZATCA
// stamp signature) are kept as raw bytes: this reader does not verify them.
 
export interface ZatcaQr {
  sellerName: string; // tag 1
  sellerVatNumber: string; // tag 2
  timestamp: string; // tag 3, ISO 8601
  totalWithVat: string; // tag 4
  vatTotal: string; // tag 5
  phase2Tags: number[]; // which of 6-9 are present
}
 
export class QrError extends Error {}
 
export function decodeZatcaQr(base64: string): ZatcaQr {
  const bytes = Buffer.from(base64.trim(), "base64");
  if (bytes.length === 0) throw new QrError("QR payload is empty or not Base64");
 
  const text = new Map<number, string>();
  const binary: number[] = [];
  let i = 0;
  while (i < bytes.length) {
    if (i + 2 > bytes.length) throw new QrError(`truncated header at byte ${i}`);
    const tag = bytes[i];
    const len = bytes[i + 1];
    const start = i + 2;
    const end = start + len;
    if (end > bytes.length) throw new QrError(`tag ${tag} runs past the end`);
    if (tag >= 1 && tag <= 5) {
      if (text.has(tag)) throw new QrError(`tag ${tag} appears twice`);
      text.set(tag, bytes.subarray(start, end).toString("utf8"));
    } else if (tag >= 6 && tag <= 9) {
      binary.push(tag);
    } else {
      throw new QrError(`unknown tag ${tag}`);
    }
    i = end;
  }
 
  for (const t of [1, 2, 3, 4, 5]) {
    if (!text.has(t)) throw new QrError(`mandatory tag ${t} is missing`);
  }
  const timestamp = text.get(3)!;
  if (Number.isNaN(Date.parse(timestamp))) {
    throw new QrError(`tag 3 is not a date: ${timestamp}`);
  }
 
  return {
    sellerName: text.get(1)!,
    sellerVatNumber: text.get(2)!,
    timestamp,
    totalWithVat: text.get(4)!,
    vatTotal: text.get(5)!,
    phase2Tags: binary,
  };
}
 
// "1150.00" -> 115000. Integer halalas, never floats.
export function toHalalas(amount: string): number {
  const m = /^(\d+)(?:\.(\d{1,2}))?$/.exec(amount.trim());
  if (!m) throw new QrError(`not an amount: ${amount}`);
  return Number(m[1]) * 100 + Number((m[2] ?? "").padEnd(2, "0"));
}

The decoder is strict on purpose. A truncated record, a duplicate text tag or an unknown tag throws a QrError, and the gate turns that error into a hold, never a deduct. Tags 6 to 9 are recorded as present or absent but not verified. Verifying the signature needs the certificate chain, and ZATCA's VAT app already does that when someone scans the code. What this reader gives you is the five text fields, which you can reconcile against the books automatically.

toHalalas converts amounts to integer halalas without ever going through a floating-point number. 1150.10 as a float is not exactly 1150.10, and a reconciliation built on floats produces mismatches nobody can explain.

Step 5: The gate

The gate compares four sources that should agree: the number typed from the invoice header, the supplier master, the registration evidence, and the QR code. Anything that disagrees becomes a named reason.

// src/gate.ts
import { checkVatNumber } from "./vatNumber";
import { decodeZatcaQr, QrError, toHalalas } from "./qr";
import { Supplier, needsLookup } from "./supplier";
 
export interface InboundInvoice {
  supplierId: string;
  kind: "tax" | "simplified";
  printedSellerVat: string; // as typed from the invoice header
  supplyDate: string; // YYYY-MM-DD
  totalWithVat: number; // halalas, as booked
  vat: number; // halalas, as booked
  qr?: string; // Base64 payload, if one was scanned
}
 
export type Decision =
  | { action: "deduct"; notes: string[] }
  | { action: "hold"; reasons: string[] }
  | { action: "no_deduction"; reasons: string[] };
 
// IR Art. 49(8): no deduction "in any period falling after five years
// from the calendar year in which the supply took place". Our reading:
// the last eligible period must end on or before 31 December of year + 5.
export function lastDeductibleDay(supplyDate: string): string {
  const year = Number(supplyDate.slice(0, 4));
  return `${year + 5}-12-31`;
}
 
// Heuristic, ours: enough to catch a different company, not a legal test.
export function sameName(a: string, b: string): boolean {
  const norm = (s: string) =>
    s
      .normalize("NFKC")
      .replace(/[ً-ْـ]/g, "") // harakat and tatweel
      .replace(/[أإآ]/g, "ا")
      .replace(/ة/g, "ه")
      .replace(/ى/g, "ي")
      .toLowerCase()
      .replace(/[^\p{L}\p{N}]+/gu, " ")
      .trim();
  return norm(a) === norm(b);
}
 
export function assessInvoice(
  inv: InboundInvoice,
  supplier: Supplier,
  periodStart: string,
  periodEnd: string,
): Decision {
  if (periodEnd > lastDeductibleDay(inv.supplyDate)) {
    return { action: "no_deduction", reasons: ["OUT_OF_TIME_ART_49_8"] };
  }
  if (supplier.registration.status === "not_registered") {
    return { action: "no_deduction", reasons: ["SUPPLIER_NOT_REGISTERED"] };
  }
 
  const reasons: string[] = [];
  const notes: string[] = [];
 
  const printed = checkVatNumber(inv.printedSellerVat);
  if (!printed.ok) reasons.push(`PRINTED_VAT_${printed.reason}`);
  else if (printed.value !== supplier.vatNumber) reasons.push("PRINTED_VAT_NOT_SUPPLIER_VAT");
 
  if (needsLookup(supplier, periodStart)) reasons.push("REGISTRATION_NOT_CHECKED_THIS_PERIOD");
 
  if (!inv.qr) {
    reasons.push("NO_QR_SCANNED");
  } else {
    try {
      const qr = decodeZatcaQr(inv.qr);
      if (qr.sellerVatNumber !== supplier.vatNumber) reasons.push("QR_VAT_NOT_SUPPLIER_VAT");
      if (!sameName(qr.sellerName, supplier.registeredName)) reasons.push("QR_NAME_NOT_REGISTERED_NAME");
      if (toHalalas(qr.totalWithVat) !== inv.totalWithVat) reasons.push("QR_TOTAL_NOT_BOOKED_TOTAL");
      if (toHalalas(qr.vatTotal) !== inv.vat) reasons.push("QR_VAT_NOT_BOOKED_VAT");
      if (qr.phase2Tags.length === 0) notes.push("QR_HAS_NO_PHASE2_TAGS");
    } catch (e) {
      if (!(e instanceof QrError)) throw e;
      reasons.push(`QR_UNREADABLE: ${e.message}`);
    }
  }
 
  if (inv.kind === "simplified") notes.push("SIMPLIFIED_INVOICE_ART_49_7_A");
 
  return reasons.length > 0 ? { action: "hold", reasons } : { action: "deduct", notes };
}

How it decides:

  • no_deduction happens in two cases only. The supplier was checked and is not registered, so no valid tax invoice exists. Or the period is beyond the Article 49(8) window. Neither can be fixed by correcting a field.
  • hold covers everything that might be a typing error, a template bug or a stale check: a header number that fails BR-KSA-40 or does not match the supplier, a QR whose seller number, total or VAT differs from what was booked, no QR scanned, or no registration check this period.
  • deduct means all four sources agree. It can still carry notes. QR_HAS_NO_PHASE2_TAGS flags a QR with only the five Phase 1 fields. SIMPLIFIED_INVOICE_ART_49_7_A flags a simplified invoice, which Article 49(7)(a) accepts as alternative evidence when it was issued correctly. Article 53(8) lists what it must contain, including the supplier's name, address and VAT number.

The name comparison is deliberately crude. It removes diacritics and tatweel, unifies the alef forms, ta marbuta and alef maqsura, and drops punctuation. That is enough to tell "شركه المورد للتجاره" and "شركة المورد للتجارة" apart from a different company. It is not a legal test, so a mismatch holds the invoice for a person to look at. It never rejects.

One check is left out of the code on purpose. Article 53(5)(e) requires the invoice to carry your name and address too, and the QR does not encode the buyer. That check belongs at invoice capture, against your own registration certificate. Add it there, not here.

Step 6: Held is not lost

The reason for keeping hold separate from no_deduction is Article 49(8): a taxable person may deduct input VAT in a period later than the one containing the supply date, as long as the right to deduct exists, but not "in any period falling after five years from the calendar year in which the supply took place".

Our reading, which the code makes explicit in lastDeductibleDay: the last period that can carry the deduction must end on or before 31 December of the supply year plus five. An October 2026 supply can still be deducted in a return for a period ending on 31 December 2031.

The practical effect is large. A team that deducts every invoice as it arrives, then finds a bad number at audit, faces a reassessment. A team that holds a doubtful invoice, asks the supplier for a corrected one and deducts it two quarters later loses nothing except the timing. The gate turns the second behaviour into the default.

When a held invoice is cleared, it enters the return for the period in which it is cleared. The VAT return engine puts it in the standard-rated purchases box of that period, not by reopening the period it came from.

Step 7: Testing Your Implementation

// tests/gate.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { checkVatNumber } from "../src/vatNumber";
import { decodeZatcaQr, QrError, toHalalas } from "../src/qr";
import { createSupplier, lookupBatch, applyLookup, Supplier } from "../src/supplier";
import { assessInvoice, lastDeductibleDay, InboundInvoice } from "../src/gate";
 
function qr(fields: [number, string][]): string {
  const parts = fields.map(([tag, value]) => {
    const v = Buffer.from(value, "utf8");
    return Buffer.concat([Buffer.from([tag, v.length]), v]);
  });
  return Buffer.concat(parts).toString("base64");
}
 
const VAT = "310122393500003";
const NAME = "شركة المورد للتجارة";
const goodQr = qr([
  [1, NAME],
  [2, VAT],
  [3, "2026-10-12T09:30:00Z"],
  [4, "1150.00"],
  [5, "150.00"],
]);
 
function supplier(): Supplier {
  const r = createSupplier("S1", NAME, VAT);
  if (!r.ok) throw new Error(r.reason);
  return applyLookup([r.supplier], [{ vatNumber: VAT, registered: true }], "2026-10-02", "lookup-2026-10-02.xlsx")[0];
}
 
const invoice: InboundInvoice = {
  supplierId: "S1",
  kind: "tax",
  printedSellerVat: VAT,
  supplyDate: "2026-10-12",
  totalWithVat: 115000,
  vat: 15000,
  qr: goodQr,
};
 
test("BR-KSA-40: 15 digits, first and last are 3", () => {
  assert.equal(checkVatNumber("310122393500003").ok, true);
  assert.equal(checkVatNumber("٣١٠١٢٢٣٩٣٥٠٠٠٠٣").ok, true);
  assert.equal(checkVatNumber("310 1223 9350 0003").ok, true);
  assert.deepEqual(checkVatNumber("1010123456"), { ok: false, reason: "LOOKS_LIKE_CR", value: "1010123456" });
  assert.equal((checkVatNumber("31012239350000") as { reason: string }).reason, "WRONG_LENGTH");
  assert.equal((checkVatNumber("310122393500001") as { reason: string }).reason, "FIRST_OR_LAST_NOT_3");
  assert.equal((checkVatNumber("31012239350000O") as { reason: string }).reason, "NOT_DIGITS");
});
 
test("a malformed number is never saved to the supplier master", () => {
  assert.deepEqual(createSupplier("S2", "x", "7001234567"), { ok: false, reason: "LOOKS_LIKE_CR" });
});
 
test("QR: reads tags 1-5 and notes phase 2 tags", () => {
  const withHash = qr([[1, NAME], [2, VAT], [3, "2026-10-12T09:30:00Z"], [4, "1150.00"], [5, "150.00"], [6, "x".repeat(44)]]);
  const d = decodeZatcaQr(withHash);
  assert.equal(d.sellerName, NAME);
  assert.equal(d.sellerVatNumber, VAT);
  assert.deepEqual(d.phase2Tags, [6]);
});
 
test("QR: missing tag, truncation and bad Base64 are errors", () => {
  assert.throws(() => decodeZatcaQr(qr([[1, NAME], [2, VAT], [3, "2026-10-12T09:30:00Z"], [4, "1150.00"]])), QrError);
  const cut = Buffer.from(goodQr, "base64").subarray(0, 20).toString("base64");
  assert.throws(() => decodeZatcaQr(cut), QrError);
  assert.throws(() => decodeZatcaQr(""), QrError);
});
 
test("amounts are compared in halalas", () => {
  assert.equal(toHalalas("1150.00"), 115000);
  assert.equal(toHalalas("1150.5"), 115050);
  assert.equal(toHalalas("1150"), 115000);
  assert.throws(() => toHalalas("1,150.00"), QrError);
});
 
test("lookup batch lists only numbers not checked this period", () => {
  const s = supplier();
  assert.deepEqual(lookupBatch([s], "2026-10-01"), []);
  assert.deepEqual(lookupBatch([s], "2027-01-01"), [VAT]);
});
 
test("a clean invoice is deducted", () => {
  const d = assessInvoice(invoice, supplier(), "2026-10-01", "2026-12-31");
  assert.equal(d.action, "deduct");
});
 
test("QR seller VAT that differs from the supplier is held", () => {
  const other = qr([[1, NAME], [2, "300000000000003"], [3, "2026-10-12T09:30:00Z"], [4, "1150.00"], [5, "150.00"]]);
  const d = assessInvoice({ ...invoice, qr: other }, supplier(), "2026-10-01", "2026-12-31");
  assert.deepEqual(d, { action: "hold", reasons: ["QR_VAT_NOT_SUPPLIER_VAT"] });
});
 
test("booked VAT that differs from the QR is held", () => {
  const d = assessInvoice({ ...invoice, vat: 15100 }, supplier(), "2026-10-01", "2026-12-31");
  assert.deepEqual(d, { action: "hold", reasons: ["QR_VAT_NOT_BOOKED_VAT"] });
});
 
test("no QR, stale lookup and a CR number in the header all hold", () => {
  const d = assessInvoice({ ...invoice, qr: undefined, printedSellerVat: "1010123456" }, supplier(), "2027-01-01", "2027-03-31");
  assert.equal(d.action, "hold");
  assert.deepEqual((d as { reasons: string[] }).reasons, [
    "PRINTED_VAT_LOOKS_LIKE_CR",
    "REGISTRATION_NOT_CHECKED_THIS_PERIOD",
    "NO_QR_SCANNED",
  ]);
});
 
test("names are compared after Arabic normalisation", () => {
  const variant = qr([[1, "شركه المورد  للتجاره"], [2, VAT], [3, "2026-10-12T09:30:00Z"], [4, "1150.00"], [5, "150.00"]]);
  assert.equal(assessInvoice({ ...invoice, qr: variant }, supplier(), "2026-10-01", "2026-12-31").action, "deduct");
});
 
test("an unregistered supplier gives no right to deduct", () => {
  const s = applyLookup([supplier()], [{ vatNumber: VAT, registered: false }], "2026-11-02", "lookup-2026-11-02.xlsx")[0];
  assert.deepEqual(assessInvoice(invoice, s, "2026-10-01", "2026-12-31"), {
    action: "no_deduction",
    reasons: ["SUPPLIER_NOT_REGISTERED"],
  });
});
 
test("Art. 49(8): a held invoice can still be deducted until year + 5", () => {
  assert.equal(lastDeductibleDay("2026-10-12"), "2031-12-31");
  assert.equal(assessInvoice(invoice, supplier(), "2026-10-01", "2031-12-31").action, "deduct");
  assert.deepEqual(assessInvoice(invoice, supplier(), "2032-01-01", "2032-03-31"), {
    action: "no_deduction",
    reasons: ["OUT_OF_TIME_ART_49_8"],
  });
});

Run them with:

npx tsx --test tests/*.test.ts

All 13 tests pass. Each core rule was also checked the other way: removing the last-digit check, the 49(8) window, the VAT reconciliation, the ten-digit CR detection or the ta marbuta normalisation each makes at least one test fail. A test suite that still passes when a rule is deleted is not testing that rule.

The test numbers are illustrative. 310122393500003 is shaped correctly and was made up for the tests; do not read it as anyone's registration. Use a fixture of your own suppliers, with numbers your team has looked up, to test against real data.

Where this sits in your system

MomentCallWhat it prevents
A buyer creates a suppliercreateSupplierA CR number, a 14-digit number or a typo entering the master
Before each VAT returnlookupBatch, then the ZATCA upload, then applyLookupDeducting on a registration that was cancelled since the last check
An invoice arrivesdecodeZatcaQr, then assessInvoiceBooking a total or a VAT amount the seller did not submit
Return preparationonly deduct decisions reach the input VAT boxesA doubtful invoice in the return instead of on the hold list

None of this needs a new system. It is a layer above the accounting software you already run, which is the kind of integration work this tutorial series exists to show.

Troubleshooting

The QR decodes, but the seller name looks like random characters. The payload was read as Latin-1 somewhere upstream, often by a scanner app or a PDF library. Take the raw Base64 string from the scanner and pass it straight to decodeZatcaQr. Tags 1 to 5 are UTF-8 by the standard.

tag 6 runs past the end, or unknown tag. The payload does not follow the one-byte tag, one-byte length layout, either because it was cut when the code was printed or because the generator departs from the standard. The decoder rejects it and the gate holds the invoice. Ask the supplier to check their QR with ZATCA's VAT app. Do not relax the parser to accept it.

The VAT number in the QR differs from the header by one digit. That is exactly the case this gate exists for. The QR on a cleared tax invoice comes from ZATCA, so the header is probably the one that is wrong. Ask for a corrected invoice before deducting.

The supplier is a VAT group. BR-KSA-39 allows the group VAT registration number on the invoice. Store the group number on each member's supplier record. The registered name the lookup returns may then belong to the group's representative member rather than the entity you deal with, and the name check will hold those invoices until someone confirms them once.

The lookup batch is empty, but you expected numbers. Every supplier was checked on or after the period start. Use a later periodStart to see who will need checking next period.

Next Steps

Conclusion

The format rule fits in one line: 15 digits, the first and the last are 3. The work is in placing it. At onboarding it keeps bad numbers out of the master. Before each return a dated lookup proves registration for that period. On every received invoice the QR is reconciled against the books. And the gate holds what it cannot confirm instead of deducting it, because Article 49(8) leaves five years to deduct a corrected invoice and a reassessment leaves none.

Start with the cheapest piece: check a supplier's VAT number format now, free and in your browser, then follow it with ZATCA's own lookup.

If your team still checks supplier numbers one by one, after posting, the problem is where the check sits, not how careful people are. Talk to us about wiring this gate into your accounts-payable flow. We work as an integration layer above the system you already run, and we will tell you plainly if your current setup already covers it.