Skip to content

Menu Items

Overview

Menu Items are the products customers can order. Each item has a name, description, price, image, and can have modifiers attached for customization.

Key Purpose: Define what products are available for customers to order.

Purpose

This page lets you create, edit, and manage the products customers can order, including their pricing, images, allergens, and modifier group assignments.

Key Concepts

  • Item: A single orderable product with a name, description, price, image, category, tax rate, and optional modifier groups.
  • Prep Time: Each item can have its own preparation time (in seconds internally); if not set, the location's average prep time is used automatically. It is currently stored but not applied — a platform flag that is off everywhere keeps item prep time out of every quoted time (see Preparation Time below).
  • Allergens & Dietary Preferences: Arrays of values stored per item for EU-compliant allergen disclosure and dietary filtering (vegetarian, vegan, halal, etc.).
  • Alcohol Handling: Items flagged as containing alcohol automatically receive a minimum-age restriction based on the location's country code and the alcohol type (beer/wine vs spirits).
  • Modifier Group Sync: Items are linked to modifier groups through a sync operation; all attached modifier groups must share the same label as the item.
  • Sales Channels (platforms): The Channels checkboxes on the item form are not a fixed list. They are fetched per location from GET /back-office/visibility-options, which returns Kiosk and Online Ordering always, Table QR Ordering only once QR ordering has been set up, and POS only once the location has a POS footprint. See Which channels appear.
  • Per-Person Item: An item whose order_quantities list is non-empty is sold per person — the guest picks a party size as well as a dish count, and the line is priced per head. The list is read-only and comes from MplusKassa. See Per-Person Items for the full rules, including what quantity means on the resulting order line.

Actions

Create Item

Add a new menu item by providing a name, price, category, tax rate, and optional image, allergens, dietary preferences, and modifier groups. The item is saved and synced to external integrations (e.g., Square) if active.

Edit Item

Update any item field including name, description, price, image, allergens, or modifier group assignments. Changes trigger a menu reload event for all ordering channels.

Delete Item

Remove an item permanently. Deletion also triggers a menu reload and syncs the removal to connected integrations.

Export Items

Export the full item list for a location to a spreadsheet with columns for name, type, PLU, price, tax amounts, label, and status.

Location

  • Backoffice Route: /menus/items
  • Backend Controller: app/Http/Controllers/Api/ItemController.php
  • Vue Component: src/views/items/Items.vue
  • Item Form: src/components/forms/items/ItemForm.vue

Fields

Item Name

PropertyValue
Field IDname
LabelItem Name
TypeText
RequiredYes
Validation`required

Description: The name of the product shown to customers on menus.

Best Practices:

  • Keep names concise but descriptive
  • Include key ingredients for clarity
  • Avoid abbreviations customers won't understand

Customer Impact:

  • Online Ordering: Displayed in menu list and item detail
  • Kiosk: Large text on item cards
  • Receipt: Printed on order receipt

Examples:

  • "Margherita Pizza"
  • "Classic Cheeseburger"
  • "Caesar Salad with Grilled Chicken"

Item Description

PropertyValue
Field IDdescription
LabelDescription
TypeTextarea
RequiredNo
Validation`nullable

Description: Detailed description of the item including ingredients, preparation method, or special notes.

Best Practices:

  • List main ingredients
  • Mention cooking style (grilled, fried, etc.)
  • Note if spicy, vegetarian, etc.
  • Keep under 150 characters for mobile readability

Customer Impact:

  • Online Ordering: Shown below item name in detail view
  • Kiosk: Displayed when item is selected

Examples:

  • "Fresh mozzarella, San Marzano tomatoes, basil, extra virgin olive oil on hand-stretched dough"
  • "100% beef patty, cheddar cheese, lettuce, tomato, pickles, special sauce on a brioche bun"

Price

