Pricing and decoration
Promotional products are priced by quantity and by how they are branded, so a price is an answer to
a question (“this variant, printed this way, at this quantity”), not a number on a product. All the
code is in app-modules/catalog/src/Pricing/. Staff-facing views are
pricing and branding.
The one entry point
Section titled “The one entry point”$result = app(ProductPricer::class)->price($product, new Configuration($variantId, $choices), $quantity);
$result->isQuoteRequired(); // and $result->reason$result->breakdown()->perUnit(); // Money$result->breakdown()->total(); // Money, with one-off chargesPrice through Pricing\ProductPricer, never $product->pricingEngine()->price(). It adds what
applies to every engine:
- The minimum order. Below
Product::minimumOrder()the answer is quote required. - The variant’s price adjustment, an amount or a percentage of the item’s price each, as its own component. Margin never applies to it.
- Split lines. A configuration with a
split(10 S, 20 M) is priced as one line: each variant at the line’s total quantity, setup charged once.
The invariant across the whole system: prices fail closed. Anything that can’t be priced is
PriceResult::quoteRequired($reason), never zero. In sales documents this becomes an awaiting_price
line, which blocks sending.
Engines
Section titled “Engines”A product names its engine in pricing_engine, and Pricing\PricingEngines (a singleton) maps
handles to classes. Contracts\PricingEngine has:
| Method | Returns |
|---|---|
handle() |
The stored handle |
options($product) |
A ConfigurationSchema: what a customer can choose |
price($product, $configuration, $quantity) |
A PriceResult |
displayQuantities($product) |
The quantities a product page shows |
printCapability($product) |
Most colours and full colour, copied to columns for filters |
fromPrices($product) |
The lowest prices, with and without charges, for the stored From price |
There are two engines. Add to catalog sets decoration on feed products. Manual and legacy products
keep grid.
To add one, implement the contract and register it in the PricingEngines singleton in
CatalogServiceProvider. ProductConfiguratorData, the price endpoint and sales documents all work
through the contract.
The grid engine and its schema
Section titled “The grid engine and its schema”Engines\GridEngine prices products.pricing_structure. Grid prices are selling prices: there is
no cost and no margin, and they don’t expire.
The JSON is read through contracts (PricingStructure, UnitPrice, OptionGroup, Option,
Price) with *Impl companions, from Product::getPricingStructure(). Quantities are columns
(pricing_tier_qty_1 to _6, num_pricing_tiers), up to PricingSchema::MAX_PRICE_TIERS, which
is 6.
PricingSchemais the contract for every JSON key. The readers, the Filament writer and the legacy importer all use its constants, so renaming a key is a one-place change. Write through its constants, never naked strings.products.pricing_schema_versionrecords the shape a row uses (PricingSchema::CURRENT_VERSION, currently 1). It is a column, not in the JSON, so a migration can query by it.- Two option group types (
Enums\OptionGroupType).FREE_FORMstores each option’s price per tier.STEP_UPstores an increment per tier and anincluded_count, andOptionGroupImplderives the options asincrement x max(0, position - included_count), so the first imprint colour adds nothing when the base price includes one. Nothing pre-multiplied is stored. - A row names its house method (
PricingSchema::UNIT_METHOD), which is how grid products reach the Imprint Method filter and whether a row prints full colour. - Setup charges are per row: once per line, or per chosen option of a group.
PricingStructureValidator guards it on save. Pricing\PriceCalculator does the arithmetic and knows
nothing about storage.
The decoration engine and its shape
Section titled “The decoration engine and its shape”Engines\DecorationEngine prices feed products from costs. It reads only the product and our own
settings, with no supplier code. Costs live on the variant’s source row, read by
ProductVariant::costAt($quantity). Branding lives on the product:
{ "version": 1, "currency": "GBP", "valid_until": "2026-12-31", "handling_per_unit": "0.05", "positions": [{ "key": "front", "label": "Front", "techniques": [{ "key": "S2", "label": "Screen printing", "applies_to": null, "min_quantity": null, "choice": ["colours"], "breaks": [1, 50, 100, 250], "rows": [{ "colours": 2, "prices": ["1.20", "1.00", "0.95", "0.91"], "setup": "58.00" }], "small_order": null }] }]}Illustrative values, abbreviated. DecorationPricingValidator is the authority for the shape and
Product::setDecorationPricing() checks against it. The column is not fillable, as the sync owns it.
coloursandareaare the only built-in choices. Anything else (panels, stitches) is a named step the importer defines in the technique’ssteps, so a new supplier’s choice needs no engine code.- Supplier rules are applied at import, in each
*Decorationsclass. A “first colour plus each extra” feed is expanded into one row per colour count, and setup per colour is multiplied out. The stored data is already final. - A null price or setup is missing and means quote required.
"0.00"means included. applies_tolimits a technique to some supplier SKUs, such as a cheaper print on white.- The shape can hold
setup_repeatandadd_ons, and importers write them, but the engine does not price them today.
Margin layers
Section titled “Margin layers”sell = cost / (1 - margin), the margin being a percentage of the selling price. Per-unit amounts
round to the penny (DecorationEngine::sell()).
Margins are Pricing\MarginBreaks: a list of {from, margin}, so they can change with quantity.
| Step | Where | Notes |
|---|---|---|
| 1 | products.margin_breaks |
The product’s own, through Product::marginAt() |
| 2 | supplier_source_settings.margin_breaks |
The feed’s default, through SupplierSourceSetting::marginAt() |
| Neither | Quote required, with “No margin is set” |
There is no margin per category or per site. Setup is priced separately, by the feed’s
Enums\SetupPricing mode:
| Mode | Setup selling price |
|---|---|
GoodsMargin |
The cost with the goods margin |
SetupMargin |
The cost with the feed’s setup_margin_percent |
HousePrice |
The house method’s setup_price. None set means quote required. |
HouseMinimum |
The setup margin, but never below the house method’s setup_minimum |
The house amounts are per colour where the method’s setup_basis says so.
Methods, rules and sheets
Section titled “Methods, rules and sheets”Staff never see a supplier’s code directly. Four things sit between the feed and the product page.
House methods and mappings. DecorationMethod is our list. SupplierMethod maps each feed’s code
to a house method, and SupplierPosition names print positions. Codes are recorded when decoration
pricing is written, keeping any mapping. An unmapped code is not offered. A product where nothing
is mapped yet shows “Branding to be confirmed” (BrandingState::ToBeConfirmed) and prices on request.
Branding rules. DecorationRule holds conditions (Enums\DecorationRuleCondition, matched all or
any) and an action (Enums\DecorationRuleAction: Hide, Rename, LimitSizes). They are applied when the
page is built, not at sync, so editing a rule changes every matching product at once.
Product overrides. products.branding_overrides and products.branding_positions rename, hide
and reorder per product, keyed by the supplier’s names so they hold across a sync.
Decoration\BrandingOptions is the one place that answers all this, in order: mapping, display
name, rules, then the product’s override, which beats the rules but can’t bring back an unmapped
code. Each answer says why, for staff. It reads rules once per request or job, and saving one calls
forgetLoaded(). catalog:check-branding {source} {eligibility} checks a feed’s branding, as the
rules offer it, against a JSON table of what the business expects.
Decoration sheets. DecorationSheet is a decorator’s price list for garments whose supplier gives
no print data. Decoration\DecorationSheetPricing turns a sheet and garment type into the same shape,
so the engine has only one form to read. Decoration\DecorationSheetAssignment decides the source, in
this order: the product’s own garment_type, the supplier’s print data, then the garment type of its
supplier category. Changing a sheet re-copies only what it priced (Jobs\ApplyDecorationSheets).
Codes at one position mapped to the same house method and name become one option, priced from whichever is cheapest for the line at that quantity among those offered on the variant.
A price, end to end
Section titled “A price, end to end”A customer on a product page picks a colour, front screen print in 2 colours, and 100 units.
-
The options.
ProductConfiguratorDataincludesengine->options(): a requireddecorations.0.optionchoice with values likefront|<house option key>, thenwhen-dependent choices for colours and logo size. -
The request. The page posts to
quote-cart.price(QuoteCartController::price), which callsProductPricer::price(). -
Pricer checks. The quantity is at least
minimumOrder(). -
Engine checks, in order. A margin exists. The feed’s prices are live (
priceFreshness()). The variant has a cost at 100. The option is offered on this variant, the quantity meets the technique’s minimum, and a row matches with a price at that break. -
The sums. Illustrative numbers, with a 20% goods margin and
GoodsMarginsetup:Line Cost Sell x 100 Blank 4.00 + handling 0.05 4.05 5.06 506.00 Front screen print, 2 colours 0.50 0.63 63.00 Setup (one-off) 40.00 50.00 50.00 Total 619.00 -
Pricer adds the variant’s price adjustment if it has one.
-
Valid until is the earliest of the variant’s cost expiry, the decoration pricing’s, and the day the feed’s prices stop.
The same call serves the cart, sales documents (Sales\Documents\Pricing\CatalogLinePricer, which
turns charges into owned setup lines) and the assistant.
From prices and minimum order value
Section titled “From prices and minimum order value”Pricing every card live would be far too slow, so products.mrp and mrp_with_charges are stored.
The engine’s fromPrices() tries the cheapest offered variant, and any with an adjustment, against
each branding option at one position, at each quantity of the display ladder.
Product::syncDerivedMrp() refreshes them on save when fromPriceColumns() change, and
Jobs\RefreshFromPrices does it in the background. catalog:refresh-from-prices is the manual
route. override_mrp lets staff set the price by hand.
Ordering\MinimumOrderValue is stored alongside: where a feed sets a minimum order value, it finds
the first of the feed’s quantity steps whose cheapest branded order at cost reaches it. It is at
cost, so it is the same on every site.
The worked-order tests
Section titled “The worked-order tests”The strongest pricing tests are in app-modules/catalog/tests/Pricing/:
PfConceptWorkedExamplesTest(10 orders)XdConnectsWorkedExamplesTest(25 orders)
Both extend WorkedExamplesTestCase. They check orders worked by the business against their totals,
to the penny, and they do so through the real path rather than mocks:
- The fixture (
tests/Fixtures/WorkedExamples/<Feed>/) holds the feed’s own files, cut to the SKUs the orders use, andorders.json. - The feed is imported for real:
refresh()thenaddToCatalog(), with the real seeders for settings, position limits, method mappings and branding rules. - The margin is set to 0%, because the business’s totals are costs before margin.
- Each order is priced through
ProductPricer, and must also be one a customer could build, with no more positions than the product allows.
{ "id": "E02", "model": "M100", "sku": "M10002", "quantity": 250, "total_pence": 12345, "decorations": [ { "position": "front", "code": "SP01", "colours": 1, "area": null, "repeats": 1 } ]}(The shape of one order, with illustrative values.)
An order we can’t price yet goes in the subclass’s gaps(), with the reason. A listed gap must not
match: when it starts matching, the test fails until you remove it, so the list stays true.
ddev artisan test app-modules/catalog/tests/Pricing/PfConceptWorkedExamplesTest.phpTo cover another feed, subclass WorkedExamplesTestCase, implement source(), fixture(),
printCodes() and gaps(), and add its fixture folder.
Unit-level coverage is in DecorationEngineTest, GridEngineTest and ProductPricerTest in the same
folder.