ZuloOne
English
Sign in
Menu

03 · Tax — universal tax engine

Updated

Layer Base(1). Depends on Common (minVersion 1.10.0), Organization, and Accounting. This is the universal MECHANISM for tax determination and calculation; tax LAW (rates, codes, rules, reporting codes) is data in country packages (localizations), not core logic.

← General specification

Purpose#

Determines the applicable tax for an operation, calculates the base and amount (several taxes per document, input/output, recoverability), keeps the tax ledger, assembles returns, and creates tax documents for electronic exchange with a tax authority. The engine knows no country. All specifics — rates, codes, rules, reporting codes, submission channel — come from the Saudi Arabia / Ukraine package. Determination and calculation are always at document/direction level, not per line: TaxService accepts one aggregated base (taxBase) per call, not a line collection.

What is created (object map)#

Kind Name Purpose
Model Tax module model, layer Base(1)
Dictionary (master) Tax, TaxAuthority, TaxJurisdiction, TaxCategory, TaxCode, TaxRate, TaxExemptionReason tax, authority, jurisdiction, rate and its category
Dictionary (determination) TaxRegistration, TaxProfile, TaxProfileAttribute, TaxMapping, TaxRule, TaxRuleCondition, TaxRuleAction who/what is taxable and by which rule
Dictionary (reporting/integration) TaxPeriod, TaxReportMapping, TaxAuthorityConnection, TaxSubmission period, return boxes, authority connection, submission log
Dictionary (classifier) TaxDirection INPUT/OUTPUT/WITHHOLDING/OTHER — tax-ledger dimension
Dictionary (settings) TaxSettings singleton — default tax/code
TablePart TaxCalculationLines, TaxPaymentLines, TaxReturnLines document lines
Document TaxCalculation, TaxPayment, TaxReturn, TaxDocument calculation, payment, return, e-invoice envelope
Register TaxLedger tax ledger (source of returns)
Analytic (owned here) TaxCode, TaxDirection TaxLedger dimensions
— reused — analytic LegalEntity (Organization) TaxLedger dimension, not owned here
Service TaxService, TaxReturnService, TaxAuthoritySubmitService, TaxPackInstaller, TaxDocumentOutcome, EInvoiceRelease determination/calculation, return, mock submission, package installation, outcome stamping, release extension point
Command BuildTaxReturn, FileTaxReturn, FinalizeTaxCalculation, PayTax document transitions
EDT TaxRateValue (scalar) + Ref EDT for every dictionary, TaxRuleOperatorEdt types
Enum TaxRuleOperator (11 values), TaxConnectionEnvironment, TaxConnectionStatus closed sets
Menu menu.json the Tax tab

Total: 1 model, 20 dictionaries, 3 table parts, 4 documents, 1 register, 2 analytics, 6 services, 4 commands, 3 enums, and 2 data packages (universal only, with no country-specific rates).

There are no LinkTables, no TaxType, no TaxPackInstallation, and no separate "rule engine" service in the model. There are no link tables for lineage/adjustments, no TaxType dictionary, no TaxPackInstallation dictionary, and no services ITaxRuleEngine, ITaxCalculationService, ITaxLedgerService, ITaxReportingService, or ITaxAuthorityIntegration. The TaxCalculation↔source-document lineage is ordinary fields SourceDocumentType/SourceDocumentId on TaxDocument plus the platform document-link graph (see TaxCalculation). Determination and calculation live entirely in TaxService (see Services). Installed packages are not tracked in a separate table by decision, not by omission: the platform already keeps that record — IDataPackageService reports pack.Applied, and ITaxPackInstaller.InstallForCountryAsync skips an already-applied pack on exactly that flag. A business-layer table would be a second source of truth for "is the Saudi pack installed". Versions are not tracked by either.

model.json#

Layer Base(1), version 1.24.0. Dependencies: Common (minVersion 1.10.0), Organization (minVersion 1.0.0), Accounting (minVersion 1.0.0). Tax does not depend on Sales, Purchasing, or Inventory — the dependency goes strictly in the opposite direction (see Links with other modules).

Number sequences#

A separate sequence for every document: TaxCalculationSeq, TaxPaymentSeq, TaxReturnSeq, TaxDocumentSeq, TaxSubmissionSeq.

EDT#

Scalar: TaxRateValue Decimal(9,6) — owned here. Money comes from Accounting. Reference: one Ref EDT for every dictionary in the model. Enumerable: TaxRuleOperatorEdt (a wrapper over TaxRuleOperator).

Dictionaries#

Classifier TaxDirection is a small Code/Name dictionary used as an analytic of TaxLedger. Recoverability is not a separate dictionary: the amount recoverable is the RecoverableAmount resource on the TaxCalculation line and on the register movement itself.

TaxAuthority#

Code, Name, CountryCode, IsActive. The handler is a template; there is no custom validation. Referenced by: TaxJurisdiction, Tax.

TaxJurisdiction#

Code, Name, CountryCode, Level (Int — hierarchy depth). Template handler. Referenced by: Tax, optionally TaxRule (rule scope narrowing).

Tax#

Code, Name, Authority→TaxAuthority (required), Jurisdiction→TaxJurisdiction (required), EffectiveFrom (required)/EffectiveTo. Template handler. Referenced by: TaxCategory, TaxCode, TaxRate, TaxPeriod, TaxRuleAction.

TaxCategory#

Tax→(required), Code, Treatment (String: STANDARD/ZERO_RATED/ EXEMPT/OUT_OF_SCOPE/REVERSE_CHARGE). Template handler — the Treatment string is read by TaxService.IsZeroTreatmentAsync (the zero-rate shortcut), by TaxService.IsSelfAssessedAsync (the reverse-charge mirror) and by TaxCalculationGLTx (which pair of accounts the leg uses). Referenced by: TaxCode, TaxCalculationLines.TaxCategory.

TaxCode#

