⚖️ Saudi Arabian localization
Updated
← Localizations User: how to turn it on · developer: how it is built.
The LocalizationSaudiArabia package (Customization layer) is Saudi law and
Saudi services on top of the core, not an if (country) branch in Core. Today
the package centres on VAT and ZATCA e-invoicing, not the whole Saudi statutory
perimeter.
The e-invoice: ZATCA. User: the e-invoice. Developer: Fatoora. Reference: 21-SaudiArabia.
The package's perimeter#
| Contour | Today |
|---|---|
15% VAT, the ZATCA S/Z/E/O codes, a tax of its own VAT-SA |
in the package |
| Determination wired to the Saudi codes for a Saudi legal entity | in the package |
| The VAT return box layout of the ZATCA form | in the package |
| The filing sheet for the Fatoora portal | in the package |
| The Fatoora e-invoice (queue, QR, CSID onboarding) | in the package |
| GOSI (social insurance) | the HR core: rates on the stand (9.75% / 11.75% / 2%, a cap of 45 000 SAR), with no Saudi object |
| Mudad / WPS, Qiwa, Nafath, Absher, SADAD | not there |
| Saudi banking gateways | not there |
The next Saudi service means objects and a channel in this package, not in Core and not in a separate "integration" model with no country.
The seller's CRN lives on the LegalEntity.LocalizationSaudiArabia extension,
while VAT is a common field on the legal entity. The address (country,
district) is in Common. The ZATCA exemption codes and text for E and O are on
the TaxCategory.LocalizationSaudiArabia extension; the category's mode stays
a common Tax field.
A dedicated tax#
The vat-SA.json seed no longer attaches its rates to the shared reference tax
VAT (jurisdiction "INTL") but creates its own VAT-SA — ZATCA, jurisdiction
SA — with its own categories. The reason: while two countries shared one tax, a
stand carrying the Ukrainian package had 15%, 20% and 7% all effective on
today's date, and rate resolution refuses to answer when more than one rate
applies — so the tax computed for neither country. The test run did not catch
it, because every test created its own tax per rate.
The VAT5 (2018-01-01 → 2020-06-30) and VAT15 (from 2020-07-01) rates sit in
the STANDARD category and are still told apart by date. The Z, E and O
codes now give 0 through the category's treatment rather than falling through
to 15% — the mechanics are described in the
Ukrainian localization.
The package installs itself: the tax pack installer applies every
when=country pack whose ISO2 is SA — today vat-SA and cities-SA,
both in this model. A Ukrainian legal entity never sees these rows; it
gets LocalizationUkraine packs instead. Organisation setup and the
hourly Ensure country packs job call the same installer. A pack that
is already in place is not applied again — the installer checks first
and writes nothing.
The installer does not run inside the legal-entity save. Listing packages dry-runs every pack; that read, on another connection, under the save's own locks, deadlocks the insert — including an entity whose country has no packs, because the listing runs before any match. The job owns its own transaction.
The VAT return boxes#
The return itself is country-neutral: TaxReturn is assembled from TaxLedger
by Build return on a TaxPeriod, and the core decides nothing about Saudi
Arabia. What the core needs from a country is the box layout — which cell of
the official form a tax code falls into. The Saudi package ships that layout. Without those rows a Saudi return adds up
correctly and still comes out with every Return box empty — a return that
cannot be filed.
The layout#
Open Ribbon → Tax → Dictionaries → Return mappings and filter by Return
type VAT-SA. Six rows arrive with the package:
| Tax code | Direction | Return box | Line of the ZATCA form |
|---|---|---|---|
S |
OUTPUT |
1 |
Standard rated domestic sales |
Z |
OUTPUT |
3 |
Zero rated domestic sales |
E |
OUTPUT |
5 |
Exempt sales |
S |
INPUT |
7 |
Standard rated domestic purchases |
Z |
INPUT |
10 |
Zero rated purchases |
E |
INPUT |
11 |
Exempt purchases |
Every row is directed on purpose: one code means two different cells
depending on the side of the trade, and an undirected row would make the choice
ambiguous. All six are effective from 2018-01-01, the day VAT started in the
Kingdom, with an empty end date — the cells of the form have not moved since,
and the 5% → 15% rate change does not touch them because rates are dated
separately in TaxRate.
O — out of scope — is deliberately absent. Operations outside the scope of
VAT are not shown on the form at all. The line still appears in the return with
its base intact; it simply carries no box.
Boxes 6 (total sales), 12 (total purchases), 13 and 16 (net VAT) are not mapped either: they are sums the form computes, not destinations of a tax code.
What you change and what happens#
| Setting | Where | Values | Default | Effect |
|---|---|---|---|---|
| Return type | the mapping row | any text; VAT-SA for this form |
VAT-SA from the package |
Stored and shown; the core does not pick a country from it |
| Return box | the mapping row | up to 32 characters | the six values above | Copied verbatim into the TaxReturn line |
| Direction | the mapping row | OUTPUT, INPUT, or empty |
directed in all six rows | A directed row wins over an undirected one for the same code |
| Effective from / to | the mapping row | dates; empty end is open-ended | 2018-01-01 / empty |
The row effective on the period end date is the one used |
Editing a row is legal and survives a reinstall of the package only if you keep the business key (Return type + Tax code + Direction); the installer upserts by that key, so a changed Return box on the same key is overwritten on the next install, while a new key is added beside the shipped rows.
Example with numbers#
A Riyadh legal entity posts one January quarter:
| Source | Direction | Code | Base | Tax |
|---|---|---|---|---|
| Sales realization | OUTPUT |
S |
1,000.00 | 150.00 |
| Export sale | OUTPUT |
Z |
500.00 | 0.00 |
| Purchase receipt | INPUT |
S |
400.00 | 60.00 |
Build return on the period produces:
| Return line | Box | Base | Tax |
|---|---|---|---|
S output |
1 |
1,000.00 | 150.00 |
Z output |
3 |
500.00 | 0.00 |
S input |
7 |
400.00 | 60.00 |
Header: Output tax 150.00, Input tax 60.00, Net payable 90.00. The zero-rated export contributes base to box 3 and nothing to the tax due — which is the point of the box existing separately from box 1.
Boundaries#
- The return is built by the Build return command on
TaxPeriod, not by a subtype select on the return: after saving, the subtype is frozen. - A closed
TaxPeriodrefuses a new return for its dates. - Payment is always a separate
TaxPaymentdocument; it does not touchTaxLedger. - If a second country package on the same stand mapped the same code and
direction to a different box, the core leaves the box empty rather than pick a
country. Saudi codes
S/Z/E/Obelong to theVAT-SAtax and do not collide with the UkrainianUA-*codes or the neutralSTD/ZERO/EXEMPT.
Determination uses the Saudi codes#
The package seeds the tax, the rates, the codes and the box layout. None of that
chooses the code for a document: determination runs rule → TaxMapping →
TaxSettings default tax code, and a country package cannot own the last one —
a stand carrying two countries has exactly one of it.
Without a Saudi mapping, an invoice would post and balance with the stand
default, then reach the return empty: the Saudi layout maps S, Z and E
and ignores any other code.
A job — PinSaudiTaxCodes, every five minutes — finds each Saudi legal
entity that has no tax mapping of its own and creates one row in Ribbon → Tax
→ Dictionaries → Tax mappings:
| Field | Value |
|---|---|
| Source type | LegalEntity |
| Source | the Saudi legal entity |
| Tax code | S of the VAT-SA tax |
| Priority | 100 |
| Effective from | the code's own start date — the pin is not a second place to date VAT |
| Disabled | off |
"Saudi" is decided by the country's ISO-2 code SA, not by a seeded id: a
tenant may have typed its own country row, and the code is what makes it the same
country. On a stand with no Saudi tax at all the run costs one query and writes
nothing.
The pin is not instant, and that is deliberate#
An entity created at 10:00 is pinned by the next run of the job — the window is the job's period, not a day — and an invoice issued inside that window is still computed with the stand default.
The pin is a job, not a LegalEntity after-insert handler. Reading
Tax/TaxCode/TaxMapping and inserting a row after next() sits inside the
entity insert's lock window, next to the country-package installer writing the
same tables in the same transaction. The platform swallows an after-event
failure, so the caller's transaction is already rolled back and every following
statement dies. A job owns its own transaction and blocks nobody's insert.
If you cannot wait for the next run, write the mapping row by hand — it is the
same row — or call ISaudiTaxDetermination.PinLegalEntityAsync for the one
entity.
What you can change#
| Setting | Values | Default | Effect |
|---|---|---|---|
| The mapping's Tax code | any code of any tax | S of VAT-SA |
The code every document of this seller falls back to |
| The mapping's Disabled | on / off | off | On means determination falls through to the stand default again |
| Priority | integer, smaller wins among equal source types | 100 | Only matters if you add a second legal-entity row |
Job PinSaudiTaxCodes Cron |
cron expression | */5 * * * * |
How long a new Saudi entity can go unpinned; switch the job off entirely and nothing is pinned automatically |
The row is created only when the entity has none. An implementer who points
a Saudi entity at zero rated, or writes a TaxRule of their own, keeps that
choice — a later run of the job does not restore S. A disabled row counts
as an answer too: somebody turned it off deliberately, and the package does not
argue.
Because the pin is per party, it does not touch another country on the same database, which setting the stand-wide default would.
What still beats it#
The entity pin is the weakest mapping on purpose. Determination tries, in
order: a matching TaxRule, then mappings by item, item group, customer and
supplier, and only then the legal entity. An exporter selling zero-rated goods is
configured by the item or by a rule, not by removing the seller's pin.
Example with numbers#
A new Riyadh entity is saved; the package installs. Within five minutes
PinSaudiTaxCodes runs and the pin appears. An invoice for 1,000.00 SAR with no
item-level tax setup then resolves code S, rate 15% on the invoice date, and
150.00 of VAT — which lands in TaxLedger under S and therefore in box 1
of the return. An invoice issued in the minutes between the save and the first
run of the job still uses the stand default until the pin exists — that is the
window, and it is why an implementer in a hurry writes the mapping by hand.
Export for filing#
ZuloOne does not file the Saudi return. Filing happens in the ZATCA portal under the taxpayer's own credentials, and nothing in the package signs a submission. What the accountant lacks at that moment is the box-by-box figure sheet, and Export for filing produces it.
The button#
Open the return and press Export for filing. The button sits on both subtypes — on the draft, while the figures are being checked, and on Filed, because a return already handed in is the one people come back to reconcile against the portal. Exporting a filed return does not change it: the frozen document stays frozen, and only a log row is written.
The sheet lands in Ribbon → Saudi Arabia → Dictionaries → VAT return export as a row with the legal entity, the form, the period and a Payload of semicolon-separated lines.
What the sheet contains#
All sixteen boxes, each with a Source column, because a zero the system vouches for and a zero nobody filled are different zeros:
| Source | Boxes | Meaning |
|---|---|---|
ledger |
1, 3, 5, 7, 9, 10, 11 | Taken from the return lines through the box layout |
computed |
6, 12, 13, 16 | Sums the form derives: total sales, total purchases, VAT due, net VAT |
manual |
2, 4, 8, 14, 15 | ZuloOne has no source — type these into the portal yourself |
Box 8 is the one to read carefully: import VAT paid at customs. A Saudi
business genuinely owes it, ZuloOne has no customs document to take it from, so
the sheet marks the box rather than printing a zero that looks computed. Box
9 — import VAT under the reverse charge mechanism — is filled from the
ledger: a purchase under the RC-SA tax code charges the buyer the tax his
supplier did not, and deducts it in the same breath.
Lines whose code has no box — out of scope, or any code an implementer left unmapped — are listed under Out of scope or unmapped below the form. They are not part of any box, and they are not silently dropped either.
Example with numbers#
One quarter of the Riyadh entity: standard sales 1,000.00 (VAT 150.00), a zero-rated sale 500.00, a standard purchase 400.00 (VAT 60.00) and one out-of-scope line of 700.00.
| Box | Description | Amount | VAT | Source |
|---|---|---|---|---|
| 1 | Standard rated domestic sales | 1,000.00 | 150.00 | ledger |
| 3 | Zero rated domestic sales | 500.00 | 0.00 | ledger |
| 6 | Total sales | 1,500.00 | 150.00 | computed |
| 7 | Standard rated domestic purchases | 400.00 | 60.00 | ledger |
| 12 | Total purchases | 400.00 | 60.00 | computed |
| 13 | Total VAT due for the current period | 90.00 | computed |
|
| 16 | Net VAT due (or reclaimed) | 90.00 | computed |
Below the form: Out of scope or unmapped;1;700.00;0.00. Note that box 6 is
1,500.00 and not 2,200.00 — the out-of-scope 700.00 is reported nowhere on the
form, which is the point of keeping it visible underneath. Box 13 equals the
return header's Net payable: the two are not allowed to disagree.
Example with a reverse-charge import#
The same quarter plus a service bought from a non-resident supplier for 500.00. His invoice carries no VAT; the buyer owes 75.00 and deducts 75.00.
| Box | Description | Amount | VAT | Source |
|---|---|---|---|---|
| 1 | Standard rated domestic sales | 1,000.00 | 150.00 | ledger |
| 6 | Total sales | 1,500.00 | 150.00 | computed |
| 7 | Standard rated domestic purchases | 400.00 | 60.00 | ledger |
| 9 | Imports under the reverse charge mechanism | 500.00 | 75.00 | ledger |
| 12 | Total purchases | 900.00 | 135.00 | computed |
| 13 | Total VAT due for the current period | 90.00 | computed |
Read box 13 carefully: it is 150.00 + 75.00 − 135.00, not 150.00 − 135.00. The import appears once, among the purchases, where box 12 deducts its 75.00; box 13 adds the owed half back, which is why a fully recovering buyer pays nothing for the import and still declares it. Drop the middle term and the sheet would say 15.00 — the business would under-declare by exactly the import VAT.
Both halves sit in the return itself (Output tax 225.00, Input tax 135.00,
Net payable 90.00), so box 13 and the return header still agree.
Repeating the export#
The row is keyed by legal entity, period and form. Exporting the same return again replaces its own row instead of adding a second sheet for the same quarter — rebuild the return, press the button again, and there is still exactly one current sheet.
Mixed VAT rates in one invoice#
Each invoice line may carry its own tax code. A coded line goes into the ZATCA XML with its own VAT category (S/Z/E/O) and percent and its settled line tax; a line without a code uses the document rate (TaxRateApplied) and the category of the posted tax calculation. The header TaxTotal has one TaxSubtotal per category/percent group with its taxable amount and tax. For E and O groups the exemption reason comes from the category's VATEX fields, or from the code's exemption reason when those are empty. Line amounts are the settled line totals after line and header discounts. VatPayable, the printed tax invoice and QR tags 4–5 use the same per-line figures.
| Line | Category | Taxable | VAT |
|---|---|---|---|
| 100 SAR, standard code | S 15 % | 100 | 15 |
| 100 SAR, zero-rated code | Z 0 % | 100 | 0 |
| Invoice | 200 | VatPayable +15, QR total 215 |
Every new invoice now carries at least one TaxSubtotal (ZATCA BR-CO-18), single-rate invoices included.
What's not there yet#
- Credit and debit notes use the document rate for every line in
VatPayableand the ZATCA XML. - The sheet is figures, not an electronic return and not an upload: there is no ZATCA return schema and no live submission. Filing stays in the portal.
- Box 8 (imports at customs) has no source document:
Purchasinghas no customs clearance, so that figure is typed in by hand. - Box 15 (credit carried forward from a previous period) is not computed: returns do not read each other.
- There is no Mudad / WPS, Qiwa, Nafath, Absher or SADAD setup in the package.
- There is no Saudi bank gateway in the package.
- GOSI uses the HR core rates; there is no Saudi-specific GOSI object in the package.
Same topic for other readers: Developer