← All tests

R10 — Product Variants (modifiers) PROD

2026-05-10 on prod. Adds the cafe-modifier pattern Narong asked for on the 2026-05-07 call (ice/hot, sugar level). Three new tables back the relational model (cafe.product_attributes + product_attribute_values + product_attribute_assignments); chosen variants snapshot to cafe.order_lines.variants jsonb. Both Starter and Pro tiers. New Modifiers tab in Settings, per-product Attributes section in product edit form, POS variant picker on add-to-cart, lineKey-based cart dedup so “Latte hot” and “Latte iced” stay on separate cart lines. Pro Odoo sync appends variant labels to pos.order.line.customer_note (real product.attribute Odoo modeling deferred per scope).

16/16 R10 prod test green. End-to-end on get-coffee config 7: schema verify, SSO login, create “Temperature R10” attribute via Modifiers admin UI, expand + add Hot ($0) + Iced (+$0.50) values, assign to product “Bread” via product edit form with Hot as default, create test cashier, open POS popup, PIN unlock + open shift, click Bread tile → variant picker opens, click Iced chip → picker total reflects +$0.50, Add to cart → cart line shows variant subtitle, pay cash → success modal → DB verify cafe.order_lines.variants jsonb populated, switch to Orders tab → Paid filter → click order → variant subtitle in detail panel. Cleanup runs in finally{} (force-close session, deactivate cashier, drop test attribute).

Commits

1606716 (backend) — feat(backend R10): cafe.product_attributes + values + assignments
                    tables + cafe.order_lines.variants jsonb
  migrations/20260510100000_cafe_product_variants.ts
  migrate.js (idempotent registry entry)

144fa81 (cafe) — feat(cafe R10): product variants — Modifiers admin + POS variant
                 picker + lineKey-based cart dedup + variants jsonb on order_lines
                 + Pro Odoo customer_note labels (both Starter+Pro)
  32 files changed, 1963 insertions(+), 72 deletions(-)
  + lib/db/product_attributes.ts, lib/actions/product_attributes.ts
  + lib/pos/attribute-pairs.ts (server-side helper)
  + app/(authed)/settings/modifiers/{page.tsx, modifiers-client.tsx}
  + app/(authed)/pos/_components/{variant-picker.tsx, line-key.ts}
  + integrations into ProductForm, RegisterShell, Cart, CartLineRow,
    pro-handlers, starter-handlers, draft_orders, odoo_sync, queries,
    native-receipt, khmer-receipt, orders POST + Odoo sync route, mounts,
    lockable-shell, starter-lockable-shell, orders-view detail rendering

e509918 (cafe) — fix(cafe R10): translate cafe.products.id → odoo_product_id
                 when building POS productAttributePairs
  Pro+cafe-master uses ShellProduct.id = String(odoo_product_id), but
  cafe.product_attribute_assignments FKs to cafe.products.id (UUID).
  buildShellAttributePairs was keying output by cafe UUID → mounts'
  attributesByProductId.get(p.id) lookup with the int-stringified id
  never matched → picker never opened on prod. Surfaced in the first
  Gate 2 run (10/16 with the picker step timing out). Fix: helper now
  takes a `Map` translation map; Pro+cafe-master
  callers build it from MirrorProduct.cafe_product_id (new field on the
  DAO result). Starter passes identity since ShellProduct.id IS
  cafe.products.id there.
  5 files changed, 53 insertions(+), 20 deletions(-)

R10 prod test (16/16)

#StepStatus
1Schema: 3 attribute tables + order_lines.variants jsonbpass
2Pre-flight: drop prior “Temperature R10” + close stray sessionspass
3SSO login as get-coffee owner (narongix)pass
4Modifiers admin: create attribute “Temperature R10”pass
5Add value “Hot” ($0)pass
6Add value “Iced” (+$0.50)pass
7Assign attribute to “Bread” via product edit formpass
8Create test cashier → capture PINpass
9Open /cafe/pos/register/7 popuppass
10PIN unlock + open shift $0pass
11Click “Bread” tile → variant picker opens with both chipspass
12Click Iced chip → picker total reflects +$0.50 extrapass
13Add to cart → cart line shows variant subtitlepass
14Pay cash → success modalpass
15DB: cafe.order_lines.variants jsonb populated for the new linepass
16Orders tab → Paid filter → variant subtitle in detailpass

Regression sweep

SuiteResultNotes
test-r10-prod.mjs (this ship)16/16Full Modifiers admin + POS picker + DB persistence flow on get-coffee config 7
test-phase1-prod.mjs11/11SSO routing + subscription page
test-phase2-sso-outdoor-prod.mjs6/6SSO bridge across products
test-phase2-cafe-multishop-prod.mjs6/6Demo creds, parallel-safe
test-m1-prod.mjs10/10Shop scoping
test-r7-prod.mjs14/14Dashboard + manager-live drawer
test-r8-prod.mjs4/4Auth/security trio (single-session, per-tenant scope)
test-r9-prod.mjs16/16Orders view + per-line refund

83/83 effective across the R10 ship + the full regression sweep. Sequential execution per the post-R8.2 sid rule (parallel runs sharing the narongix cred would mutually kick).

