Skip to content

Loyalty Program

Overview

The Loyalty Program rewards customers for repeat purchases. Customers earn points (or stars, or a custom unit) on orders and unlock rewards once their balance reaches a reward's fixed threshold.

Key Purpose: Encourage repeat business through threshold-based rewards.

Purpose

This page lets you configure a loyalty program that awards points to customers based on their spending or visits, and define rewards they can unlock once they accumulate enough points.

Key Concepts

  • Amount-Based Program: Customers earn points proportional to the order total, calculated as order total multiplied by the configured points_earned_each_dollar rate (there is no subtotal option, and no per-category exclusion).
  • Visit-Based Program: Customers earn a fixed number of points (points_earned_each_visit) per completed order regardless of the amount spent, optionally requiring a minimum spend threshold (using_minimum_spend / minimum_spend_to_earn_points).
  • Reward Redemption (threshold-based): Each reward defines a fixed points_needed threshold (or visits_needed for visit-based programs). A reward unlocks once the customer's balance reaches that threshold, and redeeming it deducts the required points. There is no point-to-currency conversion, no point value, and no maximum-redemption percentage cap. Rewards are either a Discount (percentage or fixed amount) or a Free Item with selected eligible items.
  • Reward Unit: The points are labelled by the configured reward_unit — Points, Stars, or a Custom singular/plural label (e.g. "Bean"/"Beans").
  • Bonus Time Multiplier: During configured bonus windows (specific days, optionally a start/end time), earned points are automatically doubled or tripled based on specific_day_time_bonus_amount (Double Points / Triple Points).
  • Suspicious Activity Flag: When program_alert is on, if a customer completes more than 3 orders at the same location in a single day (location timezone), the customer is flagged as suspicious and an alert email is sent to the merchant.

Actions

Configure Loyalty Program

Set the reward unit (Points/Stars/Custom), program type (Amount Based or Visit Based), earning rate, point expiry period (in months), sign-up bonus, birthday bonus, and day/time bonus settings via the 5-step wizard. Loyalty is free of charge — completing the wizard's final "Review & Create" step saves and activates the program directly for that location; there is no payment or subscription step. In production, activation is available only for locations whose connected POS is on the supported allowlist (MplusKassa, or Kassanet — Hendrickx/Vanhoutte); other POS integrations are blocked with a "Loyalty is not yet available for this POS integration" error. Test/staging environments are not restricted by POS.

Manage Rewards

Create, update, or delete rewards that customers unlock with their points. Each reward is a Discount (percentage or fixed amount) or a Free Item with eligible item selection, and defines a points_needed (amount-based) or visits_needed (visit-based) threshold.

Manage Birthday Bonuses

Configure a birthday bonus reward per location, including discount type, value, and eligible items for free-item bonuses.

Location

  • Backoffice Route: /marketing/loyalty
  • Backend Controller: app/Http/Controllers/Api/BackOffice/LoyaltyController.php
  • Backend Service: app/Services/BackOffice/LoyaltyService.php
  • Validation: app/Http/Requests/BackOffice/Loyalty/StoreLoyaltyRequest.php
  • Vue Page: src/pages/marketing/loyalty/index.vue (renders src/views/marketing/loyalty/Loyalty.vue)
  • Create/Edit Wizard: src/views/marketing/loyalty/LoyaltyWizard.vue, rendered from src/pages/marketing/loyalty/create-loyalty.vue (5 steps under src/views/marketing/loyalty/components/loyaltyCreateStep/)

Fields

Fields below come from StoreLoyaltyRequest (validation) and RawModels/Loyalty.php (persisted shape). The wizard is organized into 5 steps; the step each field belongs to is noted.

Reward Unit

PropertyValue
Field IDreward_unit (label, singular, plural)
LabelWhat do you call your points?
TypeObject — label is one of Points, Stars, Custom
Requiredreward_unit.label required. For Custom, singular and plural are also required
Step1 (Customize)

Description: Controls how the loyalty currency is labelled. For Points and Stars the singular/plural are filled automatically; for Custom the merchant supplies their own (e.g. "Bean"/"Beans").


Program Type

PropertyValue
Field IDprogram
LabelProgram Type
TypeSelect (enum LoyaltyPrograms)
OptionsAmount Based, Visit Based
RequiredYes
Step2 (Choose Program)