Code, Name, Tax→(required), TaxCategory→(required — part of the rate resolution key, not decoration), TaxRate→ (historical/compatibility, not read by live calculation), EffectiveFrom/EffectiveTo. Template handler. Referenced by: TaxRule/TaxRuleAction/TaxMapping, TaxCalculationLines.TaxCode, analytic TaxCode on TaxLedger.

TaxRate#

Tax→(required), Code, Rate (TaxRateValue), EffectiveFrom (required)/ EffectiveTo. Real validation is below.

TaxRate.OnBeforeSaveAsync/resolution: FindOverlappingRateAsync(taxId, from, to) rejects overlapping windows within one Tax (category is not part of the overlap key — different categories of one tax share one date scale through TaxCode, not through a column on TaxRate). Adjacent non-overlapping windows are allowed. The rate is resolved again for every calculation through ResolveRateAsync; TaxCode.TaxRate is never read directly.

Rate resolution is the chain TaxCode → (Tax + TaxCategory) → TaxRate. TaxCategory is part of the lookup key, not an ornament. Treatment ∈ {ZERO_RATED, EXEMPT, OUT_OF_SCOPE} returns 0% without calling TaxRate at all; REVERSE_CHARGE is intentionally excluded from that list — a rate is still needed for the buyer's self-assessment.

Referenced by: TaxCode (see TaxCode above). The rate itself is not read by external objects — only through the code.

TaxExemptionReason#

Tax→(required), Code, Text. Real validation: Code is unique within one Tax.

TaxRegistration#

PartyType (String — polymorphic, no FK: Tax is a Base-layer model and cannot reference Customer/Supplier from a higher layer), PartyId (Guid), Country/Jurisdiction, RegistrationNumber, EffectiveFrom/EffectiveTo. Validation: FindOverlappingRegistrationAsync (same PartyType+PartyId+ Jurisdiction) — on save and inside ResolveRegistrationNumberAsync, unconditionally (there is no IsDisabled escape hatch). If there is no row for the document date, Saudi Arabia/Ukraine read the fallback LegalEntity.TaxRegistrationNumber.

TaxProfile#

PartyType+PartyId (polymorphic), EffectiveFrom/EffectiveTo, IsDisabled (Boolean, default false). Validation: FindOverlappingProfileAsync — skipped completely when IsDisabled=true (allows superseded profiles to coexist).

TaxProfileAttribute#

TaxProfile→, AttributeCode, AttributeValue. Validation: AttributeCode is unique within the profile. Read by TaxService as buyer.profile.{code}/ seller.profile.{code}/supplier.profile.{code} — see How the rate is determined.

TaxMapping#

SourceType+SourceId (polymorphic: Item/ItemGroup/Customer/Supplier/ LegalEntity), TaxCode→, Priority, EffectiveFrom/EffectiveTo, IsDisabled. Validation: FindOverlappingMappingAsync (same SourceType+ SourceId) — also skipped when IsDisabled=true. ResolveMappingAsync priority order: Item > ItemGroup > Customer > Supplier > LegalEntity. Referenced by: Inventory — assigning a tax code to an item/item group without binding Inventory itself to a country.

TaxRule#

Tax determination rule — data, not code. Default action TaxCode→, Priority (Int), optional Jurisdiction→/Tax→ (scope narrowing), EffectiveFrom/EffectiveTo, IsDisabled. Validation on save: none — pure template (see the callout below).

TaxRuleCondition#

TaxRule→, ConditionGroup (Int — AND inside the group, OR between groups), Path (dot path, seller.id), Operator→TaxRuleOperator, Value. Validation: none — pure template; an empty condition set means the rule always matches.

TaxRuleAction#

TaxRule→, Tax→ (supports several taxes on one transaction), TaxCode→, RateOverride (Decimal — 0 means "no override"; this field cannot express exactly 0%, use TaxCategory.Treatment for that). Real validation exists.

TaxRuleOperator (11 values, without And/Or — boolean structure is entirely via ConditionGroup): Eq, Neq, In, NotIn, Gt, Gte, Lt, Lte, Between, Exists, NotExists.

Rule match order: Priority ascending → EffectiveFrom descending → number of conditions descending (the more specific rule wins).

TaxRule/TaxRuleCondition can be saved as anything — there is no write error. Unlike TaxRate/TaxPeriod/TaxMapping/TaxProfile/ TaxRegistration/TaxExemptionReason/TaxProfileAttribute/TaxRuleAction, whose handlers really reject bad data, TaxRule and TaxRuleCondition are pure templates: a rule with no conditions silently becomes catch-all, and a typo in Path (seller.Id instead of seller.id) saves without any warning and silently never matches on read. This asymmetry is not documented in code as an intentional decision — treat it as an open data-integrity hole.

TaxPeriod#

LegalEntity→(required), Tax→(required), Code (required, not unique — the natural key is the date range), FromDate/ToDate (ToDate ≥ FromDate). A window overlap for the same LegalEntity+Tax pair is rejected unconditionally — unlike TaxMapping/TaxProfile, there is no IsDisabled escape hatch here.

TaxReportMapping#

Mapping from direction/category to a return box; read by TaxReturnService.ResolveBoxesAsync (Services).

TaxSettings#

Singleton (queried with "1 = 1", like other workspace singleton dictionaries). DefaultTax/DefaultTaxCode — the handler synchronises them both ways on save (changing one recalculates/checks the other).

TaxAuthorityConnection#

Universal authority-connection setup — one record per legal entity × authority.

Fields: see TaxAuthorityConnection in Objects below.

Field Business note
LegalEntity Seeded from RecordDefaults on create.
Environment Uses the authority environment scale Sandbox(0), Simulation(1), Production(2).
Status Uses the onboarding scale NotOnboarded(0), Onboarded(1), Active(2).
CredentialRef Stores only the secret reference; the secret itself is not in this record.
IsMockReject Switches the mock submission service to the rejected outcome for tests and demos.

The handler is a pure template, without any checks. Allowed environments and the specific protocol/endpoint are the country package's responsibility (Saudi Arabia, Ukraine); submission inside Tax is a mock (see TaxAuthoritySubmitService).

TaxSubmission#

