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
tsxto 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:
vatNumber.ts: is this string even shaped like a Saudi VAT number? The rule is ZATCA's BR-KSA-40, and nothing more.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.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.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:
createSupplierreturns 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".registrationis evidence, not a flag. A booleanisRegisteredtells an auditor nothing. A status withcheckedOnandevidence(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.
needsLookuptreats 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:
| Tag | Field | Required from |
|---|---|---|
| 1 | Seller's name | 4 December 2021 |
| 2 | VAT registration number of the seller | 4 December 2021 |
| 3 | Invoice timestamp, ISO 8601 | 4 December 2021 |
| 4 | Invoice total with VAT | 4 December 2021 |
| 5 | VAT total | 4 December 2021 |
| 6 | Hash of the XML invoice | 1 January 2023 |
| 7 | ECDSA signature of the hash | 1 January 2023 |
| 8 | ECDSA public key | 1 January 2023 |
| 9 | ZATCA 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_deductionhappens 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.holdcovers 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.deductmeans all four sources agree. It can still carry notes.QR_HAS_NO_PHASE2_TAGSflags a QR with only the five Phase 1 fields.SIMPLIFIED_INVOICE_ART_49_7_Aflags 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.tsAll 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
| Moment | Call | What it prevents |
|---|---|---|
| A buyer creates a supplier | createSupplier | A CR number, a 14-digit number or a typo entering the master |
| Before each VAT return | lookupBatch, then the ZATCA upload, then applyLookup | Deducting on a registration that was cancelled since the last check |
| An invoice arrives | decodeZatcaQr, then assessInvoice | Booking a total or a VAT amount the seller did not submit |
| Return preparation | only deduct decisions reach the input VAT boxes | A 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
- Feed the
deductdecisions into the Saudi VAT return engine, and leave the held list out of the return until each item is cleared - Read the decision-stage companion, four ways to verify a Saudi VAT number and what each proves, for the finance team that owns the process
- If a held invoice turns out to be a simplified invoice, the Article 53 simplified invoice guide covers what it must contain
- Once input VAT exceeds output, the Article 69 refund guide explains when a credit balance can be reclaimed
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.