Description: Whether customers earn by amount spent or by completed visits. One type per program — not both.


Points Earned Each Dollar

PropertyValue
Field IDpoints_earned_each_dollar
LabelPoints earned per currency unit
TypeNumber
RequiredRequired when program = Amount Based
Step2

Description: Points awarded per unit of order total for amount-based programs. Points earned = points_earned_each_dollar × order total (× bonus multiplier). Calculated on the order total — there is no subtotal option.


Points Earned Each Visit

PropertyValue
Field IDpoints_earned_each_visit
LabelPoints earned per visit
TypeNumber
RequiredRequired when program = Visit Based
Step2

Description: Fixed points awarded for each completed order, regardless of amount, for visit-based programs.


Using Minimum Spend

PropertyValue
Field IDusing_minimum_spend
LabelRequire a minimum spend
TypeBoolean
RequiredRequired when program = Visit Based (defaults false)
Step2

Description: When enabled (visit-based only), an order must meet the minimum spend before any points are awarded.


Minimum Spend To Earn Points

PropertyValue
Field IDminimum_spend_to_earn_points
LabelMinimum spend
TypeNumeric (money)
RequiredRequired when using_minimum_spend is true
Step2

Description: The order total must be greater than or equal to this amount for visit-based points to be awarded.


Do Points Expire

PropertyValue
Field IDdo_points_expire
LabelDo points expire?
TypeBoolean
RequiredYes
Step2

Description: Master switch for point expiry. When off, points never expire.


Months To Expire Points

PropertyValue
Field IDmonths_to_expire_points
LabelPoints expire after
TypeSelect (enum LoyaltyMonthsToExpirePoints), months
Options3, 6, 12, 18, 24 months — these are the only options
RequiredRequired when do_points_expire is true
Step2

Description: Number of months after earning that points expire (not days). On earn, expires_at = now + months_to_expire_points. Expired point records are marked is_expired and skipped during redemption.


Sign-Up Bonus

PropertyValue
Field IDsign_up_bonus (boolean) + sign_up_bonus_points (number)
LabelSign-up bonus
TypeBoolean + Number
Requiredsign_up_bonus required; sign_up_bonus_points required when it is true
Step4 (Personalize)

Description: One-time fixed points granted when a customer first joins (no prior customer-vendor relationship). Respects the same expiry settings as regular points.


How Customer Sign Up

PropertyValue
Field IDhow_customer_sign_up
LabelSign-up method
TypeSelect (enum LoyaltySignUpOptions)
OptionsEmail, Phone, Both
RequiredNo (nullable; defaults to empty)
StepNot surfaced in the setup wizard

Description: A persisted enum for how customers identify/join the program. Note: this field is accepted by the API but is not collected anywhere in the current back-office loyalty setup wizard, so in practice it stays at its default (empty) value.


Birthday Bonus

PropertyValue
Field IDbirthday_bonus (boolean) + birthday_bonus_details (object)
LabelBirthday bonus
TypeBoolean + Object
Requiredbirthday_bonus required; when true, birthday_bonus_details.type (enum RewardTypes: Discount / Free Item) is required
Step4

Description: A birthday gift — a Discount or a Free Item with eligible items. It is a reward-style gift, not a fixed points grant. Stored per location as a boolean map plus a linked BirthdayBonus document. For a Discount the sub-fields are birthday_bonus_details.discount_unit (percent / amount) and birthday_bonus_details.discount — note the value field is discount, not discount_value as on rewards; percentage values must be >0 and <=100, amounts >0. For a Free Item, birthday_bonus_details.eligible_items[] is required. (Verified: upvendo-backend app/Http/Requests/BackOffice/Loyalty/StoreLoyaltyRequest.php lines 218-242.)


Specific Day/Time Bonus

PropertyValue
Field IDspecific_day_time_bonus (boolean), specific_day_time_bonus_amount, specific_day_time_bonus_days[], specific_day_time_bonus_time (boolean), specific_day_time_bonus_time_start, specific_day_time_bonus_time_end
LabelBonus days/times
TypeBoolean + select + array + time window
Optionsspecific_day_time_bonus_amount = Double Points or Triple Points (enum LoyaltyDayTimeBonusOptions); days are weekday names; times are H:i
Requiredspecific_day_time_bonus required; when true, amount, days and the time toggle are required (and start/end when the time toggle is on)
Step4

