Saltar al contenido principal

Menus & Modifier Groups — Developer Reference

Admin UI for restaurant menus (restaurantMenus) and per-product modifier configuration. Covers PWA components, state, REST API, full schema column reference, validation/error codes, delete/archive rules, order persistence, POS flow, and downstream consumers (orders, pricing, KDS).

Last verified against: apps/frontend-pwa Menus page · apps/backend MenuModule / ProductModifiersController

Quick links: Restaurant architecture · Restaurant PWA screens · Bundles & modifiers (combos) · Bruno collections

On this page: All related tables · Schema columns · Constraints · DTOs · Validation errors · Delete/archive · Order persistence · Channel visibility · Modifier types · POS flow · REST API


Overview

The Menus screen (/forms/restaurantMenus) lets merchants:

  1. Create and pin menus to a location (menu, location_menu_assignment).
  2. Add menu items that reference catalog products (menu_item).
  3. Configure modifier groups and options per product (product_modifier_group, product_modifier, product_modifier_group_assignment).
  4. Manage recipes / BOM per product (product_recipe) on a separate tab.

Modifier groups are business-scoped library entries attached to products via a join table. The same group (e.g. “Tipo de leche”) can be shared across many products.

At POS, selections are validated against effective min/max constraints and persisted on order_item_modifier with immutable snapshots.


PWA route and layout

ItemValue
Route/forms/restaurantMenus
Page componentapps/frontend-pwa/src/components/forms/restaurant/MenusPage.tsx
Remote config form keyrestaurantMenus (see config/firebase/remote-config/pwaMenu.json)

Modifier library (business-wide)

ItemValue
Tree editor route/forms/restaurantModifierGroups
Tree editor pageModifierGroupsLibraryPage.tsxModifierGroupsWorkspace (mode: library)
Legacy flat list route/forms/restaurantModifierLibrary (unchanged)
Data hook (tree + options)useModifierLibraryGroupsWithModifiers
Shared UImenus/ModifierGroupFormPanel, ModifierGroupList, appearance editors

Library mode uses the same tree/options/colors UI as the product tab but does not attach/detach groups to products or reorder product prompt order. Creating a group only calls POST modifier groups (no attachModifierGroup).

Page grid

┌─────────────────────────────────────────────────────────────────┐
│ MenusPageHeader (title, Menu Gateway, Go to Dining, Refresh) │
├──────────────────────┬──────────────────────────────────────────┤
│ MenusPageLeftColumn │ MenusPageContent (right column) │
│ xl:col-span-4 │ xl:col-span-8 2xl:col-span-9 │
│ │ │
│ · location hint │ · MenuRightPanel (when menu selected) │
│ · MenuFormPanel │ - tabs: Menu Items | Modifier Groups │
│ · MenuListPanel │ | Recipes / BOM │
│ · MenuLocationsDialog│ │
└──────────────────────┴──────────────────────────────────────────┘

The right column shell is MenusPageContent (line ~324 in MenusPage.tsx). It only renders MenuRightPanel when selectedMenuId is set.


Component hierarchy (Modifier Groups tab)

flowchart TB
MenusPage --> MenusPageContent
MenusPageContent --> MenuRightPanel
MenuRightPanel --> Tabs[Menu Items | Modifier Groups | Recipes]
MenuRightPanel --> ModifierGroupsTab
ModifierGroupsTab --> ActionBar[ModifierGroupActionBar]
ModifierGroupsTab --> CreateForm[ModifierGroupCreateForm]
ModifierGroupsTab --> List[ModifierGroupList]
ModifierGroupsTab --> LibraryPicker[ModifierGroupLibraryPicker]
List --> Row[ModifierGroupRow]
Row --> Header[ModifierGroupRowHeader]
Row --> Override[ModifierGroupOverrideEditor]
Row --> OptionRow[ModifierOptionRow]
Row --> OptionAdd[ModifierOptionAddForm]

File map (frontend)

ComponentPathRole
MenusPageMenusPage.tsxPage shell, menusPageReducer, data/actions hooks
MenuRightPanelmenus/MenuRightPanel.tsxTab bar; routes to Items / Modifiers / Recipes
ModifierGroupsTabmenus/ModifierGroupsTab.tsxOrchestrates bar, form, list, library picker
useModifierGroupsTabmenus/useModifierGroupsTab.tsLocal reducer + API handlers
ModifierGroupActionBarmenus/ModifierGroupActionBar.tsxCreate new · Attach from library
ModifierGroupListmenus/ModifierGroupList.tsxMaps groups → rows
ModifierGroupRowmenus/ModifierGroupRow.tsxExpandable card, options, overrides
ModifierGroupRowHeadermenus/ModifierGroupRowHeader.tsxName, required badge, min–max pill, reorder, detach/delete
ModifierGroupLibraryPickermenus/ModifierGroupLibraryPicker.tsxModal to attach existing library groups
useRestaurantModifiershooks/restaurant/useRestaurantModifiers.tsTanStack Query for product groups
restaurant-modifier.serviceservices/restaurant/restaurant-modifier.service.tsTyped API client

ModifierGroupActionBar

ButtonBehavior
Create newToggles mode between "list" and "create" in useModifierGroupsTab. Primary variant when create mode is active. Shows ModifierGroupCreateForm below.
Attach from libraryOpens ModifierGroupLibraryPicker (showLibraryPicker: true).