Append-only log; only LegalEntity is seeded on create, and there is no save validation at all. Written by TaxAuthoritySubmitService.

Menu anomaly (NEEDS_VERIFICATION): TaxSubmission is a dictionary by kind (no subtypes, no posting), but in menu.json it is attached to Documents, not Dictionaries. Do not change it as an error without confirmation — perhaps this is intentional for visibility next to TaxDocument.

Documents#

TaxCalculation#

Header: LegalEntity, Currency, TaxPointDate (tax-event date — not necessarily the source document date), DeterminationReason (free text — the only fallback link to the source document when the document graph has not yet been written; see the callout below). Subtypes: Draft → Finalized. Table part TaxCalculationLines: TaxCode, TaxCategory, Direction→TaxDirection, TaxBase, RateValue, RateOverridden (Boolean), TaxAmount, RecoverableAmount.

The source-document link is soft, not an FK and not a LinkTable. The main path is the platform document-link graph (IDocumentManager.AddLinkAsync/ GetDocumentFamilyAsync), which the calling handler writes immediately after CreateCalculationAsync. The fallback path (when the graph is not written) is text parsing of DeterminationReason by prefix ("Sales invoice ", "Purchase order "), used by Purchasing's TaxCalculationPayableSeamEventHandler. This is fragile (depends on exact prefix match), but it is the only real mechanism — there is no link table for it.

OnBeforePostAsync is the only cancellable check in the model, per line: the rate is re-resolved (ResolveRateAsync) — no rate active on TaxPointDate → refusal (нет действующей ставки); the rate was not explicitly overridden and does not match the date-resolved rate → refusal (не действовала на); |TaxBase × RateValue − TaxAmount| > 0.01 → refusal (не сходится). All three message substrings are asserted verbatim by integration tests.

Posting (Finalized) writes 1:1 (without aggregation) into TaxLedger: .An(TaxCode).An(TaxDirection).An(LegalEntity).Res(TaxBase).Res(TaxAmount).Res(RecoverableAmount). TaxCalculationGLTx writes the VAT leg into GL on the same subtype.

Reverse charge: one calculation, two lines

An INPUT line whose code carries Treatment=REVERSE_CHARGE gets a mirror OUTPUT line in the same calculation: same code, same base, same rate, same amount. The supplier — abroad, or domestically relieved — charged no tax; the buyer declares that tax himself and deducts it in the same breath, so a fully recovering buyer pays nothing and both halves still appear on the return.

Both halves are written by TaxService.WriteCalculationAsync. They are in one document on purpose: they are one tax event, they must post and unpost together, and a credit note reversing the purchase has to find them in one place — CreateReversalFromCalculationAsync keeps each source line's own direction, so the deduction and the liability are each undone on their own side.

Consequences you can rely on:

  • The return needs no special case. NetPayable = outputTax − inputTax already nets a full recovery to zero, and both halves stay visible per direction (this is what fills box 9 of the Saudi form).
  • GL posts the pair among the tax accounts — Dr VatReceivableAccountCode / Cr VatPayableAccountCode, touching neither receivables nor payables. The supplier is not credited with VAT he never charged.
  • A partly recoverable reverse-charge code is refused. The declared half is owed in full whatever the deduction is, so the remainder is a real cost — and the only place that capitalises non-recoverable tax is the purchase receipt, which under a reverse charge saw no tax to capitalise. NonRecoverablePct > 0 on such a code throws instead of posting the difference somewhere plausible.
  • Only the buyer's side. On an OUTPUT line a reverse-charge code means the opposite — the seller shifts the tax to his buyer and owes nothing — and that is not implemented: the engine still computes the seller's tax at the code's rate. There is no caller for it today, and changing it would silently alter the books of anyone who has such a code on a sale.
  • An INPUT reverse-charge line with no mirror still posts its deduction: TaxCalculationGLTx falls back to the ordinary input entry rather than dropping it.

A calculation lives and dies with its source document. The source (SalesRealization, PurchaseOrder, SalesCreditNote, SalesDebitNote, PurchaseCreditNote) spawns it after posting and links it as a child. Its handlers call ITaxCalculationLifecycle and ITaxService:

Source event Call Effect on the calculation
OnBeforeUnpostAsync — a step back, a re-post after an edit, the first step of a delete WithdrawCalculationsAsync(sourceId) Finalized → Draft: its TaxLedger, GL and Payable-seam movements are reversed with the source's own
OnAfterPostAsync CreateCalculationAsync / CreateReversalAsync a Draft calculation with the same DeterminationReason is re-determined in place — same document, fresh lines; a Finalized one is left alone (the after-post event can run twice in one posting)
OnBeforeDeleteAsync DeleteCalculationsOfAsync(sourceId, reason) the calculation is deleted

Example: an invoice 4 × 25 at 15% is issued — the calculation holds tax 15. The invoice goes back to Draft — the calculation is Draft, TaxLedger and the VAT leg are 0. The quantity becomes 6 and the invoice is issued again — the same calculation is Finalized with tax 22.5. A leftover Finalized calculation at the old tax 15 would block that re-issue from creating anything.

The one hard stop is a filed TaxReturn whose period covers the calculation's TaxPointDate for the same legal entity: withdrawing would make the books disagree with what was reported. The source's unpost or delete is then refused with the period and the return number; the correction is a credit or debit note.

OnBeforeDeleteAsync finds the calculation by its DeterminationReason, not by the link: by the time the delete event fires, the platform has already removed the document's links.

TaxCalculationLifecycle is a separate service with two dependencies on purpose. It runs inside the source's posting transaction, before the engine sets its savepoint; resolving the heavy TaxService there broke that transaction ("Cannot issue SAVE TRANSACTION when there is no active transaction").

TaxPayment#

LegalEntity, lines Amount (validation: > 0, at least one line when moving to Paid). Writes no movement to any Tax register — GetTransactions is only into the book: TaxPaymentTx writes the payment's own GL leg (Dr VAT payable / Cr cash, books FIN,TAX) — a separate tax register would duplicate the ledger and the book with a third contour. The Paid payment is filed once with the tax-authority mock (TaxAuthoritySubmitService.SubmitPaymentOnceAsync).

