Guide
Reading and checking
import { decodeInvoice, check, describe, isZatcaPayload } from '@devix-labs/e-invoice-qr';
decodeInvoice(payload)
decodeInvoice('AQVTYWxsYQIKMTIzNDU2Nzg5MQMU…');
// {
// seller: 'Salla', vatNumber: '1234567891',
// timestamp: '2021-07-12T14:25:09Z', total: '100.00', vatTotal: '15.00',
// phase: 1,
// unknown: [],
// }
Values come back exactly as they were encoded — strings, not numbers — because
that is what was stamped. unknown holds any tag outside 1–9 rather than
dropping it, so nothing is lost on the way through.
A payload that is not base64, or whose lengths do not add up, throws a TlvError
that says where:
TlvError: Tag 1 says it is 20 bytes but only 12 remain
isZatcaPayload(text) is the quiet version: true or false, no throwing, for
deciding whether a scanned string is an invoice at all before you try to read it.
check(payload, options)
const result = check(payload, { rate: 0.15 });
result.ok; // boolean
result.phase; // 1 | 2 | null
result.invoice; // the decoded fields, so you need not decode twice
result.problems; // [{ code, field, actual }]
| Option | Default | |
|---|---|---|
rate |
— | The VAT rate to sanity-check against, as a fraction. Saudi Arabia is 0.15. Omit it and the rate is not checked at all. |
tolerance |
0.02 |
How far the VAT may sit from that rate. |
now |
the clock | What counts as "the future". |
The reason codes
| Code | Means |
|---|---|
missing, empty |
A required tag is absent or blank. |
too-long |
A value is over the 255 bytes a length field holds. |
vat-number-length |
Not 15 digits. |
vat-number-digits |
Something in it is not a digit. |
vat-number-prefix |
Does not begin and end with 3. |
timestamp-format |
Not ISO 8601. |
timestamp-future |
Dated later than now, with an hour of slack for clock drift. |
amount-format |
Not a plain decimal number. |
amount-negative |
Below zero. |
vat-exceeds-total |
More VAT than invoice. |
vat-implausible |
Does not match the rate you gave. |
phase-2-incomplete |
Some of the stamp tags but not all of them. |
describe(problem) turns one into a sentence — "vatNumber does not begin and
end with 3 (1234567891)" — for a log or a form. MESSAGES is the wording, so
you can translate it.
What a VAT number check can honestly tell you
Fifteen digits, beginning and ending with 3. That is all.
Saudi Arabia publishes no check digit for the VAT registration number, so there is no arithmetic that can tell a real one from a well-formed invention. Any library implying otherwise is overselling. The only way to confirm a number is registered is ZATCA's own lookup.
This is the same position the rest of our regional validators take: say what was checked, and never let a passing result mean more than it does.
Checking as you issue, not after
The useful place for this is the moment before an invoice is saved:
const payload = encodeInvoice(invoice);
const result = check(payload, { rate: 0.15 });
if (!result.ok) {
throw new Error(`This invoice would not pass: ${result.problems.map(describe).join('; ')}`);
}
Every problem it reports is one a scanner would have found later, in front of a customer or an inspector.