Create new calls POST /product-modifier-groups then POST /products/:productId/modifier-groups (create + attach in one flow).

Attach from library calls only POST /products/:productId/modifier-groups.

ModifierGroupRowHeader

Left → right:

  1. Expand/collapse — chevron; rows default to collapsed.
  2. Group namegroup.name.
  3. Required badge — when effectiveMinSelection ?? minSelection >= 1.
  4. Selection range{effectiveMin}–{effectiveMax} (e.g. 0–1).
  5. Move up / down — swaps sortOrder via PATCH /product-modifier-groups/:id.
  6. Detach (Link2Off) — when onDetach is provided (product assignment context). Removes product_modifier_group_assignment row only.
  7. Delete (Trash2) — when not detachable; deletes/archives the library group.

Expanded body (in ModifierGroupRow):

  • ModifierGroupOverrideEditor — per-product min/max overrides (PATCH …/modifier-group-assignments/:id).
  • Active ModifierOptionRow list + ModifierOptionAddForm.

Prerequisites on the Modifier Groups tab

ModifierGroupsTab receives productId from selectedProductId in page state. That ID comes from selecting a row on the Menu Items tab (menu_item.product_id). Without a selected product, the tab shows a dashed empty state.


State management

Page-level (menusPageReducer)

State fieldPurpose
selectedMenuIdActive menu (left column)
selectedProductIdProduct for Modifiers / Recipes tabs
rightTab"items" | "modifiers" | "recipes"

Hooks: useMenusPageData, useMenusPageActions, useMenusBulkAdd.

Query keys (hooks/restaurant/queryKeys.ts):

  • restaurantKeys.menus(businessId, locationId)
  • restaurantKeys.menuItems(menuId)
  • restaurantKeys.modifierGroups(productId)
  • restaurantKeys.modifierLibrary(businessId, params)
  • restaurantKeys.recipe(productId)

Tab-level (useModifierGroupsTab reducer)

FieldPurpose
mode"list" | "create"
showLibraryPickerLibrary modal visibility
groupName, minSelection, maxSelectionCreate form
formError, operationErrorUser-facing errors

Data: useRestaurantModifiers(productId)GET /products/:productId/modifier-groups?include=modifiers, staleTime: 5 minutes.

Per-row state lives in ModifierGroupRow’s local reducer (expand, archive/detach confirms, option form, override editor).


Data model (PostgreSQL)

Single checklist of every PostgreSQL object that touches modifiers — catalog, orders, pricing, import, and delivery. Normative column detail is in Schema column reference below.

Core catalog (configure in Menus / REST)

TableKyselyRole
product_modifier_groupproductModifierGroupBusiness-wide modifier group library (min_selection, max_selection, auto_prompt, is_active)
product_modifierproductModifierOptions inside a group (group_id, price_adjustment, labels, linked_product_id, channel flags, modifier_type)
product_modifier_group_assignmentproductModifierGroupAssignmentAttaches a group to a product_id with optional min/max overrides

Shared catalog FK: product (sellable PLU), business.

PostgreSQL enum: modifier_type — values modifier, instruction, combo_choice (on product_modifier; snapshotted on order_item_modifier).

TableRole
menuNamed menu (BEBIDAS, DESAYUNOS, …)
menu_itemmenu_id + product_id — selecting a row drives which product’s modifiers load
location_menu_assignmentMenus enabled per location

Orders / POS runtime

TableRole
order_itemParent sale line
order_item_modifierImmutable snapshots of guest choices (name, price delta, labels, channel flags, modifier_type)

Legacy: order_item.modifiers JSONB may exist on old rows; new flows use order_item_modifier.

Pricing

TableRole
price_list_itemWhen item_type = 'modifier', references product_modifier_id

RPApos import (mapping — not modifier catalog rows)

Table / enum valueRole
rpapos_id_mapentity_typeplu_option_group, plu_option_item, plu_option_group_assignment → FlowPOS UUIDs
rpapos_extraction_archiveRaw SOAP rows per import job

RPApos source tables (tenant DB / SOAP Catalogo_4, not in FlowPOS):

RPApos pTablaFlowPOS targetCanonical wire columns (tenant-confirmed)
Producto_Grupoproduct_modifier_groupProducto_Grupo, Descripcion, Minimo, Maximo
Producto_Grupo_Detalleproduct_modifierProducto_Grupo_Detalle, Producto_Grupo, Codigo_PLU, Orden
Producto_Grupo_Relacionproduct_modifier_group_assignmentCodigo_PLU / Id_PLU, Producto_Grupo / Id_Grupo

See RPApos entity mapping and External RPApos API.

Import order: groups → items → product↔group links (items require plu_option_group id_map rows).

Delivery marketplace

TableRole
marketplace_menu_mappingentity_typemodifier_group, modifier — external menu IDs (e.g. PedidosYa)

Combos / bundles (separate subsystem)

TableRole
bundleCombo / mix-and-match catalog definition
bundle_componentComponent lines; may reference products
bundle_component_groupChoice groups for restaurant combos
bundle_applicationAudit of bundle applied to a document

product_modifier.modifier_type = combo_choice is a label-only hook; combo runtime uses bundle tables. See Bundles & modifiers (combos).

Recipes tab (same Menus screen, not modifiers)

TableRole
product_recipeBOM / ingredient lines per product

