Skip to content

Offers & Promotions

Production gating note: The Offers back-office pages are test-environment only — a production merchant cannot reach them at all, from the menu or by URL. All four routes (/marketing/offers, /marketing/offers/new-offer, /marketing/offers/edit/{id}, /marketing/offers/duplicate/{id}) declare testOnly: true route meta, and canNavigate returns false for any matched route carrying that meta whenever the build is not a test environment, so the router redirects to /not-authorized. isTestEnv is a build-time constant, !['production', 'staging'].includes(import.meta.env.VITE_ENV), so the gate is closed in the production and staging builds. Separately, in src/navigation/vertical/index.ts the Offers nav entry is pushed onto the Marketing menu only inside an if (isTestEnv) block; the code comment there names Offers explicitly alongside Gift Cards — both still over-count POS revenue on Mplus/Kassanet (gift-card redemption + monetary-discount reconciliation) and must not be exposed in production until that is fixed. testOnly is the layer that actually blocks: it is evaluated before the abilities-hydration early return, and the sibling CASL view/offers meta is a no-op for MERCHANT_OWNER, whose backend permission list is ['*']. Only the back-office UI is gated. The /back-office/offers API is permission-gated, not env-gated, and offer evaluation is unchanged in production: offers that already exist keep being computed, pushed to kiosk / online ordering via getActiveOffersForD1, and applied to customer orders. So never walk a production merchant through creating or editing an offer — but do not tell them their existing discounts have stopped working either. (Verified: upvendo-backoffice src/pages/marketing/offers/index.vue lines 7-13, new-offer.vue lines 5-11, edit/[id].vue lines 5-11, duplicate/[id].vue lines 5-11; src/@layouts/plugins/casl.ts lines 12 and 149-167; src/plugins/1.router/guards.ts lines 33-45; src/navigation/vertical/index.ts lines 238-258; upvendo-backend app/Constants/Roles.php lines 181-184; routes/api/backoffice/offers.php; app/RawModels/Location.php line 838.)

Overview

Offers and Promotions allow merchants to create discounts and deals. An offer applies either to specific items/groups, to the whole order, or as a Buy-X-Get-Y (BOGO) deal. Discounts can be a percentage, a fixed amount, or "free" (BOGO only), and are applied automatically when conditions are met or unlocked with a discount code.

Key Purpose: Create promotional offers to attract and retain customers. The back-office pages that do this are reachable only in a test environment — see the gating note above.

Purpose

For a test-flagged merchant, this page lets you create, schedule, and manage promotional offers that apply discounts to customer orders based on configurable conditions such as item quantity, order amount, or buy-X-get-Y rules. In production the pages are route-blocked, so no offer can be created or edited there; offers that already exist continue to be applied to orders by the backend.

Key Concepts

  • Offer Types (type, enum OfferTypes): Three types exist -- ITEMS (discount on specific items or display groups), ORDER (discount on the whole cart), and BOGO (buy X items, get Y items discounted or free).
  • Offer Methods (method, enum OfferMethods): An offer is either Automatic (auto-applied when conditions are met) or Code (requires the customer to enter a unique discount code at checkout). The discount_code field is only collected for the Code method.
  • Offer Statuses (status, enum OfferStatuses): Offers follow a lifecycle of Draft, Scheduled (enum case UPCOMING), Active, Expired, and Archived. Apart from manually-set Draft and Archived, the status is computed automatically from the configured date range and the total usage limit.
  • Minimum Purchase Validation (minimum_purchase_type, enum OfferMinimumPurchaseTypes): Offers can require no-minimum-requirements, a minimum-quantity-items (with minimum_quantity_items), or a minimum-purchase-amount (with minimum_purchase_amount).
  • Offer Combination (can_be_combined, bool): If false, the offer cannot be applied alongside other offers in the same order.

Actions

Every action below lives on a testOnly route and is therefore available only in a test environment (see the gating note). None of them can be performed by a production merchant, regardless of role or permissions.

Create an Offer

First choose a type (Amount of items / Amount of order / Buy X get Y) in the "Select offer type" modal -- the type cannot be changed afterwards. Then define the offer name, discount method (Automatic or Code), discount unit and value, what it applies to (specific groups or specific items), combination rule, minimum purchase requirements, usage limits, availability channels, active days, and the active date range.

