Smart Catalogues — End-to-End Integration Guide

Copy Markdown View Source

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.Catalogue and PhoenixKitCatalogue.Schemas.CatalogueRule. The repo's AGENTS.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:

  1. Standalone fee — for a smart item with no rules, default_value
    • default_unit is the price (e.g. default_value: 50, default_unit: "flat" means "this item costs $50 flat").
  2. Fallback for rule rows — a CatalogueRule row's value is nullable; when nil, it inherits from the item's default_value. The same is true at the data layer for unit, 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:

  1. Compute the standard line totals: panel.base_price * 1 = 100.
  2. Build a per-catalogue ref-sum: %{kitchen.uuid => 100}.
  3. For the delivery item, sum each rule: 15% × 100 = 15.
  4. 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_price

Default behaviour:

  • :line_totalentry.item.base_price * entry.qty (returns 0 when base_price is nil).
  • :write_to:smart_price.
  • Smart items with no rules get Decimal.new("0.00").
  • A rule referencing a catalogue not present in entries contributes 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.