Decouple price calculation basis from tax display state
INFO
This document represents an architecture decision record (ADR) and has been mirrored from the ADR section in our Shopware 6 repository. You can find the original version here
Context
Every product price stores a gross and a net value. A single tax state (gross / net / tax-free, derived from customer_group.display_gross) decides three things at once: which stored value is used, how taxes are calculated, and whether the customer sees "incl. VAT" or "excl. VAT".
Because of this coupling, gross display always means "fixed gross price". The included tax depends on the customer's country, so the merchant's net proceeds vary per country. Merchants want the opposite combination: calculate from the fixed net price (same proceeds everywhere) while still showing gross prices to B2C customers.
Two code facts shape the solution:
- There is no display layer.
CalculatedPricecarries one number, already in the right tax state. Templates, Store API, documents and admin print it as-is; only the VAT labels branch on the tax state. The display value has to come out of the calculation correct, we cannot convert it at the end. - The conversion we need already exists in one direction.
QuantityPriceDefinition::$isCalculated = falsetellsGrossPriceCalculator"this value is net, gross it up with the tax rules first". Today almost nothing sets that flag.NetPriceCalculatorhas no such flag; it only rounds.
Decision
We add a price basis setting that decides which stored value is authoritative. The tax state keeps its current meaning (tax math flavor + labels), so display_gross finally means what its name says. Both fields are independent and all four combinations are supported.
- New field
customer_group.price_basis:'net'or'gross', never empty.- The basis only applies where it differs from the display mode, and only in the tax state of that display mode. Everywhere else the tax state picks the stored value as before, also where an extension or an order edit sets another tax state.
- Existing groups get the basis matching their display mode, so every price stays the same on update. A group created without a basis derives it the same way. From then on, changing the display mode keeps the basis.
'net'with gross display: the stored net value is used, and the price definition is passed withisCalculated = false, soGrossPriceCalculatorderives the gross from the shipping country's tax rules.'gross'with net display: the stored gross value is used, and the net is derived in the selection layer (gross minus the tax it contains at the applicable rules) and handed over as an already calculated value, soNetPriceCalculatoronly rounds it and stays untouched.- Tax-free deliveries always charge the stored net value, on every basis. Nothing is derived at 0%.
- A new internal
PriceSelectorservice replaces the four duplicated gross-vs-net branches (ProductPriceCalculator,DeliveryCalculator,CurrencyPriceCalculator, the app-script price facades). It takes the applicableTaxRuleCollectionalong with the price, and returns the selected value plus theisCalculatedflag. Plugins hook into it through the pre and post events ofPriceSelectorExtensioninstead of decorating it, so its signature can still change. - Nothing downstream changes. Cart math, orders, documents, tax reporting, rules, recalculation, both API schemas and all storefront templates keep their current shape, because the derived value enters the pipeline at the same point a stored one does today.
How exact is the derived flavor? The displayed value must be a valid cash amount, so it gets rounded, and the non-authoritative side ends up within half a rounding unit per unit sold. That shrinks the per-country variance from full VAT percentage points to sub-cent noise. Exactness to the cent on both sides would need per-surface derivation with visible penny mismatches (Alternative A). We accept the sub-cent variance. With several tax rules, as for proportional shipping costs or a discount spread over tax rates, the rule shares are first converted to the price the downstream calculator applies them to, so the authoritative value survives the round trip.
Loose ends handled:
- Caching: derived prices depend on the country's tax rates, not just the tax state. The HTTP cache cookie and the entity cache hash additionally include a fingerprint of the context's tax rules exactly when prices are derived (
netbasis with gross display in the gross state,grossbasis with net display in the net state). The selector decides with the same check, so both can't drift apart. A basis matching its display mode, any other tax state and every tax-free context do not fragment the cache. A visitor who picks a state gets a cache hash in that case as well, because state tax rules change the derived value. - Sorting and filtering keep using the stored value of the display flavor, because
Contextonly knows the tax state, not the price basis. The same gap decides which variant or rule price becomes a listing's cheapest price. With linked prices that matches the derived value at the product's home tax rate; unlinked or stale values of the non-authoritative flavor drift. - Tax providers replace the calculated taxes after the calculation and keep the displayed flavor fixed, as before. The basis therefore only holds while a provider's rates match the shop's tax rules; a basis-aware tax adjustment is a possible follow-up.
- Single amounts such as absolute and fixed-price promotion discounts, the cap of percentage discounts, manual shipping costs and credits carry one value instead of a net/gross pair. The basis has nothing to choose from, so they keep the display flavor, as before. Under a net basis the net value of an absolute discount therefore differs per country, by about 0.7 % of the discount between 19 % and 20 %. Reading single amounts in the basis flavor is a possible follow-up.
- Admin: the customer group detail page offers the tax display and the price basis as two independent controls, each writing its own field.
The column is NOT NULL DEFAULT 'gross' from the start, so inserts by old core during blue-green still get a value. End state: with v6.8 the basis is part of every create payload. Both fields are required, with entity defaults (gross display, gross basis), and the write subscriber deriving the basis from the display mode goes away. With v6.8 the price basis also moves onto Context next to the tax state, so the DAL and Elasticsearch price accessors and the cheapest price resolution key on the authoritative stored column instead of the display flavor.
Alternatives considered
A) Second tax state on the context: calculate on one flavor, derive the other per display surface. Exact to the cent, but the displayed unit price times quantity no longer matches the charged total (up to a cent per line), it cannot reconcile with vertical tax calculation at all, and it touches CalculatedPrice, both API schemas, ~30 templates and every headless client. Not doable without a major.
B) Display-only decoration in the product layer (listing shows gross, cart stays net). Rejected: listing and cart would show different prices.
C) Global system config switch. Cannot express mixed setups (B2C gross display + B2B net display, both on a fixed net basis), and the display toggle already lives on the customer group.
D) Keep price_basis nullable, NULL meaning "follows the display mode". Rejected: an extra state with its own API contract (nullable field, schema, admin fallbacks) for a single case, a field-unaware writer flipping display_gross later. A basis that matches the display mode already gives today's behavior without that extra state.
Consequences
- Merchants can opt into fixed net proceeds with gross display, or a fixed customer-facing gross with net display, per customer group.
- Derived prices vary by country and are not psychological prices (net 10.00 € shows as 11.90 € in DE, 12.00 € in AT). Needs prominent admin help text and merchant docs.
GrossPriceCalculator'sisCalculated = falsepath becomes load-bearing and needs promoted test coverage (list, regulation and reference prices, cash rounding intervals).- Existing groups keep their prices: additive column, service and API field, no template or schema changes. Flipping the display mode afterwards keeps the basis; with unlinked net/gross pairs that changes what customers are charged.
- Release docs: RELEASE_INFO entry, plus UPGRADE entries for the removed admin block and the required fields.
- Enabled follow-ups: charm-price rounding of derived prices, an exact mode as a third basis value.
Pseudo-code
// PriceSelector::select(Price $price, TaxRuleCollection $taxRules, SalesChannelContext $context): SelectedPrice
$taxState = $context->getTaxState();
// a basis matching the display mode, any other tax state and tax-free deliveries take the stored value of the tax state
if (!TaxRuleFingerprint::derivesPricesFromTaxRates($context)) {
return new SelectedPrice(
$taxState === CartPrice::TAX_STATE_GROSS ? $price->getGross() : $price->getNet(),
isCalculated: true
);
}
// net basis with gross display: GrossPriceCalculator derives the gross from the stored net
if ($taxState === CartPrice::TAX_STATE_GROSS) {
return new SelectedPrice($price->getNet(), isCalculated: false);
}
// gross basis with net display: the contained tax is taken out of the stored gross here
return new SelectedPrice(
$price->getGross() - $taxCalculator->calculateGrossTaxes($price->getGross(), $taxRules)->getAmount(),
isCalculated: true
);