Activate an Offer

A Scheduled offer has an Activate action that makes it live immediately (POST .../activate/{id}).

Archive and Unarchive Offers

To take a live offer down there is no "Deactivate" button in the list view -- use Archive (POST .../archive/{id}), which appears only on Active rows. (A POST .../deactivate/{id} endpoint does exist on the API and moves an Active offer to Expired, but the back office never calls it. Verified: upvendo-backend routes/api/backoffice/offers.php line 14; upvendo-backoffice src/views/marketing/offers/Offers.vue lines 511-575.) (Scheduled rows show Activate instead, and Archived rows show Unarchive.) Archiving hides the offer from active listings without deleting it. Unarchive (POST .../unarchive/{id}) is available on Archived rows and restores the offer to its computed current status based on its date range.

Edit an Offer

The row-level Edit action only appears on Draft and Expired offers. Active, Scheduled and Archived offers are not editable from the list — use Duplicate instead. (Verified: upvendo-backoffice src/views/marketing/offers/Offers.vue lines 524-532.)

Duplicate an Offer

Duplicate appears on Active, Scheduled and Archived rows and opens /marketing/offers/duplicate/{id} pre-filled with the source offer's settings. (Verified: upvendo-backoffice src/views/marketing/offers/Offers.vue lines 514-522; src/pages/marketing/offers/duplicate/[id].vue.)

Delete an Offer

Permanently remove an offer and its associated item/group relations from the system. Available on any row for users with the delete-offers permission.

Location

  • Backoffice Routes (all test-env only — testOnly route meta blocks them in production; see gating note above): /marketing/offers, /marketing/offers/new-offer, /marketing/offers/edit/{id}, /marketing/offers/duplicate/{id}
  • List View: src/views/marketing/offers/Offers.vue (mounted via src/pages/marketing/offers/index.vue)
  • Create/Edit Form: src/views/marketing/offers/CreateOfferForm.vue (with step components under components/create-offers/)
  • Store Module: src/store/modules/offers.ts
  • Backend Controller: app/Http/Controllers/Api/OfferController.php
  • Backend Request: app/Http/Requests/BackOffice/Offer/StoreOfferRequest.php
  • Backend Model: app/RawModels/Offer.php

Offer Types

The type field (OfferTypes enum) has exactly three values:

Amount of items (ITEMS)

Discount on specific items or display groups. Uses a discount unit of percentage or fixed amount.

Amount of order (ORDER)

Discount applied to the whole cart total. Uses a discount unit of percentage or fixed amount.

Buy X get Y / BOGO (BOGO)

Customer buys a required quantity of qualifying items/groups and gets a benefit quantity of items/groups at a discount. The discount unit here is percentage, fixed amount, or free (DiscountUnitsExpanded).


Fields

All fields below come from StoreOfferRequest::rules() and the Offer model constructor. Many fields are conditional on type, method, offer_applies_to, minimum_purchase_type, and discount_unit. When is_draft is true every field becomes nullable; otherwise the listed fields are required.

Offer Name

PropertyValue
Field IDname
TypeText (string)
RequiredYes
ValidationRequired, must be unique within the location (UniqueInConnectionWithModel). An offer keeps its own name when editing.

Description: Name of the offer. There is no character-limit rule in StoreOfferRequest.


Offer Type

PropertyValue
Field IDtype
TypeSelect (enum OfferTypes)
RequiredYes
OptionsITEMS (Amount of items), ORDER (Amount of order), BOGO (Buy X get Y)

Description: Chosen in the "Select offer type" modal and cannot be changed after creation.


Method

PropertyValue
Field IDmethod
TypeSelect (enum OfferMethods)
RequiredYes
OptionsAutomatic, Code

Description: Automatic applies when conditions are met; Code requires the customer to enter a discount code at checkout.


Discount Code

PropertyValue
Field IDdiscount_code
TypeText (string)
RequiredRequired only when method = Code
ValidationMax 10 characters (front-end rule), must be unique within the location (UniqueInConnectionWithModel).

Description: Code the customer enters to unlock the offer. The "Generate random code" button fills an 8-character A–Z/0–9 code. Only collected for the Code method.