Architecture notes

Schema:
  cafe.product_attributes         — tenant-scoped templates (name, name_kh, sort_order)
  cafe.product_attribute_values   — options under each attribute, with price_extra_usd
  cafe.product_attribute_assignments — (product_id, attribute_id) PK + is_required + default_value_id
  cafe.order_lines.variants       — JSONB snapshot of chosen values at order time

Cart-line dedup:
  computeLineKey(productId, variants) returns either productId (no variants) or
  `${productId}:${sortedValueIds.join(",")}`. Sort is order-independent so the same
  selection in any array order produces the same key. RegisterShell uses lineKey
  for adjustQty / removeLine / setLineDiscount and as the React key.

Picker UX:
  VariantPicker reads product.attributes (from buildShellAttributePairs server-side).
  Each attribute renders as a row of selectable chips. Required attributes block
  the Add button until chosen. Defaults pre-fill from assignment.default_value_id.
  Cancel discards the click; Add computes unitPriceUsd = base + sum(extras), pushes
  to cart with the lineKey + variants[] snapshot.

Persistence:
  cafe.order_lines.variants jsonb populated on createNativeOrder (orders.ts).
  Drafts (cafe.draft_orders.payload jsonb) carry variants in payload.lines[].variants
  so park/resume preserves the choices.

Pro Odoo sync:
  Worker reads cafe.order_lines.variants, joins valueName labels with ", ",
  passes as CreateOrderLine.variantNote → pos.order.line.customer_note in Odoo.
  Skips real product.attribute modeling (heavy schema + Narong hasn't asked
  for variant-level reporting on the Odoo side yet — defer until requested).

Tier scope:
  Both Starter (uuid product ids) and Pro+cafe-master (Odoo-int shell ids).
  buildShellAttributePairs takes a Map<cafeProductId, shellId> translation
  so the page-level wiring decouples assignment lookup (cafe UUIDs) from the
  ShellProduct id used by tiles + the picker's productAttributePairs map.

Lessons from this session

1. Pro+cafe-master uses two distinct product ids on the same row.
   cafe.products.id (UUID, real PK) is what assignments + product edit URLs key
   off. ShellProduct.id (= String(cafe.products.odoo_product_id)) is what the POS
   register tiles use, since the legacy Pro path (Odoo direct) hands int ids
   straight through to the shell. The first Gate 2 run failed at 10/16 because
   buildShellAttributePairs keyed output by the UUID but the mount looked up by
   the Odoo int. Fixed by introducing a translation map at the helper boundary
   + threading cafe_product_id through MirrorProduct.

2. Bundle-all gate cycles surface integration bugs the per-phase runs
   would have caught earlier. R10.1–R10.4 typechecked clean and the
   local DAO probe passed in isolation, but the cross-component assumption that
   ShellProduct.id matched cafe.products.id only failed on real Pro+cafe-master
   data. Future ARCs that bundle should still run a brief end-to-end smoke
   against prod-shaped data before declaring local "Gate 1 done."

3. Hyperdrive cache wraps weren't needed for the picker reads
   (we set them up in listAssignmentsForProducts proactively). Confirmed — the
   first variant-on-product test resolved cleanly without read staleness. Good
   that the pattern is now reflexive after R9's two repro cases.
01Modifiers admin (empty)
New “Modifiers” tab in the Settings master-detail layout. Empty-state copy explains the use case (customer choices on products) before any attributes exist.
02Attribute created
“Temperature R10” lands in the list with 0 values. Clicking the row expands an inline values panel.
03Values added
Hot ($0) and Iced (+$0.50) added through per-attribute “Add value” modal. Each value carries a price_extra_usd that the picker adds to base unit price.
04Product edit form
/cafe/products → click Bread card. New Modifiers section renders only when the tenant has at least one attribute; otherwise the form looks identical to before.
05Modifier assigned
Toggle reveals is_required + default_value_id controls. Default = Hot pre-fills the picker on click in the POS register.
06Register opened
PIN unlock + $0 open-shift confirmed. Standard product grid renders for Pro+cafe-master with Odoo-int product ids on the tile testids.
07Variant picker opens
Modal renders: title = product name, base price line, attribute row with chips (Hot pre-selected per default_value_id, Iced shows the +$0.50 extra inline). Add button enabled because the only required attribute already has a selection.
08Iced selected
Click Iced chip → highlights green. Footer Total updates to base + $0.50 (0.50 + 0.50 = $1.00 for Bread @ $0.50 base; the test asserts the math from cafe.products.price_usd).
09Cart line + variant subtitle
Variant labels render as a small italic muted line under the product name. Line testid uses lineKey (productId+sortedValueIds) so two of the same product+variants stack qty.
10Paid
Pay flow unchanged from R9. cafe.orders row created; order_lines.variants jsonb persisted on the new line (asserted in test step 15).
11Orders detail
Variant labels surface in the in-POS Orders detail (“Iced” under product name on the refundable line list). Same JSONB column drives this rendering as the cart line + receipt + Odoo customer_note sync.

Probe output

loading…