Description: During the selected days (and optional time window), earned points are multiplied ×2 (Double) or ×3 (Triple).


Program Alert

PropertyValue
Field IDprogram_alert
LabelSuspicious activity alert
TypeBoolean
RequiredYes
Step4

Description: When on, if a customer completes more than 3 completed orders at the same location in a single day, they are flagged suspicious and the merchant is emailed.


Name & Description

PropertyValue
Field IDname, description
TypeString
RequiredYes
Step4

Description: Program name and description shown to customers.


Rewards

PropertyValue
Field IDrewards[]
TypeArray of reward objects
RequiredYes (at least one)
Step3 (Rewards)

Each reward object (RawModels/Reward.php):

Sub-fieldNotes
typeenum RewardTypes: Discount or Free Item
namerequired string
discount_unitenum DiscountUnits (required for Discount)
discount_valuerequired for Discount; for percentage must be >0 and <=100; for amount must be >0
eligible_items[]required for Free Item (item ids)
points_neededthreshold for Amount Based programs (required, >0). On an Amount Based save, if it is missing the request back-fills it from visits_needed
visits_neededthreshold for Visit Based programs (required, >0). On a Visit Based save, if it is missing the request derives it as points_needed ÷ points_earned_each_visit

Description: Rewards are unlocked by reaching a fixed points_needed (amount-based) or visits_needed (visit-based) threshold. Redemption itself is always checked against the stored points_needed, for both program types. There is no point-to-currency conversion or maximum-redemption cap. (Verified: upvendo-backend app/Http/Requests/BackOffice/Loyalty/StoreLoyaltyRequest.php lines 65-73; app/Services/Loyalty/LoyaltyService.php lines 738-750.)


Business Logic

Points Earning

Customer places a Complete order


Is it bonus time? (specific_day_time_bonus, day/time match)
- Yes → multiplier = 2 (Double) or 3 (Triple)
- No  → multiplier = 1


Amount Based:
  points = points_earned_each_dollar × order TOTAL × multiplier
Visit Based:
  if (!using_minimum_spend OR total >= minimum_spend_to_earn_points):
      points = points_earned_each_visit × multiplier
  (no minimum met → no points)


If points == 0 → skip


Save LoyaltyPoint record (with expires_at = now + months_to_expire_points
                          when do_points_expire is on)


Recalculate customer lifetime points / visits / spend

Reward Redemption (threshold-based)

Customer selects one or more rewards at checkout


checkIfRewardsAreRedeemable:
- For each reward, available points >= reward.points_needed?
  └── No → 422 "You do not have enough points to redeem <reward name>."
