ZuloOne
English
Sign in
Menu

⚖️ 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 TaxPeriod refuses a new return for its dates.
  • Payment is always a separate TaxPayment document; it does not touch TaxLedger.
  • 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/O belong to the VAT-SA tax and do not collide with the Ukrainian UA-* codes or the neutral STD/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 VatPayable and 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: Purchasing has 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

Where next#

Was this page helpful?