Appearance
Coupons
Overview
A coupon is an Offer with method "Code". Customers must know and enter the code at checkout to receive the discount. Unlike an Automatic offer (which applies on its own when its conditions are met), a Code offer only applies when the customer enters its discount code. Coupons are not a separate feature — they are created and managed under Marketing → Offers.
Key Purpose: Create shareable discount codes for marketing campaigns.
Purpose
This page (the Offers page) lets you create and manage offers, including code-based offers (coupons) that customers enter at checkout. A coupon provides a percentage or fixed-amount discount on qualifying items or on the order total, subject to minimum-purchase, usage, scheduling, and channel rules.
Key Concepts
- Coupon as Offer: A coupon is an Offer (
method = 'Code'). It shares the same model, validation, redemption tracking, and discount calculation as Automatic offers; the only difference is that it requires the customer to enter adiscount_code. - Discount Code: A unique string tied to a Code offer (
discount_code); on publish the backend validates uniqueness across offers at the location to prevent duplicate codes. - Limit One Usage Per Customer: When
limit_one_usage_per_customeris enabled, the system checks that customer's prior redemption records for the offer before allowing another redemption. - Usage Limit: When
limit_discount_usageis enabled,limit_discount_usage_amountcaps total redemptions. Each successful use writes a redemption record and increments the offer'sredemptionscounter; once the cap is reached the offer becomes Expired and the code stops working.
Actions
Create a Coupon
Create a new offer, set its method to Code, and define the discount code, offer type (Amount of items, Amount of order, or Buy X Get Y), discount unit and value, minimum-purchase requirements, usage limits, availability channels, and start/end schedule.
Edit a Coupon
Update an existing offer's discount value, minimum-purchase, usage limits, applicable items/groups, availability, or schedule. Draft offers can be freely edited; once an offer leaves Draft it can be updated but cannot revert to Draft.
Deactivate a Coupon
Deactivate or archive an offer so it can no longer be redeemed. An offer past its usage cap or end date becomes Expired automatically.
Location
- Backoffice Route:
/marketing/offers - Backend Request:
app/Http/Requests/BackOffice/Offer/StoreOfferRequest.php - Backend Controller:
app/Http/Controllers/Api/OfferController.php - Backend Service:
app/Services/Offer/OfferService.php - Vue Components:
src/views/marketing/offers/Offers.vue,src/views/marketing/offers/CreateOfferForm.vue,src/views/marketing/offers/components/create-offers/OfferInformation.vue
Fields
Method
| Property | Value |
|---|---|
| Field ID | method |
| Label | Method |
| Type | Tab / Select |
| Options | Automatic, Code |
| Required | Yes |
Description: How the offer is triggered. Code makes the offer a coupon (the customer must enter the discount code); Automatic applies on its own when conditions are met. Switching to a different method clears the discount code.
Discount Code
| Property | Value |
|---|---|
| Field ID | discount_code |
| Label | Discount Code |
| Type | Text |
| Required | Yes (only when method = 'Code') |
| Validation | Unique across non-deleted offers at the location (request validator) and, on publish, across every offer in the merchant's account; max 10 characters (front-end) |
Description: The code customers enter at checkout. The "Generate" button produces a random 8-character code using A–Z and 0–9.
Best Practices:
- Keep codes short and memorable
- Avoid confusing characters (0/O, 1/I)
- Make codes relevant to campaign
Examples:
- "SUMMER20"
- "WELCOME10"
- "PIZZA50"
Name
| Property | Value |
|---|---|
| Field ID | name |
| Label | Name |
| Type | Text |
| Required | Yes |
| Validation | Unique across offers at the location; max 25 characters (front-end) |
Description: Internal name for the offer.
Examples:
- "Summer Campaign"
- "Influencer Code"
- "Newsletter Discount"
Offer Type
| Property | Value |
|---|---|
| Field ID | type |
| Label | Offer Type |
| Type | Select |
| Options | ITEMS (Amount of items), ORDER (Amount of order), BOGO (Buy X Get Y) |
| Required | Yes |
Description: What the offer discounts. "Amount of items" discounts specific items/groups, "Amount of order" discounts the order total, and "Buy X Get Y" applies a benefit when a requirement is met.
Discount Unit
| Property | Value |
|---|---|
| Field ID | discount_unit |
| Label | Discount Unit |
| Type | Select |
| Options | percent, amount (for ITEMS/ORDER); percent, amount, free for BOGO |
| Required | Yes |
Description: Whether the discount is a percentage or a fixed amount. The free unit exists only for Buy X Get Y offers.
Discount Value
| Property | Value |
|---|---|
| Field ID | discount_value |
| Label | Discount Value |
| Type | Number |
| Required | Yes (for ITEMS/ORDER, and BOGO percent/amount) |
| Validation | percent: > 0 and <= 100; amount: > 0 |
Description: Amount of discount.
Examples:
- Percent: 20 (20% off)
- Amount: 5 (€5 off)
Offer Applies To
| Property | Value |
|---|---|
| Field ID | offer_applies_to |
| Label | Applies To |
| Type | Select |
| Options | specific-groups, specific-items |
| Required | Yes (for ITEMS type) |
Description: Whether an "Amount of items" offer applies to selected display groups (display_groups) or selected items (items). For BOGO, the equivalents are requirement_applies_to / benefit_applies_to.
Minimum Purchase Type
| Property | Value |
|---|---|
| Field ID | minimum_purchase_type |
| Label | Minimum Purchase Requirements |
| Type | Select |
| Options | no-minimum-requirements, minimum-purchase-amount, minimum-quantity-items |
| Required | Yes |
Description: The threshold a cart must meet. minimum-purchase-amount requires minimum_purchase_amount (> 0); minimum-quantity-items requires minimum_quantity_items (> 0). BOGO offers do not allow "no minimum requirements".
Limit Discount Usage
| Property | Value |
|---|---|
| Field ID | limit_discount_usage |
| Label | Limit discount usage |
| Type | Boolean |
| Required | Yes |
Description: When enabled, limit_discount_usage_amount (> 0) caps total redemptions across all customers. Once reached, the offer becomes Expired.
Limit One Usage Per Customer
| Property | Value |
|---|---|
| Field ID | limit_one_usage_per_customer |
| Label | Limit one usage per customer |
| Type | Boolean |
| Required | Yes |
Description: When enabled, each customer may redeem the offer only once.
Can Be Combined
| Property | Value |
|---|---|
| Field ID | can_be_combined |
| Label | Can be combined |
| Type | Boolean |
| Required | Yes |
Description: Whether this offer can stack with other offers.
Availability
| Property | Value |
|---|---|
| Field ID | availability |
| Label | Availability (channels) |
| Type | Multi-select (array) |
| Options | Kiosk, POS, Online Ordering, Table Qr Ordering, Uber Eats, Takeaway, Shopify |
| Required | Yes |
Description: The sales channels on which the offer (code) is valid.
Active Days
| Property | Value |
|---|---|
| Field ID | active_days |
| Label | Active days |
| Type | Multi-select (array) |
| Options | Sunday–Saturday |
| Required | Yes |
Description: Days of the week the offer is intended to be active. Stored but not enforced — the day-of-week check is commented out in OfferService and active_days is not sent to the kiosk / online-ordering channels, so the coupon works on any day inside its date range.
Active Dates
| Property | Value |
|---|---|
| Field ID | active_dates |
| Label | Schedule |
| Type | Object |
| Required | Yes |
Description: Scheduling fields: active_dates.start_date (Y-m-d), active_dates.start_time (H:i), active_dates.set_end_date (boolean) and, when set, active_dates.end_date / active_dates.end_time. Before the start the status is Scheduled; after the end it becomes Expired.
Status
| Property | Value |
|---|---|
| Field ID | status |
| Label | Status |
| Type | Select (computed) |
| Options | Draft, Active, Scheduled, Expired, Archived |
Description: The offer's lifecycle status. Drafts are set via is_draft. Draft and Archived are sticky — getCurrentStatus() returns them unchanged; every other status is computed from the schedule and redemption cap (Scheduled before start, Expired past end or once the usage cap is reached, otherwise Active). Once an offer leaves Draft it cannot return to Draft. (Verified: upvendo-backend app/RawModels/Offer.php lines 225-292; app/Services/BackOffice/OfferService.php lines 176-178.)
Business Logic
Coupon Validation
Customer enters discount code
│
▼
Find offer by discount_code (case-insensitive)
├── Not found → "Invalid promo code"
│
└── Found → Validate:
├── Stored status is Expired? Yes → "Offer <name> is not active"
├── can_be_combined respected? No → "Offer <name> cannot be combined with other offers"
├── limit_discount_usage reached? Yes → "Offer <name> max redemption has been met"
├── limit_one_usage_per_customer already used? Yes → "Offer <name> has already been redeemed by this customer"
├── Minimum purchase met? No → "Offer <name> requires a minimum order amount of X in total"
└── All valid → Apply discountThe schedule is enforced indirectly: only offers whose computed status is Active are published to the kiosk / online-ordering channels, so a Scheduled or Expired coupon is never offered in the first place. There is no date check inside the redemption path — OfferService::validateOfferDates() and its call site are both commented out, so active_days (day-of-week) is stored but never enforced. (Verified: upvendo-backend app/Services/Offer/OfferService.php lines 56-80 and 316-327; app/RawModels/Location.php lines 777-824.)
Discount Calculation
Percent unit:
Discount = base × (discount_value / 100) (discount_value ≤ 100)
Amount unit:
Discount = discount_value
Cannot exceed the base it applies to
Where "base" is the eligible items/groups (ITEMS),
the order total (ORDER), or the benefit items (BOGO).Customer Impact
Checkout Flow
- Customer enters coupon code
- System validates code
- If valid → Discount applied, shown in summary
- If invalid → Error message displayed
Error Messages
Online-ordering storefront (code entry, checked client-side against the offers published to the channel):
| Scenario | Message |
|---|---|
| Code doesn't match any published Code offer | "Invalid promo code" |
| Offer found but its requirements aren't met | "Requirements not met" |
| Offer already on the order | "This offer is already applied" |
| Non-combinable offer | "This offer cannot be combined with other offers" |
| A discount loyalty reward is already applied and the code is an ORDER-type (whole-cart) offer | "Cannot apply offers when a discount reward is active" — only ORDER-type code offers are blocked; ITEMS and BOGO coupons can still be entered while a discount reward is active |
(Verified: zestidoo-online-ordering src/stores/offer.ts lines 534-561; src/plugins/i18n/locales/en.ts lines 945-982.)
Backend, when the order is submitted (Offer <name> is the offer's own name):
| Scenario | Message |
|---|---|
| Offer is Expired | "Offer <name> is not active" |
| Non-combinable offer sent with others | "Offer <name> cannot be combined with other offers" |
| Total usage cap reached | "Offer <name> max redemption has been met" |
| Per-customer limit already used | "Offer <name> has already been redeemed by this customer" |
| Minimum quantity not met | "Offer <name> requires at least <n> items in total" |
| Minimum amount not met | "Offer <name> requires a minimum order amount of <x> in total" |
(Verified: upvendo-backend app/Services/Offer/OfferService.php lines 56-102 and 160-180.)
Relations
Depends On
- Customers: For per-customer usage tracking
- Display Groups/Items: For "Amount of items" and Buy X Get Y targeting
Affects
- Transactions: Discount applied
- Reports: Coupon usage analytics
Related Features
Business Rules
- Discount codes are checked twice: the request validator rejects a code already used by another non-deleted offer at the same location, and on publish (any non-Draft save) the service additionally rejects a code used by any other offer in the merchant's account with "The discount code is already used". Draft saves skip the second check. (Verified:
upvendo-backendapp/Http/Requests/BackOffice/Offer/StoreOfferRequest.php lines 55-65; app/Rules/UniqueInConnectionWithModel.php lines 81-83; app/Services/BackOffice/OfferService.php lines 75-84.) - Before applying a discount the system validates the stored status, the combination rule (
can_be_combined), the total usage limit (limit_discount_usage_amount), per-customer usage (limit_one_usage_per_customer), and minimum-purchase requirements. Start/end dates are handled by the status computation (onlyActiveoffers are published to a channel), and the day-of-week list (active_days) is not enforced at all. (Verified:upvendo-backendapp/Services/Offer/OfferService.php lines 56-80 and 316-327.) - For a percentage discount,
discount_valuemust be greater than 0 and at most 100; for an amount discount it must be greater than 0. - Redemptions are tracked per customer; each successful use creates an offer redemption record and increments the offer's
redemptionscounter. Oncelimit_discount_usage_amountis reached the offer becomes Expired. - An offer in any non-Draft status cannot be reverted to Draft; once published, it can only be deactivated, expired, or archived.
FAQs
"Can a coupon be used together with another offer?" Only if the offers allow it via the
can_be_combinedflag; otherwise the system rejects the combination."What happens if a coupon code is entered incorrectly?" The storefront matches the entered code against the Code offers published to that channel, ignoring case; if nothing matches it shows "Invalid promo code".
"How is the per-customer limit tracked?" When
limit_one_usage_per_customeris enabled, the system checks that customer's prior redemption records for the offer; if one exists, further redemptions are blocked."Can I reuse a coupon code after removing the old coupon?" Yes — once the old offer is removed, its discount code is no longer matched and can be assigned to a new offer.
"Where do I create a coupon code in the back office?" Coupons live inside Offers, not a separate page. Go to Marketing → Offers, create a new offer, and on the Information step set the method to Code instead of Automatic; that turns the offer into a code customers must enter.
"What's the difference between a coupon and an offer?" They're the same underlying object. An offer with method "Automatic" applies on its own when conditions are met; an offer with method "Code" requires the customer to enter the code (a coupon). All other settings and redemption tracking are identical.
"How long can my coupon code be?" The discount code field accepts up to 10 characters. The Generate button produces a random 8-character code using A-Z and 0-9.
"What discount types can a coupon give?" You set a discount unit of percentage or fixed amount. For percentage, the value must be greater than 0 and at most 100; for fixed amount it must be greater than 0. (A "free" unit exists only for Buy X Get Y offers, not as a general coupon discount.)
"What kinds of discounts can a coupon apply to?" Choose the offer type: Amount of items (specific items/groups), Amount of order (the order total), or Buy X Get Y. The type determines which items the code discounts.
"Can I limit how many times a coupon is used overall?" Yes. Enable "limit discount usage" and set the amount. Once total redemptions reach that number, the offer auto-expires and the code stops working.
"Can I stop the same customer from using a coupon more than once?" Yes. Turn on "limit one usage per customer." The system checks that customer's redemption records and blocks a repeat use.
"Can I set when a coupon starts and stops working?" Yes. Each offer has a start date/time and an optional end date/time. Before the start it shows Scheduled; after the end it becomes Expired. The form also collects a list of active days of the week, but that list is not currently enforced — the coupon works on any day inside its date range.
"Which sales channels can a coupon apply to?" You pick channels under availability: Kiosk, POS, Online Ordering, Table QR Ordering, Uber Eats, Takeaway, and Shopify. The code only works on the channels you select.
"Can a coupon be combined with other offers?" Each offer has a single "can be combined" setting. Enable it to let the code stack with other offers; otherwise it applies on its own.
"How do I require a minimum spend before a coupon works?" Set the minimum purchase type to "minimum purchase amount" and enter the amount, or "minimum quantity items" and enter the count. There's also a "no minimum requirements" option.
"Why does saving my coupon say 'The discount code is already used'?" Discount codes must be unique. On publish, the system checks every offer in your account for the same code (the form itself already blocks duplicates within the location); if another offer uses it, pick a different code. Draft saves skip the publish-time check.
"Can I reuse a code after I'm done with an old coupon?" Yes. Once the old offer is removed, its code is no longer matched, so the same code can be assigned to a new offer.
"How do I turn off a coupon immediately?" Set its status so it's no longer active (Expired/Archived). An expired coupon can no longer be redeemed. Note that once an offer leaves Draft it can't be moved back to Draft.
Troubleshooting
Problem: Coupon code not working
Causes:
- Code entered incorrectly
- Offer expired or not yet started (start/end schedule)
- Usage limit reached (
limit_discount_usage_amount) - Minimum purchase not met
- Offer not active, or channel not selected in
availability
Solutions:
- Check exact code spelling
- Verify the start/end schedule (the
active_dayslist is not enforced, so it is never the cause) - Check the redemption count vs the usage cap
- Add more items / meet the minimum purchase
- Activate the offer and confirm the channel is enabled
Problem: Discount amount wrong
Causes:
- Only some items/groups are eligible (
offer_applies_to) - Percentage vs amount unit
- Discount applies to the wrong base (items vs order)
Solutions:
- Review the targeted items/display groups
- Verify the discount unit and value
- Confirm the offer type matches the intended base
Examples
Welcome Discount (one per customer, on the order total)
json
{
"method": "Code",
"discount_code": "WELCOME15",
"name": "Welcome",
"type": "ORDER",
"discount_unit": "percent",
"discount_value": 15,
"minimum_purchase_type": "minimum-purchase-amount",
"minimum_purchase_amount": 20,
"limit_discount_usage": false,
"limit_one_usage_per_customer": true,
"can_be_combined": false
}Flash Sale (capped total usage, scheduled window)
json
{
"method": "Code",
"discount_code": "FLASH30",
"name": "Flash Sale",
"type": "ORDER",
"discount_unit": "percent",
"discount_value": 30,
"minimum_purchase_type": "minimum-purchase-amount",
"minimum_purchase_amount": 30,
"limit_discount_usage": true,
"limit_discount_usage_amount": 100,
"limit_one_usage_per_customer": true,
"can_be_combined": false,
"active_dates": {
"start_date": "2024-01-15",
"start_time": "12:00",
"set_end_date": true,
"end_date": "2024-01-15",
"end_time": "14:00"
}
}Item-Group Specific (half off pizzas)
json
{
"method": "Code",
"discount_code": "PIZZA50",
"name": "Half Off Pizzas",
"type": "ITEMS",
"discount_unit": "percent",
"discount_value": 50,
"offer_applies_to": "specific-groups",
"display_groups": [{ "id": "<pizzas-group-id>" }],
"minimum_purchase_type": "no-minimum-requirements",
"limit_discount_usage": false,
"limit_one_usage_per_customer": false,
"can_be_combined": false
}Fixed Amount Off Order
json
{
"method": "Code",
"discount_code": "SAVE5",
"name": "€5 Off",
"type": "ORDER",
"discount_unit": "amount",
"discount_value": 5,
"minimum_purchase_type": "minimum-purchase-amount",
"minimum_purchase_amount": 15,
"limit_discount_usage": false,
"limit_one_usage_per_customer": false,
"can_be_combined": false
}