📑 The tax profile and mapping — for the developer
Updated
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
ResolveDeterminationAsynctakelegalEntityas a required argument while the context is optional, so reading the seller out ofseller.idwould 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.idremains 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
FirstOrDefaultoverTaxSettings. - A disabled
TaxMappingstill counts as an answer.ResolveMappingAsyncfilters disabled rows out, so the country step asks a different question — does this entity have a mapping row at all — throughHasAnyMappingAsync. 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