Key migrations

FilePurpose
2026-02-18t19-00-00.000z-product-modifiers.mjsproduct_modifier_group, product_modifier
2026-05-08t12-00-00-product-modifier-group-assignment.mjsproduct_modifier_group_assignment
2026-03-14t00-04-00-order-item-modifiers.mjsorder_item_modifier
2026-05-18t10-00-00-modifier-channel-flags.mjsChannel visibility on product_modifier
2026-05-18t11-00-00-modifier-type-enum.mjsmodifier_type enum + column
2026-05-18t12-00-00-order-item-modifier-channel-snapshots.mjsChannel snapshots on order_item_modifier
2026-05-18t14-00-00-rpapos-modifier-entities.mjsrpapos_entity_type extension for import

Core modifier catalog

TableKyselyPurpose
product_modifier_groupproductModifierGroupBusiness-wide library group: name, min_selection, max_selection, sort_order, auto_prompt, is_active
product_modifierproductModifierOptions in a group: group_id, name, price_adjustment, sort_order, is_default, labels, optional linked_product_id
product_modifier_group_assignmentproductModifierGroupAssignmentLinks group → product_id with min_selection_override, max_selection_override, sort_order

Effective constraints (shown in UI):

effective_min = COALESCE(assignment.min_selection_override, group.min_selection)
effective_max = COALESCE(assignment.max_selection_override, group.max_selection)

Required is not a column: effective_min >= 1.

Unique constraint: one assignment per (product_id, modifier_group_id).

TablePurpose
menuNamed menu (BEBIDAS, DESAYUNOS, …), optional time windows
menu_itemmenu_id + product_id, sort, availability, metadata (button colors, 86d, combo)
location_menu_assignmentWhich menus are available at a location

Runtime / downstream

TablePurpose
order_item_modifierImmutable snapshots per order line
order_itemParent line; validation on add/update
price_list_itemOptional product_modifier_id when item_type = 'modifier'
product_recipeBOM for Recipes tab
Bundle tablesCombo lines may reference product_modifier_id
erDiagram
menu ||--o{ menu_item : contains
menu_item }o--|| product : references
product ||--o{ product_modifier_group_assignment : has
product_modifier_group_assignment }o--|| product_modifier_group : links
product_modifier_group ||--o{ product_modifier : contains
order_item }o--|| product : sold_as
order_item ||--o{ order_item_modifier : snapshots
order_item_modifier }o--o| product_modifier : catalog_ref

End-to-end flow

  1. Merchant selects menu BEBIDAS → loads menu_item rows.
  2. Selecting a menu item sets productId from menu_item.product_id.
  3. Modifier Groups tab loads assignments + groups + modifiers for that product.
  4. At POS, ModifierValidationService enforces constraints; order_item_modifier stores choices.

Schema column reference

Normative DDL lives in packages/backend/database/src/migrations/. Kysely table names use camelCase.

product_modifier_group

Business-scoped library entry. Not tied to a product directly (use product_modifier_group_assignment).

ColumnTypeDefaultDescription
iduuidgen_random_uuid()Primary key
business_iduuidFK → business.id ON DELETE CASCADE
namevarcharDisplay name (e.g. “Tipo de leche”)
min_selectioninteger0Minimum options guest must/can pick
max_selectioninteger1Maximum distinct options allowed; 0 with min_selection = 0 = all optional (group not required, no upper cap)
selection_modemodifier_group_selection_mode'unique'How picks count toward min/max: unique (distinct options), checkbox (on/off per option; distinct picks like unique), repeatable_bounded (total slot count, same option can repeat), repeatable_unlimited (no max; price × quantity)
sort_orderinteger0Order in library lists / group reorder
auto_promptbooleanfalseIf true, force modifier picker open even when all groups are optional
is_activebooleantruefalse = archived (hidden from active lists)
created_attimestamptzCURRENT_TIMESTAMP
created_byuuidnullFK → user.id SET NULL
updated_attimestamptznull
updated_byuuidnullFK → user.id SET NULL

Derived (not stored): is_requiredmin_selection >= 1 (or effective min on product endpoints).

product_modifier

Individual option within a group.

ColumnTypeDefaultDescription
iduuidgen_random_uuid()Primary key
group_iduuidFK → product_modifier_group.id ON DELETE CASCADE
namevarcharOption label (e.g. “Deslactosada”)
price_adjustmentnumeric(20,4)0Added to line price; negative allowed (e.g. “Sin queso −Q0.50”); rendered as signed badge (+Q1.50 / −Q0.50) in the POS modifier dialog; zero renders no badge
sort_orderinteger0Order within group
is_defaultbooleanfalsePre-select when POS dialog opens for a new order item in unique (and other non-checkbox) modes (ignored when editing an existing line; first default wins for single-select groups with multiple defaults, and a warning toast is shown)
default_checkedbooleanfalseCatalog “starts on” for checkbox selection mode — pre-checks the option when the POS opens for a new item (separate from is_default; not stored on order lines)
is_activebooleantruefalse = archived
kitchen_labelvarchar(150)nullKDS override (e.g. XTRA CHEESE, SIN CEBOLLA); admin-editable via ModifierEditor
receipt_labelvarchar(150)nullReceipt/PDF override; admin-editable via ModifierEditor
linked_product_iduuidnullFK → product.id SET NULL — optional inventory link
quantity_per_modifiernumeric(20,6)nullUnits of linked product per selection; required if linked_product_id set
modifier_typemodifier_type'modifier'Semantic type: modifier (paid add-on), instruction (kitchen-only note), combo_choice (label only; combo runtime via bundle subsystem). See Modifier types.
show_on_kitchen_ticketbooleantrueInclude on kitchen / KDS tickets. Filtered at print and KDS WebSocket render points.
show_on_receiptbooleantrueInclude on customer receipts and bill PDFs.
show_on_customer_displaybooleantrueInclude on CFD screen (snapshot captured at v1; no CFD consumer yet).
show_on_online_orderingbooleantrueInclude in online ordering catalog push (PedidosYa menu push filters on this live column, not snapshot).
created_at, created_by, updated_at, updated_byAudit

