Skip to main content

Pricing

Every price a shopper sees comes from one place: the pricing engine in site/src/Service/Pricing. This page explains the order in which a price is worked out, the classes you may use from an extension or a template, the shape of the price data, and the stored prices that lists and filters read (the price index).

The words price index, customer group, place and combination are explained in the glossary.

What it does​

The engine takes an item, a quantity and a description of the visitor, and returns one result that holds every figure of the line: the price before discounts, the discounts, the tax of each rate, and what the customer pays.

Not every class in the folder is meant to be called. This table says which is which.

ClassWhat it isWho it is for
PriceCalculatorThe one entry that works out a priceCore calls it. The code does not say whether an extension may. See Asking for a price.
PriceContextWho the price is for: currency, customer, customer groups, placeExtensions may build one. A shipped module does (mod_alfa_items).
PricingIntentWhich quantities a list of items is priced atCore's cart passes one to the site items model. The code does not say whether an extension may.
PriceResultThe answer: every figure of one lineExtensions and templates read it. Never build one.
Money, CurrencyAn amount in a currency, and the currencyExtensions and templates use them freely. The shipped payment plugin does.
PriceSettings, PriceSettingsBuilder (site/src/Helper)Which figures a price block showsTemplates and extensions
PriceUnits (site/src/Helper)The per unit figures of a result, as textTemplates
PriceIndexSyncService (administrator/src/Service)Writes the stored price indexCore's own writer. See The stored price index.
PriceComputationEngine, PriceDataLoader, TaxChain, ExactAmount, ExactMoney, StoredDecimal, PublishedDecimal, PriceBreakdown, PriceReference, ChargeableQuantity and the other classes of the folderThe inside of the engineCore's own. They are described here only so that you understand the result. Do not call them.
ExplicitPriceInstruction, CustomPriceCommandA price that staff agreed for one line of a customer's cartCore's own. Do not build one.

All the classes of the engine live in the namespace Alfa\Component\Alfa\Site\Service\Pricing.

When it is used​

WhereWhat happens
An item page, a cart line, the checkoutThe price is worked out live by PriceCalculator, for the visitor and the quantity at hand
A list of itemsThe prices shown on the cards are worked out live too, for all the items of the page in one pass
A price filter, an "on sale" or discount filter, a sort by price or by discountThey read the stored price index, which was worked out in advance by the same engine
The REST API's catalogueIt publishes the result's data, as PriceResult::toArray() gives it, with the unit figures stated exactly (see The price data)
An item field pluginIt adds its own lines to the price of a configured item. See How an item field changes a price.

How a price is worked out​

The order​

Each step in plain words:

  1. The price row. An item can have several price rows: for a quantity range, for one customer, for a customer group, with or without publish dates. One row wins (see Which price row is used).
  2. The line. The row's unit price is multiplied by the quantity before anything is rounded. If the row carries a sale price (override_value), the sale replaces every regular discount of step 3 and step 6.
  3. Discounts before tax are taken from that exact amount.
  4. The taxes are charged in order. Each rate charges on what the rate before it left.
  5. One rounding. Only now is the amount rounded to the currency's decimals. This rounded amount with tax is the figure everything else is derived from: the net amount and the tax of each rate are the split of it.
  6. Discounts after tax are taken from the amount with tax.
  7. Tax follows the final amount. When a discount after tax removed something, the net amount and each rate's tax are worked back out of the final amount, so the tax shown is the tax on what the customer really pays.

Staff can also set an agreed price for one line of a customer's cart. That is a separate, later stage that only core uses (ExplicitPriceInstruction). With no such instruction it does not run.

Which price row is used​

Rows are first filtered by currency, customer, customer group, place and period (publish dates). Among the rows whose quantity range holds the quantity, the winner is decided in this order (PriceComputationEngine::selectBaseRow()):

  1. A row for this customer, before a row for a customer group, before a general row.
  2. Between two group rows, the row of the group nearest to the customer's own group.
  3. The narrower quantity range.
  4. A row with a period (publish dates) before a row without one.
  5. A row with a sale price before a row without one.
  6. The lower price.

If no row matches, the line has no price and every figure is zero.

Which discounts and taxes apply (the scope)​

