Skip to content

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.

$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 charges

Price 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.

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.

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.

  • PricingSchema is 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_version records 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_FORM stores each option’s price per tier. STEP_UP stores an increment per tier and an included_count, and OptionGroupImpl derives the options as increment 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.

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.

  • colours and area are the only built-in choices. Anything else (panels, stitches) is a named step the importer defines in the technique’s steps, so a new supplier’s choice needs no engine code.
  • Supplier rules are applied at import, in each *Decorations class. 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_to limits a technique to some supplier SKUs, such as a cheaper print on white.
  • The shape can hold setup_repeat and add_ons, and importers write them, but the engine does not price them today.

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.

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 customer on a product page picks a colour, front screen print in 2 colours, and 100 units.

  1. The options. ProductConfiguratorData includes engine->options(): a required decorations.0.option choice with values like front|<house option key>, then when-dependent choices for colours and logo size.

  2. The request. The page posts to quote-cart.price (QuoteCartController::price), which calls ProductPricer::price().

  3. Pricer checks. The quantity is at least minimumOrder().

  4. 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.

  5. The sums. Illustrative numbers, with a 20% goods margin and GoodsMargin setup:

    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
  6. Pricer adds the variant’s price adjustment if it has one.

  7. 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.

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 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:

  1. The fixture (tests/Fixtures/WorkedExamples/<Feed>/) holds the feed’s own files, cut to the SKUs the orders use, and orders.json.
  2. The feed is imported for real: refresh() then addToCatalog(), with the real seeders for settings, position limits, method mappings and branding rules.
  3. The margin is set to 0%, because the business’s totals are costs before margin.
  4. 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.

Terminal window
ddev artisan test app-modules/catalog/tests/Pricing/PfConceptWorkedExamplesTest.php

To 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.