TaxReturn#

Built from TaxLedger, not from source documents. Header: LegalEntity, Tax, period window, subtypes Draft → Filed. Table part TaxReturnLines is built by TaxReturnService.ResolveBoxesAsync using TaxReportMapping. Also writes no movements — it is a pure turnover snapshot at build time, not a posting source.

OnAfterPostAsync when transitioning to Filed calls ITaxAuthoritySubmitService.SubmitReturnAsync inside a bare try{}catch{} — a submission failure is fully invisible (no log, no user error). This is justified by an explicit code comment: a mock failure must not roll back a return filing that already happened.

TaxDocument#

Envelope for e-invoice/submission to a government system. Header: LegalEntity, SourceDocumentType/SourceDocumentId (the same polymorphic soft link as in TaxCalculation), EInvoiceKind (INVOICE/CREDIT_NOTE/DEBIT_NOTE/ADJUSTMENT), InvoiceType (country string — for example Standard/Simplified for KSA, an M.E.Doc form code for Ukraine), Uuid, InvoiceHash, PreviousInvoiceHash, InvoiceCounter, Payload, ExternalId, Authority, RejectionReason. Subtypes: Draft → Issued → Cleared/Reported/Rejected (the last three are target readonly subtypes; editing after them is blocked).

Writes no movements — TaxDocumentTransactions is a fully commented template.

Write order matters. TaxDocumentOutcome.ApplyAsync (Services) writes Authority/RejectionReason through UpdateDocumentAsync before SetSubtypeAsync transitions to the target readonly subtype — the reverse order would make the readonly-subtype guard silently reject writing the fields.

Example 1 — output VAT on a sales invoice#

Legal entity ACME KSA, customer X, base 100, code resolves to STD (Treatment=STANDARD), rate on invoice date is 0.15.

Field Before (Draft) After (Issued + TaxCalculation Finalized)
SalesRealization.TaxRateApplied 0.00 0.15 (stamped by Sales OnBeforePostAsync)
TaxCalculation — TaxBase=100.00, RateValue=0.15, TaxAmount=15.00 (100×0.15, AwayFromZero), Direction=OUTPUT
TaxLedger — +TaxBase 100.00 / +TaxAmount 15.00, dimensions TaxCode=STD, Direction=OUTPUT, LegalEntity=ACME KSA
GL (TaxCalculationGLTx, output > 0) — Dr ArAccountCode 15.00 / Cr VatPayableAccountCode 15.00

Example 2 — input VAT on a purchase#

Supplier Y, legal entity ACME KSA, base 1000, rate on receipt date is 0.15, NonRecoverablePct=0 (default — fully recoverable).

Field Before After (Received + TaxCalculation Finalized)
PurchaseOrder.NonRecoverableVat 0.00 0.00
TaxCalculation — TaxBase=1000.00, RateValue=0.15, TaxAmount=150.00, RecoverableAmount=150.00, Direction=INPUT
TaxLedger — +TaxBase 1000.00 / +TaxAmount 150.00 / +RecoverableAmount 150.00
GL (inputRecoverable > 0) — Dr VatReceivableAccountCode 150.00 / Cr PayableAccountCode 150.00

Example 2a — a reverse-charge import#

Same legal entity, a service bought from a non-resident supplier, base 500, code RC-SA (Treatment=REVERSE_CHARGE), rate 0.15, NonRecoverablePct=0. The supplier's invoice carries no tax — the figures below are entirely the buyer's own.

Field Before After (Received + TaxCalculation Finalized)
TaxCalculation line 1 — Direction=INPUT, TaxBase=500.00, RateValue=0.15, TaxAmount=75.00, RecoverableAmount=75.00
TaxCalculation line 2 (mirror) — Direction=OUTPUT, TaxBase=500.00, RateValue=0.15, TaxAmount=75.00
TaxLedger — two movements, one per direction, both TaxCode=RC-SA
GL (selfAssessed > 0) — Dr VatReceivableAccountCode 75.00 / Cr VatPayableAccountCode 75.00 — no payables leg
TaxReturn for the period — OutputTax +75.00, InputTax +75.00, NetPayable unchanged

Change NonRecoverablePct on RC-SA to 50 and the same purchase throws instead of posting: 37.50 of tax would be owed with nowhere to charge it.

Example 3 — reversal by credit note when the rate changes (open sharp edge)#

Original invoice from 2024-06-01, base 100, rate on that date is 0.15 (TaxAmount=15.00, copied to SalesRealization.TaxRateApplied). The rate changes to 0.20 from 2025-01-01. A credit note for the same base 100 is issued and posted on 2025-03-01.

Field Original invoice (2024-06-01) Credit note (posted 2025-03-01)
Determination context seller/customer/item — real same customer ⇒ same tax code (the historical bug with context=null, which gave the wrong code, is fixed in all known call sites)
Date passed to TaxService invoice.DocumentDate = 2024-06-01 note.DocumentDate = 2025-03-01, not the original invoice date
Resolved rate 0.15 0.20 — active on 2025-03-01, not 0.15 from the original invoice
TaxCalculation (reversal) TaxAmount = +15.00 TaxAmount = **−20.00** (100×0.20, minus sign — reversal)
Net effect in TaxLedger +15.00 −20.00 ⇒ balance −5.00, not zero

The credit note's tax code is guaranteed to match the original invoice (the context is now built correctly in all checked call sites — Sales/Purchasing invoice and credit note). The rate is not guaranteed to match: CreateReversalAsync always re-resolves the rate on the credit-note date, not on the original-document date. TaxRateApplied, copied to the credit-note header, is used only to check that there is something to reverse; it is not substituted into the reversal amount. The code does not say whether this is an intentional accounting decision or an open defect; treat it as NEEDS_VERIFICATION, not ready behaviour.

Registers#

TaxLedger#