Note: Stock deduction from linked_product_id at sale finalization is not implemented yet (TODO(modifier-inventory) in migration comments).

product_modifier_group_assignment

Links a library group to a product with optional per-product rules.

ColumnTypeDefaultDescription
iduuidgen_random_uuid()Primary key; API field assignmentId
product_iduuidFK → product.id ON DELETE CASCADE
modifier_group_iduuidFK → product_modifier_group.id ON DELETE CASCADE
sort_orderinteger0Order of this group on the product
min_selection_overrideintegernullNULL → use group min_selection
max_selection_overrideintegernullNULL → use group max_selection
created_at, created_by, updated_at, updated_byAudit

Unique: (product_id, modifier_group_id) — attach once per product.

Effective constraints (computed in queries):

effective_min = COALESCE(min_selection_override, pmg.min_selection)
effective_max = COALESCE(max_selection_override, pmg.max_selection)

order_item_modifier

Transactional record of one modifier choice on an order line. Snapshot columns are immutable — catalog renames must not alter history.

ColumnTypeDefaultDescription
iduuidgen_random_uuid()Primary key
order_item_iduuidFK → order_item.id ON DELETE CASCADE
product_modifier_group_iduuidnullFK → product_modifier_group.id SET NULL
product_modifier_iduuidnullFK → product_modifier.id SET NULL
quantitynumeric(20,6)1Units (e.g. 2× extra shot)
unit_price_adjustmentnumeric(20,4)0Per-unit price delta at order time
total_price_adjustmentnumeric(20,4)0unit_price_adjustment × quantity
name_snapshotvarchar(150)Required — modifier name at order time
kitchen_label_snapshotvarchar(150)nullKitchen display string
group_name_snapshotvarchar(150)nullGroup name at order time — rendered as [Group Name] section headers on kitchen tickets; null modifiers print without a header
receipt_label_snapshotvarchar(150)nullReceipt display string
is_default_snapshotbooleanfalseWas default option at order time
linked_product_id_snapshotuuidnullNo FK — survives catalog deletes
modifier_type_snapshotmodifier_typenullType at order time; null for pre-migration rows → treated as 'modifier'
show_on_kitchen_ticket_snapshotbooleannullChannel flag at order time; null treated as true (IS NOT FALSE filter)
show_on_receipt_snapshotbooleannullChannel flag at order time; null treated as true
show_on_customer_display_snapshotbooleannullChannel flag at order time; null treated as true
show_on_online_ordering_snapshotbooleannullChannel flag at order time; null treated as true
created_attimestamptzCURRENT_TIMESTAMP
created_byuuidnullFK → user.id SET NULL

Legacy order_item.modifiers JSONB may still exist on older rows; new flows use this table.


Constraints and indexes

Check constraints

TableConstraintRule
product_modifier_groupchk_modifier_group_selection_rangemin_selection >= 0, max_selection >= 0, min_selection <= max_selection
product_modifierchk_modifier_quantity_positivequantity_per_modifier IS NULL OR quantity_per_modifier > 0
product_modifierchk_modifier_inventory_link_consistency(linked_product_id IS NULL) = (quantity_per_modifier IS NULL)
product_modifier_group_assignmentchk_assignment_override_rangeOverrides non-negative; if both set, min <= max

Indexes

IndexTableColumnsPurpose
idx_product_modifier_group_businessproduct_modifier_groupbusiness_idLibrary list by business
idx_product_modifier_group_idproduct_modifiergroup_idOptions by group
idx_product_modifier_linked_productproduct_modifierlinked_product_id (partial, WHERE NOT NULL)Inventory-linked modifiers
idx_assignment_product_idproduct_modifier_group_assignmentproduct_idGroups for product
idx_assignment_modifier_group_idproduct_modifier_group_assignmentmodifier_group_idProducts using a group
idx_order_item_modifier_order_item_idorder_item_modifierorder_item_idModifiers on a line
idx_order_item_modifier_product_modifier_idorder_item_modifierproduct_modifier_idModifier usage analytics

API request bodies (DTOs)

Backend DTOs: apps/backend/src/restaurant/interfaces/dtos/. PWA Zod: apps/frontend-pwa/src/schemas/restaurant/modifier-group.schema.ts.

Create modifier group — POST /product-modifier-groups

FieldTypeRequiredNotes
businessIduuidyes
namestringyes
minSelectionint 0–100nodefault 0
maxSelectionint 0–100nodefault 1; must be ≥ min
sortOrderint ≥ 0no
autoPromptbooleannoForce picker on POS