Discount Unit

PropertyValue
Field IDdiscount_unit
TypeSelect
RequiredYes (for ITEMS, ORDER, and BOGO)
OptionsITEMS / ORDER: percent, amount (enum DiscountUnits). BOGO: percent, amount, free (enum DiscountUnitsExpanded).

Description: Whether the discount is a percentage, a fixed amount, or (BOGO only) free.


Discount Value

PropertyValue
Field IDdiscount_value
TypeNumber
RequiredRequired when discount_unit is percent or amount (not used for free)
Validationpercent: numeric, > 0, <= 100. amount: numeric, > 0.

Description: The discount magnitude. Stored as a Money value for amount discounts, a float for percentages.


Offer Applies To (ITEMS only)

PropertyValue
Field IDoffer_applies_to
TypeSelect (enum OfferAppliesTo)
RequiredRequired for ITEMS offers
Optionsspecific-groups, specific-items

Description: For ITEMS offers, whether the discount targets display groups or individual items.

  • If specific-groups: display_groups (array of { id }, validated against DisplayGroup) is required.
  • If specific-items: items (array of { id }, validated against Item) is required.

Minimum Purchase Type

PropertyValue
Field IDminimum_purchase_type
TypeSelect (enum OfferMinimumPurchaseTypes)
RequiredYes (for ITEMS and ORDER any of three; BOGO restricted to the two non-empty options)
Optionsno-minimum-requirements, minimum-quantity-items, minimum-purchase-amount

Description: The minimum-spend requirement. For BOGO only minimum-quantity-items and minimum-purchase-amount are allowed.

  • If minimum-purchase-amount: minimum_purchase_amount (numeric, > 0) is required.
  • If minimum-quantity-items: minimum_quantity_items (numeric, > 0) is required.

Can Be Combined

PropertyValue
Field IDcan_be_combined
TypeBoolean
RequiredYes

Description: If false, the offer cannot be applied alongside other offers in the same order.


Usage Limits

Field IDTypeRequiredDescription
limit_discount_usageBooleanYesEnable a total redemption cap across all customers.
limit_discount_usage_amountNumber (> 0)Required when limit_discount_usage is trueThe total redemption cap. When reached, the offer is computed as Expired.
limit_one_usage_per_customerBooleanYesIf true, a customer who already redeemed the offer is blocked from redeeming it again.

Availability & Schedule

Field IDTypeRequiredDescription
availabilityArray of enum ChannelOptionsYesChannels the offer is available on: Kiosk, POS, Online Ordering, Table Qr Ordering, Uber Eats, Takeaway, Shopify.
active_daysArray of weekday namesYesDays of week the offer is intended to be active (validated against Carbon::getDays()). Stored only — not currently enforced at redemption; see the note under Offer Validation.
active_dates.start_dateDate Y-m-dYesStart date.
active_dates.start_timeTime H:iYesStart time.
active_dates.set_end_dateBooleanYesWhether the offer has an end date.
active_dates.end_dateDate Y-m-dRequired when set_end_date is trueEnd date.
active_dates.end_timeTime H:iRequired when set_end_date is trueEnd time.

Status & Draft

Field IDTypeRequiredDescription
is_draftBooleanNoWhen true, the offer is saved as a Draft and all other fields become nullable.
statusString (enum OfferStatuses)Set to Draft automatically when is_draft is trueOtherwise the effective status is computed by getCurrentStatus().
location_idStringYesThe location the offer belongs to.

BOGO (Buy X Get Y) Fields

These fields apply only when type = BOGO.

Customer Buys (requirement)

Field IDTypeRequiredDescription
requirement_applies_toEnum OfferAppliesTo (specific-groups / specific-items)YesWhat the customer must buy to qualify.
requirement_display_groupsArray of { id }Required when requirement_applies_to = specific-groupsQualifying display groups.
requirement_itemsArray of { id }Required when requirement_applies_to = specific-itemsQualifying items.

The minimum (quantity of items or purchase amount) is set via minimum_purchase_type as described above.

Customer Gets (benefit)