A discount and a tax each have a scope of five parts: categories, manufacturers, places, customer groups and customers. A rule applies to an item only when all five match. One part matches when:

  • the rule has no entry for that part, or
  • the rule's entry is 0, which means "all", or
  • the rule names a value the item or the visitor has (one of the item's categories or manufacturers, the visitor's place, one of the visitor's groups, the visitor's own account).

A rule must also be published and inside its period (its publish dates). The customer groups compared are the visitor's own groups, not the groups that may see the item.

When several discounts or taxes apply (the behaviour)​

Rules are read in their ordering, then by id. Each rule has a behaviour:

BehaviourValueFor discountsFor taxes
Only this0The rules read before it are dropped and reading stops. This rule applies alone.This tax applies alone
Combine1The combined rules of a stage form one pool. Their percentages are added and taken once from the amount that enters the stage. Their fixed amounts are added to that.The combined rates are added into one rate, which is charged first
Sequential2Each rule is taken from what the rule before it leftEach rate charges on the amount plus the tax of the rates before it

"Only this" is decided by position, not by size: nothing compares amounts.

A stage is "before tax" or "after tax" (the discount's apply_before_tax). Inside one stage the pool is taken first, then the sequential rules.

Two 10 percent discounts on 100.00 show the difference. Combined, they are one 20 percent, and 80.00 is left. Sequential, the first takes 10.00 and the second takes 10 percent of 90.00, and 81.00 is left.

Limits that always hold:

  • A percentage is never above 100 and never below 0. A negative discount never becomes a surcharge.
  • A fixed discount is per unit: it is multiplied by the quantity.
  • A stage never takes more than the amount that entered it, so a price never goes below zero.

Rounding and precision​

These rules are the code's own (PriceComputationEngine::compute(), Money, TaxChain):

  • A unit price is a decimal of at most eight places. It is read as text and never held as a floating point number.
  • Inside the line nothing is rounded. The unit times the quantity, every discount and every tax are carried exactly.
  • The line is rounded once, when it becomes money: half away from zero, at the currency's number of decimals.
  • The net amount and each rate's tax are the split of that one rounded amount, so net plus tax always equals the amount with tax, to the last minor unit.

The code's own example: a net unit of 9.95161290 at 24 percent, quantity 3. The exact amount with tax is 37.019999988, which is 37.02. The net is 29.85 and the tax 7.17. Rounding the unit first would have charged 7.16.

A currency's number of decimals is its decimal_place, chosen by the shop owner, from 0 to 8 (Currency::SCALE_MIN, Currency::SCALE_MAX). A currency with no decimals is valid.

A worked example​

An item with a net unit price of 10.00, quantity 3, one discount of 10 percent that applies before tax, one tax of 24 percent, a currency with two decimals.

StepCalculationFigure
The line10.00 times 330.00
Discount before tax10 percent of 30.00 is 3.0027.00 left
Tax27.00 times 1.2433.48 with tax
Rounding33.48 has two decimals already33.48
Split33.48 divided by 1.2427.00 net, 6.48 tax
Price before discounts30.00 net, 37.20 with taxshown struck through

The result then gives:

MethodValue
getBaseTotal()30.00
getSubtotal()27.00
getTaxTotal()6.48
getTotal()33.48
getBaseTotalWithTax()37.20
getSavingsTotalWithoutTax()3.00
getSavingsTotalWithTax()3.72

What you must provide​

Asking for a price: PriceCalculator​

PriceCalculator is how core asks for a price: the item models, the cart and the order screens call it. In 2.2.0 no shipped plugin, module or template calls it, and its comments do not say whether an extension may. So this section describes the class and does not recommend the call. A template reads the result that the item already carries (see In a template). The sample shows the call as core makes it.

use Alfa\Component\Alfa\Site\Service\Pricing\PriceCalculator;
use Alfa\Component\Alfa\Site\Service\Pricing\PriceContext;

$calculator = new PriceCalculator();

// One item, quantity 3, for the current visitor.
$result = $calculator->calculate(12, 3);

echo $result->getTotal()->format();

// Several items, each with its own quantity. The map is keyed by item id.
$results = $calculator->calculate([12, 15], [12 => 3, 15 => 1], PriceContext::fromSession());

echo $results[15]->getTotal()->format();
MethodWhat it doesWhat it guaranteesWhat it refuses
__construct(?PriceDataLoader $loader = null, ?PriceComputationEngine $calculator = null)Builds the calculator. Core passes no arguments.
calculate($productIds, $quantities = 1, ?PriceContext $context = null, ?VariantSelection $selection = null, ?ExplicitPriceInstruction $price = null)Prices one item or manyOne id gives one PriceResult. An array of ids gives an array of results keyed by item id. Without a context it uses the current visitor's (PriceContext::fromSession()). The data of all the items is loaded together.An agreed price ($price) together with an array of ids: InvalidArgumentException. Planned for 2.2.1, which is not released: also a map of quantities that leaves out one of the items, or gives one a quantity that is not a whole number of at least 1: InvalidArgumentException naming the item. A single quantity given for all items is still taken as at least 1.
calculateVariant(int $itemId, int $quantity, VariantSelection $selection, ?PriceContext $context = null, ?ExplicitPriceInstruction $price = null): PriceResultPrices one item for a chosen combination or chosen optionsThe item's own taxes and discounts apply as for the plain item. An empty selection gives the same as calculate().
calculateBatch(array $itemIds, $quantities = 1, ?PriceContext $context = null, array $selections = []): arrayPrices many items in one pass, each with its own selectionResults keyed by item id. A fixed number of queries for the whole list.Planned for 2.2.1, which is not released: a map of quantities that leaves out one of the items, or gives one a quantity that is not a whole number of at least 1: InvalidArgumentException naming the item
tierStarts(int $itemId, ?int $combinationId, PriceContext $context): arrayLists the quantities at which the unit price of a line may changeAscending, without repeats. It may list more quantities than really change the price.
breakdownVariant(int $itemId, int $quantity, VariantSelection $selection, ?PriceContext $context = null): arrayGives the net base and one line for each adjustment of a variantThe keys base_net, base_sale, tax_factor and lines

A VariantSelection is built with new VariantSelection(int $combinationId = 0, array $optionValueIds = [], array $contributions = []).

Leave the last argument of calculate() and calculateVariant() out. It is core's agreed price for one line.

Saying who the price is for: PriceContext​

A PriceContext cannot be changed. Every with...() method returns a new one.

use Alfa\Component\Alfa\Site\Service\Pricing\PriceContext;

$context = PriceContext::fromSession(); // the current visitor
$other = $context->withCurrency(2); // the same visitor, another currency
MethodWhat it doesWhat it guarantees
fromSession(): selfThe current visitor: currency and place from the session, the signed in account and its groupsA place that is unpublished, or inside an unpublished place, counts as "no place". The currency falls back to the shop's default.
forIndex(int $currencyId, int $placeId, int $usergroupId, ?int $userId = null): selfOne customer group alone, in one currency and place. It is what the index writer uses.The group is taken with every group above it
withCurrency(int $currencyId): selfAnother currencyThe id is the row id in #__alfa_currencies, not the ISO number
withLocation(?int $placeId): selfAnother place, or null for "no place"A place that may not price a visitor becomes "no place"
withCustomer(User $customer): selfThe price for one customer account: its id and its groupsA guest, or an account that no longer exists, is priced as a visitor with no account
withUserGroups(array $groups): selfOther customer groupsThe value 0 ("any group") is always added
withUserId(?int $userId): selfAnother user id only. To price for an account use withCustomer().
withoutUser(): selfNo account
withReferenceDate(?string $referenceDate): self, getReferenceDate(): ?stringThe moment the price is worked out for
getCurrencyId(): int, getUserId(): ?int, getUserGroups(): array, getLocationId(): ?int, isLoggedIn(): bool, hasLocation(): boolRead the context
toCacheKey(): stringA text that is the same for two contexts that price alike. Use it in your own cache keys.
toArray(): array, getUserGroupsSql(): string, groupDistances(): arrayUsed by the engine
customerGroups(User $customer): array, groupLineage(int $groupId): array, groupLineages(array $groupIds): array (static)The groups a customer holds, and a group with every group above it

The session keys are alfa.currency_id and alfa.location_id. A plugin that detects the visitor's country sets the second one to the id of the place. Planned for 2.2.1, which is not released: a page priced for a visitor whose session holds either key is kept out of Joomla's page cache, whatever the state of "System - Alfa Commerce Sync". While that plugin runs for visitors who are not logged in, such a visitor is also never given a page stored for another visitor. See Caches.

Saying which quantities a list is priced at: PricingIntent​

The site items model prices its list with a PricingIntent. By default it is PricingIntent::catalog(): each item at its minimum quantity. Core's cart passes another one with the model's setPricingIntent() (CartHydrator). It is the only caller in 2.2.0, and the code does not say whether an extension may call it.

use Alfa\Component\Alfa\Site\Service\Pricing\PricingIntent;

// The map is keyed by the item id, as an integer.
$intent = PricingIntent::cart([123 => 3, 124 => 1]);
MethodWhat it does
catalog(): selfEach item at its own minimum quantity
cart(array $quantities): selfThe quantities of a cart. A map of item id to quantity.
checkout(array $quantities): selfThe quantities being checked out. The same map.
quote(array $quantities, int $minQuantity = 1): selfQuantities for a quote, with a minimum of your own
getQuantityForItem(object $item): intThe quantity for one item. It reads $item->id, and never answers less than 1.
getQuantities(): array, getScope(): stringRead the intent
isTransactional(): bool, requiresStockValidation(): bool, isCacheable(): boolTrue or false by the kind of intent. Nothing outside the class reads them in 2.2.0.

A key that is not the item's integer id, such as 'item_123', never matches, and the item is priced at its minimum quantity without an error.

What happens next​

The result: PriceResult​

A PriceResult holds the whole line. Amounts are Money objects.

For the whole lineFor one unitMeaning
getBaseTotal()getBasePrice()Before discounts, without tax
getBaseTotalWithTax()getBasePriceWithTax()Before discounts, with tax (the struck through price)
getSubtotal()getSubtotalPrice()After discounts, without tax
getTaxTotal()getTaxPrice()The tax
getTotal()getPrice()What the customer pays
getSavingsTotalWithoutTax()getSavingsPriceWithoutTax()What the discounts saved, without tax
getSavingsTotalWithTax()getSavingsPriceWithTax()What the discounts saved, with tax
getSavingsTotal()getSavingsPrice()What the discount stages removed, each on its own basis

Other readers:

MethodMeaning
hasDiscount(): boolWhether a discount took money from the line
getSavingsPercent(): floatThe saving as a percentage, two decimals
getSavingsPercentBreakdown(): arrayThe same by stage: before_tax, after_tax, total
getQuantity(): int, getCurrency(): CurrencyThe quantity and the currency of the line
getDiscounts(): DiscountSummary, getTaxes(): TaxSummary, getBreakdown(): PriceBreakdownThe rules that applied and the steps of the calculation
getAuthoritativeUnit(): stringThe unit the line is priced from, exactly as stated, as decimal text
getPayableUnit(): string, getPayableUnitExact(): stringWhat one piece costs with tax before discounts, as decimal text
getListUnit(): string, getDiscountedUnitExact(bool $withTax): ?stringThe list unit, and one piece after discounts, as decimal text
getStatedOrigin(): UnitOrigin, getStatedAxis(): UnitAxis, isReduced(): boolWhere the unit comes from and on which side of the tax it is stated. For display only.
hasExplicitPrice(), getExplicitPrice(), getExplicitPriceTotal(), getExplicitPriceTotalWithTax(), getLineSaving(), getLineSavingWithTax(), getLineSavingPercent(), getReference()About a price staff agreed for the line. hasExplicitPrice() is false for every ordinary price.
toArray(): array, toJson(int $options = JSON_PRETTY_PRINT): string, toMinimalArray(): array, formatAll(bool $includeSymbol = true, bool $includeUnitPrices = false): arrayThe result as data. See below.
The per unit methods lose small prices

getBasePrice(), getPrice() and the other "for one unit" methods divide the rounded line by the quantity. A unit of 0.004 reads 0.00 through them. To print a unit price, use PriceUnits::page() or the layout item.price, as Alfa's own templates do.

In a template​

The item a template receives carries its result in $item->price. Alfa's own templates print it with the layout item.price, which shows the figures the shop owner switched on:

use Alfa\Component\Alfa\Site\Helper\Layout;
use Alfa\Component\Alfa\Site\Helper\PriceSettings;

echo Layout::render('item.price', ['item' => $item, 'settings' => PriceSettings::get()]);

To print single figures yourself:

use Alfa\Component\Alfa\Site\Helper\PriceUnits;

$price = $item->price;

// A line total is money: format it.
echo $price->getTotal()->format();

// A unit price is text made for display.
$unit = PriceUnits::page(price: $price, figures: ['final', 'base_with_tax']);

echo $unit['final'];

if ($price->hasDiscount()) {
echo $unit['base_with_tax'];
}

The figure names PriceUnits::page() knows are base, discount, subtotal, tax, base_with_tax, discount_with_tax and final (PriceUnits::FIGURES). How many decimals a unit price shows follows the shop's setting "unit display precision" (unit_display_precision).

Keep the attribute data-item-prices on the element that wraps the prices. Alfa's scripts use it to refresh the price when the quantity or an option changes.

Which figures are shown: PriceSettings​

Alfa\Component\Alfa\Site\Helper\PriceSettings answers which figures a price block shows. The shop owner sets them in the component's options, and can set them again for a customer group.

use Alfa\Component\Alfa\Site\Helper\PriceSettings;

$settings = PriceSettings::get(); // for the current visitor
$minimal = PriceSettings::minimal(); // the final price only, without its label
$custom = PriceSettings::make()->show('final')->show('base', false)->get();
MethodWhat it doesWhat it guarantees
PriceSettings::get(?int $userId = null): arrayThe settings for the current visitor, or for one accountWhen several of the customer's groups have their own settings, the nearest group in the group tree decides. With none, the component's options apply. On any error it falls back to the component's options. The answer is kept for the request.
PriceSettings::minimal(): arrayOnly the final price, without label
PriceSettings::make(): PriceSettingsBuilderStarts a builder with everything hidden
PriceSettingsBuilder::show(string $element, bool $withLabel = true): selfShows one figureA name it does not know is ignored, without an error
PriceSettingsBuilder::hide(string $element): selfHides one figure
PriceSettingsBuilder::withoutLabels(): selfHides every label
PriceSettingsBuilder::get(): arrayThe finished settings

PriceSettings has no other public method. The names the builder accepts include base, subtotal, discount, tax, base_with_tax, final and breakdown (the full list is PriceSettings::ALIASES).

The settings array has these keys (PriceSettings::FIELDS), each 1 or 0: base_price_show, base_price_with_discounts_show, discount_amount_show, tax_amount_show, base_price_with_tax_show and final_price_show, each with a ..._show_label twin, and price_breakdown_show.

The price data: toArray()​

PriceResult::toArray() is the data the REST API's catalogue publishes. Every amount is exact decimal text, never a floating point number, with its formatted text beside it. The catalogue replaces three unit figures with exact unit values (PriceUnits::published()): price becomes amount, formatted and minor_units, and savings.price_amount and savings.price_formatted are stated the same way.

KeyContent
base_total, subtotal, tax_total, totalEach ['amount' => '30.00', 'formatted' => ...], for the whole line
priceThe same pair for one unit of the final price
currencyid, name, code, number, symbol, decimal_places, decimal_separator, thousand_separator, format_pattern
quantityThe quantity priced
savingsbasis (always tax_excluded), total_amount, total_formatted, price_amount, price_formatted, percent, has_discount
has_explicit_price, explicit_pricefalse and null for every ordinary price
discountsbasis (always tax_excluded), total (amount, formatted), count, has_discounts, applied
taxestotal (amount, formatted), effective_rate, count, applied (each id, name, rate, jurisdiction, amount)
breakdownsteps, the steps of the calculation

Each entry of discounts.applied has id, name, code, amount, percent, type (percentage or fixed_amount), timing (before_tax or after_tax) and the badge settings (tag_list_show, tag_list_slot, tag_item_show, tag_item_slot, tag_bg, tag_color, tag_class). For a fixed discount, amount is ['value' => ..., 'formatted' => ..., 'basis' => ...]. Its basis is tax_excluded for a rule applied before tax and tax_included for a rule applied after tax. For a percentage, amount is null.

Changed in 2.2.0. Every saving and discount figure of this data is stated without tax, and says so: savings.basis and discounts.basis are tax_excluded, and discounts.total is the same figure as savings.total_amount. For a discount applied after tax, 2.1.4 gave the amount with tax there. A discount of 5 percent applied after tax on 200.00 net under a tax of 24 percent now reads 10.00, where 2.1.4 read 12.40.

The other forms:

MethodContent
toMinimalArray()total (decimal text), formatted, currency (the code), quantity
toJson()toArray() as JSON
formatAll($includeSymbol, $includeUnitPrices)Formatted text only: base_total, subtotal, tax_total, total, savings_total, and with the second argument base_price, subtotal_price, tax_price, price, savings_price. The savings are without tax. The code marks this method as a helper for data exports, not for templates.

Money and Currency​

A Money is an amount in one currency. It is stored as a whole number of the currency's smallest unit, so it has no rounding noise, and it cannot be changed: every operation returns a new Money.

use Alfa\Component\Alfa\Site\Service\Pricing\Currency;
use Alfa\Component\Alfa\Site\Service\Pricing\Money;

$currency = Currency::getDefault();

$price = Money::of('19.99', $currency);
$line = $price->multiply(3);
$parts = $line->allocate(2); // two Money objects that add up to $line exactly

echo $line->format(); // with the currency symbol
echo $line->format(false); // without it
MethodWhat it doesWhat it refuses
Money::of(string|int|float $amount, Currency $currency): selfAn amount in major units. Rounded once, half away from zero, at the currency's decimals. Pass text when you have text.An amount that is not a finite number, or too large: InvalidArgumentException
Money::ofMinor(int $minorUnits, Currency $currency): selfAn amount in the smallest unit (1999 for 19.99 at two decimals)
Money::zero(Currency $currency): selfZero
Money::parse(string $amount, Currency $currency, ?MoneyInputFormat $format = null): selfReads an amount as a person typed it, in the currency's own separators
add(Money $other): self, subtract(Money $other): selfSum and differenceTwo different currencies: InvalidArgumentException
multiply(string|int|float $multiplier): selfTimes a factor, rounded onceA factor that is not a finite number
percentage(string|int|float $percent): self, timesRatio($numerator, $denominator): self, divide($divisor): selfA percentage, a ratio or a quotient of the amount
allocate(int $n): arraySplits the amount into $n parts that add up exactly$n below 1
allocateByRatios(array $ratios): arraySplits the amount by weights, with nothing lost
isZero(), isPositive(), isNegative(), equals(), compareTo(), greaterThan(), greaterThanOrEqual(), lessThan(), lessThanOrEqual()Comparisons
getAmount(): float, getMinorUnits(): int, getCurrency(): CurrencyRead the amount
toScale(int $scale): intThe amount as a whole number at another number of decimals, for a provider that asks for one
format(bool $includeSymbol = true): stringThe amount as the currency writes it
Money::sum(Money ...$amounts), min(), max()Over several amounts

Money has more public methods (abs(), negate(), nonNegative(), round(), convertTo(), avg(), allocateToTargets(), toArray() and helpers the engine uses). They are listed under "Not documented yet".

Currency methodWhat it does
Currency::getDefault(): selfThe shop's default currency
Currency::loadById(int $id): selfBy row id. Throws RuntimeException when there is none.
Currency::loadByCode(string $code): self, Currency::loadByNumber(int $number): selfBy ISO code (EUR) or ISO number (978)
getId(), getName(), getCode(), getNumber(), getSymbol(), getDecimalPlaces(), getDecimalSeparator(), getThousandSeparator(), getFormatPattern()Read the currency
format(float $amount, bool $includeSymbol = true): stringA number as the currency writes it
equals(Currency $other): bool, is(string $code): boolComparisons
toArray(): arrayThe currency as data, as in toArray() above

The stored price index​

What it does​

A price filter or a sort by price has to compare the prices of every item of the catalogue, and working each one out live would be far too slow. So Alfa works the prices out in advance and stores them in the table #__alfa_items_price_index. The price filter, the "on sale" and discount filters, and the sorts by price and by discount of a list read that table.

One stored row is the price of:

Part of the keyColumnMeaning
The itemitem_id
The combinationcombination_id0 for the item's own row
The currencycurrency_id0 for any
The placeplace_id0 for any, or unknown
The customer group setgroup_set_idOne list of the customer groups that prices, discounts or taxes name and that some customers hold together
The periodvalid_fromWhen the row starts to apply. valid_until is its end, empty for "no end".

Each row stores the same figures the price settings name: base_price, discount_amount, base_price_with_discounts, tax_amount, base_price_with_tax, final_price and discount_percent. They are unit prices at quantity 1.

The prices a shopper sees on an item page, on the cards of a list and in the cart are not read from the index. They are worked out live.

When it is rebuilt​

Core rebuilds the rows that a change touches, in the same request:

What staff doWhat is rebuilt
Save an item, or publish itThat item's rows
Unpublish, trash or delete an itemThat item's rows are removed
Save, publish, unpublish or delete a discount or a taxThe rows of the items in its scope. A rule for every category rewrites every item.
Delete a category or a manufacturerThe rows of the items that lost it
Change which groups customers holdNew group sets are requested, unused ones are removed

A customer group set that customers hold for the first time is not written by the request that brought it. The background job price.set.prepare prepares it, a part of the items at a time. Until it is finished, the set is not read at all.

A failure never blocks the save. It is logged as a warning, the item is marked as owed, and the background job catalogue.rebuild rebuilds it.

The rebuild lock​

Two rebuilds of one item must not overlap, or the older one could write last. So every writer of an item's rows first takes a lock on that item (IndexRebuildLock), reads the item only after it holds the lock, and saves before it lets go.

  • A writer waits up to 10 seconds for the lock (IndexRebuildLock::WAIT_SECONDS).
  • If it does not get the lock, it does not write. The item is marked as owed and the catalogue.rebuild job rebuilds it later.

PriceIndexSyncService takes the lock itself. A caller does not.

PriceIndexSyncService​

This class is the only writer of the index, and it is core's own. The code names only core callers: the item, discount, tax and category models, ProjectionManager, the price.set.prepare job, the Tools screen, the update script, and core's own system plugin (alfasync), which calls syncGroupSets() when the groups customers hold change. The class comment lists a "Public API" and maps each method to the core model that calls it. It does not address extensions, and no shipped payment, shipping or field plugin, module or template calls it.

So the code does not settle whether an extension may call the sync methods, and this page does not recommend it. What the code does establish:

  • An extension that changes prices through Alfa's own screens or models needs no call. Core rebuilds the rows.
  • Staff can rebuild the stored prices of every item with Tools > Database Sync, "Rebuild catalogue projections". It marks every item as owed and the catalogue.rebuild job rebuilds them.
  • A plugin that keeps its own copy of prices listens to onAlfaPricesChanged (below).

The methods are described so that you can read core's code and its log lines. The sync methods are instance methods: core builds the service first.

MethodWhat it doesWhat it guaranteesWhat it refuses
__construct(?PriceCalculator $calculator = null)Builds the service. Core passes no arguments.
syncItem(int $itemId, string $cause = PricesChangedEvent::CAUSE_REBUILD, int $causeId = 0): voidRebuilds one item's rowsNever throws. A failure is logged and the item is owed to the background job.An id of 0 or less does nothing
syncItems(array $itemIds, string $cause = ..., int $causeId = 0): intRebuilds the rows of a list of itemsReturns the number of rows written. Never throws. A part that fails is skipped and logged, the others are written.
syncItemsOrFail(array $itemIds, string $cause = ..., int $causeId = 0): intThe same, but a failure reaches the callerCore's projection manager calls it
syncByDiscount(int $discountId): int, syncByTax(int $taxId): intRebuilds the items a discount or a tax can touchA rule for every category rebuilds everythingNot for a delete: the scope is gone by then. Core's models collect the ids first with getItemIdsForDiscount() or getItemIdsForTax() and call syncItems() after the delete.
syncAll(string $cause = ..., int $causeId = 0): intRewrites every published itemSafe to run again. It prepares no new group set. On a large catalogue with many group sets it can exceed the request's time limit.
syncGroupSets(): intRequests the group sets customers hold that the index does not know, and removes the ones nobody holdsWrites no price row itself
deleteForItem(int $itemId, ...): void, deleteForItems(array $itemIds, ...): voidRemoves the rows of items
getItemIdsForDiscount(int $discountId): array, getItemIdsForTax(int $taxId): arrayThe items a rule can touchRead before the rule is deleted

The following public methods serve the preparation of group sets and the reading of the index. Do not call them: prepareSetChunk(), alignSets(), queueWaitingSets(), waitingSetId(), publishedItemCount(), preparationState(), accountGroupSet(), holdsGroupSet(), announcing(), and the read helpers pricingGroups(), slotKey(), slotSetJoin() and slotRanking() that the site items model uses to read the index.

What happens next: the event​

After a write to the index is saved for good, Alfa announces onAlfaPricesChanged once for the whole write, with the items and the rows that changed. A write that was undone is never announced. A plugin that keeps its own copy of prices listens to it. The event is described on Item and catalogue events.

How an item field changes a price​

An item field plugin does not call the engine. When Alfa prices an item that the shopper configured, it hands the event onCalculateItemPrice to the field's plugin, and the plugin adds price lines: "add 5.00 to the price with tax", "take 10 percent off the net price". Alfa applies the lines in order and then runs the taxes and discounts as for any item. Each line appears as a step of the price breakdown.

The event and the methods of a price line are described on Item field plugins.

A shipping charge's tax chain​

The engine on this page prices items. A shipping charge is priced by the shipping classes, but it uses the same tax rule: an ordered list of rates, each charged on what the one before left. That list is the charge's tax chain.

Changed in 2.2.0. Each entry of a chain names the tax rule of its rate:

[
['rate' => '24', 'name' => 'VAT'],
]

Before 2.2.0 the chain held the rate texts alone, for example ['10', '5'].

Where you read itShape
ShippingCharge::$chainA list of ['rate' => string, 'name' => string]. name is empty when no rule applied the rate. An empty list is an untaxed charge.
CartTotals::shipmentCosts()['chain'], CartTotals::shipmentTaxChain()The same list
PlacementCart::shipmentTaxChain()The same list
Order::shipmentTaxChain()The same list, or null when the order states none
The column #__alfa_order_shipments.shipping_tax_chainThe same entries as JSON text

A carrier plugin that passes its own shipping_tax_chain to the shipment ledger writes each entry either as a rate text ('24') or as exactly the two keys rate and name. A rate is plain decimal text above zero. Anything else is refused. A chain stored before 2.2.0 is still read, as rates with no rule named.

What can go wrong​

What you seeThe usual cause
Every figure of a result is zeroNo price row matched the quantity, the currency, the customer and the place
A discount never appliesOne of the five parts of its scope does not match, or it is outside its period. Check the visitor's groups, not the groups that may see the item.
An item in a list is priced at its minimum quantity although you passed a quantityThe map given to PricingIntent::cart() is not keyed by the integer item id
calculate() fails for an array of idsThe quantity map does not hold every id of the array. Give each id a quantity.
A unit price prints as 0.00It was printed with a per unit method of the result. Use PriceUnits::page().
Call to private method PriceSettings::global() or to an unknown methodglobal() is private, and compact(), full(), only() and except() do not exist in 2.2.0. Use get(), minimal() or make().
Non-static method ... cannot be called statically on the index serviceIts sync methods are instance methods. The service is core's own, see PriceIndexSyncService.
The price filter or the sort by price uses an old price after price data was written without Alfa's modelsThe index was not rebuilt for those items. Staff rebuild it with Tools > Database Sync, "Rebuild catalogue projections".
The price filter has no stored prices for some customers for a whileTheir customer group set is still being prepared by the price.set.prepare job
add() or subtract() throwsThe two Money objects are in different currencies

Changed in 2.2.0​

  • Changed in 2.2.0. In toArray() and formatAll() every saving is stated without tax, and savings.basis and discounts.basis say so.
  • Changed in 2.2.0. A shipping charge's tax chain is a list of rate and name pairs, not of rate texts.
  • Since 2.2.0. onAlfaPricesChanged is announced after each write to the price index.
  • Since 2.2.0. A new customer group set is prepared by the background job price.set.prepare, and every write of an item's rows holds the item's rebuild lock.
  • Changed in 2.2.0. A place prices a visitor only when it and every place above it are published.

Not documented yet​

  • The remaining public methods of Money (abs(), negate(), nonNegative(), round(), convertTo(), avg(), allocateToTargets(), toArray(), perUnitAtMaxScale(), ofRateTimesQuantity(), roundToCurrency(), timesRatioSum(), scaleCount(), percentOf(), floatDigits()) and of Currency (atScale(), formatRate(), formatUnit(), formatStored(), unitFigure(), rateDecimals(), groupFigure(), unitPrecisionIsAuto(), fromDatabase()).
  • The other methods of PriceUnits (line(), publishedLine(), exact(), published()).
  • DiscountSummary, TaxSummary and PriceBreakdown beyond their data shape.
  • How a combination's own price and adjustments form the unit price of a variant.
  • The price staff agree for one line of a customer's cart (ExplicitPriceInstruction, CustomPriceCommand).
  • The shipping classes themselves (ShippingCharge, ShippingTaxResolver and the others).
  • ProjectionManager and the catalogue.rebuild job.