registerEngineType: Standard, isDoubleEntry: false, allowNegativeBalance: true, useBalanceTable: true. Resources: TaxBase, TaxAmount, RecoverableAmount (all Money). Analytics (all isRequired: true): TaxCode (owned here), TaxDirection (owned here), LegalEntity — not owned here, declared in Organization · Analytics and reused here the same way as Accounting · GL.

Analytics are references to sets (AnalyticSetMetaId), not physical columns: TaxReturnService.CollectAsync reads raw movements, expands them through AnalyticSetService.ExpandAsync, and groups them in memory — SQL cannot filter or group by an analytic value directly.

Not every TaxLedger line is born from a TaxCalculation document. Ukraine's single tax (Єдиний податок) under the simplified system is an exception: asynchronous totals driver UaVatFirstEventTotalDriver (Ukraine) writes directly into TaxLedger through raw IRegisterMovementService, bypassing TaxCalculation and CreateCalculationAsync entirely. For most countries (KSA) that is wrong — there every TaxLedger line really comes from the Finalized posting of TaxCalculation — but as a general model invariant the statement is false.

Services#

Service Key methods Meaning
TaxService CalculateTax, ResolveRateAsync, CreateCalculationAsync, CreateReversalAsync, DeterminationContext, NonRecoverableOfAsync determination and calculation core — see the callouts below
TaxReturnService BuildAsync, BuildFromPeriodAsync, ResolveBoxesAsync, CollectAsync builds a return from the ledger
TaxCalculationLifecycle WithdrawCalculationsAsync, DeleteCalculationsOfAsync a source document's calculation follows its unpost and delete; a filed period refuses — see TaxCalculation
TaxAuthoritySubmitService SubmitReturnAsync, SubmitPaymentAsync, SubmitPaymentOnceAsync, SubmitDocumentAsync mock submission channel; writes TaxSubmission; outcome is determined by TaxAuthorityConnection.IsMockReject
TaxPackInstaller InstallForCountryAsync(iso2) scans IDataPackageService.ListAsync() across all models, not only Tax, and looks for When:"country" by ISO2
TaxDocumentOutcome ApplyAsync, JournalAsync, ConnectionOfAsync single point for stamping Authority/RejectionReason and transitioning TaxDocument subtype by the outcome of any channel (KSA Fatoora, UA M.E.Doc/ЄРПН)
EInvoiceRelease BuyerReleaseBlockAsync Tax implementation always returns null (does not block) — Chain of Command extension point for a country

Exactly 6 services. There is no separate rule-engine interface (ITaxRuleEngine and others; see the callout in Object map).

Why there is no separate "rule engine": determination and calculation are fully concentrated in TaxService; ledger movements are written by the documents themselves on posting; each country implements the tax-authority integration contract by itself (see Links with other modules).

How the rate is determined#

Context (TaxService.DeterminationContext(...)) is required input. Canonical keys: seller.id, buyer.id, supplier.id, item.id, item.groupId, plus dynamic buyer.profile.{code}/seller.profile.{code}/supplier.profile.{code} from active TaxProfile + TaxProfileAttribute. The context is an ordinary Dictionary<string,object?>, not a typed class: the service contract is compiled into a separate assembly that cannot see document types declared by scripts.

Main sharp edge: context = null does not throw; it silently skips the whole machinery. If caller code passes null (or a typo path such as seller.Id instead of seller.id), ResolveDeterminationAsync skips both rules and mappings and quietly falls back to ResolveDefaultTaxCodeAsync() — no log, no error. Concrete scenario: a customer is mapped in TaxMapping to code EXEMPT; if the calling handler does not pass buyer.id, the exemption is never checked and the default-code VAT is charged, with no warning anywhere.

This historical bug is documented verbatim in a code comment (SalesCreditNoteEventHandler): this is exactly how the machinery (rules, profiles, mappings) stayed unreachable; no document passed context, null arrived here, and tests could not catch it because they passed context themselves. Status today: fixed in all four known call sites (sales invoice and credit note, purchase receipt and credit note — all now build real context before calling TaxService). The mechanism is still not structurally fixed: this is not a "context is required" check, but still a quiet default fallthrough; future caller code that forgets to build context will hit the same mine without any platform warning.

The seller's country answers before the stand does

ResolveCountryDefaultTaxCodeAsync(legalEntity, taxPoint) is the step between TaxMapping and TaxSettings.DefaultTaxCode. It walks LegalEntity → Country → ISO-2 → TaxJurisdiction → Tax → TaxCategory whose Treatment is STANDARD → TaxCode, and answers only when exactly one such code also has a rate in force on the tax point. Anything else returns null and the stand default applies exactly as before.

The step exists because one TaxSettings row cannot serve a stand that carries two countries: a Saudi invoice computed with a Ukrainian default code posts, balances, and reaches the return — where the VAT-SA box layout, which maps only S/Z/E, comes out empty.

Deliberate choice Why
The seller is a parameter, not a context key Both callers take legalEntity as required while the context is optional. Reading seller.id would enable the step only for the callers that already know the most about determination. seller.id stays as a fallback for a caller that passes a context but no entity.
Exactly one, never "the first match" Ukraine's jurisdiction carries VAT, the single tax, personal income tax and the military levy — more than eight standard codes. Which one applies depends on the entity's regime, and that question belongs to TaxMapping.
A code without a rate in force is not an answer The step runs before a path that still works without this answer. Handing back a code the engine cannot price would turn a working calculation into a "no rate in force" refusal. The base vat package is exactly that shape: jurisdiction ZZ, code STD, no rates.
A disabled TaxMapping still counts ResolveMappingAsync filters disabled rows out, so this step asks a different question — HasAnyMappingAsync, enabled or not. A disabled pin is a decision, not a blank.

This closes the window left by PinSaudiTaxCodes (the job that writes a TaxMapping for every Saudi legal entity). It is a job because the same work in a LegalEntity after-insert handler sits inside the entity insert's lock window. The job stays — the pin is what makes the decision visible as data.