Field IDTypeRequiredDescription
benefit_quantityNumber (> 0)YesHow many benefit items the customer gets.
benefit_applies_toEnum OfferAppliesTo (specific-groups / specific-items)YesWhat the benefit applies to.
benefit_display_groupsArray of { id }Required when benefit_applies_to = specific-groupsBenefit display groups (stored as benefit_display_group_ids).
benefit_itemsArray of { id }Required when benefit_applies_to = specific-itemsBenefit items (stored as benefit_item_ids).
discount_unitEnum DiscountUnitsExpanded (percent / amount / free)YesDiscount applied to the benefit items.
enable_max_usage_per_orderBooleanYesWhether to cap how many times the deal applies per order.
max_usage_per_orderNumber (> 0)Required when enable_max_usage_per_order is trueThe per-order cap.

Business Logic

Offer Validation

Customer applies offer


Check offer status (getCurrentStatus):
├── Draft or Archived? → not redeemable
├── Before start date/time? → Scheduled (not yet valid)
├── Total usage limit reached? (limit_discount_usage_amount) → Expired
├── Past end date/time (if set_end_date)? → Expired

└── Computes to Active → Continue


Check redemption + order conditions:
├── limit_one_usage_per_customer and already redeemed? → blocked
├── minimum_purchase_type met? (quantity of items or purchase amount) → else not applied
├── Qualifying items/groups in cart? → else not applied
├── Channel in `availability`? → else the offer is not published to that channel

└── All conditions met → Apply discount

active_days is stored but not enforced. The weekday check lives in OfferService::validateOfferDates(), which is commented out along with its call site, and active_days is not included in the offer payload pushed to the kiosk / online-ordering channels. An offer therefore applies on any day within its date range. (Verified: upvendo-backend app/Services/Offer/OfferService.php lines 63-80 and 320; app/RawModels/Location.php lines 777-824 — getActiveOffersForD1() does not emit active_days.)

Discount Calculation

Percentage (discount_unit = percent):
- Discount = Eligible Amount × (discount_value / 100)

Fixed Amount (discount_unit = amount):
- Discount = discount_value
- Cannot exceed the eligible amount

Free (BOGO only, discount_unit = free):
- Benefit items become free

ORDER type:
- Whole-cart discount distributed proportionally across all cart items
  based on each item's share of the cart total

BOGO type:
- Customer buys the required quantity of qualifying items/groups
- benefit_quantity benefit items receive the discount
- enable_max_usage_per_order can cap how many times the deal applies per order

Combination Rules

Can multiple offers be combined on one order?
├── Each offer carries a can_be_combined flag
├── If every applied offer has can_be_combined = true → allowed
└── If any applied offer has can_be_combined = false and more than one
    offer is applied → the order is rejected

Customer Impact

The customer-facing side is not environment-gated. Active offers are serialized to D1 for the kiosk and online-ordering channels by getActiveOffersForD1 and applied by the backend with no env check, so any offer a merchant already has keeps discounting orders in production even though the back-office pages are blocked there.

Online Ordering

  1. Discount Code Entry: Field at checkout (for Code offers)
  2. Automatic: Discount shown automatically (for Automatic offers)
  3. Available Offers: List of applicable offers
  4. Validation: Error messages for invalid codes
  5. Discount Display: Discount line in order summary

Kiosk

  • Discount code entry (for Code offers)
  • Automatic offers applied when conditions are met
  • Discount shown on screen

Receipt

Subtotal:           €25.00
Discount (SUMMER20): -€5.00
Total:              €20.00

Relations

Depends On

  • Items: For item-specific offers (items / requirement_items / benefit_items)
  • Display Groups: For group-specific offers (display_groups / requirement_display_groups / benefit_display_groups)
  • Customers: For per-customer usage tracking (OfferRedemption)

Affects

  • Online Ordering: Discount at checkout
  • Kiosk: Discount applied
  • Transactions: Discount recorded
  • Reports: Offer usage analytics