Attach to product — POST /products/:productId/modifier-groups

FieldTypeRequiredNotes
modifierGroupIduuidyesLibrary group to link
sortOrderint ≥ 0noAssignment order
minSelectionOverrideint 0–100nonull = use group base
maxSelectionOverrideint 0–100nonull = use group base

Update assignment — PATCH /products/:productId/modifier-group-assignments/:assignmentId

Same override fields as attach (sortOrder, minSelectionOverride, maxSelectionOverride).

Create modifier option — POST /product-modifiers

FieldTypeRequiredNotes
groupIduuidyesParent group
namestringyes
priceAdjustmentnumbernoNegative allowed on API/DB
sortOrderint ≥ 0no
isDefaultbooleannoPre-select on dialog
kitchenLabelstring ≤150noKDS override; null = use name
receiptLabelstring ≤150noReceipt override; null = use name
linkedProductIduuidnoMust pair with quantityPerModifier
quantityPerModifiernumber > 0noRequired when link set
modifierTypemodifier | instruction | combo_choicenoDefault modifier
showOnKitchenTicketbooleannoDefault true
showOnReceiptbooleannoDefault true
showOnCustomerDisplaybooleannoDefault true
showOnOnlineOrderingbooleannoDefault true

Update modifier option — PATCH /product-modifiers/:id

All fields optional (sparse update). Same field set as create except groupId is not updatable. Channel flags and modifierType omitted from the payload when not explicitly provided (not reset to defaults).

FieldNotes
name, priceAdjustment, sortOrder, isDefaultCore fields
kitchenLabel, receiptLabelSend null to clear
linkedProductId, quantityPerModifierMust be updated together
modifierTypemodifier | instruction | combo_choice
showOnKitchenTicket, showOnReceipt, showOnCustomerDisplay, showOnOnlineOrderingBoolean channel flags

POS / order — selection payload

Used in add-item and replace-modifiers requests:

interface SelectedModifierItem {
modifierId: string;
groupId: string;
quantity?: number; // default 1
}

PATCH /orders/:orderId/items/:itemId/modifiers body: { selectedModifiers: SelectedModifierItem[] }.


Validation and error codes

ModifierValidationService (pure, no DB)

Service: apps/backend/src/restaurant/application/modifier-validation.service.ts.

Input: selected[] with { modifierId, groupId, quantity? } and groups[] with effective constraints plus selectionMode per attached group.

Selection modes (product_modifier_group.selection_mode, enum modifier_group_selection_mode):

ModePOS behaviorMin/max applies to
unique (default)Tap toggles each option; radio UI when max = 1Distinct options selected
checkboxCheck/uncheck each option; checkbox UI even when max = 1 (swap pick, not radiogroup)Distinct options selected
repeatable_boundedEach tap adds a slot; same option can repeatTotal slot count (sum of quantities)
repeatable_unlimitedEach tap adds; max ignoredTotal slot count; min still enforced

Pricing: extra = Σ (priceAdjustment × quantity); persisted rows use collapsed { modifierId, groupId, quantity }.

CodeConditionMessage pattern
REQUIRED_GROUP_MISSINGisRequired and zero slots in groupSelection required for "{groupName}"
MIN_SELECTIONS_NOT_METslot count < minSelectionsAt least N selection(s) required…
MAX_SELECTIONS_EXCEEDEDslot count > maxSelections (bounded modes when max > 0)At most N selection(s) allowed…
DUPLICATE_MODIFIER_IN_GROUPunique / checkbox mode and same modifierId twiceDuplicate option is not allowed…
INVALID_QUANTITY_FOR_UNIQUEunique / checkbox mode and quantity > 1Only one of each option is allowed…

isRequired on each constraint = effectiveMinSelection >= 1.

HTTP: 422 Unprocessable Entity with body:

{
"message": "Modifier validation failed.",
"code": "MODIFIER_VALIDATION_FAILED",
"errors": [ { "groupId", "groupName", "code", "message", "actualSelections", ... } ]
}

Order edit lock

CodeHTTPWhen
ORDER_ITEM_LOCKED_FOR_EDIT409PATCH …/modifiers when order_item.status !== "pending" (already sent to kitchen)

Delete / routing conflicts

CodeHTTPEntityWhen
MODIFIER_GROUP_IN_ROUTING409GroupAny option in group appears in product_station_assignment.modifier_routing
MODIFIER_IN_ROUTING409OptionModifier id keyed in modifier_routing JSONB

Routing check uses PostgreSQL ?| (any key exists) for groups and ? for single modifier id.


Delete and archive behavior

Implemented in ProductModifierGroupService.deleteModifierGroup and ProductModifierService.deleteModifier.

Modifier group delete (DELETE /product-modifier-groups/:id)

flowchart TD
A[DELETE group] --> B{Any option in modifier_routing?}
B -->|yes| C[409 MODIFIER_GROUP_IN_ROUTING]
B -->|no| D{order_item_modifier rows for group?}
D -->|yes| E[Archive: is_active = false + warning]
D -->|no| F[Hard delete group + cascade options]

Modifier option delete (DELETE /product-modifiers/:id)

Same three paths; routing code MODIFIER_IN_ROUTING (single modifier).

Detach (DELETE /products/:productId/modifier-groups/:groupId)

  • Deletes only product_modifier_group_assignment row.
  • Library group and options remain; other products keep the attachment.
  • UI shows Detach when onDetach is wired (product-scoped list).