- And available points >= SUM(all selected rewards' points_needed)?
  └── No → 422 "You do not have enough points to redeem the selected rewards."
- (Mplus-linked customers: the balance is re-fetched live first; if the POS is
   unreachable and no prior balance is known →
   422 "We could not verify your points balance right now. Please try again in a moment.")

└── Yes → apply reward(s):
            - Discount: percentage or fixed amount off eligible/all items
            - Free Item: eligible item becomes free


        Deduct points across active (non-expired) LoyaltyPoint records
        in order, spanning multiple records if needed
        (expired records are marked is_expired and skipped)

Note: redemption is threshold-based. There is NO point-to-currency value, NO minimum-redemption-points setting, and NO maximum-redemption-% cap.

Points Balance

Available points = sum of non-expired LoyaltyPoint records,
                   minus points already used (points_used) on each.

(For Mplus-linked customers the online balance is read from the POS
 instead, falling back to the local balance if Mplus is unavailable.)

Customer Impact

Online Ordering

  1. Account: Points balance shown in profile
  2. Ways to earn: Earn rate, bonus days, birthday treat shown
  3. Cart: "You'll earn X points on this order"
  4. Checkout: An inline "Use your points" section in the order summary on the Order & Pay page (CheckoutLoyaltyRewards.vue) shows unlocked rewards and today's birthday bonus so the customer can redeem or remove them without leaving checkout. If they still have unused redeemable rewards when they tap to pay, a one-time prompt (per checkout session) offers to redeem points first or continue without them.
  5. Rewards: Each reward shows its points/visits threshold; unlocks when the balance reaches it
  6. Confirmation: "You earned X points"

Kiosk

  • Points features may be limited
  • Requires customer identification (phone/email)
  • Balance shown if identified

Receipt

--- Loyalty ---
Points Earned: 25
Reward Redeemed: Free Coffee (100 points)
New Balance: 425 points

Relations

Depends On

  • Customers: Points tied to customer-merchant records
  • Items: For Free Item rewards / birthday Free Item (eligible items)
  • POS Integration: Loyalty is free of charge (no subscription), but in production it's only available for locations connected to a supported POS (MplusKassa, or Kassanet — Hendrickx/Vanhoutte); not restricted in test/staging environments

Affects

  • Online Ordering: Points display and reward redemption
  • Transactions: Points earned and rewards redeemed recorded
  • Mplus (MplusKassa) POS: Points/redemptions synced for linked customers

Business Rules

  • Points are only awarded on orders with a status of "Complete"; pending or cancelled orders do not earn points.
  • For visit-based programs with a minimum spend enabled, the order total must meet or exceed the configured minimum spend threshold before any points are awarded.
  • When redeeming rewards, the system iterates through active (non-expired) point records in order and deducts points across multiple records if needed; if total available points are insufficient, the redemption fails with an exception.
  • Points expiry is set in months (not days) from the time of earning; expired point records are marked as expired and skipped during redemption.
  • Loyalty is free of charge — there is no Stripe or reseller subscription for the feature. Availability is gated only by POS support: Location::isLoyaltySubscribed() (kept for backward compatibility with the is_loyalty_subscribed API field) returns true only when a program is configured for the location and, in production, the location's connected POS is on the supported allowlist (MplusKassa, or Kassanet — Hendrickx/Vanhoutte); getLoyaltyButOnlyIfSubscribed() returns null (disabling all loyalty features) otherwise. Test/staging environments allow loyalty regardless of connected POS.

FAQs

  • "Can a customer use both amount-based and visit-based programs?" No, each loyalty program is configured as either amount-based or visit-based, not both simultaneously.

  • "What happens to points when an order is refunded?" Points are awarded only on completed orders; however, the system does not automatically reverse points if an order is later refunded -- manual adjustment may be needed.

  • "How does the sign-up bonus work?" When a new customer-vendor record is created (i.e., the customer has no prior relationship with this vendor), the system automatically awards the configured sign-up bonus points, respecting the same expiry settings as regular points.

  • "Can rewards be combined in a single order?" Yes, multiple reward IDs can be submitted in a single order, but the system validates that the customer has enough total available points to cover all selected rewards combined.

  • "What triggers the suspicious activity alert?" If a customer has more than 3 completed orders at the same location within a single calendar day (in the location's timezone), the customer is flagged and the merchant receives an alert email.

  • "How do I set up a loyalty program — what are the steps?" Go to Marketing → Loyalty and follow the 5-step wizard: Customize (choose your reward unit), Choose Program (Amount Based or Visit Based and earn rate), Rewards, Personalize (sign-up/birthday/bonus-time options), then Review & Create.

  • "What's the difference between an Amount Based and a Visit Based program?" Amount Based awards points based on how much is spent (points per €1), while Visit Based awards a fixed number of points for every completed visit regardless of spend. You pick one type per program — not both.

  • "Can I require customers to spend a minimum before they earn points?" Yes, but only on Visit Based programs. Enable "minimum spend" and set the amount; the order total must meet or exceed it for any points to be awarded.

  • "What can I name the points — do they have to be called 'points'?" No. In step 1 you choose a reward unit: Points, Stars, or a Custom unit. If you pick Custom, you enter your own singular and plural label (e.g. "Bean"/"Beans").

  • "How long until points expire, and what options do I have?" Expiry is optional. If you turn on "Do points expire," you choose a fixed period of 3, 6, 12, 18, or 24 months — those are the only options. If expiry is off, points never expire.

  • "How do customers redeem points for a reward?" You define rewards, each with a points (or visits) threshold. A reward unlocks once the customer's balance reaches that threshold, and redeeming it deducts the required points. Rewards can be a Discount (percentage or fixed amount) or a Free Item with selected eligible items.

  • "Can a customer redeem more than one reward in the same order?" Yes. Multiple rewards can be applied to one order, but the system checks the customer has enough total available points to cover them all combined.

  • "How do I offer double or triple points on certain days?" In Personalize, turn on the day/time bonus and choose Double or Triple Points, then pick the days (and optionally a start/end time). Points earned in those windows are automatically multiplied.

  • "What do the sign-up and birthday bonuses do, and how do customers join?" You can grant a one-time sign-up bonus (fixed points when a customer first joins) and a birthday bonus (a Discount or Free Item gift). (There is a persisted how_customer_sign_up enum — Email/Phone/Both — but the current setup wizard does not surface it.)

  • "Why isn't a customer earning points?" Points are only awarded on orders that reach "Complete" status, and only when the location's loyalty program is active. Loyalty is free, but in production it's only active for locations connected to a supported POS (MplusKassa, or Kassanet — Hendrickx/Vanhoutte); other POS integrations can't turn it on. For Visit Based programs with minimum spend, the order must also meet the minimum.

  • "Do I have to pay to turn on loyalty, and is it per location?" No. Loyalty is free of charge — there's no subscription or payment step; completing the setup wizard's final "Create" step saves and activates the program directly. It is configured per location, but availability depends on the location's POS integration: in production it's limited to MplusKassa and Kassanet (Hendrickx/Vanhoutte) integrations, which get a "Loyalty is not yet available for this POS integration" error otherwise. Test/staging environments aren't restricted by POS.

  • "Can customers redeem their points during checkout, not just from their account?" Yes. On the Order & Pay checkout page, an inline "Use your points" section (in the order summary) shows any unlocked rewards — and today's birthday bonus, if available — that the customer can redeem or remove right there, without leaving checkout. If they still have unused redeemable rewards when they tap to pay, a one-time prompt (per checkout session) asks whether to redeem points first or continue without them.

  • "I use Mplus (MplusKassa) POS — will loyalty points stay in sync?" Yes. For Mplus-linked customers, earned points and redemptions are pushed to the Mplus relation, and the online balance is read from the POS. The sync is non-fatal — if Mplus is unavailable it falls back to the local balance rather than blocking the order.

Troubleshooting

Problem: Customer not earning points

Causes:

  1. No loyalty program configured for the location, or (in production) the location's POS isn't on the supported allowlist (MplusKassa, or Kassanet — Hendrickx/Vanhoutte)
  2. Customer not identified / not logged in
  3. Visit-based program with minimum spend not met
  4. Order not in "Complete" status

Solutions:

  1. Confirm a loyalty program is set up for the location and, in production, that its POS integration is MplusKassa or Kassanet (Hendrickx/Vanhoutte) — loyalty is free, so this POS check is the only activation gate
  2. Ensure the customer is identified (email/phone per sign-up method)
  3. Check the minimum spend setting and the order total
  4. Verify order status is Complete

Problem: Can't redeem a reward

Causes:

  1. Balance below the reward's points_needed / visits_needed threshold
  2. Multiple rewards selected exceed total available points
  3. Points expired (records marked is_expired)
  4. Mplus balance stale / POS unavailable

Solutions:

  1. Check the reward's threshold versus the customer's balance
  2. Reduce the number of rewards applied to one order
  3. Check expiry settings and earned dates
  4. For Mplus-linked customers, the balance is refreshed before redemption; verify the POS integration

Problem: Points balance incorrect

Causes:

  1. Points expired
  2. Redemption not recorded
  3. Order cancelled/refunded
  4. Mplus sync issue (POS balance overrides local)

Solutions:

  1. Check expiry settings and dates
  2. Review transaction history
  3. Check for refunded orders (points are not auto-reversed)
  4. Verify the Mplus integration / cached balance

Problem: Points expiring too fast

Causes:

  1. Short expiry period configured (3 or 6 months)
  2. Customer inactive

Solutions:

  1. Increase the expiry period (up to 24 months) or turn expiry off
  2. Send reminder communications before expiry

Assistant Guidance

When answering questions about this feature:

  • Loyalty is free of charge for merchants — never say it requires a subscription, a Stripe charge, or a "subscribe/unsubscribe" step. Completing the 5-step wizard's "Review & Create" step activates it directly for that location.
  • Availability is gated by POS support, not billing: in production, a location can only activate loyalty if its connected POS is MplusKassa or Kassanet (Hendrickx/Vanhoutte) — other POS integrations are blocked. Test/staging environments are not restricted by POS.
  • Redemption is threshold-based only — never invent a point-to-currency conversion rate or a maximum-redemption percentage; the product doesn't have either.
  • Customers can redeem loyalty rewards from two places in Online Ordering: the account/loyalty view and an inline "Use your points" section on the checkout page itself (with a one-time nudge prompt before paying). Mention both when asked how customers use their points.
  • If a merchant asks why loyalty isn't showing or working for a location, check in this order: is a program configured for that location, is the location's POS on the supported allowlist (production only), and is the customer identified/logged in.

Examples

JSON below reflects the real StoreLoyaltyRequest payload shape.

Simple Amount-Based Program

json
{
  "reward_unit": { "label": "Points", "singular": "Point", "plural": "Points" },
  "program": "Amount Based",
  "points_earned_each_dollar": 1,
  "do_points_expire": false,
  "rewards": [
    {
      "type": "Discount",
      "name": "€1 off",
      "discount_unit": "percent",
      "discount_value": 10,
      "points_needed": 100
    }
  ],
  "name": "Rewards Club",
  "description": "Earn points on every order",
  "how_customer_sign_up": "Email",
  "sign_up_bonus": true,
  "sign_up_bonus_points": 50,
  "birthday_bonus": false,
  "specific_day_time_bonus": false,
  "program_alert": true
}

How it works:

  • Earn 1 point per currency unit of the order total
  • A reward unlocks at 100 points (a 10% discount)
  • Points never expire
  • 50-point sign-up bonus

Visit-Based Program with Minimum Spend

json
{
  "reward_unit": { "label": "Stars", "singular": "Star", "plural": "Stars" },
  "program": "Visit Based",
  "points_earned_each_visit": 1,
  "using_minimum_spend": true,
  "minimum_spend_to_earn_points": 10,
  "do_points_expire": true,
  "months_to_expire_points": 12,
  "rewards": [
    {
      "type": "Free Item",
      "name": "Free Coffee",
      "eligible_items": [{ "id": "<item_id>" }],
      "visits_needed": 10
    }
  ],
  "name": "Coffee Card",
  "description": "Collect a star each visit",
  "how_customer_sign_up": "Both",
  "sign_up_bonus": false,
  "birthday_bonus": true,
  "birthday_bonus_details": {
    "type": "Free Item",
    "eligible_items": [{ "id": "<item_id>" }]
  },
  "specific_day_time_bonus": false,
  "program_alert": true
}

How it works:

  • Earn 1 star per visit, but only when the order total is at least 10
  • 10 stars unlocks a free coffee
  • Stars expire after 12 months
  • Birthday treat is a free item

Amount-Based with Double-Points Days

json
{
  "reward_unit": { "label": "Custom", "singular": "Bean", "plural": "Beans" },
  "program": "Amount Based",
  "points_earned_each_dollar": 2,
  "do_points_expire": true,
  "months_to_expire_points": 6,
  "rewards": [
    {
      "type": "Discount",
      "name": "€5 off",
      "discount_unit": "amount",
      "discount_value": 5,
      "points_needed": 200
    }
  ],
  "name": "Bean Club",
  "description": "Earn beans, double on weekends",
  "how_customer_sign_up": "Phone",
  "sign_up_bonus": true,
  "sign_up_bonus_points": 100,
  "birthday_bonus": false,
  "specific_day_time_bonus": true,
  "specific_day_time_bonus_amount": "Double Points",
  "specific_day_time_bonus_days": ["Saturday", "Sunday"],
  "specific_day_time_bonus_time": false,
  "program_alert": true
}

How it works:

  • Earn 2 beans per currency unit; doubled to 4 on Saturdays and Sundays
  • 200 beans unlocks a €5-off discount
  • Beans expire after 6 months
  • Custom "Bean"/"Beans" labelling, 100-bean sign-up bonus