Business Rules

  • Discount codes are checked twice. The request validator rejects a code already used by another non-deleted offer at the same location (UniqueInConnectionWithModel, scoped by location_id); then, when the offer is saved in a non-Draft status, the service rejects it with "The discount code is already used" if any other offer in the merchant's account holds the same code. Draft saves skip the second check. (Verified: upvendo-backend app/Http/Requests/BackOffice/Offer/StoreOfferRequest.php lines 55-65; app/Rules/UniqueInConnectionWithModel.php lines 81-83; app/Services/BackOffice/OfferService.php lines 75-84.)
  • When an offer has "limit discount usage" enabled and the total redemption count reaches the configured limit, the offer is no longer redeemable by any customer.
  • If "limit one usage per customer" is enabled, the system checks the offer redemption repository for an existing record matching the customer and offer; a second redemption is blocked.
  • For whole-cart (ORDER type) discounts, the discount is distributed proportionally across all items in the cart based on each item's share of the total cart price.
  • Status is computed by getCurrentStatus(): Draft and Archived are sticky (never recomputed); otherwise an offer is Scheduled before its start, Expired once its total usage limit is hit or its end date passes, and Active in between. A Scheduled offer can be activated early via the Activate action; a live offer is taken down via Archive (there is no "deactivate" action in the list view).

