Skip to main content

guides/smart_catalogues.md

# Smart Catalogues — End-to-End Integration Guide

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

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

```elixir
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_total``entry.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:

```elixir
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):

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

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