Boundary

Catalog owns tax persistence through the Tax, 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 derived base_amount.
  • compute() performs the complete stockable forward or extraction calculation.
All public graph methods accept a flat list of canonical definitions. Missing or duplicated definition IDs are rejected rather than inferred from array keys. Internally, 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 selected VatRate 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 in calculateStoredPricesFromNet.
  • A tax is never negative. A tax calculated from known amounts (every exclusive tax, every tax in forward mode such as calculateStoredPricesFromNet after a discount, and every order-level tax) has its taxable amount floored at zero (TaxGraphCalculator::taxableAmount(), also applied by calculateAmount()). A substract dependency larger than its base therefore yields a zero tax, and its snapshot line keeps base_amount + dependency_tax_amount = taxable_amount: the base is unchanged, taxable_amount is 0 and dependency_tax_amount is 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 nullable category_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 in TaxGraphCalculatorTest 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/StockableTax and 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.