Guide
Checking an invoice
use Devix\Zatca\Check;
$result = Check::payload($payload, [
'rate' => 0.15, // Saudi Arabia's standard rate
'tolerance' => 0.02, // how far the VAT may sit from it
'now' => new DateTimeImmutable('2025-03-01'), // what counts as "the future"
]);
config/zatca.php holds the rate and tolerance, so in an application it is
usually ['rate' => config('zatca.rate')].
What comes back
$result->ok; // bool
$result->phase; // 1 | 2 | null
$result->problems; // [['code' => …, 'field' => …, 'actual' => …], …]
$result->codes(); // ['vat-number-length', 'vat-implausible']
$result->messages(); // sentences, for a log or a form
$result->invoice; // the decoded fields, so you need not decode twice
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 |
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. |
Check::REASONS is the wording, so it can be translated. The JavaScript twin has
the same list, in the same order, checked by the shared vectors.
Checking as you issue, not after
The useful place for this is the moment before an invoice is saved:
$payload = Zatca::encode($invoice);
$result = Check::payload($payload, ['rate' => config('zatca.rate')]);
abort_if(! $result->ok, 422, 'This invoice would not pass: '.implode('; ', $result->messages()));
Every problem it reports is one a scanner would have found later, in front of a customer or an inspector.
In a job, over what you have already issued
use Devix\Zatca\Check;
Invoice::whereNotNull('qr_payload')->chunkById(500, function ($invoices) {
foreach ($invoices as $invoice) {
$result = Check::payload($invoice->qr_payload, ['rate' => config('zatca.rate')]);
if (! $result->ok) {
logger()->warning('Invoice QR would not pass', [
'invoice' => $invoice->id,
'problems' => $result->codes(),
]);
}
}
});
Worth running once over historic invoices. A byte-length bug in whatever produced
them shows up as missing or empty fields on exactly the ones with an Arabic
seller name.
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 distinguishes a real one from a well-formed invention. Only ZATCA's own lookup can confirm registration. Any library implying otherwise is overselling, and this one takes the same position as the rest of our regional validators: say what was checked, and never let a pass mean more than it does.