20 · Localization — country package template
Updated
Customization layer. A universal template for connecting any country on
top of the core. The package carries the country's law and services (tax,
contributions, statutory forms, bank / government portal / e-invoice). The core
does not change or branch by country. The workspace currently has two packages —
SaudiArabia and Ukraine — and their real
scope differs greatly: KSA extends three points in one model (Sales) plus Tax;
Ukraine extends thirteen points in four models (Organization/HR/Sales/Purchasing)
plus Tax, and also carries its own documents/registers/commands/jobs. Do not
expect packages to be symmetrical in scope. Only the connection mechanism is
symmetrical, not coverage.
What a country package contains (Customization(3) model)#
| Part | ZuloOne objects |
|---|---|
| Reference data | The package's DataPackages/ — declarative JSON (not seed code), see below |
| Determination rules | Optionally TaxRule + conditions/actions. In practice, neither implemented package uses it; both determine tax-code applicability manually in code (see invariants). |
| Reporting | TaxReportMapping (return boxes) — created only where the country actually submits a return through TaxReturn (currently Ukraine only; KSA has no such document/mapping at all). |
| Contributions | Rates of general social insurance (SocialInsurance) if the law fits into two rates and a ceiling (implemented for ЄСВ); otherwise package objects (ПДФО/військовий збір — UaPayrollLevy, the country's own register). |
| Document extension | Extension aggregate <BaseDocument>.<Country> — at minimum on SalesRealization; countries with wider coverage also extend other documents owned by other models. |
| Country external services | Model channel (outbound channel metadata) + host connector + Job (IOutboundGateway/IOutboundCall). Not only tax: bank, government portal, account, e-invoice. Wiki: outbound integrations. |
| Tax-authority integration | The country has its own signing service behind the channel's named signer (example: KSA IFatooraSigner), see Integration. There is no common cross-country contract for this. |
| Tests | Package integration tests (mandatory). |
model.json (real examples, not an abstract template)#
Both existing packages are Customization(3) layer, ordinary semver (1.30.1,
1.44.0), not a date-like version. The minimum dependency set for both is
Organization + Inventory + Sales + Tax; no package depends on
Accounting. A country with wider coverage adds its own models: Ukraine adds
HR (payment statements, levies) and Purchasing (input VAT on purchase).
{
"kind": "Model",
"object": {
"modelType": "Custom", "description": "Страновой пакет <XX>",
"modelVersion": "1.0.0", "publisher": "ZuloOne",
"metaId": "<GUID Model.Loc.XX>", "name": "Localization<XX>",
"modelId": "00000000-0000-0000-0000-000000000000", "layerId": 3
},
"dependencies": [ "Organization", "Inventory", "Sales", "Tax" ]
}
How the law of the country gets into the database (DataPackages)#
A package is a model in the Customization layer: metadata (JSON envelopes
for objects) + code (.cs: connector, extensions, tests). It is exported and
installed on another stand like any other model — as a folder with JSON and
.cs files.
Reference data for the law (TaxCode/TaxRate/TaxCategory/TaxAuthority/
TaxJurisdiction, and TaxReportMapping when present) is delivered not as
handwritten seed code, but by the declarative mechanism DataPackages/index.json
<package>.json: a manifest withentities[].businessKey/mode: insert|upserton top of the normaldataarray, applied by the sharedIDataPackageServicewithwhen: "country". This is the same mechanism used byCommon,Sales,Tax,Organization, and other models, not only by localizations. Reapplying (upsert) fills only empty fields of an existing record and does not overwrite what the tenant has corrected by hand. Idempotence is built into the mechanism; there is no separate "pack-installed" flag.
Prototyping data through forms during development is a normal way to get the
first version of a package .json; there is still no standard "export data
straight from the form together with metadata" command (it is in the backlog),
so the final .json is prepared/cleaned manually before the package commit.
Installation and versioning#
- Trigger:
LegalEntity.Country.CodeISO2(not a separateLocalizationPackfield — no such field exists in the workspace). The handler is not inOrganization; it is in theLegalEntity.Inventoryextension (LegalEntityTaxPackEventHandler) and runs both on legal-entity insert and on legal-entity update. Changing the country of an existing legal entity also reinstalls the package. - Real call:
ITaxPackInstaller.InstallForCountryAsync(string countryIso2)(notInstallAsync(legalEntity, packCode)). It applies, by country code, allDataPackageswithwhen: "country"from any model, not only Tax. The service name is historical; the contract is wider than tax (for example, shared dictionaries such as cities get here too). Installation failure must not block saving the legal entity (it is swallowed at this level); do not treat it as a strict transaction gate with rollback. - Upgrade (
modelVersionsemver) means new effective-dated rules/rates/codes; historical TaxCalculation and TaxLedger remain unchanged because selection is byTaxPointDate.
Sales document extension#
Extension aggregate <Document>.<Country> adds country fields to the base
document owned by another model and attaches OnAfterPostAsync (after Issued) —
it builds the country payload and enqueues it for sending. This is confirmed
byte-for-byte in both implemented packages (SalesRealizationZatcaEventHandler /
UaEInvoiceEventHandler): both check Subtype == "Issued" before building a
payload. The minimum is SalesRealization; a country with wider
coverage (Ukraine) attaches the same links to SalesCreditNote, PurchaseOrder,
VendorPayment, and other documents owned by other models.
Integration#
The boundary is the same for any country service: the business layer builds a payload in a script; signing, HTTPS, and secrets are handled by the host connector (network/crypto in scripts is forbidden). A tax authority is only a special case; a bank, government portal, taxpayer account, or e-invoice is configured by the same mechanism in this package, not in Core.
There is no single cross-country "tax-authority integration" contract in the
workspace. The real seam is the channel (outbound channel metadata) with the
signer field, resolved to the country service (example: the KSA channel
declares "signer": "fatoora" → IFatooraSigner). Platform
ICredentialSigner (ECDSA/SHA-256 only) is used only by KSA; Ukraine does not
use it at all, because ERPN is signed by the operator side (see Ukraine),
not inside the ZuloOne process. ITaxAuthoritySubmitService exists in the Tax
core as a mock tax-authority channel. It is only a mock; later the host
connector replaces this implementation. It lives inside Tax itself, countries do
not override it; KSA calls it only as a test switch for forced rejection
(IsMockRejectAsync), not as a real delivery route to ZATCA.
For e-invoice/tax delivery, the high-level flow is the same; protocol details are on country-specific pages:
- Queue: TaxDocument in
Issuedstatus. - Connector
Job(scheduler): reads the queue → signs through channel/signer→ sends to the government API → updates TaxSubmission status (Cleared/Reported/Rejected) throughITaxDocumentOutcome. - Idempotence:
IdempotencyKey+RequestHash, status check before retry. - Error classification: Validation/BusinessRule/Auth/Authorization/Network/Timeout/ GovRejected/GovAcceptedWithWarnings/Config; retry only when allowed (Network/Timeout — yes; Validation/GovRejected — no).
- Connection configuration:
TaxAuthorityConnectionrecord (legal entity × authority): environment (Environment: Sandbox/Production), endpoints, timeouts, reference to credentials. Secrets (keys/certificates) are in the platform secure store, not in a form field. See the KSA example in 21-SaudiArabia. A country's non-tax channel uses its own connection dictionary in the package and names it in the channel'sconnectionTable(confirmed by a real example: the Ukrainian bank channelpb-statementspoints toUaBankConnection, not to a tax dictionary). EmptyconnectionTablefalls back to the defaultTaxAuthorityConnection; that is a legacy of the first contour, not an invitation to put a bank in the tax dictionary.
Package tests#
Mandatory, installed and run before activation: domestic standard, zero, exempt, out-of-scope, reverse charge, credit/debit note, input/output, partial recovery, different customer/item types, and rate changes by date.
Business logic (package lifecycle)#
Business logic (package life cycle)#
Law data is declarative JSON, not handwritten code. A DataPackages/
package is the same mechanism already used by many base models (Common,
Sales, Tax, Organization, and others) for when: "country" / when: "once" data. A new package does not need a separate Job that manually
inserts TaxRate/TaxCode rows.
Version moves only forward. An update introduces new effective-dated
rules/rates/codes. Historical TaxCalculation / TaxLedger
rows remain unchanged; the choice is by TaxPointDate. A historical rate/code
is not edited — a new version with a new date is added. See Tax invariants.
Additive insulation. A package is a Customization layer model: it extends
the core (document extensions, Tax data, integration implementation)
instead of editing it. Disabling a package removes its data/extensions from the
effective set; the core remains valid (a legal entity without a package works
without country specifics).
One package per legal-entity country. Several legal entities in different
countries mean several coexisting packages. Each legal entity lives in its own
package, switched by changing Country (which reinstalls the package; see
Installation and versioning).
A tax rule is not necessarily TaxRule. The rule engine in Tax
exists and is available to any package, but neither of the two implemented
packages actually uses it. Both determine VAT applicability in their own code
(Ukrainian legal-entity regime flag, fixed 15% KSA rate). Do not treat TaxRule
as a mandatory part of a "correct" package; it is an option, not a contract.
Contributions are not always the shared SocialInsurance. The "two rates +
ceiling" model (implemented for single social contribution (ЄСВ) in Ukraine) runs through the shared social
insurance contour ISocialInsuranceService. GOSI (Saudi Arabia) is
not implemented at all in this workspace, and Saudi Arabia does not depend
on HR. More complex law (progressive rates / specific deductions — personal income tax (ПДФО) /
military levy (військовий збір) in Ukraine) is implemented by the package's own objects
(UaPayrollLevy), with direct writes to registers owned by other models — see
the next paragraph.
Direct write to another model's register is a rare but real pattern.
Ledger legs are written by the documents' owner models, which know no country
package, so posting to the ledger bypasses localization. But
Ukraine has UaPayrollLevyEventHandler (extension of PayrollAccrual and
PayrollPayment from HR) that writes directly to two foreign
registers: HR's PayrollLiability (withholding) and Tax's TaxLedger (levy
amount for the return), next to its own UaPayrollLevy register. This is more
fragile than a normal [ExtensionOf] field: document cancellation must delete
only rows where the country package is the sole writer, without touching rows
that the owner model's transaction script writes to the same register for the
same document. The Ukraine page dissects this in detail.
Implemented packages#
| Package | File | Expansion points | Own objects |
|---|---|---|---|
| Saudi Arabia | 21-SaudiArabia.md | 3 (LegalEntity, TaxCategory, SalesRealization/CreditNote/DebitNote — all in Sales/Organization/Tax) | 1 register, 2 dictionaries, e-invoice services (ZATCA) |
| Ukraine | 22-Ukraine.md | 13, four other models (Organization/HR/Sales/Purchasing) + Tax | 8 registers, 6 dictionaries, 3 documents, 13 commands, 3 jobs |