FAQs

  • "Why can't I find Offers in my back office?" Offers is test-environment only. The nav entry is wrapped in an isTestEnv check and every offers route carries testOnly meta, so in production the pages are both hidden from the Marketing menu and blocked by the router — typing /marketing/offers lands on /not-authorized. It is gated on the build's environment, not on your role or permissions, so no permission change opens it. This holds until the Mplus/Kassanet POS revenue over-counting (gift-card redemption + monetary-discount reconciliation) is fixed. Offers that already exist on the account are unaffected and still apply to orders.

  • "Can a customer combine multiple offers on one order?" Only if all selected offers have the "can be combined" flag enabled; if any offer disallows combination and more than one offer is applied, the system rejects the request.

  • "What is the difference between an Offer and a Coupon in this system?" In the backend, coupons are implemented as offers with the method set to "Code" and a unique discount code; there is no separate coupon service.

  • "How does Buy X Get Y work with display groups?" The system checks that the requirement display group contains enough qualifying items, then applies the discount to benefit items in the specified benefit display group or specific benefit items.

  • "What happens when an offer is archived?" The offer status is set to "Archived" and it is excluded from active offer queries; unarchiving restores it to its computed current status based on its date range.

  • "Are discounts applied before or after tax?" Currently, discounts are applied before tax calculation, though the codebase contains TODO annotations for a future option to apply discounts after tax.

  • "What are the offer types I can create, and how do I pick one?" Click "Create offer" and choose a type in the "Select offer type" modal: Amount of items (discount on specific items/groups), Amount of order (a full-order discount), or Buy x, get Y (a BOGO-style deal). The type can't be changed after this step, so pick carefully.

  • "What's the difference between an Automatic discount and a Discount Code offer?" On the offer information step you pick a method: Automatic Discount applies when conditions are met, while Discount Code requires the customer to enter a code at checkout. The code field (and "Generate random code") only appears for the Code method.

  • "How long can my discount code be, and can I auto-generate one?" A discount code is max 10 characters. You can type your own or use "Generate random code", which fills in a random 8-character alphanumeric code.

  • "Can two offers use the same discount code or name?" No. Offer names must be unique within the location, and discount codes must be unique within the location too — plus, when you publish (save as anything other than Draft), the system also rejects a code that any other offer in your account already uses. An offer keeps its own code/name when editing.

  • "How do I let (or stop) customers from stacking this offer with others?" On the Combinations card pick "Can be combined with other offers" or "Cannot be combined with other offers." If set to cannot-combine and a customer applies more than one offer, the order is rejected.

  • "How do I require a minimum spend or quantity before an offer applies?" On the Minimum purchase card choose No minimum, Minimum quantity of items (enter a quantity), or Minimum purchase amount (enter an amount). For BOGO the same minimum is set under "Customer buys."

  • "What do the offer statuses (Draft, Scheduled, Active, Expired, Archived) mean?" Status is computed automatically: a saved draft stays Draft; before its start date it's Scheduled; while live it's Active; after its end date or once a total usage limit is hit it's Expired; Archived is set manually. The list has tabs for All, Active, Scheduled, and Expired.

  • "Can I make a Scheduled offer go live early, and how do I pause a live one?" Yes. A Scheduled offer has an Activate action that makes it Active immediately. To take a live offer down, use Archive (there's no "deactivate" button). Archived offers can be restored with Unarchive, which recomputes the status from the date range.

  • "For a 'Buy X Get Y' offer, how do I set what the customer must buy versus get?" Under Customer buys set the required quantity/amount and whether it applies to specific groups or items. Under Customer gets set the get quantity, the benefit groups/items, and the discount on them (Percentage, Fixed amount, or Free). You can cap how many times it applies per order.

  • "Can I limit how many times an offer is used overall or per customer?" Yes. Enable a total usage limit (once total redemptions reach it, the offer auto-expires for everyone) and/or a one-use-per-customer limit (a customer who already redeemed it is blocked).

  • "For a whole-order discount, how is the discount split across items?" An Order-type discount is distributed proportionally across all items in the cart, with each item taking a share based on its portion of the cart total.

  • "Are discounts taken off before or after tax?" Discounts are currently applied before tax. There's no setting to apply them after tax yet.

Troubleshooting

Problem: Offers page missing, or /marketing/offers redirects to Not Authorized

Cause: The nav entry is registered only when isTestEnv is true, and all four offers routes declare testOnly meta, which canNavigate rejects outside a test environment.

Solution: This is intentional and cannot be worked around by permissions or by typing the URL — the check reads the build's VITE_ENV, not the user's role. The pages are test-env only until the POS over-counting issue is resolved; it is not a misconfiguration. Existing offers keep applying to orders in the meantime.


Problem: Discount code not working

Causes:

  1. Code entered incorrectly
  2. Offer expired (past end date or total usage limit reached)
  3. Offer not yet started (Scheduled)
  4. Minimum purchase requirement not met
  5. Qualifying items/groups not in cart
  6. Channel not in the offer's availability

Solutions:

  1. Check exact code spelling
  2. Verify the active date range and total usage limit (limit_discount_usage_amount)
  3. Verify the start date/time
  4. Meet the minimum_purchase_type requirement
  5. Add qualifying items/groups to the cart
  6. Verify availability (note: active_days is not enforced, so it is never the cause)

Problem: Automatic offer not applying

Causes:

  1. Method is Code, not Automatic
  2. Conditions not met (minimum purchase, qualifying items/groups)
  3. Offer status is not Active (Draft, Scheduled, Expired, or Archived)
  4. Channel not in availability

Solutions:

  1. Confirm the method is Automatic
  2. Check all conditions
  3. Check the computed status / activate or unarchive as needed
  4. Verify availability (note: active_days is not enforced, so it is never the cause)

Problem: Discount amount wrong

Causes:

  1. Only some items/groups are eligible
  2. ORDER discount spread proportionally across cart items
  3. Percentage vs fixed amount confusion

Solutions:

  1. Verify offer_applies_to and the selected items/groups
  2. For ORDER offers, the discount is split across all items by share of total
  3. Confirm discount_unit and discount_value

Problem: Customer used offer multiple times

Causes:

  1. limit_one_usage_per_customer not enabled
  2. Different accounts used
  3. Guest checkout (no tracking)

Solutions:

  1. Enable limit_one_usage_per_customer
  2. Require account for offers
  3. Track by email/phone

Examples

These payloads mirror the StoreOfferRequest schema. Items and display groups are passed as arrays of { "id": "..." }.

Order discount, automatic, percentage

json
{
  "location_id": "664a1f...",
  "type": "ORDER",
  "method": "Automatic",
  "name": "Summer Sale 20% Off",
  "discount_unit": "percent",
  "discount_value": 20,
  "minimum_purchase_type": "minimum-purchase-amount",
  "minimum_purchase_amount": 15,
  "can_be_combined": false,
  "limit_discount_usage": false,
  "limit_one_usage_per_customer": false,
  "availability": ["Online Ordering", "Kiosk"],
  "active_days": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"],
  "active_dates": {
    "start_date": "2026-06-01",
    "start_time": "00:00",
    "set_end_date": true,
    "end_date": "2026-08-31",
    "end_time": "23:59"
  }
}

Result: 20% off the whole order, auto-applied, when the cart is at least €15.


Order discount, discount code, fixed amount

json
{
  "location_id": "664a1f...",
  "type": "ORDER",
  "method": "Code",
  "discount_code": "WELCOME5",
  "name": "€5 Off First Order",
  "discount_unit": "amount",
  "discount_value": 5,
  "minimum_purchase_type": "minimum-purchase-amount",
  "minimum_purchase_amount": 20,
  "can_be_combined": false,
  "limit_discount_usage": false,
  "limit_one_usage_per_customer": true,
  "availability": ["Online Ordering"],
  "active_days": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"],
  "active_dates": {
    "start_date": "2026-06-01",
    "start_time": "00:00",
    "set_end_date": false
  }
}

Result: €5 off when the code WELCOME5 is entered on an order of at least €20, once per customer.


Items discount, automatic, specific groups

json
{
  "location_id": "664a1f...",
  "type": "ITEMS",
  "method": "Automatic",
  "name": "20% Off Burgers",
  "discount_unit": "percent",
  "discount_value": 20,
  "offer_applies_to": "specific-groups",
  "display_groups": [{ "id": "66b0c1..." }],
  "minimum_purchase_type": "no-minimum-requirements",
  "can_be_combined": true,
  "limit_discount_usage": false,
  "limit_one_usage_per_customer": false,
  "availability": ["Online Ordering", "Kiosk", "POS"],
  "active_days": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"],
  "active_dates": {
    "start_date": "2026-06-01",
    "start_time": "00:00",
    "set_end_date": false
  }
}

Result: 20% off any item in the selected display group(s), auto-applied, no minimum.


Buy One Get One free (BOGO)

json
{
  "location_id": "664a1f...",
  "type": "BOGO",
  "method": "Automatic",
  "name": "BOGO Burgers",
  "minimum_purchase_type": "minimum-quantity-items",
  "minimum_quantity_items": 1,
  "requirement_applies_to": "specific-groups",
  "requirement_display_groups": [{ "id": "66b0c1..." }],
  "benefit_applies_to": "specific-groups",
  "benefit_display_groups": [{ "id": "66b0c1..." }],
  "benefit_quantity": 1,
  "discount_unit": "free",
  "enable_max_usage_per_order": true,
  "max_usage_per_order": 1,
  "can_be_combined": false,
  "limit_discount_usage": false,
  "limit_one_usage_per_customer": false,
  "availability": ["Online Ordering", "Kiosk"],
  "active_days": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"],
  "active_dates": {
    "start_date": "2026-06-01",
    "start_time": "00:00",
    "set_end_date": false
  }
}

Result: Buy 1 burger, get 1 burger free; applies at most once per order.


Buy 2, get 1 at 50% off (BOGO percentage)

json
{
  "location_id": "664a1f...",
  "type": "BOGO",
  "method": "Code",
  "discount_code": "PIZZA3",
  "name": "Pizza Deal",
  "minimum_purchase_type": "minimum-quantity-items",
  "minimum_quantity_items": 2,
  "requirement_applies_to": "specific-items",
  "requirement_items": [{ "id": "66c2d3..." }],
  "benefit_applies_to": "specific-items",
  "benefit_items": [{ "id": "66c2d3..." }],
  "benefit_quantity": 1,
  "discount_unit": "percent",
  "discount_value": 50,
  "enable_max_usage_per_order": false,
  "can_be_combined": false,
  "limit_discount_usage": true,
  "limit_discount_usage_amount": 500,
  "limit_one_usage_per_customer": false,
  "availability": ["Online Ordering"],
  "active_days": ["Friday", "Saturday", "Sunday"],
  "active_dates": {
    "start_date": "2026-07-01",
    "start_time": "12:00",
    "set_end_date": true,
    "end_date": "2026-07-31",
    "end_time": "23:59"
  }
}

Result: With code PIZZA3, buy 2 pizzas and get a 3rd at 50% off, within the July date range; total of 500 redemptions, after which it computes as Expired. (active_days is recorded as Fri–Sun but is not enforced — the offer works on any day inside the date range.)


Draft offer

json
{
  "location_id": "664a1f...",
  "type": "ORDER",
  "method": "Automatic",
  "name": "Draft - Flash Sale",
  "is_draft": true
}

Result: Saved as a Draft. With is_draft true, all other fields are nullable and status is forced to Draft.