ZuloOne
English
Sign in
Menu

📑 The tax profile and mapping — for the developer

Updated

← Developer wiki

The Tax 2.1.0 model. The public page is Tax determination.

Why#

TaxRule already picks a code off the flat context of a transaction. Two more layers of data sit above it, with no country in the kernel:

Object Role
TaxProfile the dated attributes of a party (PartyType+PartyId, as on TaxRegistration)
TaxProfileAttribute AttributeCode/Value; unique within a profile
TaxMapping pins a TaxCode to an Item / ItemGroup / Customer / Supplier / LegalEntity

The order of determination: the profile's attributes → TaxRule → TaxMapping → the seller country's own standard code → DefaultTaxCode. A rule still outranks everything. A mapping does not write MatchedRule, and neither does the country.

The country step#

ResolveCountryDefaultTaxCodeAsync(legalEntity, taxPoint) walks LegalEntity → Country → ISO-2 → TaxJurisdiction → Tax → TaxCategory with a STANDARD treatment → TaxCode, and answers only when the walk ends on exactly one code that also has a rate in force on the tax point. Anything else — no jurisdiction for that ISO-2, several standard codes, no priceable rate — returns null, and DefaultTaxCode applies as before.

Three things about it are deliberate:

  • The seller is a parameter, not a context key. Both callers of ResolveDeterminationAsync take legalEntity as a required argument while the context is optional, so reading the seller out of seller.id would switch the step on only for the callers that already know the most about determination, and leave the silent ones on one stand-wide row. seller.id remains a fallback for a caller that passes a context but no entity.
  • "Exactly one", never "the first match". Picking arbitrarily among several would put one rate onto unrelated calculations — Ukraine's jurisdiction carries VAT, the single tax, personal income tax and the military levy, more than eight standard codes. There the honest answer is that the country does not know. Do not FirstOrDefault over TaxSettings.
  • A disabled TaxMapping still counts as an answer. ResolveMappingAsync filters disabled rows out, so the country step asks a different question — does this entity have a mapping row at all — through HasAnyMappingAsync. Otherwise switching the pin off, which is the documented escape hatch, would silently be overruled by the country.

The step exists to close the window left by the pin job (PinSaudiTaxCodes writes a TaxMapping for every Saudi legal entity every five minutes). Without it, an entity created between two runs would be computed with the stand default. Determination gives the same answer the pin would, at once. The job stays, because the pin is what makes the decision visible as data. Covered by TaxCountryDefaultTest — one case for the answer and four for the silences.

The context: without it the engine has nothing to use#

Everything above is switched on by the context and by nothing else. When context == null, ResolveDeterminationAsync skips the profiles, the rules and the mappings on its very first line and falls through to the country step and then to DefaultTaxCode — one code for the whole stand. And there is no signal about it: no exception, no log entry. A typo in a key (seller.Id instead of seller.id) looks exactly like a missing rule.

That is why Tax assembles the keys itself — ITaxService.DeterminationContext(legalEntity, customer, supplier, item, itemGroup) — rather than every caller doing it with literals. Empty Guids are not put in: Guid.Empty would match a mapping whose SourceId is not filled in. The caller adds its own keys (document.type, direction, amount, buyer.type) on top.

Document Parties Item
SalesRealization buyer.id, seller.id when the lines are homogeneous
SalesCreditNote buyer.id, seller.id when the lines are homogeneous
SalesDebitNote buyer.id, seller.id when the lines are homogeneous
PurchaseOrder (a receipt) supplier.id, seller.id when the lines are homogeneous
PurchaseCreditNote supplier.id, seller.id when the lines are homogeneous

"When the lines are homogeneous" is not caution, it is the only honest option. ONE calculation is created per document, over a common base, and it holds as many lines as the rule that fired has actions — not as many as the document has lines. An item taken from an arbitrary line would tax the whole document by that line, and the result would depend on the order of the lines. So item.id goes in only when every line carries the same item, and item.groupId only when there is a single item group; the group is computed by IItemClassification.CommonGroupAsync in Inventory (Tax is layer 1 and cannot reference Item).

Every posting document must assemble a context#

Skip the context on a credit note, debit note, receipt or return, and money diverges: an invoice mapped to a reduced code posts under that code, while the reversing credit note falls through to DefaultTaxCode. Returning a whole invoice on a base of 100 leaves TaxLedger negative by the difference between the rates. A receipt with no context never sees a mapping on the supplier.

The context: buyer.profile.{code}, seller.profile.{code}, supplier.profile.{code}. Tax does not reference Customer/Item — only Guids.

Integer, not Int. The flag is IsDisabled. Overlapping windows for one party / one source are rejected on save.

Line codes: LineTax#

Determination above picks one code per document. A goods line may carry its own TaxCode and TaxAmount (1C: СтавкаНДС / СуммаНДС); the Inventory service LineTax (contract ILineTax, Inventory 1.42.0) handles that line. A coded line is taxed at its code's rate on the document date; uncoded lines are left to determination, at the header rate TaxRateApplied.

Line event handlers make one call per field edit:

Task<Dictionary<string, object?>> ApplyLineAsync(
    string fieldName, Guid item, Guid unit, decimal quantity, decimal unitPrice, decimal discountPercent,
    decimal amount, Guid packUnit, decimal packQty, Guid taxCode, DateTime onDate);

It runs IPricingService.ApplyLineFieldAsync (quantity, price, amount, packs), then LineTax.ApplyLineFieldAsync on the recomputed line net: TaxCode from the item when fieldName == "Item" and the item has one, TaxAmount from the rate. It returns only the changed fields among Quantity, UnitPrice, Amount, PackQty, TaxCode, TaxAmount; the handler writes them back to the row.

Why one call: six line handlers repeated the same two calls, and their order matters — tax must be computed on the net after pricing recomputed the line, or it lands on the old amount. One call keeps that order in one place. Callers: the line handlers of SalesOrderLines, SalesInvoiceLines, SalesReturnLines, PurchaseOrderLines, PurchaseReturnLines, SupplierInvoiceLines.

A code with no rate on the date gives TaxAmount 0 instead of an exception: the edit goes through, and posting refuses later.

At posting, DocumentTaxAsync(codes, nets, statedTaxes, headerRate, onDate) sums coded lines by SettledLineTaxAsync — the stated line tax when it is within 0.01 of the computed one (1C rounds every line), otherwise the computed one — and the uncoded rest at the header rate. BasesByCodeAsync gives the bases per code for ITaxService.CreateCalculationByCodesAsync.

The tests#

TaxProfileMappingTest is the engine as such. It calls the service directly and passes the context itself, so by construction it cannot prove that document posting passes a real context into the engine.

TaxDeterminationFromDocumentTest (Sales) closes precisely that gap — the documents are really posted: a mapping on the legal entity outranks the default when the invoice is issued; the credit note reverses under the same code and the ledger comes out at zero; with no mapping the default applies. Taking the parties out of the credit note's context is enough to fail the ledger-zero assertion.

The Ukraine package creates this row itself when UaTaxRegime changes: Ukrainian VAT.

Same topic for other readers: User

Where next#

Was this page helpful?