Boundary
Catalog owns tax persistence through theTax, TaxDependency, and VatRate
models. Eloquent models must not cross into the shared graph engine or the Order
package. A Tax is projected once with Tax::toGraphDefinition() after
dependencyRows has been eager loaded; every downstream operation uses that
plain definition.
The canonical definition always contains identity and display fields, amount
configuration, application scope, active state, both scope defaults, effective
stockable inclusion_policy, optional effective VAT-rate metadata, and
dependency edges shaped as {tax_id, operator}.
Stockable contexts contain definitions only. They never retain a parallel
collection of Tax models. Order receives the same definitions through
CatalogInterface.
Engine API
TaxGraphCalculator is a pure engine with no database dependency:
resolve()returns the requested roots and their transitive dependencies in dependency-first order.topologicalOrder()returns dependency-first IDs and rejects missing nodes or cycles.validate()applies graph, amount-type, operator, and optional price-inclusion rules.calculateAmount()calculates one fixed or percentage tax in minor units.taxableAmount()applies the zero floor to a base and its signed dependencies.buildCalculatedTaxLine()returns the context-neutral result of one completed tax calculation, including its derivedbase_amount.compute()performs the complete stockable forward or extraction calculation.
prepareGraph() creates the id index once, resolves the requested
dependency closure, validates it when required, and returns dependency-first
definitions. Calculation methods consume that ordered list directly; the index
never becomes a second public definition shape.
Context-specific data
For stockables, Catalog overlays the selectedVatRate and price inclusion
overrides on the canonical definitions before calculation. Persistence, APIs,
canonical definitions, and the engine all use TaxInclusionPolicy with the
values inclusive and exclusive.
For orders, persisted settings are roots. Explicit settings may override a
tax’s calculation base; implicit dependencies use their own
default_order_calculation_base.
Both context calculators project all calculated lines at once through a private
buildSnapshotTaxLines() method. Their snapshots share identity, amount
configuration, base, dependency, taxable, result, and dependency fields.
Stockable lines add VAT and inclusion fields; Order lines add
calculation_base.
The current fiscal partition is always represented as the associative shape
{organization_id, country_code, fiscal_status}.
Sharing and company isolation
Tax definitions, VAT rates, tax profiles and category/stockable tax configurations are shared by every company of a fiscal profile(organization_id, country_code, fiscal_status), resolved from the selling company. They never gain a company_id.
Category and stockable fiscal properties have their own member permissions:
category_taxes.*, variant_taxes.* and bundle_taxes.*. They do not depend on the
company’s built-in manage catalog role. General catalog writes retain their existing
authorization and additionally check the fiscal update permission when a caller explicitly
supplies fiscal fields. Default initialization without fiscal input remains part of creation.
Fiscal reads and writes are available through dedicated configuration routes; their payloads
cannot change the shared category or stockable metadata. See the tax API.
Company-private resources keep a strict company_id boundary, even for administrators:
bundles, stockable prices and their catalog_price_tax_settings, orders and order tax
lines, as well as simulation and assignment inputs that address them. Every stored-price
calculation (calculateStoredPrices, calculateStoredPricesFromNet, price
tax_configuration, price create/update, order pricing) loads prices of the current
company only.
Because shared configuration changes cannot see or edit another company’s prices, a
stored price setting whose tax leaves the effective definitions becomes inert instead of
blocking the sale: StockableTaxContextBuilder::withStoredTaxSettings() ignores it,
reports it in inapplicable_tax_settings, and keeps the row. Submitted settings go
through withTaxSettings(), which rejects them when they do not apply. The behavior is
described for API clients in the catalog pricing guide.
A stored setting that still applies is never made inert: a shared change that would break the
price instead is refused at configuration time.
StockableTaxCalculator::preservingStoredPriceCalculability() lists the stored prices that the
real sale path (buildResolvableBases(), withStoredTaxSettings(), extraction) cannot calculate,
runs the change in its transaction, lists them again and throws a generic
BusinessRuleException (rollback) when a price became incalculable. It reads the prices of every
company whose partition is concerned, through TaxPartitionService::partitionsOfCompanies(), with a
bounded number of queries per partition. Stockable and category identifiers are bound in chunks of
taxes.id_chunk_size (10 000), below the PostgreSQL limit of 65 535 parameters per statement. Callers pass the stockables the change can reach:
TaxService follows the tax and the taxes depending on it to their stockable and category
assignments (every priced stockable when VAT depends on it), assignment and configuration changes
pass their stockable or the stockables of their category, product or bundle category changes
check every partition, and VatRateService passes the stockables whose configuration or category
uses the rate (every priced stockable when the default rate changes value or holder).
A stockable whose stored graph cannot be resolved (inactive or missing dependency, cycle, left by
older data) is omitted by buildResolvableBases(): its prices render tax_context_unresolved and
count as already incalculable, so they block neither the list nor a change that repairs them.
The check takes the profile lock (TaxProfileService::lockPartitions()) before its first read,
even without candidates; price creations and amount or setting updates take it before resolving
their context. Changes that span every partition lock every profile of the organization in id
order. Every write locks its business rows first (category, product, stockables in id order,
price), then the profile or profiles, so two writers never wait on each other in opposite orders:
PUT /categories/{c}/taxes locks the category before the profile, as a category update does by
saving it first, and product or bundle updates lock their product or bundle and stockables before
every profile, like price and stockable writes.
Not checked, and therefore only protected by the sale path (the sale is refused and
calculation_error is visible on the price):
- order-scope taxes;
- partitions that cannot be resolved (unmapped country), whose prices cannot be sold anyway;
- a change of a company’s country or fiscal status, which moves its prices to another profile;
- operator console commands (
LegacyTaxConfigurationService: legacy VAT resolution, adoption of unmapped configurations).
Amount rules
- A fixed tax is applied once per calculated amount. A stored line is calculated on
unit price × quantity, so a fixed tax applies once per order line, not per unit, including when it is inclusive. There is no per-unit fixed tax; adding one requires a product decision and a quantity incalculateStoredPricesFromNet. - A tax is never negative. A tax calculated from known amounts (every exclusive tax,
every tax in
forwardmode such ascalculateStoredPricesFromNetafter a discount, and every order-level tax) has its taxable amount floored at zero (TaxGraphCalculator::taxableAmount(), also applied bycalculateAmount()). Asubstractdependency larger than its base therefore yields a zero tax, and its snapshot line keepsbase_amount + dependency_tax_amount = taxable_amount: the base is unchanged,taxable_amountis0anddependency_tax_amountis the part of the deduction actually applied (-base_amount). The dependency’s own line keeps its full amount. - Extracting an inclusive tax that would need a negative base is impossible, since the
price cannot be split. Stockable calculations convert this and every other amount the
graph cannot split (a price below its inclusive fixed taxes) into a 422
BusinessRuleException(price_tax_amount_not_calculable), both when a price is stored and when it is sold. - After extraction rounding, every snapshot line uses the final net and the final
dependency amounts as its base. With
rounding_favor=net, the VAT line base therefore equals the reconciled line net.
Product category and unresolved configurations
A product has at most one category, stored in its nullablecategory_id. The product
request accepts category and rejects the retired categories array. The legacy
many-to-many links (catalog_product_categories) are kept only for inspection
and rollback.
An inherited configuration with no category or no matching configuration in the
current fiscal partition produces tax_resolution = untaxed_unconfigured and no
tax lines. A category without configuration in the current partition exposes
is_taxable: null, not false. A configured regime that does not collect VAT produces
untaxed_no_vat_regime. An unknown country or fiscal status is a configuration
error and cannot be used to price a sale.
Changing a company’s fiscal partition selects that partition’s configuration.
It does not copy tax definitions or rates between countries or regimes. Existing
configurations remain available when the company returns to an earlier
partition.
Calculation acceptance examples
An external A–E acceptance example with a total of 62 303,60 has not been reproduced: its inputs are not in the repository and could not be recovered. The executable examples inTaxGraphCalculatorTest cover inclusive VAT with both
rounding preferences, chained dependencies, two inclusive taxes on the same base,
fixed and percentage taxes together, and recalculation of exclusive taxes after
rounding. StockableTaxCalculationTest covers their catalog integration.
Acceptance is based on each example’s explicit inputs, calculated tax lines and
reconciled totals; the unexplained total alone is not a test vector.
Deferred structural work
The following concerns require separate domain or schema decisions and are not part of the definition-normalization refactor:- Evaluate whether the system VAT graph node (
Tax) and selectable VAT rates (VatRate) should remain separate concepts. They are intentionally kept separate today, while runtime model duplication is removed at the projection boundary. - Evaluate a polymorphic persistence design for
CategoryTax/StockableTaxand their configuration models. Their current tables remain explicit. - Keep request-level lookups that provide indexed validation errors alongside service-level business guards. Optimize them only after query profiling shows a material cost.