ZuloOne
English
Sign in
Menu

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.

← General specification

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 with entities[].businessKey/mode: insert|upsert on top of the normal data array, applied by the shared IDataPackageService with when: "country". This is the same mechanism used by Common, 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 separate LocalizationPack field — no such field exists in the workspace). The handler is not in Organization; it is in the LegalEntity.Inventory extension (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) (not InstallAsync(legalEntity, packCode)). It applies, by country code, all DataPackages with when: "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 (modelVersion semver) means new effective-dated rules/rates/codes; historical TaxCalculation and TaxLedger remain unchanged because selection is by TaxPointDate.

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 Issued status.
  • Connector Job (scheduler): reads the queue → signs through channel/signer → sends to the government API → updates TaxSubmission status (Cleared/Reported/Rejected) through ITaxDocumentOutcome.
  • 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: TaxAuthorityConnection record (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's connectionTable (confirmed by a real example: the Ukrainian bank channel pb-statements points to UaBankConnection, not to a tax dictionary). Empty connectionTable falls back to the default TaxAuthorityConnection; 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
Was this page helpful?