PropertyValue
Field IDprice
LabelPrice
TypeCurrency
RequiredYes
Validation`required

Description: Base price of the item before any modifiers. This is the starting price shown to customers.

Business Logic:

  • Final price = Base price + Selected modifier prices
  • Price stored without tax (tax calculated separately)
  • Displayed with currency symbol based on location settings

Customer Impact:

  • Online Ordering: Price shown on menu and in cart
  • Kiosk: Large price display on item cards
  • Receipt: Base price + modifiers itemized

Examples:

  • Pizza: €12.50
  • Burger: €9.95
  • Drink: €2.50

Category

PropertyValue
Field IDcategory_id
LabelCategory
TypeSelect
RequiredNo
ValidationMust be a valid category ID when set (backend nullable)

Description: The menu category this item belongs to. Determines where the item appears in the menu. Optional — an item can be saved without a category (the selector is clearable).

Business Logic:

  • Item can only be in one category
  • Category determines menu section
  • Category sort order affects item visibility

Customer Impact:

  • Online Ordering: Item appears under selected category tab
  • Kiosk: Item grouped with category

Related: Categories


Image

PropertyValue
Field IDimage (request object: image.source, image.file)
LabelProduct Image
TypeImage Upload
RequiredNo (but highly recommended)
Validationimage.file max:10240 KB (~10MB); image.source is an ImageSources enum; recommended 800x600px

Description: Product photo shown to customers. On save the form submits an image object (image.source + image.file); the stored item then exposes derived storage IDs — cloudflare_image_id (Cloudflare Images, preferred) and/or a legacy content_id. High-quality images significantly increase sales.

Best Practices:

  • Use professional food photography
  • Consistent lighting and background
  • Show the actual product (not stock photos)
  • Square or 4:3 aspect ratio works best
  • Minimum 800px width for quality display

Customer Impact:

  • Online Ordering: Thumbnail in menu, large in detail view
  • Kiosk: Large image on item cards (very important for kiosk UX)
  • No Image: Placeholder shown, reduces appeal

Technical:

  • Images are resized and optimized on upload
  • Multiple sizes generated for different displays
  • CDN delivery for fast loading

Status

PropertyValue
Field IDstatus
LabelStatus
TypeSelect (string enum: Active, Inactive, Unavailable, Hidden)
DefaultActive
RequiredNo

Description: Whether the item is currently available for ordering. The backend uses a string status field (the ItemStatuses enum), not a boolean toggle.

Business Logic:

  • Status = Active → Item shown normally
  • Status = Unavailable → Item's stock is written to 0 so it shows as "Sold Out"
  • Status = Inactive / Hidden → Item removed/hidden from the menu
  • Can be changed manually or automatically by inventory

Customer Impact:

  • Online Ordering: Unavailable items hidden or grayed out
  • Kiosk: Same behavior
  • Existing Carts: Items may be removed if made unavailable

Use Cases:

  • Seasonal items
  • Out of stock
  • Discontinued items
  • Time-limited specials

Tax Rate

PropertyValue
Field IDtax_rate_code
LabelTax Rate
TypeSelect
RequiredNo
ValidationMust be a valid tax rate code when set (backend nullable). Note: the tax-rate selector is currently shown only in non-production/test environments.

Description: VAT/tax rate code applied to this item. Different items may have different tax rates (e.g., food vs drinks). The code is resolved against the location's country code to determine the actual rate.

Business Logic:

  • Tax calculated using the tax rate code, the location's country code, and the dining option (dine-in, takeout, delivery)
  • Tax shown separately on receipt
  • Different rates for dine-in vs takeaway in some countries

Customer Impact:

  • Online Ordering: Prices typically shown including tax
  • Receipt: Tax breakdown shown

Examples:

  • Food: 9% (reduced rate)
  • Drinks: 21% (standard rate)
  • Alcohol: 21% (standard rate)

Related: Tax Rates


Allergens

PropertyValue
Field IDallergens
LabelAllergens
TypeMulti-select
RequiredNo (but legally required in EU)
OptionsGluten, Crustaceans, Eggs, Fish, Peanuts, Soybeans, Milk, Nuts, Celery, Mustard, Sesame, Sulphites, Lupin, Molluscs

Description: Allergen information for the item. Required by law in EU for food businesses.

Business Logic:

  • 14 major allergens defined by EU regulation
  • Displayed as icons or text
  • Searchable/filterable in some implementations

Customer Impact:

  • Online Ordering: Allergen icons shown on item
  • Kiosk: Allergen information displayed
  • Receipt: May be printed for reference

Legal Note: In the EU, businesses must inform customers about allergens. This is a legal requirement, not optional.


Dietary Preferences

PropertyValue
Field IDdietary_preferences
LabelDietary Info
TypeMulti-select
RequiredNo
OptionsVegetarian, Vegan, Halal, Kosher, Gluten-Free, Dairy-Free, etc.

Description: Dietary labels to help customers with specific dietary requirements find suitable items.

Customer Impact:

  • Online Ordering: Filter menu by dietary preference
  • Kiosk: Dietary icons displayed
  • Search: Can search for "vegetarian" items

Preparation Time

PropertyValue
Field IDprep_time_seconds
LabelPreparation Time
TypeNumber
UnitSeconds (stored internally)
Default0
RequiredNo

Description: Time needed to prepare this specific item, stored in seconds internally. The getPrepTimeMinutes() method converts to minutes by dividing by 60. If the value is 0, the location's average prep time is used as a fallback.

Item prep time does not currently change any quoted time. Whether an item's own prep time is allowed to influence online-ordering quotes is controlled by a platform configuration flag, ONLINE_ORDERING_INCLUDE_ITEM_PREP_TIME. It defaults to off and is not set in production, staging or testing, so as things stand every online-ordering quote comes from the location's average prep time plus the channel delay, whatever is entered here. The field is still stored on the item and returned by the API. (Verified: upvendo-backend config/upvendo.php line 26; app/Services/OrderCapacity/OrderCapacityService.php lines 103 and 172, both of which return 0 for the item term while the flag is off; deployed App Platform environment variables checked 2026-07-27.)

Business Logic:

  • Stored in seconds internally, displayed in minutes to the user
  • If prep_time_seconds is 0, falls back to the location's average_prep_time
  • Only when ONLINE_ORDERING_INCLUDE_ITEM_PREP_TIME is enabled (it is not, anywhere today): the longest item prep time in an order is used, never the sum, and it is compared against — not added to — the location's average prep time. The quote is max(location average prep time, longest item prep time) + channel delay, so an item can only ever make a quote longer, never shorter than the location baseline
  • Affects the earliest available time slot only while that flag is enabled

Use Cases:

  • Slow-cooked items
  • Made-to-order specials
  • Complex dishes

Use Default Prep Time

PropertyValue
Field IDuse_default_prep_time
Label(server-managed, not a user toggle)
TypeBoolean
Defaultfalse
RequiredNo

Description: A server-managed flag (not a form toggle). When the item is saved, the backend sets use_default_prep_time to false if an explicit prep time was supplied, or to true (and copies the location's average prep time into prep_time_seconds) when no prep time was entered.

Business Logic:

  • Flag is set in ItemService based on whether prep_time_seconds is present in the request
  • If no prep time is entered → flag becomes true and the location's average prep time is used
  • Only items with use_default_prep_time: false can contribute their own prep time to a quote, and only while ONLINE_ORDERING_INCLUDE_ITEM_PREP_TIME is enabled — which it is not in production, staging or testing (see the caveat under Preparation Time above)

Example (describes the behaviour that would apply if ONLINE_ORDERING_INCLUDE_ITEM_PREP_TIME were enabled; today none of it applies):

  • Location average prep time: 20 min. Order has: Burger (5 min), Pizza (15 min), Drink (1 min), all with their own prep time
  • Longest item prep = max(5, 15, 1) = 15 min
  • Quote = max(20, 15) + channel delay = 20 min + channel delay — the item times are compared against the location baseline, not added to it
  • Today the same order quotes 20 min + channel delay regardless of what the items say

Order Days in Advance (lead time)

PropertyValue
Field IDlead_time_days
LabelOrder days in advance
TypeNumber (suffix "days")
Default0
RequiredNo
Validationnullable|integer|min:0|max:365

Description: How many days ahead customers must order this item. 0 means same-day ordering is allowed. If the item leaves this at 0, it inherits the value from its category (see Advance-Order Inheritance below).

(Verified: form field upvendo-backoffice src/views/items/forms/ItemProductSpecifications.vue lines 1070-1107; store default src/store/modules/item.ts line 27, null-coerced on load lines 126-128; validation upvendo-backend app/Http/Requests/BackOffice/Item/StoreItemRequest.php line 146.)


Available Pickup Days

PropertyValue
Field IDavailable_pickup_days
LabelAvailable pickup days
TypeMulti-select (weekday names)
Default[] (empty = every day)
RequiredNo
Validationnullable|array; each entry must be one of Carbon::getDays()Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday

Description: The weekdays this item can be picked up on. Leave empty to allow every day. If the item leaves this empty, it inherits the list from its category. Values are full English weekday names, not codes or indices.

(Verified: form field ItemProductSpecifications.vue lines 1108-1147; store default src/store/modules/item.ts line 28, null-coerced on load lines 123-125; validation StoreItemRequest.php lines 147-148.)


Advance-Order Inheritance from the Category

lead_time_days and available_pickup_days exist on both the item and its category. The resolution rule is:

The item wins where it sets a value; the category fills in only what the item leaves unset.

The two fields resolve independently — an item may define its own pickup days and still inherit the category's lead time. Inheritance walks the category's ancestor chain nearest-first, so an item under a subcategory that sets nothing takes the first ancestor that does. The fold happens in memory at menu-build and order-validation time and is never written onto the item document.

Because an item's stored 0 / empty list cannot be told apart from "never set", it reads as "inherit" — an item currently cannot opt out of its category's rule back down to same-day ordering. This limitation is recorded in the code.

At order submission the backend rejects an order containing an item that fails its effective rule with a 422 and the message ":item is not available for the selected pickup date." naming the first offending item.

(Verified: Item::applyCategoryPickupRule upvendo-backend app/RawModels/Item.php lines 338-349 and Item::isAvailableForPickup lines 362-372; CategoryRepository::getEffectivePickupRuleMap lines 500-552; call sites app/RawModels/Menu/MenuLoadContext.php lines 330-356 and app/Repositories/ItemRepository.php lines 147-188; enforcement OnlineOrderingOrchestrator::assertItemsAvailableForPickup line 1642, KioskOrchestrator line 1057. How the storefront or kiosk displays an item that fails the rule is a channel concern and is not verified here.)


PLU (Product Lookup Unit)

PropertyValue
Field IDplu
LabelPLU / Product Code
TypeText
RequiredYes — but auto-generated if left blank

Description: Product Lookup Unit - internal product code for POS integration, inventory, and reporting. Indexed in the database for search. The plu field is required by the request and must be unique within the location; if you leave it blank the form auto-generates one (first 3 alphanumerics of the name, uppercased, plus a timestamp), so you rarely type it yourself.

Use Cases:

  • POS integration
  • Inventory management
  • Reporting and exports
  • Third-party integrations

Modifiers

PropertyValue
Field IDmodifier_groups
LabelModifier Groups
TypeArray of { id } objects (request key modifier_groups, each with id)
RequiredNo

Description: Modifier groups attached to this item. Allows customers to customize the item.

Business Logic:

  • Item can have multiple modifier groups
  • Each group can be required or optional
  • Modifier prices added to base price

Customer Impact:

  • Online Ordering: Modifier selection shown when adding to cart
  • Kiosk: Modifier screens after item selection

Related: Modifiers


Business Logic

Price Calculation

Base Price (item.price)
    +
Modifier 1 Price Adjustment
    +
Modifier 2 Price Adjustment
    +
...
    =
Item Total

Item Total × Quantity = Line Total

Sum of all Line Totals = Subtotal
Subtotal + Delivery Fee = Order Total

For a per-person item the Quantity in Item Total × Quantity = Line Total is not the number of dishes — see the next section.

Per-Person Items (MplusKassa "bestelbeperking")

Some items are sold per person: a sharing platter, a set menu, a fondue for four. An item is a per-person item when its order_quantities list is non-empty. An empty list (the normal case) means no restriction and the item behaves exactly like any other.

  • order_quantities is read-only in Upvendo. It is synced from the article's bestelbeperking in MplusKassa into external_data.mpluskassa.order_quantities, and no back-office control creates or edits it — there is no such field on the item form. To change the allowed party sizes, change the bestelbeperking on the article in MplusKassa and re-sync. (Verified: upvendo-backend app/RawModels/Item.php lines 180-194; the string order_quantities does not appear anywhere in upvendo-backoffice/src.)
  • The guest gets two controls, not one. On a per-person item the storefront (online ordering and table QR) and the kiosk show a party-size picker limited to the values in order_quantities, in addition to the ordinary +/- quantity stepper, which still picks how many of the dish. The party size starts on the first allowed value; the dish count starts at 1. (Verified: zestidoo-online-ordering src/components/item-detail/ItemDetailActions.vue lines 10-41 and src/components/ItemDetail.vue lines 201-207; upvendo-kiosk src/components/PanelItemDetail.vue lines 145-151, 617.)
  • Line total = unit price × party size × dish count. One dish for a party of 6 is priced as six units; two of that dish for a party of 6 is priced as twelve.

⚠️ quantity on a per-person line means ARTICLE UNITS, not dishes.

The order line stores quantity = party size × dish count. Two of a dish for a party of 6 is stored as quantity: 12, persons: 6not quantity: 2. This is the single most common source of a wrong answer about these orders: never read quantity as "how many dishes were ordered".

The dish count is quantity ÷ persons, and that is what every human-facing surface shows — POS ticket, KDS, printed receipt, and the back-office order detail (which also prints "For N people" under the line). The division is only applied when the party size is greater than 1 and the units divide evenly; otherwise the raw unit count is shown rather than a made-up number. (Verified: upvendo-backend app/Support/PerPersonLine.php; upvendo-backoffice src/composables/useOrderDetail.ts lines 171-173 and src/components/OrderDetailView.vue line 322.)

persons is a new order-line field, and it is null on every normal line. It holds the party size on a per-person line only. An ordinary order line carries no persons value at all, and its quantity is simply the quantity — nothing about normal lines changed. Only a line with a non-null persons needs the article-units-to-dishes division above. (Verified: upvendo-backend app/RawModels/TransactionItem.php lines 52, 106-121.)

Which channels appear, and why POS may not

The Channels checkboxes (right column of the item form) and the channel list inside the custom-price modal are both data-driven per location — the back office never hardcodes them. Channels are fetched from two sibling endpoints and rendered exactly as returned:

PickerEndpointBack-office source
Channels checkboxes (item field platforms)GET /back-office/visibility-options?location_id=…ItemRightColumn.vue lines 59-60, 236
Custom-price channel list (item field pricing)GET /back-office/item-pricing-options?location_id=…CustomPriceModal.vue lines 20, 103-104

What each endpoint returns:

ChannelChannels checkboxesCustom-price list
Kioskalwaysalways
Online Orderingalwaysalways
Table QR Orderingonly when the location's QR-ordering setting has first_time_setup explicitly false (i.e. QR ordering has been through first-time setup)only when the location's QR-ordering setting has at least one section
POSonly when the location has a POS footprint (below)only when the location has a POS footprint (below)
Uber Eats / Takeaway / Shopifynever returnednever returned

"POS footprint" is a live check on the location, not a setting you can tick. POS is added to both lists only when either of these is true:

  1. the location has at least one non-retired POS register, or
  2. the location has at least one POS-type device registered under the same merchant.

Retire the last register and remove the last POS device and the POS checkbox disappears again on the next load — the item itself is not changed.

A hidden channel does not erase a stored one. An item that already has POS in its platforms (or a POS entry in its pricing map) keeps it: the value is simply no longer rendered, so it cannot be unticked from the form while the channel is hidden. The backend validates platforms and the pricing keys against the full channel enum, not against the location's current list, so anything already stored round-trips on save.

(Verified: route upvendo-backend routes/api.php line 164 → DynamicConstantController::channelOptions; DynamicConstantOrchestrator::getChannelOptions lines 40-59, getItemPricingOptions lines 67-88, locationHasPos lines 90-112; PosRegisterRepository::hasNonRetiredForLocation lines 53-62; enum app/Enums/ChannelOptions.php lines 5-14; validation StoreItemRequest.php lines 126-127 and the enum filter at lines 56-65. Back office: src/store/modules/common.ts lines 15-20, 25-30.)

Availability Logic

Is item available?

    ├── item.status = Unavailable → Sold Out (stock forced to 0)

    ├── item.status = Inactive / Hidden → Removed/hidden from menu

    ├── item.category.visible = false → Hidden

    ├── Inventory tracking enabled?
    │   └── Stock = 0 → Sold Out

    └── Menu availability schedule?
        └── Outside schedule → Hidden

Search & Filter

Items can be searched/filtered by:

  • Name (text search)
  • Category
  • Allergens (exclude items with allergen)
  • Dietary preferences (include items with preference)
  • Price range
  • Availability

Customer Impact

Online Ordering

  1. Menu View: Items displayed in category sections
  2. Item Card: Image, name, short description, price
  3. Item Detail: Full description, allergens, modifiers
  4. Add to Cart: Select modifiers and quantity (plus a party size on a per-person item), add

Kiosk

  1. Category Selection: Large category buttons
  2. Item Grid: Large images with name and price
  3. Item Selection: Full-screen item with modifiers
  4. Customization: Step through modifier groups

KDS (Kitchen Display)

  • Item name displayed
  • Selected modifiers shown
  • Special instructions included
  • Quantity highlighted

Receipt

  • Item name
  • Selected modifiers (indented)
  • Unit price
  • Quantity
  • Line total

Relations

Depends On

  • Categories: An item may optionally belong to a category (category_id is nullable)
  • Tax Rates: An item may optionally have a tax rate (tax_rate_code is nullable)
  • Modifier Groups: Optional customization

Affects

  • Order Capacity: Items counted for capacity limits
  • Inventory: Stock decremented on order
  • Reports: Sales tracked per item
  • Offers: Can be targeted by promotions

Business Rules

  • An item's modifier groups must all share the same label as the item itself; mismatched labels are rejected with a 400 error.
  • If prep_time_seconds is 0, the getPrepTimeMinutes() method automatically falls back to the location's average prep time. On save, ItemService sets use_default_prep_time to false when an explicit prep time is supplied, or to true (copying the location's average prep time into prep_time_seconds) when none is provided.
  • Item prep time is gated by ONLINE_ORDERING_INCLUDE_ITEM_PREP_TIME, which defaults to false and is not set in production, staging or testing. While it is off, OrderCapacityService treats the slowest item prep as 0, so no item's prep time reaches a customer-facing quote. Quotes are the location's average prep time plus the channel delay. (Verified: config/upvendo.php:26; the rule itself is Location::getDiningOptionPrepTime() at app/RawModels/Location.php:2001-2018, which OrderCapacityService::effectivePrepMinutes calls at app/Services/OrderCapacity/OrderCapacityService.php:86-89; the gates are maxItemPrepFromSnapshot line 103 and maxItemPrepForIds line 172.)
  • Items containing alcohol are automatically assigned a minimum purchase age based on the location's country code and alcohol type (e.g., 16 for beer/wine in Belgium, 21 for any alcohol in the US).
  • A category can be set to null on an item, but items without a category will not appear in any menu structure.
  • Deleting or updating an item triggers a ReloadMenu event so all active ordering channels (online ordering, kiosk) reflect the change immediately.
  • The advance-order rule (lead_time_days, available_pickup_days) is per-item with category inheritance: the item wins where it sets a value, the category fills in only what the item leaves unset, and the two fields resolve independently. Inheritance walks the category's ancestor chain nearest-first. An item's stored 0 / empty list reads as "inherit", so it cannot opt out of its category's rule. (Verified: Item::applyCategoryPickupRule, CategoryRepository::getEffectivePickupRuleMap.)
  • Both fields validate identically on the item and category endpoints: lead_time_days is nullable|integer|min:0|max:365, and available_pickup_days is an array of full English weekday names from Carbon::getDays(). (Verified: StoreItemRequest.php lines 146-148, StoreCategoryRequest.php lines 50-52.)
  • An order containing an item that fails its effective advance-order rule is rejected with a 422 naming the first offending item. (Verified: ItemRepository::firstUnavailableForPickup, Item::isAvailableForPickup.)
  • The item's Channels list is resolved per location, not fixed. Kiosk and Online Ordering are always offered; Table QR Ordering is offered only when the location's QR-ordering setting reports first_time_setup as false; POS is offered only when the location has ≥1 non-retired POS register or ≥1 POS-type device under the same merchant. Uber Eats, Takeaway and Shopify exist in the channel enum but are never returned by this endpoint, so they are never offered as checkboxes. (Verified: DynamicConstantOrchestrator::getChannelOptions lines 40-59 and locationHasPos lines 90-112.)
  • The custom-price channel list uses a sibling endpoint with a different Table QR Ordering condition — QR appears there when the QR-ordering setting has ≥1 section, not on first_time_setup. The POS condition is identical to the Channels list. (Verified: DynamicConstantOrchestrator::getItemPricingOptions lines 67-88.)
  • Hiding a channel never rewrites an item. platforms and the pricing keys are validated against the whole ChannelOptions enum, so a stored POS value survives saves made while the POS channel is not being offered. (Verified: StoreItemRequest.php lines 56-65, 126-127.)
  • An item with a non-empty order_quantities is a per-person item: the guest picks a party size in addition to the dish count, the line total is unit price × party size × dish count, and the stored line quantity is article units (party size × dish count) with the party size in a separate persons field. persons is null on every normal line. The list is read-only and comes from MplusKassa. See Per-Person Items.

FAQs

  • Can an item belong to multiple categories? No. Each item has a single category_id and appears under only one category in the menu.

  • What happens if I do not upload an image? The item will display with a placeholder image on all channels. Items with images have significantly higher conversion rates.

  • How is the final price calculated? Final price = item base price + sum of all selected modifier price adjustments. Tax is calculated separately based on the item's tax_rate_code, the location's country code, and the dining option (dine-in, takeout, delivery).

  • Does changing an item's price update existing carts? Changes trigger a menu reload, but in-progress carts may still show the old price until the customer refreshes or proceeds to checkout.

  • Can I bulk-import items? There is no bulk import via the backoffice UI, but items can be synced from Square if the integration is active. You can also export items to a spreadsheet for review.

  • "What's the difference between the Active, Inactive, Unavailable, and Hidden statuses?" An item's Status (in the right-hand Status card) has four values: Active (shown and orderable), Inactive, Unavailable, and Hidden. Setting an item to Unavailable writes its stock to 0 so it shows as sold out; Hidden removes it from the menu. Both fire a menu reload so every channel updates immediately.

  • "Can I sell an item at a different price on different channels?" Yes. In the Pricing card, open the custom-price modal and add per-channel prices on top of the base price. Each channel you pick gets its own price field; channels with no custom price use the base price. The channels on offer are the ones your location qualifies for — Kiosk and Online Ordering always, Table QR Ordering once QR ordering has sections configured, and POS once the location has a POS register or POS device. See Which channels appear.

  • "How do I control which sales channels an item appears on?" Use the Channels checkboxes in the right column of the item form. The list is per location, not a fixed set: Kiosk and Online Ordering are always there, Table QR Ordering appears once QR ordering has been set up, and POS appears only once the location has a POS footprint. See Which channels appear.

  • "Why is POS missing from the Channels checkboxes on my items?" It is not a bug and there is no toggle for it. POS is offered only when the location already has a POS footprint — at least one non-retired POS register, or at least one POS-type device registered to that location. Create the register (or activate the POS device) first — then switch location or reload the back office, because the option list is fetched once per location change (App.vue:167-174) and the item form only reads the cached value (ItemRightColumn.vue:59-60); reopening the item form alone will not refresh it. Retiring the last register and removing the last POS device makes it disappear again. Note that the POS register screen is reachable only by merchants set up as first-party POS merchants (src/pages/pos/registers/index.vue:14 firstPartyPosOnly); the Devices page is not gated that way, though creating a POS-type device is refused server-side for a non-first-party merchant.

  • "I ticked POS on an item and now the checkbox is gone — did the setting get lost?" No. Hiding the channel does not clear what is stored on the item: the POS entry stays in the item's channel list (and any POS custom price stays in its pricing map) and is saved back untouched. It just cannot be seen or unticked from the form until the location has a POS footprint again.

  • "How do I limit how many of one item a customer can order?" Set the Max Item Order Limit field (in QTY) on the Product Specifications tab. If left blank, no per-item cap is enforced.

  • "Why can't I create or edit items in the back office for some locations?" When a location's catalog is owned (read-only) by its POS, certain fields are locked (greyed out) because they sync from the POS — the locked set varies by provider (e.g. Square, MplusKassa, Kassanet, ShopCaisse, Lightspeed). New-item creation is blocked for Kassanet POS (Hendrickx and Vanhoutte) both server-side (config/pos-providers.php sets item_create to false for both) and in the back office. For ShopCaisse the back office still hides the New Item button, although the backend can now push simple items to ShopCaisse (item_create is true); Lightspeed blocks only category creation.

  • "How does the 'Contains alcohol' setting work?" Turn on Contains Alcohol on the Product Specifications tab and choose Beer & Wine or Spirits. The system assigns a minimum purchase age automatically based on your location's country and the alcohol type (e.g. 16 for beer/wine and 18 for spirits in Belgium; 21 for any alcohol in the US).

  • "Do I have to enter a PLU, and where does the suggested one come from?" A PLU is required, but you don't have to type it — the form auto-generates one from the item name plus a timestamp, which you can overwrite. If left blank, the backend also generates one on save.

  • "How do prep times work and what unit do I enter?" Enter the preparation time in minutes on the Product Specifications tab; it defaults to your location's average prep time. Be aware that item prep time does not currently change the times customers are quoted — a platform setting that keeps item prep time out of the calculation is switched off in every environment, so quotes come from the location's Customer prep time (Settings → Locations) plus any pickup, delivery or eat-in delay. If you need to lengthen the times customers see, change the location's Customer prep time or the channel delay. (Were the setting on, the longest item prep time would be used, never the sum, and compared against the location value rather than added to it.)

  • "How do I make an item order-ahead only (e.g. needs 2 days' notice)?" Set Order days in advance on the Product Specifications tab (lead_time_days, 0-365). 0 means same-day ordering. If you leave it at 0, the item inherits the lead time from its category — so for a whole range you can set it once on the category instead of on every item.

  • "How do I restrict an item to certain pickup days?" Use Available pickup days on the Product Specifications tab and pick the weekdays. Leave it empty to allow every day; empty also means the item inherits its category's pickup days.

  • "My item's category has a lead time but this item should be same-day — how do I exempt it?" You can't at the moment. An item's stored 0 lead time is indistinguishable from "not set", so it reads as "inherit" rather than as an opt-out. Move the item to a category without a lead time instead. (Verified: the limitation is recorded in the Item::applyCategoryPickupRule docblock, app/RawModels/Item.php lines 338-349.)

  • "What happens if a customer picks a pickup date the item isn't available for?" The order is rejected server-side with a 422 and a message naming the first offending item: ":item is not available for the selected pickup date." (Verified: ItemRepository::firstUnavailableForPickup; OnlineOrderingOrchestrator::assertItemsAvailableForPickup.)

  • "How do I add upsell suggestions to an item?" Open the Upsell Groups tab on the item form, search for and add existing upsell groups, then drag to set their order. Upsell groups are created under Marketing; this tab only assigns ones you've already built.

  • "What gets included when I export my items?" The export spreadsheet has columns for Name, Type, PLU, Price, Dine In / Take Out / Delivery Tax Amount, Label, and Status. Variant-group child items appear as indented rows under their group.

  • "Why does this item ask the customer 'for how many people?' and where do I change the numbers?" That item has a bestelbeperking on its article in MplusKassa, which Upvendo reads as its allowed party sizes. The customer picks one of those sizes and separately picks how many of the dish, and the line is priced at unit price × party size × dish count. There is no setting for it in the back office — change the bestelbeperking on the article in MplusKassa and re-sync. See Per-Person Items.

  • "An order says quantity 12 but the guest only ordered two of the dish — is that a bug?" No. On a per-person line the stored quantity is article units (party size × dish count), so two of a dish for a party of 6 reads as 12 units with persons: 6. Every screen a person reads — POS ticket, KDS, receipt, order detail — divides it back to the dish count and shows "For 6 people" alongside.

  • "What are the AI 'Suggested' chips I see when I type an item name?" As you type the name, the system looks up a reference database and suggests an image, description, calories, allergens, dietary preferences, supplements, prep time, and whether it contains alcohol. You can Accept All, Decline All, or approve/dismiss each one; nothing is applied until you accept it.


Troubleshooting

Problem: Item not showing on menu

Causes:

  1. Item status is not active
  2. Category is hidden
  3. Item not assigned to active menu
  4. Menu not assigned to location

Solutions:

  1. Enable item availability
  2. Check category visibility
  3. Verify menu contains item's category
  4. Assign menu to location

Problem: Price showing incorrectly

Causes:

  1. Wrong price entered
  2. Tax display setting (incl/excl)
  3. Modifier prices not configured

Solutions:

  1. Update item price
  2. Check tax display settings
  3. Review modifier price adjustments

Problem: Image not displaying

Causes:

  1. No image uploaded
  2. Image too large (failed upload)
  3. Unsupported format
  4. CDN/caching issue

Solutions:

  1. Upload an image
  2. Resize image before upload
  3. Convert to JPG/PNG/WebP
  4. Clear cache or wait for CDN refresh

Problem: POS (or Table QR Ordering) is missing from the item's Channels list

Causes:

  1. POS — the location has no POS footprint: no non-retired POS register and no POS-type device registered to it
  2. POS — the merchant is not set up as a first-party POS merchant. The register screen is route-gated (firstPartyPosOnly) and creating a POS-type device is refused server-side with a 403, so neither way of creating the footprint is open to them. The Devices page itself is still reachable
  3. Table QR Ordering — QR ordering has not been through first-time setup on this location (and, in the custom-price modal, has no sections configured)
  4. Wrong location selected in the back office — the list is fetched per location

Solutions:

  1. Create a POS register for the location, or activate a POS-type device on it, then switch location or reload the back office — reopening the item form alone does not re-fetch the option list
  2. Confirm the location in context is the one you mean
  3. Complete QR-ordering setup for the location to unlock Table QR Ordering

Not a cause: the item itself. Nothing on the item hides a channel, and a channel already stored on the item is not removed when it stops being offered.


Problem: Item shows "Sold Out" unexpectedly

Causes:

  1. Inventory tracking enabled with 0 stock
  2. Item manually set to unavailable
  3. Category disabled

Solutions:

  1. Restock item in Inventory
  2. Enable item availability
  3. Enable category

Examples

Simple Item (No Modifiers)

json
{
  "name": "French Fries",
  "description": "Crispy golden fries with sea salt",
  "price": 3.50,
  "category_id": "sides",
  "cloudflare_image_id": "abc123",
  "status": "Active",
  "tax_rate_code": "food-9",
  "allergens": [],
  "dietary_preferences": ["vegetarian", "vegan"],
  "modifier_groups": []
}

Item with Modifiers

json
{
  "name": "Build Your Own Burger",
  "description": "Start with our signature beef patty and customize to your taste",
  "price": 8.95,
  "category_id": "burgers",
  "cloudflare_image_id": "def456",
  "status": "Active",
  "tax_rate_code": "food-9",
  "allergens": ["gluten", "eggs"],
  "dietary_preferences": [],
  "modifier_groups": [
    { "id": "burger-size-id" },
    { "id": "burger-cheese-id" },
    { "id": "burger-toppings-id" },
    { "id": "burger-sauce-id" }
  ]
}

Item with Long Prep Time

json
{
  "name": "Slow-Roasted Ribs",
  "description": "Fall-off-the-bone pork ribs, smoked for 6 hours, served with coleslaw and fries",
  "price": 18.95,
  "category_id": "mains",
  "cloudflare_image_id": "ghi789",
  "status": "Active",
  "tax_rate_code": "food-9",
  "allergens": ["celery", "mustard"],
  "prep_time_seconds": 1800
}

Seasonal/Limited Item

json
{
  "name": "Pumpkin Spice Latte",
  "description": "Limited time! Espresso with pumpkin spice and steamed milk",
  "price": 4.95,
  "category_id": "drinks",
  "cloudflare_image_id": "jkl012",
  "status": "Active",
  "tax_rate_code": "drinks-21",
  "allergens": ["milk"],
  "dietary_preferences": []
}