Appearance
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_dollarrate (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_neededthreshold (orvisits_neededfor 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_alertis 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(renderssrc/views/marketing/loyalty/Loyalty.vue) - Create/Edit Wizard:
src/views/marketing/loyalty/LoyaltyWizard.vue, rendered fromsrc/pages/marketing/loyalty/create-loyalty.vue(5 steps undersrc/views/marketing/loyalty/components/loyaltyCreateStep/)
Fields
Fields below come from
StoreLoyaltyRequest(validation) andRawModels/Loyalty.php(persisted shape). The wizard is organized into 5 steps; the step each field belongs to is noted.
Reward Unit
| Property | Value |
|---|---|
| Field ID | reward_unit (label, singular, plural) |
| Label | What do you call your points? |
| Type | Object — label is one of Points, Stars, Custom |
| Required | reward_unit.label required. For Custom, singular and plural are also required |
| Step | 1 (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
| Property | Value |
|---|---|
| Field ID | program |
| Label | Program Type |
| Type | Select (enum LoyaltyPrograms) |
| Options | Amount Based, Visit Based |
| Required | Yes |
| Step | 2 (Choose Program) |
Description: Whether customers earn by amount spent or by completed visits. One type per program — not both.
Points Earned Each Dollar
| Property | Value |
|---|---|
| Field ID | points_earned_each_dollar |
| Label | Points earned per currency unit |
| Type | Number |
| Required | Required when program = Amount Based |
| Step | 2 |
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
| Property | Value |
|---|---|
| Field ID | points_earned_each_visit |
| Label | Points earned per visit |
| Type | Number |
| Required | Required when program = Visit Based |
| Step | 2 |
Description: Fixed points awarded for each completed order, regardless of amount, for visit-based programs.
Using Minimum Spend
| Property | Value |
|---|---|
| Field ID | using_minimum_spend |
| Label | Require a minimum spend |
| Type | Boolean |
| Required | Required when program = Visit Based (defaults false) |
| Step | 2 |
Description: When enabled (visit-based only), an order must meet the minimum spend before any points are awarded.
Minimum Spend To Earn Points
| Property | Value |
|---|---|
| Field ID | minimum_spend_to_earn_points |
| Label | Minimum spend |
| Type | Numeric (money) |
| Required | Required when using_minimum_spend is true |
| Step | 2 |
Description: The order total must be greater than or equal to this amount for visit-based points to be awarded.
Do Points Expire
| Property | Value |
|---|---|
| Field ID | do_points_expire |
| Label | Do points expire? |
| Type | Boolean |
| Required | Yes |
| Step | 2 |
Description: Master switch for point expiry. When off, points never expire.
Months To Expire Points
| Property | Value |
|---|---|
| Field ID | months_to_expire_points |
| Label | Points expire after |
| Type | Select (enum LoyaltyMonthsToExpirePoints), months |
| Options | 3, 6, 12, 18, 24 months — these are the only options |
| Required | Required when do_points_expire is true |
| Step | 2 |
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
| Property | Value |
|---|---|
| Field ID | sign_up_bonus (boolean) + sign_up_bonus_points (number) |
| Label | Sign-up bonus |
| Type | Boolean + Number |
| Required | sign_up_bonus required; sign_up_bonus_points required when it is true |
| Step | 4 (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
| Property | Value |
|---|---|
| Field ID | how_customer_sign_up |
| Label | Sign-up method |
| Type | Select (enum LoyaltySignUpOptions) |
| Options | Email, Phone, Both |
| Required | No (nullable; defaults to empty) |
| Step | Not 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
| Property | Value |
|---|---|
| Field ID | birthday_bonus (boolean) + birthday_bonus_details (object) |
| Label | Birthday bonus |
| Type | Boolean + Object |
| Required | birthday_bonus required; when true, birthday_bonus_details.type (enum RewardTypes: Discount / Free Item) is required |
| Step | 4 |
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
| Property | Value |
|---|---|
| Field ID | specific_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 |
| Label | Bonus days/times |
| Type | Boolean + select + array + time window |
| Options | specific_day_time_bonus_amount = Double Points or Triple Points (enum LoyaltyDayTimeBonusOptions); days are weekday names; times are H:i |
| Required | specific_day_time_bonus required; when true, amount, days and the time toggle are required (and start/end when the time toggle is on) |
| Step | 4 |
Description: During the selected days (and optional time window), earned points are multiplied ×2 (Double) or ×3 (Triple).
Program Alert
| Property | Value |
|---|---|
| Field ID | program_alert |
| Label | Suspicious activity alert |
| Type | Boolean |
| Required | Yes |
| Step | 4 |
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
| Property | Value |
|---|---|
| Field ID | name, description |
| Type | String |
| Required | Yes |
| Step | 4 |
Description: Program name and description shown to customers.
Rewards
| Property | Value |
|---|---|
| Field ID | rewards[] |
| Type | Array of reward objects |
| Required | Yes (at least one) |
| Step | 3 (Rewards) |
Each reward object (RawModels/Reward.php):
| Sub-field | Notes |
|---|---|
type | enum RewardTypes: Discount or Free Item |
name | required string |
discount_unit | enum DiscountUnits (required for Discount) |
discount_value | required for Discount; for percentage must be >0 and <=100; for amount must be >0 |
eligible_items[] | required for Free Item (item ids) |
points_needed | threshold for Amount Based programs (required, >0). On an Amount Based save, if it is missing the request back-fills it from visits_needed |
visits_needed | threshold 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 / spendReward 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
- Account: Points balance shown in profile
- Ways to earn: Earn rate, bonus days, birthday treat shown
- Cart: "You'll earn X points on this order"
- 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. - Rewards: Each reward shows its points/visits threshold; unlocks when the balance reaches it
- 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 pointsRelations
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
Related Features
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 theis_loyalty_subscribedAPI field) returnstrueonly 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()returnsnull(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_upenum — 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
MplusKassaand 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:
- 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) - Customer not identified / not logged in
- Visit-based program with minimum spend not met
- Order not in "Complete" status
Solutions:
- Confirm a loyalty program is set up for the location and, in production, that its POS integration is
MplusKassaor Kassanet (Hendrickx/Vanhoutte) — loyalty is free, so this POS check is the only activation gate - Ensure the customer is identified (email/phone per sign-up method)
- Check the minimum spend setting and the order total
- Verify order status is Complete
Problem: Can't redeem a reward
Causes:
- Balance below the reward's
points_needed/visits_neededthreshold - Multiple rewards selected exceed total available points
- Points expired (records marked is_expired)
- Mplus balance stale / POS unavailable
Solutions:
- Check the reward's threshold versus the customer's balance
- Reduce the number of rewards applied to one order
- Check expiry settings and earned dates
- For Mplus-linked customers, the balance is refreshed before redemption; verify the POS integration
Problem: Points balance incorrect
Causes:
- Points expired
- Redemption not recorded
- Order cancelled/refunded
- Mplus sync issue (POS balance overrides local)
Solutions:
- Check expiry settings and dates
- Review transaction history
- Check for refunded orders (points are not auto-reversed)
- Verify the Mplus integration / cached balance
Problem: Points expiring too fast
Causes:
- Short expiry period configured (3 or 6 months)
- Customer inactive
Solutions:
- Increase the expiry period (up to 24 months) or turn expiry off
- 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
MplusKassaor 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
StoreLoyaltyRequestpayload 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