Rate resolution is date-only: EffectiveFrom is required, empty EffectiveTo means an open window, both boundaries are inclusive, and comparison is by calendar date (not time). The window is chosen in memory, not through date SQL literals, for SQL Server/Postgres portability. Rounding is Math.Round(base × rate, GlobalConstants.Get<int?>("AmountScale") ?? 2, MidpointRounding.AwayFromZero) — the constant and rounding mode are shared by TaxService and country e-invoice assembly (verified by test: 33.33 × 0.20 = 6.67, not banker's 6.66).

TaxReturnService.BuildCoreAsync sums InputTax by RecoverableAmount, not raw TaxAmount — the non-recoverable part (TaxCode.NonRecoverablePct, default 0) does not enter the return.

Global constants (GlobalConstants/Tax.json)#

Tax has no global-constants group of its own. The only constant read by the calculation is AmountScale (cross-model, owned by Common, not Tax) for tax-amount rounding (see How the rate is determined). Neither Tax.RoundingMode, nor Tax.DefaultEnvironment, nor Tax.Submission.MaxRetries exists in code. Connection environment (Sandbox/Simulation/Production) is an ordinary field TaxAuthorityConnection.Environment (see TaxAuthorityConnection), set on each connection record. There is no global constant-level default.

Business logic and invariants#

  • Tax determination requires explicit context — context = null does not throw; it quietly skips rules and mappings. See How the rate is determined.
  • The rate is re-resolved for every calculation; TaxCode.TaxRate is historical and is not read by live code. See TaxCode/TaxRate.
  • TaxRule/TaxRuleCondition are not validated on save — asymmetry with other model objects. See the callout in TaxRule.
  • TaxCalculation.OnBeforePostAsync is the model's only cancellable check. See TaxCalculation.
  • TaxPayment and TaxReturn write no movements — see their sections in Documents.
  • Not every TaxLedger line is born from TaxCalculation — Ukraine's single tax writes directly. See TaxLedger.
  • A credit note may fail to bring the balance back to zero if the rate changed between the original document and the reversal. See Example 3.
  • Document lineage is a soft link, not an FK and not a LinkTable. See the callout in TaxCalculation.

The Tax group → Dictionaries (all 20 objects from the section above) · Documents (TaxCalculation, TaxReturn, TaxPayment, TaxDocument, TaxSubmission — the last one despite being a dictionary by kind; see TaxSubmission) · Registers (TaxLedger) · Settings (TaxSettings, TaxAuthorityConnection).

  • Outgoing: Common — only a model.json dependency, not used directly in calculation; Organization — LegalEntity, analytic LegalEntity on TaxLedger; Accounting — Money and IGeneralLedgerService: TaxCalculationGLTx and TaxPaymentTx write the calculation's and the payment's own GL legs; see Who writes the general ledger.

  • Incoming — who calls Tax, and how:

    Source Mechanism
    Sales SalesRealization OnBeforePostAsync — guard for missing rate on invoice date, stamps TaxRateApplied; OnAfterPostAsync — CreateCalculationAsync(..., "OUTPUT", ..., TaxContextAsync(...)) with real context
    Sales SalesCreditNote OnAfterPostAsync — CreateReversalAsync(..., "OUTPUT", ..., taxPoint=note.DocumentDate, ctx) — see Example 3
    Purchasing PurchaseOrder (receipt) same guard + NonRecoverableOfAsync; OnAfterPostAsync — CreateCalculationAsync(..., "INPUT", ...)
    Purchasing PurchaseCreditNote CreateReversalAsync(..., "INPUT", ..., taxPoint=note.DocumentDate, ctx)
    Inventory LegalEntityTaxPackEventHandler (CoC extension of LegalEntity) ITaxPackInstaller.InstallForCountryAsync(iso) by legal entity country — best-effort, failure does not block saving the legal entity; why this is Inventory, not Tax/Organization, is explained in Organization · LegalEntity
    Purchasing TaxCalculationPayableSeamEventHandler [ExtensionOf("TaxCalculation")] — adds input VAT of a receipt to the supplier's Payable
    Saudi Arabia SaudiVatTx reads TaxRateApplied, calls TaxService.CalculateTax into its separate VatPayable register (not TaxLedger, not GL — a parallel contour only for reporting); EInvoiceReleaseSa wraps EInvoiceRelease by CoC; SaudiEInvoice calls ITaxDocumentOutcome.ApplyAsync and reads TaxCategory.Treatment for the ZATCA letter (S/Z/E/O)
    Ukraine UaVatTx writes only the shipment base into its UaVatFirstEvent register (without code/rate — VAT (ПДВ) recognition by the "first event" is incompatible with the "one date per document" model and is solved outside Tax); UaVatFirstEventTotalDriver — see the TaxLedger callout; UaEInvoice calls the same ITaxDocumentOutcome.ApplyAsync

    Both localizations follow one rule: only the universal calculation TaxCalculation/TaxPayment is posted to GL — country contours (VatPayable in KSA, UaVatPayable in Ukraine) exist for reporting to a specific authority and are never posted to the ledger; otherwise VAT would be doubled.

Objects#

Generated from workspace metadata by node tools/gen-reference.mjs; edit the metadata, not this block.

Dictionaries#

Tax — Taxes

A tax = type + authority + jurisdiction, effective-dated

Field Caption Type Required
Authority Authority → TaxAuthority Yes
Code Code String(32) Yes
EffectiveFrom Effective from DateTime Yes
EffectiveTo Effective to DateTime No
ID Code String(50) Yes
Jurisdiction Jurisdiction → TaxJurisdiction Yes
Name Name String(128) Yes

TaxAuthority — Tax authorities

Government tax authority (ZATCA/HMRC/FTA/…)

Field Caption Type Required
Code Code String(16) Yes
CountryCode Country code String(2) Yes
ID Code String(50) Yes
IsActive Active Boolean No
Name Name String(128) Yes

TaxAuthorityConnection — Authority connections

Field Caption Type Required
AgentTag Agent tag String(100) No
Authority Authority → TaxAuthority Yes
BaseUrl Base URL String(256) Yes
Code Code String(128) Yes
CredentialRef Credential reference String(128) No
Environment Environment TaxConnectionEnvironmentEdt Yes
ID Code String(50) Yes
IsMockReject Mock reject Boolean No
LegalEntity Legal entity → LegalEntity Yes
MaxRetries Max retries Integer No
Status Status TaxConnectionStatusEdt No
TimeoutSeconds Timeout, s Integer No

TaxCategory — Tax categories

Treatment: STANDARD/ZERO_RATED/EXEMPT/OUT_OF_SCOPE/REVERSE_CHARGE

Field Caption Type Required
Code Code String(32) Yes
ID Code String(50) Yes
Name Name String(128) No
Tax Tax → Tax Yes
Treatment Treatment String(24) Yes

TaxCode — Tax codes

User-facing classification: tax + category + rate, effective-dated

Field Caption Type Required
Code Code String(32) Yes
EffectiveFrom Effective from DateTime Yes
EffectiveTo Effective to DateTime No
ExemptionReason Exemption reason → TaxExemptionReason No
ID Code String(50) Yes
Name Name String(128) Yes
NonRecoverablePct Non-recoverable % Decimal(9,4) No
Tax Tax → Tax Yes
TaxCategory Tax category → TaxCategory Yes
TaxRate Tax rate → TaxRate No

TaxDirection — Tax directions

Classifier: INPUT / OUTPUT / WITHHOLDING / OTHER

Field Caption Type Required
Code Code String(16) Yes
ID Code String(50) Yes
Name Name String(128) Yes

TaxExemptionReason — Tax exemption reasons

Why a supply is exempt or out of scope. Country letters (VATEX-SA-*) stay on the localization category extension; this catalog is the core stamp on TaxCalculation.

Field Caption Type Required
Code Code String(32) Yes
EffectiveFrom Effective from DateTime Yes
EffectiveTo Effective to DateTime No
ID Code String(50) Yes
Name Name String(256) Yes
Tax Tax → Tax Yes

TaxJurisdiction — Tax jurisdictions

Hierarchical jurisdiction (country / region / …)

Field Caption Type Required
Code Code String(32) Yes
CountryCode Country code String(2) Yes
ID Code String(50) Yes
Level Level Integer No
Name Name String(128) Yes
ParentId Parent → TaxJurisdiction No

TaxMapping — Tax mappings

Pins a TaxCode to Item/ItemGroup/Customer/Supplier/LegalEntity when no TaxRule matched. SourceId is a Guid; Tax has no FK to Inventory.

Field Caption Type Required
EffectiveFrom Effective from DateTime Yes
EffectiveTo Effective to DateTime No
ID Code String(50) Yes
IsDisabled Disabled Boolean No
Priority Priority Integer Yes
SourceId Source Guid Yes
SourceType Source type String(24) Yes
TaxCode Tax code → TaxCode Yes

TaxPeriod — Tax periods

Named return window of a legal entity and tax. Closing it refuses a new TaxReturn for those dates. Posting lock is FiscalPeriod, not this dictionary.

Field Caption Type Required
Code Code String(32) Yes
FromDate From DateTime Yes
ID Code String(50) Yes
IsClosed Closed Boolean No
LegalEntity Legal entity → LegalEntity Yes
Name Name String(128) No
Tax Tax → Tax Yes
ToDate To DateTime Yes

TaxProfile — Tax profiles

Dated party attributes for tax rules. PartyType+PartyId because Tax cannot reference Customer. Empty IsDisabled is live.

Field Caption Type Required
EffectiveFrom Effective from DateTime Yes
EffectiveTo Effective to DateTime No
ID Code String(50) Yes
IsDisabled Disabled Boolean No
Name Name String(128) Yes
PartyId Party Guid Yes
PartyType Party type String(16) Yes

TaxProfileAttribute — Tax profile attributes

AttributeCode+Value on a TaxProfile. Copied to buyer.profile.{code} (or seller/supplier) before rules run.

Field Caption Type Required
AttributeCode Attribute String(32) Yes
ID Code String(50) Yes
TaxProfile Profile → TaxProfile Yes
Value Value String(64) Yes

TaxRate — Tax rates

Effective-dated rate of a tax within a category (historical rate is immutable). A tax may carry several rates at once — standard, reduced, zero — told apart by TaxCategory; the date then picks the version of each.

Field Caption Type Required
Code Code String(32) Yes
EffectiveFrom Effective from DateTime Yes
EffectiveTo Effective to DateTime No
ID Code String(50) Yes
Rate Rate TaxRateValue Yes
Tax Tax → Tax Yes
TaxCategory Tax category → TaxCategory No

TaxRegistration — Tax registrations

Dated VAT (or other tax) number of a party. PartyType+PartyId because Tax cannot reference Customer/Supplier. The party-card TaxRegistrationNumber is the print fallback.

Field Caption Type Required
ID Code String(50) Yes
Jurisdiction Jurisdiction → TaxJurisdiction Yes
PartyId Party Guid Yes
PartyType Party type String(16) Yes
RegistrationNumber Registration number String(64) Yes
Tax Tax → Tax Yes
ValidFrom Valid from DateTime Yes
ValidTo Valid to DateTime No

TaxReportMapping — Tax report mappings

Which return box a tax code fills. Boxes are data, not hardcode. Directed mapping beats an undirected one.

Field Caption Type Required
Direction Direction → TaxDirection No
EffectiveFrom Effective from DateTime Yes
EffectiveTo Effective to DateTime No
ID Code String(50) Yes
ReturnBox Return box String(32) Yes
ReturnType Return type String(32) Yes
Tax Tax → Tax Yes
TaxCode Tax code → TaxCode Yes

TaxRule — Tax determination rules

Field Caption Type Required
Code Code String(64) Yes
EffectiveFrom Effective from DateTime Yes
EffectiveTo Effective to DateTime No
ID Code String(50) Yes
IsDisabled Disabled Boolean No
Jurisdiction Jurisdiction → TaxJurisdiction No
Name Name String(128) Yes
Priority Priority Integer Yes
Tax Tax → Tax No
TaxCode Tax code → TaxCode Yes

TaxRuleAction — Tax rule actions

Field Caption Type Required
DisplayOrder Order Integer No
ID Code String(50) Yes
RateOverride Rate override TaxRateValue No
TaxCode Tax code → TaxCode Yes
TaxRule Tax rule → TaxRule Yes

TaxRuleCondition — Tax rule conditions

Field Caption Type Required
ConditionGroup Condition group Integer No
Field Context field String(64) Yes
ID Code String(50) Yes
Operator Operator TaxRuleOperatorEdt Yes
TaxRule Tax rule → TaxRule Yes
Value Value String(256) No

TaxSettings — Tax settings

Field Caption Type Required
DefaultTax Default tax code → TaxCode No
DefaultTaxCode Default tax code String(128) No
ID ID String(50) Yes
PricesIncludeTax Prices include tax Boolean No

TaxSubmission — Authority submissions

Append-only journal of filing attempts: one row per SubmitReturn/SubmitPayment. The mock writes Accepted or Rejected; a country package keeps writing here.

Field Caption Type Required
Authority Authority → TaxAuthority No
ConnectionCode Connection String(128) No
Environment Environment String(16) No
ID Code String(50) Yes
Kind Kind String(16) Yes
LegalEntity Legal entity → LegalEntity No
Receipt Receipt String(128) Yes
ResponseMessage Response String(1024) No
SourceId Source Guid Yes
Status Status String(24) Yes
SubmittedAt Submitted at DateTime Yes

Documents#

TaxCalculation — Tax calculation

Header fields:

Field Caption Type Required
Currency Currency → Currency Yes
DeterminationReason Determination reason String(1024) No
ID Number String(50) Yes
LegalEntity Legal entity → LegalEntity Yes
MatchedRule Matched rule → TaxRule No
RegistrationNumber Registration number String(64) No
TaxPointDate Tax point date DateTime Yes

Subtypes:

Subtype Caption
Draft Draft
Finalized Finalized

Commands:

Command Caption Target subtype
FinalizeTaxCalculation Finalize tax calculation —

TaxDocument — Tax document

Country-blind e-invoice envelope. The country pack fills it; the host connector later signs and posts. Immutable after Cleared/Reported.

Header fields:

Field Caption Type Required
Authority Authority → TaxAuthority No
EInvoiceKind Kind String(16) Yes
ExternalId External id String(128) No
ID Number String(50) Yes
InvoiceCounter Chain counter Integer No
InvoiceHash Invoice hash String(128) No
InvoiceType Document class String(32) No
LegalEntity Legal entity → LegalEntity No
Payload Payload LargeText No
PreviousInvoiceHash Previous hash String(128) No
RejectionReason Rejection reason String(1024) No
SourceDocumentId Source Guid Yes
SourceDocumentType Source type String(64) Yes
Uuid UUID Guid Yes

Subtypes:

Subtype Caption
Draft Draft
Issued Issued
Cleared Cleared
Reported Reported
Rejected Rejected

Commands:

Command Caption Target subtype
UaResolveTaxInvoice Ask the operator about this invoice —

TaxPayment — Tax payment

Header fields:

Field Caption Type Required
ID Number String(50) Yes
LegalEntity Legal entity → LegalEntity No

Subtypes:

Subtype Caption
Draft Draft
Paid Paid

Commands:

Command Caption Target subtype
PayTax Pay tax —

TaxReturn — Tax return

Header fields:

Field Caption Type Required
ID Number String(50) Yes
InputTax Input tax Money No
LegalEntity Legal entity → LegalEntity Yes
NetPayable Net payable Money No
OutputTax Output tax Money No
PeriodFrom Period from DateTime Yes
PeriodTo Period to DateTime Yes
TaxPeriod Tax period → TaxPeriod No

Subtypes:

Subtype Caption
Draft Draft
Filed Filed

Commands:

Command Caption Target subtype
FileTaxReturn File tax return —
UaExportTaxReturn Export for filing —
SaExportTaxReturn Export for filing —
UaExportPayrollLevy Export payroll levies (4ДФ workpaper) —
UaExportQuarterlyReturn Export quarterly payroll (ЄСВ+ПДФО+ВЗ workpaper) —
UaExportDpsPayroll Export DPS payroll forms (J0500111) —
UaExportSingleTax Export single tax (J0103509) —
UaExportFopSingleTax Export FOP single tax (F0103309) —

Registers#

TaxLedger — Tax ledger

Source of tax returns; input/output tax base and amount by code/direction/entity

Resources:

Resource Caption Type Required
RecoverableAmount RecoverableAmount Money —
TaxAmount TaxAmount Money —
TaxBase TaxBase Money —

Analytics:

Analytic Caption Type Required
TaxLedger-Direction TaxLedger-Direction — Yes
TaxLedger-LegalEntity TaxLedger-LegalEntity — Yes
TaxLedger-TaxCode TaxLedger-TaxCode — Yes

Table parts#

TaxCalculationLines — Lines

Used by: TaxCalculation.

Field Caption Type Required
Direction Direction → TaxDirection Yes
ExemptionReason Exemption reason → TaxExemptionReason No
RateOverridden Rate overridden Boolean No
RateValue Rate TaxRateValue Yes
RecoverableAmount Recoverable amount Money No
TaxAmount Tax amount Money Yes
TaxBase Tax base Money Yes
TaxCategory Category → TaxCategory No
TaxCode Tax code → TaxCode Yes
TaxRate Rate ref → TaxRate No

TaxPaymentLines — Lines

Used by: TaxPayment.

Field Caption Type Required
Amount Amount Money Yes
TaxCode Tax code → TaxCode Yes

TaxReturnLines — Lines

Used by: TaxReturn.

Field Caption Type Required
Direction Direction → TaxDirection Yes
ReturnBox Return box String(32) No
TaxAmount Tax amount Money No
TaxBase Tax base Money No
TaxCode Tax code → TaxCode Yes
Was this page helpful?