Skip to content
Devix Open Source

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.

Updated 15 Sep 2026