This guide walks a host application through working with smart catalogues: catalogues whose items price themselves as a function of other catalogues. The schema and CRUD APIs are documented per-module; this guide covers the consumer side — how a host turns rule rows plus a live order into computed prices.
Looking for the data model? See
PhoenixKitCatalogue.CatalogueandPhoenixKitCatalogue.Schemas.CatalogueRule. The repo'sAGENTS.md"Smart catalogues" section is the schema-level reference; this guide is the integration-side companion.
1. Concepts
Catalogue kind
Every catalogue has a kind field — "standard" (the default) or
"smart". A standard catalogue holds items with intrinsic prices.
A smart catalogue holds items whose price is computed from rules that
reference other (standard) catalogues.
Concrete example: a "Services" smart catalogue holds a "Delivery" item with a rule "5% of Kitchen + 3% of Plumbing + $20 flat of Hardware". Each leg references a standard catalogue. The math happens host-side at order time — this module only stores the user's intent.
default_value / default_unit on items
Smart items have two extra columns that ride on the existing Item
schema: default_value (Decimal, nullable) and default_unit
(String, nullable, vocabulary "percent" / "flat" in V1).
Standard items leave both nil.
These serve two roles:
- Standalone fee — for a smart item with no rules,
default_valuedefault_unitis the price (e.g.default_value: 50,default_unit: "flat"means "this item costs $50 flat").
- Fallback for rule rows — a
CatalogueRulerow'svalueis nullable; whennil, it inherits from the item'sdefault_value. The same is true at the data layer forunit, but the UI does not surface unit inheritance — see the duality note below.
CatalogueRule rows
Each row is one (item, referenced_catalogue, value, unit, position)
tuple. The item lives in a smart catalogue; the referenced catalogue
must be kind: "standard" (the changeset rejects smart→smart
references — see issue #16). Self-references are rejected by the same
guard, since the only way an item could self-reference is if its own
catalogue were the referenced one, which is smart by definition.
UNIQUE(item_uuid, referenced_catalogue_uuid) prevents duplicates;
deleting a referenced catalogue cascades the rule rows away (FK has
ON DELETE CASCADE).
2. Schema overview
Catalogue (kind: "standard" | "smart")
│
├─ Category (mostly used on standard catalogues)
│ └─ Item
│ ├─ default_value, default_unit (smart-only, nullable)
│ └─ has_many :catalogue_rules
│
└─ CatalogueRule
├─ item_uuid (the smart-catalogue item)
├─ referenced_catalogue_uuid (must be kind: "standard")
├─ value, unit (nullable; inherits from item.default_value)
└─ position (UI ordering)3. Worked example
alias PhoenixKitCatalogue.Catalogue
# A standard catalogue with priced items
{:ok, kitchen} = Catalogue.create_catalogue(%{name: "Kitchen"})
{:ok, panel} = Catalogue.create_item(%{
name: "Oak Panel",
catalogue_uuid: kitchen.uuid,
base_price: Decimal.new("100")
})
{:ok, hinge} = Catalogue.create_item(%{
name: "Brass Hinge",
catalogue_uuid: kitchen.uuid,
base_price: Decimal.new("8")
})
# A smart catalogue with a service item
{:ok, services} = Catalogue.create_catalogue(%{name: "Services", kind: "smart"})
{:ok, delivery} = Catalogue.create_item(%{
name: "Delivery",
catalogue_uuid: services.uuid,
default_value: Decimal.new("5"),
default_unit: "percent"
})
# Replace-all the delivery item's rules in one transaction
{:ok, _rules} = Catalogue.put_catalogue_rules(delivery, [
%{referenced_catalogue_uuid: kitchen.uuid, value: Decimal.new("15"), unit: "percent"}
])A consumer building a price for an order with one panel and one delivery would:
- Compute the standard line totals:
panel.base_price * 1 = 100. - Build a per-catalogue ref-sum:
%{kitchen.uuid => 100}. - For the delivery item, sum each rule:
15% × 100 = 15. - Set the delivery line's price to
15.
4. Computing prices
Use Catalogue.evaluate_smart_rules/2 — the canonical implementation
of the algorithm. It pairs with Catalogue.item_pricing/1 for the
smart-catalogue case: standard entries pass through unchanged, smart
items get a computed price written to a configurable key.
alias PhoenixKitCatalogue.Catalogue
# Items must have :catalogue (and, for smart items, :catalogue_rules
# with :referenced_catalogue) preloaded. The bulk fetchers accept a
# :preload opt for exactly this:
items =
Catalogue.list_items_for_catalogue(catalogue_uuid,
preload: [catalogue_rules: :referenced_catalogue]
)
entries = Enum.map(items, &%{item: &1, qty: qty_for(&1)})
priced = Catalogue.evaluate_smart_rules(entries)
# => standard entries unchanged; smart entries get :smart_priceDefault behaviour:
:line_total—entry.item.base_price * entry.qty(returns 0 whenbase_priceisnil).:write_to—:smart_price.- Smart items with no rules get
Decimal.new("0.00"). - A rule referencing a catalogue not present in
entriescontributes 0.
Customizing line_total
The one piece of consumer policy is what an entry contributes to its
catalogue's ref-sum. Override :line_total to apply discounts before
smart-pricing, exclude tax, or compose your own snapshot rules:
discounted_line_total = fn %{item: i, qty: q} ->
base = i.base_price |> Decimal.mult(Decimal.new(q))
markup = Decimal.add(Decimal.new(1), Decimal.div(i.markup_percentage || 0, 100))
discount = Decimal.sub(Decimal.new(1), Decimal.div(i.discount_percentage || 0, 100))
base |> Decimal.mult(markup) |> Decimal.mult(discount)
end
Catalogue.evaluate_smart_rules(entries, line_total: discounted_line_total)Customizing the output key
For consumers that want a different field on each entry (e.g. to align with their snapshot's column naming):
Catalogue.evaluate_smart_rules(entries, write_to: :computed_price)5. Pitfalls
Smart items must be loaded with rules preloaded
Catalogue.list_items_for_category/2, list_items_for_catalogue/2,
list_uncategorized_items/2, search_items/2, get_item/2,
get_item!/2, and list_items_by_uuids/2 all accept a :preload opt
that merges into their default preloads. For smart-pricing, pass:
Catalogue.list_items_for_catalogue(uuid,
preload: [catalogue_rules: :referenced_catalogue]
)evaluate_smart_rules/2 raises ArgumentError if :catalogue or
:catalogue_rules is %Ecto.Association.NotLoaded{} on any entry —
no silent zero-pricing. Catalogue.list_catalogue_rules/1 and
Catalogue.catalogue_rule_map/1 do preload the referenced catalogue
already, so if you fetch rules separately you don't need to chain
another preload.
unit does not inherit at the UI layer (only value does)
CatalogueRule.effective/2 falls back to item.default_unit for
backward compat with rows persisted before the picker pinned unit
explicitly. New writes from the form always seed unit: "percent" (or
the dropdown's selected value). When you build a host UI for editing
rules, do not rely on the user changing default_unit to retroact
into rule rows — each row carries its own.
value is the opposite: a NULL value on a rule row inherits
item.default_value at math time, and the picker surfaces this with
an Inherit: N placeholder. Treat default_value as a "set 5% across
all my legs" shortcut.
Smart→smart references are rejected at the changeset layer
Trying to point a rule at a smart catalogue returns
{:error, %Ecto.Changeset{}} with the error
"must reference a standard catalogue, not a smart catalogue" on
:referenced_catalogue_uuid. The picker in ItemFormLive already
filters candidates to Catalogue.list_catalogues(kind: :standard), so
the UI never offers a smart catalogue as a candidate. Programmatic
callers (CLI, IEx, scripts) hit the changeset guard.
Referencing a deleted catalogue
Soft-delete sets status: "deleted" but leaves the FK valid, so
existing rule rows survive the catalogue's deletion. The
Catalogue.list_catalogue_rules/1 preload carries the
referenced_catalogue.status so the UI can dim or warn on dead refs.
Hard delete cascades the rule rows via ON DELETE CASCADE.
Decimal precision
Decimal.div keeps full precision (28 digits by default). Hosts that
serialize prices as strings should Decimal.round(2) (or whatever
your store conventions require) before write — otherwise you'll ship
14.99999999999999999999999999 to the client.
Live UI re-computation
If your host computes smart prices only at order-save time, users won't see the smart row update during editing. The reference implementation above is a pure function — call it from your LV's render path so prices stay live as quantities change.