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. Reference implementation
defmodule MyApp.SmartRules do
@moduledoc "Reference: applies CatalogueRule rows to a snapshot."
alias PhoenixKitCatalogue.Schemas.{CatalogueRule, Item}
@doc """
`entries` is a list of `%{item: %Item{}, qty: integer}`. Items in a
smart catalogue must have `:catalogue_rules` and the rules' nested
`:referenced_catalogue` preloaded — see "Pitfalls" below.
"""
def apply_rules(entries) do
ref_sums = build_ref_sums(entries)
Enum.map(entries, &compute_price(&1, ref_sums))
end
# Sum each standard catalogue's contribution to the order. Smart
# items deliberately don't contribute — their prices are themselves
# rule-computed and would yield 0 anyway.
defp build_ref_sums(entries) do
entries
|> Enum.filter(&(&1.item.catalogue.kind == "standard"))
|> Enum.group_by(& &1.item.catalogue_uuid)
|> Map.new(fn {catalogue_uuid, group} ->
{catalogue_uuid, Enum.reduce(group, Decimal.new(0), &Decimal.add(line_total(&1), &2))}
end)
end
defp line_total(%{item: %Item{base_price: nil}}), do: Decimal.new(0)
defp line_total(%{item: %Item{base_price: price}, qty: qty}),
do: Decimal.mult(price, Decimal.new(qty))
defp compute_price(%{item: %Item{catalogue: %{kind: "smart"}} = item} = entry, ref_sums) do
price =
Enum.reduce(item.catalogue_rules, Decimal.new(0), fn rule, acc ->
Decimal.add(acc, rule_amount(rule, item, ref_sums))
end)
Map.put(entry, :computed_price, price)
end
defp compute_price(entry, _ref_sums), do: entry
defp rule_amount(rule, item, ref_sums) do
{value, unit} = CatalogueRule.effective(rule, item)
ref_sum = Map.get(ref_sums, rule.referenced_catalogue_uuid, Decimal.new(0))
case {value, unit} do
{nil, _} -> Decimal.new(0)
{v, "percent"} -> Decimal.div(Decimal.mult(v, ref_sum), Decimal.new(100))
{v, "flat"} -> v
{_, _} -> Decimal.new(0)
end
end
end5. Pitfalls
Smart items must be loaded with rules preloaded
Neither Catalogue.list_items_for_category/1 nor
Catalogue.search_items/2 preloads :catalogue_rules. Hosts that
render smart prices must do this themselves:
items
|> MyApp.Repo.preload(catalogue_rules: :referenced_catalogue)Catalogue.list_catalogue_rules/1 and Catalogue.catalogue_rule_map/1
do preload the referenced catalogue, 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.