Inactive group delete in admin UI

If group is already archived (is_active = false), delete click shows ModifierGroupArchiveConfirm before calling delete API again.


Order persistence (POS)

Service: OrderItemModifierPersistenceService
Path: apps/backend/src/restaurant/application/order-item-modifier-persistence.service.ts

Always called inside a caller-owned transaction (trx).

persist(trx, input)

Used when adding an order item with modifiers (OrdersService).

  1. If selectedModifiers empty → no-op.
  2. Unless skipGroupValidation: true:
    • Load effective groups via findActiveGroupsWithEffectiveConstraints(productId).
    • Run ModifierValidationService.validate.
    • On failure → 422 MODIFIER_VALIDATION_FAILED.
  3. Load catalog rows (modifier + group name) for all modifierIds.
  4. Build order_item_modifier rows with snapshots and price math (Decimal.js).
  5. Bulk insert.

replace(trx, input)

Used by PATCH /orders/:id/items/:itemId/modifiers.

  1. Load order_item; if missing → return.
  2. If status !== "pending"409 ORDER_ITEM_LOCKED_FOR_EDIT.
  3. DELETE existing order_item_modifier for that order_item_id.
  4. If new selections non-empty → same insert path as persist.

skipGroupValidation

Set true when BundlesService persists combo-driven selections already validated against bundle rules (not product modifier groups). Snapshots are still built from the catalog.

Snapshot fields written at insert

Snapshot columnSource
name_snapshotproduct_modifier.name
kitchen_label_snapshotkitchen_label
receipt_label_snapshotreceipt_label
group_name_snapshotparent group name
is_default_snapshotis_default
linked_product_id_snapshotlinked_product_id
modifier_type_snapshotmodifier_type
show_on_kitchen_ticket_snapshotshow_on_kitchen_ticket
show_on_receipt_snapshotshow_on_receipt
show_on_customer_display_snapshotshow_on_customer_display
show_on_online_ordering_snapshotshow_on_online_ordering
unit_price_adjustment / total_price_adjustmentprice_adjustment × quantity

Channel visibility flags

Each product_modifier carries four boolean columns controlling which output channels render the option. All default to true (fully backward-compatible). Values are captured as immutable snapshots at order time so historical orders render correctly even if catalog flags are later changed.

FlagCatalog columnSnapshot columnHonored byDefault
Kitchen ticketshow_on_kitchen_ticketshow_on_kitchen_ticket_snapshotprint-job.repository.ts (KDS print) · orders.service.ts (KDS WebSocket — 2 call sites)true
Receipt / billshow_on_receiptshow_on_receipt_snapshotdocument-print-payload.repository.ts (loadModifierLabels)true
Customer displayshow_on_customer_displayshow_on_customer_display_snapshotSnapshot captured at v1 — no CFD consumer yettrue
Online orderingshow_on_online_orderingshow_on_online_ordering_snapshot¹pedidosya-menu.mapper.tstrue

¹ The PedidosYa menu push is a live catalog push, not a per-order render — it reads the live show_on_online_ordering column directly (not the snapshot). All other channels filter on the snapshot column.

Null-safe filter pattern used at every channel render point:

WHERE show_on_kitchen_ticket_snapshot IS NOT FALSE

IS NOT FALSE passes both TRUE and NULL, so pre-migration rows (null snapshots) are rendered as visible — preserving existing behavior.

ModifierDialog (POS) must NOT filter. Cashiers need all options when building an order regardless of channel flags. Filtering applies only to output/rendering surfaces.


Modifier types

The modifier_type column (PostgreSQL enum modifier_type) classifies each option's semantic role. Snapshotted as modifier_type_snapshot on order_item_modifier.

ValueAdmin labelMeaning
modifierModifier (paid add-on)Default. Any option that adds or removes price. Charged to the guest.
instructionInstruction (kitchen note)Free kitchen instruction with no price (e.g. "Sin cebolla"). Typically price_adjustment = 0, show_on_receipt = false. Displayed with a small "note" badge in the admin option list.
combo_choiceCombo choiceLabel only at v1. Combo runtime semantics (bundled items, per-component pricing) live in the bundle subsystem (bundle_component table). See Bundles & modifiers.

Admin UI: ModifierEditor (inline expand panel within ModifierGroupRow) exposes a Select for modifierType, four channel visibility switches, and the kitchenLabel / receiptLabel text inputs.

Null-safety: modifier_type_snapshot IS NULL on pre-migration rows → treated as 'modifier' at all render points.


POS flow

sequenceDiagram
participant Waiter as POS PWA
participant API as Backend
participant DB as PostgreSQL

Waiter->>API: GET /products/:productId/modifier-groups?include=modifiers
API->>DB: assignment JOIN group + active modifiers
DB-->>API: effective min/max per group
API-->>Waiter: groups + options

Note over Waiter: Modifier dialog (one level deep in V1)

Waiter->>API: POST /orders/:orderId/items (+ selectedModifiers)
API->>API: ModifierValidationService
alt invalid
API-->>Waiter: 422 MODIFIER_VALIDATION_FAILED
else valid
API->>DB: INSERT order_item + order_item_modifier snapshots
API-->>Waiter: order item with modifiers
end

