21 · Saudi Arabia — VAT + ZATCA/FATOORA (`LocalizationSaudiArabia`)
Updated
Customization layer. One of the two implemented country packages.
It seeds Saudi VAT data (SA jurisdiction, 5%/15% rates, codes S/Z/E/O)
on top of the common Tax model and implements the ZATCA/FATOORA
e-invoice (Phase 2, Integration Phase) for Sales documents. The
common tax objects and tax engine are not duplicated. The package only seeds
data, extends three documents owned by other models, and adds its own
services/channel/job.
← General specification · Package template
Contour fact. Three transaction scripts (
SaudiVatTxonSalesRealization,SaudiCreditVatTxonSalesCreditNote,SaudiDebitVatTxonSalesDebitNote) read the rate fromdocument.TaxRateApplied, a field on the document itself, not from the TaxRule engine. The package creates noTaxRulerecords at all. Rate applicability in KSA is determined by the value that Tax writes to the document, not by a separate package rule (see Localization · invariants). The live sales process is Sales and purchasing.
What is created (object map)#
The package does not create new kinds of Tax objects. It seeds data into Tax dictionaries, extends two dictionaries and three documents owned by other models, and adds one own dictionary, one own register, and the e-invoice service set.
| Kind | Name | Purpose |
|---|---|---|
| Model | LocalizationSaudiArabia |
country package, Customization(3) layer |
| DataPackage | DataPackages/vat-SA.json |
TaxAuthority ZATCA, TaxJurisdiction SA, Tax VAT-SA, 5×TaxCategory, 4×TaxRate (5%/15%, standard and reverse-charge), 5×TaxCode (S/Z/E/O/RC-SA), 8×TaxReportMapping |
| Dictionary | LocalizationSaudiArabiaSettings |
singleton switch: EInvoiceEnabled, VatRegistrationNumber |
| Dictionary | FatooraStubDelivery |
dev Fatoora stub state as data: accepted payloads by idempotency key |
| DictionaryExtension | LegalEntity.LocalizationSaudiArabia |
CommercialRegistration (seller CRN, UBL fallback scheme) |
| DictionaryExtension | TaxCategory.LocalizationSaudiArabia |
ExemptionReasonCode/ExemptionReasonText (VATEX-SA-* for UBL, E/O categories) |
| DocumentExtension ×3 | SalesRealization.LocalizationSaudiArabia (→SalesRealization), SalesCreditNote.LocalizationSaudiArabia, SalesDebitNote.LocalizationSaudiArabia |
the same 5 e-invoice envelope fields (UUID/type/hash/PIH/QR) |
| Transaction scripts ×3 | SaudiVatTx, SaudiCreditVatTx, SaudiDebitVatTx |
VAT posting into VatPayable |
| Register | VatPayable |
VAT payable by customer/outlet/contract |
| Service | SaudiEInvoice (ISaudiEInvoice) |
central orchestrator: UBL, hash, PIH/ICV, queue, outcome application |
| Service | EInvoiceRelease (CoC link of IEInvoiceRelease from Tax) |
blocks buyer release of a Standard invoice until Cleared |
| Service | SaudiTaxDetermination (ISaudiTaxDetermination) |
writes the per-entity TaxMapping pin; PinLegalEntitiesAsync for the job, PinLegalEntityAsync for one entity now |
| Service (host seam) | ZatcaQr, ZatcaXades, ZatcaCsr, FatooraSigner (IOutboundSigner, profile fatoora) |
QR-TLV, XAdES hash/signature, CSR/key pair, HTTP channel signature |
| OutboundChannel ×5 | fatoora-clearance, fatoora-reporting, fatoora-compliance, fatoora-compliance-invoices, fatoora-production-csid |
same baseUrl, different only by path |
| Job | ApplyFatooraOutcomes (every minute) |
takes completed channel outcomes and moves TaxDocument to Cleared/Reported/Rejected |
| Job | PinSaudiTaxCodes (every five minutes) |
pins each Saudi legal entity without a mapping of its own to code S of VAT-SA, via ISaudiTaxDetermination |
| WebServices ×10 | 5 FatooraStub* dev stubs (isDevOnly:true) + 5 Zatca* (CSR/Compliance/ComplianceInvoices/ComplianceSamples/ProductionCsid, onboarding, also isDevOnly:true) |
manual CSID onboarding from the development stand |
| Menu | Menu/menu.json |
Saudi Arabia group → Registers/Settings subgroups |
| — reused — | all Tax engine and objects, including TaxDocument/TaxAuthorityConnection/TaxSubmission; SalesRealization and paired Sales documents | not created here |
model.json#
{
"modelType": "Custom", "modelVersion": "1.35.0", "layerId": 3,
"metaId": "8d2f5a41-9c6b-4e3f-8a7d-1b4c6e2f9a50", "name": "LocalizationSaudiArabia"
}
Dependencies: Inventory (≥1.9.0), Organization (≥1.4.0), Sales (≥1.33.0),
Tax (≥1.11.0). There is no Accounting dependency. This package does
not write VAT accounts or GL postings; KSA VAT lives only in its own register
VatPayable (see below).
Reference data (DataPackages/vat-SA.json)#
Manifest DataPackages/index.json: when: "country", country: "SA",
sort: 25. The common IDataPackageService applies it when installing a
package for a legal entity whose country is SA. Mechanism:
Localization · DataPackages.
| Object | Values |
|---|---|
| TaxAuthority | ZATCA (CountryCode SA) |
| TaxJurisdiction | SA |
| Tax | VAT-SA (EffectiveFrom 2018-01-01) |
| TaxCategory ×5 | STANDARD, ZERO_RATED, EXEMPT, OUT_OF_SCOPE, REVERSE_CHARGE (Treatment = category code) |
| TaxRate ×4 | VAT5 — 0.05, 2018-01-01 → 2020-06-30; VAT15 — 0.15, 2020-07-01 → indefinitely, both under category STANDARD. VAT5-RC and VAT15-RC repeat the same two windows under REVERSE_CHARGE |
| TaxCode ×5 | S, Z, E, O, RC-SA; each references its own TaxCategory |
| TaxReportMapping ×8 | ReturnType VAT-SA: S/OUTPUT→1, Z/OUTPUT→3, E/OUTPUT→5, S/INPUT→7, RC-SA/INPUT→9, RC-SA/OUTPUT→9, Z/INPUT→10, E/INPUT→11 |
Why the reverse charge has its own rates. Rate resolution narrows to the
code's category, and both standard rows are pinned to STANDARD. A code under
REVERSE_CHARGE would therefore find no rate at all and the calculation would
refuse, so the package ships the same two windows a second time. The code is
named RC-SA, not RC: TaxCode's business key is Code globally, and a
second RC would upsert over the base vat package's own and rebind it to
VAT-SA.
The return and the filing sheet#
KSA filing happens in the ZATCA portal under the taxpayer's credentials — this
package signs and submits nothing. What it produces is the box-by-box figure
sheet (SaTaxFiling.ExportAsync → SaTaxFilingExport, a semicolon CSV), built
from a universal TaxReturn whose lines were stamped
with a box by the mappings above.
Ten of the sixteen boxes are sourced from the ledger, four are computed by the
form, and two have no source in ZuloOne. Every box is printed with a Source
column — ledger, computed or manual — because a zero the system vouches
for and a zero nobody filled are different zeros:
| Box | Source | Note |
|---|---|---|
| 1, 3, 5 | ledger |
standard / zero-rated / exempt sales |
| 2, 4 | manual |
healthcare-education to citizens, exports |
| 6 | computed |
sum of 1–5 |
| 7, 10, 11 | ledger |
standard / zero-rated / exempt purchases |
| 8 | manual |
import VAT paid at customs — still unsourced. ZuloOne has no customs document, so the figure comes off the clearance papers by hand |
| 9 | ledger |
imports under the reverse charge |
| 12 | computed |
sum of 7–11 |
| 13 | computed |
box 6 VAT + the self-assessed half of box 9 − box 12 VAT |
| 14, 15 | manual |
prior-period corrections, credit carried forward |
| 16 | computed |
13 + 14 − 15 |
Box 9 with numbers. Sales 1 000 at 15% (box 1: 1 000 / 150), a standard
purchase 200 at 15% (box 7: 200 / 30), and an import of 500 under RC-SA
(box 9: 500 / 75). The import is written by one calculation as two lines — an
INPUT deduction and its OUTPUT mirror (Tax ·
reverse charge) — and the
sheet shows it once, on the purchases side: box 12 = 700 / 105, which
already deducts the 75. Box 13 adds the owed half back: 150 + 75 − 105 = 120,
exactly TaxReturn.NetPayable (225 − 105). Without that middle term the sheet
would claim 45 and the business would under-declare the import.
Rate change on 2020-07-01, with numbers. A realization dated 2020-06-30 uses
VAT5 (5%): base 100.00 SAR → VAT 5.00 SAR. A realization dated 2020-07-01 at
the same price uses VAT15 (15%): base 100.00 SAR → VAT 15.00 SAR. Rate
selection uses the EffectiveFrom/EffectiveTo window of the TaxRate record
valid on the document TaxPointDate. Historical TaxCalculation records of
already posted documents are not recalculated retroactively. See
Tax · invariants.
Dictionaries#
LocalizationSaudiArabiaSettings (singleton)#
The only package switch. SaudiEInvoice checks it first in every operation
and exits silently if the record is missing or EInvoiceEnabled is not set.
Fields: see LocalizationSaudiArabiaSettings in Objects below.
| Field | Business note |
|---|---|
EInvoiceEnabled |
Enables the whole e-invoice pipeline; unset means off. |
VatRegistrationNumber |
Seller VAT number used for setup and reference. |
FatooraStubDelivery#
Data for the Fatoora dev stub: what it has already accepted by idempotency key
(IdempotencyKey, RequestBody, ResponseBody, StatusCode). It replaces the
static in-process dictionary that the stand controller used before. Stub state
survives a restart and is visible in the grid, not only in process memory.
Dictionary extensions#
| Extension | Field | Meaning |
|---|---|---|
LegalEntity.LocalizationSaudiArabia |
CommercialRegistration (String 32) |
seller CRN; UBL fallback identifier when the seller has no VAT number: sellerScheme = VAT number not empty ? "VAT" : "CRN" |
TaxCategory.LocalizationSaudiArabia |
ExemptionReasonCode (String 32), ExemptionReasonText (String 256) |
VATEX-SA-* code/text for categories EXEMPT/OUT_OF_SCOPE; read only for letters E/O (see below) |
The package has no separate “exemption reason” dictionary. These are two fields
on the TaxCategory extension, not a standalone object.
Sales document extensions#
Three extensions have the same set of five fields. The meanings below are specific to the Saudi e-invoice flow.
| Extension folder | Whose document? |
|---|---|
SalesRealization.LocalizationSaudiArabia |
SalesRealization |
SalesCreditNote.LocalizationSaudiArabia |
SalesCreditNote |
SalesDebitNote.LocalizationSaudiArabia |
SalesDebitNote |
The extension folder is SalesRealization.LocalizationSaudiArabia. Scripts
inside bind "objectName": "SalesRealization". The extension identity is its
metaId.
| Field | Meaning |
|---|---|
InvoiceUuid |
envelope UUID: copy of TaxDocument.Uuid, for printing |
ZatcaInvoiceType |
Standard (B2B) / Simplified (B2C) |
InvoiceHash |
hash of the current invoice |
PreviousInvoiceHash |
PIH: hash of the previous invoice in the chain |
QrCode |
TLV in Base64 |
SaudiEInvoice writes all five fields by raw dictionary-record update through
IDocumentManager.UpdateDocumentAsync, not by typed property assignment. A code
search for document.InvoiceHash = finds nothing. Print forms for invoices and
notes read these fields.
Register VatPayable#
isDoubleEntry: false, useBalanceTable: true, allowNegativeBalance: true
(unlike, for example, LoyaltyPoints in CRM, which forbids a
negative balance). Resource: Amount. Analytics: all three are required,
isRequired: true.
| Analytic | Source |
|---|---|
Customer |
document.Customer |
CustomerOutlet |
document.Outlet |
SalesContract |
document.Contract |
Exactly three transaction scripts write to it: SaudiVatTx (SalesRealization,
+VAT), SaudiCreditVatTx (SalesCreditNote, −VAT), and SaudiDebitVatTx
(SalesDebitNote, +VAT). All three return without a movement when
document.TaxRateApplied <= 0: a zero or missing rate does not create a
VatPayable line. SaudiVatTx calculates the base with
pricing.LineAmount(qty, unitPrice, discount), including the line discount.
Credit and debit notes use the same signature without the discount parameter.
Services#
SaudiEInvoice (ISaudiEInvoice) — central orchestrator#
The only point that builds an e-invoice. EnsureAsync(sourceDocumentId) is
called once from document OnAfterPostAsync, after the transition to Issued,
and performs the whole path:
- Reads
LocalizationSaudiArabiaSettings.EInvoiceEnabled; missing/off means exit with no side effects. - Best-effort, swallowing and logging exceptions, calls
ApplyChannelOutcomesAsync: the same method as the separate jobApplyFatooraOutcomes, called here because the orchestrator is already running. - Idempotency: looks for an existing
TaxDocumentbySourceDocumentId. If it exists, returns itsMetaId; a repeated call recreates nothing. - Resolves customer and legal entity by source type (
SalesRealization/SalesCreditNote/SalesDebitNote). - Chooses the branch:
!string.IsNullOrWhiteSpace(customer.TaxRegistrationNumber) ? "Standard" : "Simplified"— notCustomer.CustomerType. - Takes ICV+PIH through
IKeyedCounterService.TakeAsync("zatca-icv", legalEntity); the counter is per legal entity. For the legal entity's first invoice, PIH =Base64(SHA256("")), the hash of an empty string. - Builds UBL 2.1 (
BuildUblAsync), calculatesInvoiceHashthroughIZatcaXades, stamps QR and XAdES signature if a certificate is configured (StampAsync), and seals the envelope withxades.Seal. Sealing does not changeInvoiceHash. - Saves
TaxDocumentand stores the just-calculatedInvoiceHashas the PIH for the next invoice of the same legal entity. - Moves
TaxDocumentto subtypeIssuedthroughIDocumentPostingService.SetSubtypeAsync. - Stamps the same five fields back to the source document by raw dictionary record; see Sales document extensions.
- Enqueues the payload to
fatoora-clearanceorfatoora-reportingthroughIOutboundGateway.EnqueueAsync, withIdempotencyKey = "zatca:" + uuid. - Does not fill
TaxSubmissiondirectly. It is filled only by the mock rejection path whenTaxAuthorityConnection.IsMockRejectis enabled; in the normal path the log remains empty until the real channel/job response.
ApplyChannelOutcomesAsync reads completed packages from both real channels
(fatoora-clearance/fatoora-reporting) through
IOutboundGateway.TakeCompletedAsync and maps statuses: Succeeded → Cleared
(clearance) or Reported (reporting); Failed/Abandoned/Cancelled →
Rejected. InDoubt and other statuses are intentionally not confirmed. A
code comment warns that confirmation would “bury” a request that reached the
network without a response; such packages stay questionable until a person
resolves them. The outcome is applied through ITaxDocumentOutcome.ApplyAsync.
BuyerReleaseBlockAsync(sourceId) returns null (do not block) except when
EInvoiceEnabled is true, an envelope exists for the source,
InvoiceType == "Standard", and Subtype != "Cleared". In that case it returns
an explicit block reason. Simplified invoices, disabled e-invoices, and missing
envelopes never block release.
NoteReferenceXmlAsync (credit/debit note) builds
cac:BillingReference/cac:InvoiceDocumentReference to the original
SalesRealization (note.OriginalInvoice/debit.OriginalInvoice). It reads the
original InvoiceUuid through a raw bag lookup with IDataService.GetByIdAsync,
not through typed access, and adds cac:PaymentMeans/InstructionNote with
reason CANCELLATION for a credit note or ADDITIONAL_CHARGES for a debit note.
Category letter in UBL#
LetterOf(taxCalculation):
TaxCategory.Code |
Letter |
|---|---|
EXEMPT |
E |
OUT_OF_SCOPE |
O |
ZERO_RATED |
Z |
STANDARD or REVERSE_CHARGE |
S |
no TaxCalculation (fallback) |
S if rate > 0; otherwise Z |
For E/O, UBL also includes TaxExemptionReasonCode/
TaxExemptionReason: values of the TaxCategory.LocalizationSaudiArabia
extension fields (ExemptionReasonCode/ExemptionReasonText). Code format is
VATEX-SA-*. The code list is only the official ZATCA Code List; it is not
seeded in the package.
UBL 2.1 envelope identifiers (important clarification)#
The document cbc:UUID is the UUID of the envelope itself (envelope.Uuid,
the same GUID as TaxDocument.Uuid and the InvoiceUuid extension field),
not the ordinal ICV counter. ICV and PIH are two separate
cac:AdditionalDocumentReference blocks:
cbc:ID |
Content |
|---|---|
ICV |
cbc:UUID = counter number (IKeyedCounterService value) |
PIH |
nested binary object: hash of the previous invoice |
The XML root and UBL type code depend on document kind: Invoice/CreditNote/
DebitNote, codes 388/381/383. Saudi subtype is the name attribute on
cbc:InvoiceTypeCode: 0100000 (Standard) / 0200000 (Simplified).
VAT rounding in the header, with numbers#
The header VAT total is the sum of already-rounded line VAT amounts, not a rounding of the header base. Rounding the header separately would make the header total differ from the sum of its own lines. Example: three lines of 0.10 SAR at 15%.
| Calculation | Result |
|---|---|
By line: 0.10 × 0.15 = 0.015 → round → 0.02 per line, ×3 lines |
0.06 SAR (what the code does) |
By header base: 0.30 × 0.15 = 0.045 → round → 0.05 |
0.05 SAR (not used; differs from the line sum by 0.01 SAR) |
Rounding everywhere uses GlobalConstants.Get<int?>("AmountScale") ?? 2
(Tax · global constants),
not a hardcoded 2. A tenant with another AmountScale gets the same number of
decimal places in code and in the print form.
Two more code-confirmed invariants in the same category: UBL IssueDate is the
date of the document itself, not UtcNow at posting time; only IssueTime,
which is not on the document, comes from the posting clock. Seller country and
buyer country are resolved separately, side by side, not by one shared variable;
otherwise the seller country leaks into the buyer address on export invoices.
QR-TLV (ZatcaQr), XAdES (ZatcaXades), CSR (ZatcaCsr), channel signature (FatooraSigner)#
- QR: tags 1–6 are unsigned: seller name, VAT number, timestamp, invoice total, VAT total, invoice hash. Tags 7–9 are added only when a certificate is configured: ECDSA signature, DER public key, certificate signature. Base64 is limited to 500 characters; only the seller name is shortened for that limit, other tags are not cut.
- XAdES:
InvoiceHash(xml)=Base64(SHA-256(C14N 1.0 XML minus UBLExtensions/Signature/the QR reference itself)).SignedInfoDigestis calculated separately and signed independently.Sealalways embeds QR; XAdES signature/certificate are embedded only when both are present. Sealing does not change the already calculatedInvoiceHash. - CSR: mints a P-256 ECDSA key pair plus PKCS#10 CSR. Template
PREZATCA-Code-Signingis for Sandbox;ZATCA-Code-Signingis for Simulation/Production. The two non-final environments, Sandbox and Simulation, use different templates, while Simulation and Production shareZATCA-Code-Signing. FatooraSignerimplements the platformIOutboundSigner. The channel finds it by profile name"fatoora"(the channelsignerfield), not by service name. It buildsAccept-Version: V2, conditionalClearance-Status: 1, andAuthorization: Basic base64(token:secret). The OTP header is intentionally not included. It is a one-time onboarding header; including it in the signature for repeated sending would risk replay.
Outbound channels and job#
All five OutboundChannel records have the same baseUrl
(https://gw-fatoora.zatca.gov.sa/e-invoicing/developer-portal) and differ only
by path. All use signer: "fatoora", authMode: "Signer". Environment
(Sandbox/Simulation/Production) is not stored on the channel. It is stored on
TaxAuthorityConnection; see below.
| Channel | path |
|---|---|
fatoora-clearance |
/invoices/clearance/single |
fatoora-reporting |
/invoices/reporting/single |
fatoora-compliance |
/compliance |
fatoora-compliance-invoices |
/compliance/invoices |
fatoora-production-csid |
/production/csids |
Job ApplyFatooraOutcomes (cronExpression: "* * * * *", once per minute,
executeSingle: true) runs the same ApplyChannelOutcomesAsync as the side call
inside EnsureAsync. The separate pass is required for envelopes whose channel
outcome arrived after the source document had already posted and EnsureAsync
had long finished.
Determination — SaudiTaxDetermination + PinSaudiTaxCodes#
The package seeds the tax, the rates, the codes and the box layout; none of that
decides WHICH code a document gets. Determination runs
rule → TaxMapping → the seller country's own standard
code → the stand-wide TaxSettings.DefaultTaxCode. The last step is one row
for the whole stand, and a country package cannot own it — a stand carrying two
countries has exactly one of it. Before the country step existed, a Saudi
invoice without a per-entity row was computed with whatever the stand default
is: it posts, it balances, and it reaches the return, where the VAT-SA layout
maps S/Z/E and has nothing to say about the code that actually arrived —
every box comes out empty.
ISaudiTaxDetermination writes that row.
| Method | Does |
|---|---|
PinLegalEntitiesAsync() |
every legal entity whose country has CodeISO2 = 'SA' and no TaxMapping of its own gets one pointing at code S of VAT-SA; returns the number written |
PinLegalEntityAsync(Guid) |
the same for one entity, for a test or an implementer who has just created the card |
The written row is SourceType = 'LegalEntity', SourceId = the entity,
TaxCode = S of VAT-SA, Priority = 100 (the weakest mapping on purpose —
item, item group and counterparty rows must still beat it), EffectiveFrom =
the code's own start date, IsDisabled = false. Any existing mapping for the
entity, enabled or disabled, stops the write: a disabled row is an answer
too. The tax is looked up by code, so on a stand without the vat-SA package the
run returns 0 and writes nothing.
Job PinSaudiTaxCodes (cronExpression: "*/5 * * * *", executeSingle: true,
queueTtlMinutes: 15) calls PinLegalEntitiesAsync and logs pinned=N.
Why a job and not a LegalEntity after-insert handler. Tax reads and the
TaxMapping insert after next() sit inside the entity insert's lock window,
next to the country-pack 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. The pin is not instant — the window is the job's
period — and SaudiTaxDeterminationTest asserts that window (zero mappings
immediately after the card is saved).
The seller's country answers before the stand default
(the country step),
so a Saudi entity with no mapping of its own resolves to the one
standard-treatment code of VAT-SA — the same S this pin writes — from the
moment the card exists. The pin is kept because a TaxMapping row is the
decision visible as data: it can be read, overridden or disabled, while the
engine fallback is invisible and goes quiet the moment a country names more
than one standard code. Should the package ever seed a second Saudi tax, the
fallback stops answering and this pin is the only thing between the tenant
and a foreign code.
WebServices — onboarding (all isDevOnly: true)#
Five Zatca* services manually obtain CSID from the development stand. The
minted key pair and CSR/OTP exchange must never go through the production UI
as a normal flow; only these service endpoints on a development stand are used:
ZatcaCsr (mints CSR + private key; the secret is not saved),
ZatcaCompliance (OTP+CSR → Compliance CSID, returns the secret),
ZatcaComplianceInvoices (sends one compliance invoice sample),
ZatcaComplianceSamples (all six ZATCA compliance-kind samples), and
ZatcaProductionCsid (Compliance CSID → Production CSID, returns the secret).
Five FatooraStub* services are mirror dev stubs that answer instead of real
gw-fatoora and write to FatooraStubDelivery.
Connection setup (TaxAuthorityConnection)#
The connection object is universal and defined in Tax.
KSA does not create a new object. It only fills a record (legal entity × ZATCA)
and connects the channel/signer "fatoora". The record Environment
(Sandbox/Simulation/Production) chooses the Fatoora environment logically. The
channels themselves, as above, use the same baseUrl for all environments; the
split is the Environment value, not separate OutboundChannel records. Secrets
(private key, CSID) are not form fields; only CredentialRef points to secure
platform storage.
Menu#
The Saudi Arabia group (displayOrder: 11) → Registers (VatPayable) and
Settings (LocalizationSaudiArabiaSettings). There are no reports in the
menu: the package has no CustomReport and no TaxReportMapping.
Business logic and invariants#
The Clearance/Reporting branch is selected by the buyer VAT number, not by
CustomerType. Non-empty Customer.TaxRegistrationNumber ⇒ Standard →
Clearance. The invoice is not valid for the buyer until status Cleared, and
BuyerReleaseBlockAsync blocks release. Empty VAT number ⇒ Simplified →
Reporting. The invoice is issued immediately and reported after the fact; it is
never blocked.
The PIH+ICV chain is per legal entity, not global.
IKeyedCounterService.TakeAsync("zatca-icv", legalEntity) gives each legal
entity an independent counter. The first invoice of a legal entity carries PIH =
hash of an empty string; each following invoice carries the hash of the previous
invoice of the same legal entity. A chain break, such as missed or substituted
PIH, is a reason for rejection during ZATCA clearance. The package only writes a
correct chain on its side.
cbc:UUID is not a sequence number. UUID is the envelope identity. ICV
(sequence number) and PIH (chain hash) are separate
AdditionalDocumentReference blocks; see above.
Confusing them in an integration means submitting XML that is invalid for ZATCA.
KSA VAT is not posted to the general ledger by this package. There is no
Accounting dependency. VAT reaches the ledger through the universal TaxCalculation
(TaxCalculationGLTx in Tax), not through this package. See
Localization · direct entry into another model's register.
Unlike Ukraine, KSA writes to no foreign register at all.
Idempotency has two levels. EnsureAsync is idempotent by
SourceDocumentId, so a repeated call does not create a second TaxDocument.
Channel sending uses IdempotencyKey = "zatca:" + uuid, so a repeated network
send does not create a duplicate on the ZATCA side.
InDoubt outcomes are not confirmed. See ApplyChannelOutcomesAsync above.
This is a deliberate invariant, not a missing feature: confirming a status that
is not really known would hide the real delivery problem.
Package tests#
Package integration tests (Tests/) cover the mandatory scenarios from the
package template plus KSA-specific
checks: rate change 5%→15% on 2020-07-01; Standard branch (buyer VAT number) vs
Simplified branch (no VAT number); category letters S/Z/E/O, including fallback
without TaxCalculation; PIH chain across several consecutive invoices of one
legal entity; blocking Standard-invoice release until Cleared and unblocking
afterward; VAT rounding in the header as the sum of rounded lines (the
3×0.10 SAR at 15% case above). Separate unit-level checks cover ZatcaQr/
ZatcaXades hash definition and TLV correctness.
Links with other modules#
- Depends on: Inventory, Organization, Sales, Tax.
- Extends:
LegalEntity(Organization),TaxCategory(Tax),SalesRealization/SalesCreditNote/SalesDebitNote(Sales). - Does not depend on:
Accounting,HR. This package has no GL postings and no contributions/payroll logic, unlike Ukraine, which depends on both. - Incoming dependencies: none. This is a leaf package; no model depends on it.
Objects#
Generated from workspace metadata by node tools/gen-reference.mjs; edit the metadata, not this block.
Dictionaries#
FatooraStubDelivery — Fatoora stub delivery
| Field | Caption | Type | Required |
|---|---|---|---|
ID |
Code | String(50) | Yes |
IdempotencyKey |
Idempotency key | String(200) | No |
RequestBody |
Request body | String(4000) | No |
ResponseBody |
Response body | String(4000) | No |
StatusCode |
Status code | Integer | No |
LocalizationSaudiArabiaSettings — LocalizationSaudiArabia settings
| Field | Caption | Type | Required |
|---|---|---|---|
EInvoiceEnabled |
E-invoice (ZATCA) enabled | Boolean | No |
ID |
Code | String(50) | Yes |
VatRegistrationNumber |
VAT registration number | String(128) | No |
SaTaxFilingExport — VAT return export
The ZATCA VAT return as figures ready to be typed into the Fatoora portal. Payload is a semicolon CSV of the official boxes. This is not a signed submission and not an upload: filing stays in the portal.
| Field | Caption | Type | Required |
|---|---|---|---|
CreatedOn |
Created on | DateTime | No |
ID |
Code | String(50) | Yes |
LegalEntity |
Legal entity | → LegalEntity |
Yes |
Payload |
Payload | LargeText | No |
PeriodFrom |
Period from | DateTime | Yes |
PeriodTo |
Period to | DateTime | Yes |
ReturnType |
Return type | String(32) | Yes |
Registers#
VatPayable — VAT payable
Resources:
| Resource | Caption | Type | Required |
|---|---|---|---|
Amount |
Amount | Money |
— |
Analytics:
| Analytic | Caption | Type | Required |
|---|---|---|---|
VatPayable-Customer |
VatPayable-Customer | — | Yes |
VatPayable-CustomerOutlet |
VatPayable-CustomerOutlet | — | Yes |
VatPayable-SalesContract |
VatPayable-SalesContract | — | Yes |