opt Edit before send to kitchen
Waiter->>API: PATCH /orders/:orderId/items/:itemId/modifiers
API->>DB: DELETE + re-INSERT snapshots (status must be pending)
end

Load rules for POS lists:

  • Only groups where product_modifier_group.is_active = true and assignment exists for product.
  • Only modifiers where product_modifier.is_active = true.
  • Ordering: assignment sort_order, then group created_at; modifiers by sort_order.

Default pre-selection:
When the modifier dialog opens for a new item (initialSelections is absent), ModifierDialog seeds each group from catalog flags:

  • unique (and repeatable modes): options with is_default = true.
  • checkbox: options with default_checked = true (admin Starts on control; star / is_default hidden in Menus for checkbox groups).
  • Single-select group (maxSelection = 1) with multiple catalog defaults → first default wins, warning toast shown.
  • When editing an existing item (initialSelections is provided), catalog defaults are not applied — saved selections are honored verbatim. Order lines store only actual picks (order_item_modifier), not default_checked.

REST API

Base URL: API root (e.g. http://localhost:4000). Auth: Firebase ID token (AuthGuard). Swagger tag: Restaurant Product Modifiers / Restaurant Menus.

Modifier groups tab (direct)

MethodPathPWA usage
GET/products/:productId/modifier-groups?include=modifiersLoad groups + options (single request)
POST/products/:productId/modifier-groupsAttach / attach after create
DELETE/products/:productId/modifier-groups/:groupIdDetach
PATCH/products/:productId/modifier-group-assignments/:assignmentIdPer-product min/max overrides
GET/product-modifier-groups?businessId=&search=&page=&pageSize=Library picker
POST/product-modifier-groupsCreate library group
GET/product-modifier-groups/:idGet group
PATCH/product-modifier-groups/:idUpdate group (incl. sortOrder reorder)
DELETE/product-modifier-groups/:idDelete or archive group
GET/product-modifier-groups/:groupId/modifiersList options (legacy; prefer include=modifiers)
POST/product-modifiersCreate option
GET/product-modifiers/:idGet option
PATCH/product-modifiers/:idUpdate / reorder option
DELETE/product-modifiers/:idDelete or archive option
MethodPathPurpose
POST/menusCreate menu
GET/menusList menus
GET/menus/:idGet menu
PATCH/menus/:idUpdate menu
DELETE/menus/:idDelete menu
GET/menus/:menuId/locations?businessId=Location assignment matrix
POST/menus/:menuId/itemsCreate menu item
GET/menus/:menuId/itemsList items (pagination, available_at)
GET/menus/:menuId/items/:itemIdGet item
PATCH/menus/:menuId/items/:itemIdUpdate item
DELETE/menus/:menuId/items/:itemIdDelete item
POST/locations/:locationId/menusPin menu to location
DELETE/locations/:locationId/menus/:menuIdUnpin
GET/locations/:locationId/menusMenus for location

Orders (POS — same catalog)

MethodPathPurpose
PATCH/orders/:id/items/:itemId/modifiersReplace modifiers on a line
Order create/updatevia OrdersServiceValidates selections via ModifierValidationService

Controller: apps/backend/src/restaurant/interfaces/product-modifiers.controller.ts
Menus: apps/backend/src/restaurant/interfaces/menus.controller.ts

Kitchen ticket modifier rendering

Kitchen tickets produced by buildGroupedKitchenLines / buildSingleKitchenLines (packages/receipt-layout) now render modifier groups using the structured KitchenModifierLine[] type:

2x Hamburguesa Clásica
[Cooking Temp]
- Rare
[Extras]
- Cheese
- ×2 Bacon
  • Group headers appear when consecutive modifiers share a non-null group_name_snapshot. Modifiers with null group name print without a header.
  • Quantity prefix (×N) is shown when order_item_modifier.quantity > 1.
  • mapKitchenModifierLines(rows) (exported from @flowpos-workspace/receipt-layout) converts order_item_modifier DB rows to KitchenModifierLine[]. Both print-job.repository.ts (KDS) and document-print-payload.repository.ts use this helper.
  • The old string[] path in the layout functions is retained for backward compatibility with the Print Bridge agent (which builds its own modifier list outside this package).

Backend module

apps/backend/src/restaurant/
├── modules/menu.module.ts
│ ├── MenusController
│ └── ProductModifiersController
├── application/
│ ├── menu.service.ts
│ ├── product-modifier-group.service.ts
│ ├── product-modifier.service.ts
│ ├── product-modifier-group-assignment.service.ts
│ ├── modifier-validation.service.ts # pure; no DB
│ └── order-item-modifier-persistence.service.ts
└── infrastructure/
├── menu.repository.ts
├── menu-item.repository.ts
├── product-modifier-group.repository.ts
├── product-modifier.repository.ts
└── product-modifier-group-assignment.repository.ts

listActiveGroupsWithEffectiveConstraints joins product_modifier_group_assignmentproduct_modifier_group and computes effective min/max in SQL.

See Delete and archive behavior, Order persistence (POS), and Validation and error codes for full detail.


Authorization (CASL)

ResourceTypical actions on Menus UI
ModifierGroupCRUD on groups and options
ModifierGroupAssignmentAttach, list, detach, patch overrides

Enums: packages/global/policies/src/enums/resource.enums.ts
Rules: packages/global/policies/src/rules/business.rules.ts


AreaConnection
Order taking (restaurantOrders)Modifier picker dialog; validation; order_item_modifier persistence
Pricingprice_list_item.product_modifier_id for modifier-level prices
Bundles / combosPre-selected modifiers on combo lines
KDS / kitchenkitchen_label_snapshot, receipt_label_snapshot; modifiers grouped by group_name_snapshot on kitchen tickets (rendered as [Group Name] headers)
Recipes / BOMSame productId; product_recipe table; separate tab in MenuRightPanel
Delivery marketplacesMenu push mappers include modifier structure

See also: document line item shape, restaurant combos.


Migrations

Migration fileWhat it adds
2026-02-18t19:00:00.000z-product-modifiers.mjsproduct_modifier_group, product_modifier
2026-02-18t23:00:00.000z-menu-management.mjsmenu, menu_item, location_menu_assignment
2026-04-02t07-00-00-modifier-is-active.mjsis_active on group + modifier
2026-03-14t00-04-00-order-item-modifiers.mjsorder_item_modifier
2026-05-08t12-00-00-product-modifier-group-assignment.mjsAssignment join table
2026-05-08t15-00-00-modifier-schema-backfill.mjsBackfill from old product_id on groups
2026-04-02t03-00-00-product-recipe.mjsproduct_recipe (Recipes tab)
2026-05-18t10-00-00-modifier-channel-flags.mjsshow_on_kitchen_ticket, show_on_receipt, show_on_customer_display, show_on_online_ordering on product_modifier (boolean, DEFAULT true)
2026-05-18t11-00-00-modifier-type-enum.mjsmodifier_type PostgreSQL enum + modifier_type column on product_modifier (DEFAULT 'modifier')
2026-05-18t12-00-00-order-item-modifier-channel-snapshots.mjsFive snapshot columns on order_item_modifier (nullable, DEFAULT null)

After schema changes: pnpm run generate:types.


TypeScript types (PWA)

apps/frontend-pwa/src/types/restaurant-modifier.ts:

  • ProductModifierGroup, ProductModifierGroupWithModifiers, ProductModifier
  • ModifierType"modifier" | "instruction" | "combo_choice" type alias
  • UpdateModifierPayload — sparse update payload; all fields optional including modifierType and the four showOn* booleans
  • isGroupRequired(group)(effectiveMinSelection ?? minSelection) >= 1
  • Payload types for attach, create, update assignment

Zod schemas: apps/frontend-pwa/src/schemas/restaurant/modifier-group.schema.ts


i18n keys (modifier UI)

KeyDefault (en)
restaurant.menus.tabModifiersModifier Groups
restaurant.menus.modifiers.createNewCreate new
restaurant.menus.modifiers.attachFromLibraryAttach from library
restaurant.menus.modifiers.requiredRequired
restaurant.menus.modifiers.detachDetach
restaurant.menus.selectItemForModifiersSelect a menu item to manage its modifier groups.
restaurant.menus.noModifierGroupsNo modifier groups yet.
restaurant.menus.modifiers.kitchenLabelKitchen label
restaurant.menus.modifiers.receiptLabelReceipt label
restaurant.menus.modifiers.modifierTypeModifier type
restaurant.menus.modifiers.typeModifierModifier (paid add-on)
restaurant.menus.modifiers.typeInstructionInstruction (kitchen note)
restaurant.menus.modifiers.typeComboChoiceCombo choice
restaurant.menus.modifiers.typeInstructionBadgenote
restaurant.menus.modifiers.channelVisibilityChannel visibility
restaurant.menus.modifiers.showOnKitchenTicketShow on kitchen ticket
restaurant.menus.modifiers.showOnReceiptShow on receipt
restaurant.menus.modifiers.showOnCustomerDisplayShow on customer display
restaurant.menus.modifiers.showOnOnlineOrderingShow on online ordering
restaurant.menus.modifiers.editOptionEdit option details
restaurant.menus.modifiers.updateModifierErrorFailed to update option.

Testing

LayerLocationCovers
PWAmenus/__tests__/ModifierGroupsTab.test.tsxTab orchestration, create/delete/reorder
PWAmenus/__tests__/ModifierGroupRow.test.tsxExpand, archive confirm, option list
PWAmenus/__tests__/ModifierEditor.test.tsxChannel switches, modifier type select, label nullification, save/cancel
Backendapplication/__tests__/modifier-validation.service.spec.tsConstraint validation
Backendapplication/__tests__/product-modifier-channel-flags.service.spec.tsmodifierType + showOn* round-trip create/update; sparse update pattern
Backendinfrastructure/pedidosya/mappers/pedidosya-menu.mapper.spec.tsshowOnOnlineOrdering=false filtered from menu push; undefined backward-compat
pnpm --filter frontend-pwa run test -- ModifierGroups
pnpm --filter frontend-pwa run test -- ModifierEditor
pnpm --filter backend run test -- modifier

UX notes

  • Detach vs delete: On the product tab, attached groups show Detach (unlink only). Delete/archive applies to the library entity and may be blocked by order history or KDS routing.
  • Archive confirm: Inactive groups with history show ModifierGroupArchiveConfirm before delete.
  • Library sharing: Same group ID can attach to many products; overrides are per assignment.
  • POS dialog: V1 supports one level of modifier groups only (see feature-restaurant-pwa).