Appearance
Loyalty Points System
Flow ID: CF-32 | Module(s): eshop (
Adv_loyaltylibrary), Checkout domain (LoyaltyRedemption), job (AdvAddPointsToCustomerDeliver,AdvAddPointsToCustomerFromStore,AdvSentMailToCustomerBirthday,AdvSentMailToCustomerRemainingPoints) | Complexity: High | Last Updated: 2026-09-29
Business Overview
The loyalty points system incentivizes repeat purchases by awarding customers numeric points based on product prices, which they can later redeem at checkout for cash discounts. Points operate on a simple multiplier-and-threshold model with no expiry.
Earning formula: points = round(product_price * point_factor) -- the price field used is configurable via ESHOP.POINT_FACTOR_TYPE (one of price, price_without_vat, original_price, final_price). Each product has its own point_factor column; a global default is provided by ESHOP.DEFAULT_POINT_FACTOR.
Spending formula: Only whole multiples of SPEND_POINTS can be redeemed. cash_discount = (redeemable_points / SPEND_POINTS) * REWARD_CASH.
Example: If SPEND_POINTS=100 and REWARD_CASH=10, a customer with 350 total points can redeem 300 points for a 30 EUR discount. The remaining 50 points stay in their account.
Key business behaviors:
- Points are awarded asynchronously after delivery (not at order creation) -- two separate cron jobs handle courier delivery and in-store pickup
- Point redemption happens at checkout: the customer opts in via a checkbox, and redeemable points are immediately deducted from
shop_customer.total_points - On cancellation (admin, checkout failure, incomplete order cleanup, or REST
cancel-paymentendpoint): both spent points (returned to customer) and earned points (removed from customer) are reversed; the REST path uses theOrderCanceleddomain event andRestoreSpentPointsListenerrather than calling the legacyAdv_loyaltylibrary directly - New customer bonus: configurable welcome points via
ESHOP.POINTS_TO_NEW_CUSTOMER - Birthday bonus: configurable bonus points via
EMAIL_FOR_BIRTHDAY.POINTS_TO_ADD, awarded when birthday email is sent - Product review reward: configurable points via
ECOMMERCEN_PLUS.CUSTOMER_REVIEW_REWARD, awarded when admin approves a review - Remaining points reminder: automated email to customers with points above a threshold who have not ordered recently
- No point expiry -- points accumulate indefinitely in
shop_customer.total_points - Admin can manually add/remove points per customer and per order
API Reference
Points data is exposed via existing resources, plus REST redemption support at checkout (#495):
| Endpoint | Field | Description |
|---|---|---|
GET /rest/customer/me | totalPoints | Customer's current point balance (integer) |
GET /rest/product/{id} | pointFactor | Product's point multiplier (float, nullable) |
GET /rest/features | loyalty (boolean) | Capability gate mirroring POINT_SYSTEM.IS_ENABLED (application/config/rest_features.php:55) |
GET /rest/storefront-config | loyalty.spendPoints, loyalty.rewardCashPerUnit | Redemption ratio values (ESHOP.SPEND_POINTS / ESHOP.REWARD_CASH), so a client can render "what my points are worth" before checkout. No enabled key — that's the /rest/features gate above (src/Domains/StorefrontConfig/Loyalty/LoyaltyConfigResolver.php) |
POST /rest/checkout/totals | redeemPoints/redeem_points request | Preview the redemption for the current cart. Response includes pointsSpend (point count) and pointsCash (cash value) alongside the total, plus a loyalty decision object with all state: {pointSystemEnabled, spendPoints, rewardCashPerUnit, balance, redeemablePoints, redeemableCash, canRedeem, wouldExceed} (all fields in schema required list). Built by LoyaltyRedemption::preview() at src/Domains/Checkout/LoyaltyRedemption.php:169-195; response body at src/Rest/Checkout/Controllers/Checkout.php:859-876, the totals() body (:712-877) that builds it, with the loyalty part running from the // Loyalty redemption (#495) comment at :799 to :857 |
POST /rest/checkout/place-order | redeemPoints/redeem_points request; 422 loyalty_redemption_exceeds_order_total | Charges the redemption computed by the same collaborator as the totals preview (src/Domains/Checkout/PlaceOrderService.php:370-373). Returns 422 (Unprocessable Entity) if the redeemed cash value covers the entire payable amount — rule #17 below. Predicate LoyaltyRedemption::exceedsPayable() at src/Domains/Checkout/LoyaltyRedemption.php:242-249; exception thrown at src/Domains/Checkout/PlaceOrderService.php:460-463; exception class src/Domains/Checkout/Exceptions/LoyaltyRedemptionExceedsOrderTotalException.php:38 |
The storefront exposes loyalty data via server-rendered JSON state in the checkout page (previewOrderLoyalty() method), which provides pointSystemIsEnabled, customerPoints, customerCash, and customerTotalPoints to the Vue checkout component. This is the legacy path only — see §2b REST Redemption Path below for the modern headless equivalent.
Code Flow
1. Point Calculation on Product Display
When products are parsed for display (catalog, detail pages, cart), the product parser calculates display points.
Adv_product_parser_model::setPoints($product)is called during product parsing- Checks
POINT_SYSTEM.IS_ENABLEDregistry flag - Computes points for all four price variants:
price,price_without_vat,original_price,final_price-- each asround(price * point_factor) - Selects the variant matching
$this->pointFactorType(loaded fromESHOP.POINT_FACTOR_TYPE) and assigns it to$product->points - The
point_factorcolumn is loaded fromshop_productvia the product SELECT queries
Key file: ecommercen/eshop/models/Adv_product_parser_model.php (setPoints() method spans :588-619, docblock from :580)
2. Spending Flow (Checkout)
When a customer opts to redeem points during checkout:
Preview order page (
Adv_order::previewOrder()/previewOrderLoyalty()):- Loads
loyaltylibrary, callscalcLoyalty($customerId) calcLoyalty()readsshop_customer.total_points, calculates max redeemable points viacalcPointsToSpend()(floor division:floor(total / SPEND_POINTS) * SPEND_POINTS), converts to cash viapointsToCash()- Returns
{points, cash, totalPoints}to Vue checkout component via two distinct paths:- Render vars (
Adv_order.php:378-384) — server-rendered JSON state in the checkout page view - JSON state writer (
previewOrderLoyalty()method starting at:1711) — the actual method building the state object
- Render vars (
- Loads
Vue checkout (
assets/main/vue/CheckoutPage.vue):- Displays loyalty block with total points, redeemable points, and equivalent cash value
- Customer checks the
redeemPointscheckbox (value1) - On form submit,
redeemPoints=1is POSTed
Checkout view (
Adv_order::checkoutView()):- If
POINT_SYSTEM.IS_ENABLEDandredeemPointsis posted, recalculates loyalty viacalcLoyalty() - Sets
orderData['points_spend']andorderData['points_reward'](cash equivalent) - Points reward is subtracted from order total:
total_vat = cart_total_vat + transport + delivery - points_reward + gift_packaging
- If
Order submission (
Adv_order::submittedOrder()):- Calls
deductLoyaltyPoints($customerId, $orderData) deductLoyaltyPoints()checksPOINT_SYSTEM.IS_ENABLEDandredeemPointsinput- Calls
loyalty->removePointsFromCustomer($customerId)which:- Recalculates redeemable points via
calcLoyalty() - Calls
savePointsToCustomer($customerId, 0 - points)-- executesUPDATE shop_customer SET total_points = total_points + (-points) WHERE id = $customerId
- Recalculates redeemable points via
- Resulting
points_spendandpoints_rewardare stored onshop_order
- Calls
Order creation (
Adv_order_model::create_order()):- Stores
points_spendandpoints_rewardin theshop_orderrecord total_vatis reduced bypoints_reward
- Stores
Key files:
ecommercen/eshop/controllers/Adv_order.php(lines 378-384, 680-687, 963-988 —refuseLoyaltyRedemptionExceedingTotal()+deductLoyaltyPoints()calls incheckout())ecommercen/libraries/Adv_loyalty.php(methods:calcLoyalty,removePointsFromCustomer,savePointsToCustomer)ecommercen/eshop/models/Adv_order_model.php(points_reward/points_spendhandling at:428-441;setUpAdminOrderDataPoints()writes at:644-652)
2b. REST Redemption Path (#495)
The headless equivalent of the legacy flow above, built to be exact legacy parity: same gate, same all-or-nothing redemption, same floor-to-a-multiple maths, same columns written. Advisable\Domains\Checkout\LoyaltyRedemption (src/Domains/Checkout/LoyaltyRedemption.php) is the single collaborator both REST entry points call, so the previewed and charged discount can never diverge:
- Client opt-in: the customer sends a boolean —
redeemPointsorredeem_points— never an amount. Accepted by bothPOST /rest/checkout/totals(src/Rest/Checkout/Controllers/Checkout.php:813-817) andPOST /rest/checkout/place-orderviaPlaceOrderData::$redeemPoints(src/Domains/Checkout/PlaceOrderData.php:45, parsed withFILTER_VALIDATE_BOOLEANat:160-163). The earlier RESTpointsSpendinteger field (a client-named amount) is retired — legacy has no such parameter anywhere. LoyaltyRedemption::resolve(?int $customerId, bool $redeem)(LoyaltyRedemption.php:95-134): returns a zero redemption for a non-request, a guest ($customerId === null),POINT_SYSTEM.IS_ENABLEDoff, orESHOP.SPEND_POINTS <= 0. Otherwisepoints = floor(balance / SPEND_POINTS) * SPEND_POINTSandcash = (points / SPEND_POINTS) * REWARD_CASH(:321-328) — mirroringAdv_loyalty::calcPointsToSpend()/pointsToCash()(ecommercen/libraries/Adv_loyalty.php:161,:178), with the missing zero-divisor guard added.- Totals preview:
Checkout::totals()callsresolve()and returnspointsSpend(point count) andpointsCash(cash value) alongside the total (resolve()atCheckout.php:817;pointsSpend/pointsCashat:868-869). - Charge:
PlaceOrderService::placeOrder()calls the sameresolve()(PlaceOrderService.php:370-373), passingnullfor a guest (PlaceOrderData::isGuest(),:116) rather than relying on a zero balance to no-op. The cash value is subtracted from the total (:430);points_spend(count) andpoints_reward(cash) are both written (:543-544). - Debit: guarded on
points > 0 && $customerId !== null(PlaceOrderService.php:620-621),LoyaltyRedemption::debit()(:302-309) issues a single atomic SQL decrement —Customer\Customer\Repository\WriteRepository::decrementPoints()(src/Domains/Customer/Customer/Repository/WriteRepository.php:30),GREATEST(total_points - N, 0)— replacing the previous read-modify-write and closing a lost-update race between concurrent checkouts by the same customer. LegacysavePointsToCustomer()has no floor at all; theGREATEST(..., 0)clamp is a deliberate, documented deviation. - Ratio discovery:
GET /rest/storefront-config'sloyaltysection (Advisable\Domains\StorefrontConfig\Loyalty\LoyaltyConfigResolver::resolve(),src/Domains/StorefrontConfig/Loyalty/LoyaltyConfigResolver.php:55-67) exposesspendPoints/rewardCashPerUnitlive from Registry, with noenabledkey by design (POINT_SYSTEM.IS_ENABLEDis theloyaltyboolean already onGET /rest/features).
Two legacy behaviors are ported verbatim on purpose, not fixed: (a) with IS_ENABLED on but REWARD_CASH = 0, the flow still debits the balance and writes points_spend/points_reward while discounting €0; (b) SPEND_POINTS = 0 fails closed to zero points and zero cash rather than assuming a 1:1 ratio.
Restoration on cancellation is unchanged by this work — see §4 Reversal Flow below, RestoreSpentPointsListener still reads shop_order.points_spend.
Redemption ceiling check (Rule 17): Both the REST path (via LoyaltyRedemption::exceedsPayable() at src/Domains/Checkout/LoyaltyRedemption.php:246-248) and the legacy path (via shared helper loyaltyRedemptionExceedsOrder() at ecommercen/helpers/loyalty_helper.php:100-112) enforce an identical refusal rule: the redeemed cash value must be strictly less than the payable amount. The boundary condition is $pointsCash >= $payableBeforePoints || round($payableBeforePoints - $pointsCash, 2) <= 0.0 — the checkout refuses an exactly-covered cart (rounding-safe equality test). Both predicates follow "both paths or neither" parity: any future change to the redemption ceiling must update both implementations.
Legacy enforcement sites (five direct refusals via loyaltyRedemptionExceedsOrder()):
ecommercen/eshop/controllers/Adv_order.php:725(checkout deduction guard)ecommercen/eshop/controllers/Adv_order.php:1083(declaration at:1042)ecommercen/eshop/controllers/Adv_orders_admin.php:2288-2332(admin order edit refusal,loyaltyRedemptionExceedsAdminOrderTotal(); called at:2260and:1175)ecommercen/checkout/controllers/Adv_checkout.php:2413-2425(payment failure cancel, delegating to localreturnPointsToCustomers()at:2427-2433)ecommercen/eshop/models/Adv_order_model.php:428-431(model layer refusal increate_order(); the admin twin is at:2277-2280)
Legacy term-list sites (five additional readers of loyaltyPayableBeforePoints(), which computes the payable amount for the predicate):
ecommercen/eshop/models/Adv_order_model.php:413:809:884:2148:2264
Key files:
src/Domains/Checkout/LoyaltyRedemption.php— shared redemption maths (resolve(),debit())src/Domains/Checkout/PlaceOrderService.php(:370-373,:430,:543-544,:620-621) — charge pathsrc/Domains/Checkout/PlaceOrderData.php(:45,:116,:160-163) —$redeemPointsboolean + guest detectionsrc/Rest/Checkout/Controllers/Checkout.php(redeemPointsparse +resolve():813-817,wouldExceeddecision:853-857,totals():712-877with the loyalty part from the// Loyalty redemption (#495)comment:799to:857) — totals preview pathsrc/Domains/Customer/Customer/Repository/WriteRepository.php(:30) — atomicdecrementPoints()src/Domains/StorefrontConfig/Loyalty/LoyaltyConfigResolver.php—loyaltystorefront-config section
3. Earning Flow (Post-Delivery)
Points are awarded asynchronously by two cron jobs after order fulfillment:
Job 1: AdvAddPointsToCustomerDeliver (courier delivery)
- Checks
POINT_SYSTEM.IS_ENABLED - Queries
shop_orderfor orders where:points_added = false,gtflag = 1(delivery confirmed by tracking poller),store_id IS NULL, and status NOT IN('CANCELED', 'RETURN') - Collects matching
order_serialvalues - Calls
loyalty->saveProductPointToCustomers($orderSerials)
Job 2: AdvAddPointsToCustomerFromStore (in-store pickup)
- Checks
POINT_SYSTEM.IS_ENABLED - Queries
shop_orderfor orders where:points_added = false,status = 'INVOICED',store_id IS NOT NULL - Collects matching
order_serialvalues - Calls
loyalty->saveProductPointToCustomers($orderSerials)
saveProductPointToCustomers() internal flow:
- Joins
shop_order_basketwithshop_orderfor the given serials wherepoints_added = false - Computes
SUM(item_points * qty)asproduct_pointsper order, grouped by order serial - For each order: calls
savePointsToCustomer(customer_id, product_points)-- executesUPDATE shop_customer SET total_points = total_points + product_points - Marks processed orders as
points_added = trueviasetOrdersMark() - Returns arrays of successful and failed order serials
How item_points gets stored: When an order is created, the eshop_helper function insertOrderBasket() copies $item['points'] (calculated during product parsing) into shop_order_basket.item_points.
Key files:
ecommercen/job/libraries/AdvAddPointsToCustomerDeliver.phpecommercen/job/libraries/AdvAddPointsToCustomerFromStore.phpecommercen/libraries/Adv_loyalty.php(method:saveProductPointToCustomers, lines 291-322)ecommercen/helpers/eshop_helper.php(line 118:'item_points' => $item['points'])
4. Reversal Flow (Cancellation)
When an order is canceled, both spent and earned points must be reversed:
Admin single-order cancellation (Adv_orders_admin, lines 417-427):
- Detects
status == 'CANCELED'in order update - Calls
loyalty->returnPointsToCustomers([$order_serial])-- returns thepoints_spendvalue back to customer'stotal_points - Calls
loyalty->removeProductPointFromCustomers([$order_serial])-- removes the earnedproduct_pointsfrom customer'stotal_pointsand setspoints_added = false
Admin batch cancellation (Adv_orders_admin::setBatchCanceled(), lines 1805-1825):
- Same two-step reversal for each successfully canceled order serial
Checkout payment failure (Adv_checkout::cancelOrder(), lines 2413-2425):
- Sets order status to
CANCELED - Calls
loyalty->returnPointsToCustomers([$orderSerial])-- returns spent points only (earned points have not been added yet since order was never delivered)
Incomplete order cleanup job (AdvCancelIncompleteOrders, Cronjob):
- For each pending order that exceeds its timeout (varies by payment method)
- Cancels the order and calls
returnPointsToCustomers([$order_serial])-- returns spent points
REST checkout cancellation (POST /rest/checkout/cancel-payment/{orderId} → PaymentConfirmationService::cancelPayment() → OrderCanceled event → RestoreSpentPointsListener):
This is the modern domain-layer path introduced on feature/rest-checkout-event-bus. PaymentConfirmationService::cancelPayment() is called by Checkout::cancelPayment() (src/Rest/Checkout/Controllers/Checkout.php:1682), Rest\Order\Controllers\Order (src/Rest/Order/Controllers/Order.php:290) and the PollPayByBankStatus job (src/Domains/Checkout/Jobs/PollPayByBankStatus.php:107, :128); the REST webhook family was retired (#721). It dispatches an OrderCanceled event via OrderEventDispatcher::dispatchCanceled().
src/Domains/Checkout/PaymentConfirmationService.php lines 181-187:
php
$this->orderEventDispatcher->dispatchCanceled(new OrderCanceled(
orderId: (int) $order->id,
customerId: (int) (isset($order->customer_id) ? $order->customer_id : 0),
orderSerial: (string) (isset($order->order_serial) ? $order->order_serial : ''),
payway: (string) (isset($order->payway) ? $order->payway : ''),
metaData: $metaData,
));OrderCanceled (src/Domains/Order/Event/OrderCanceled.php) carries four mandatory fields — orderId, customerId, orderSerial, payway — plus an optional metaData string (free-form audit trail of which webhook or handler triggered the cancellation).
RestoreSpentPointsListener::onOrderCanceled(OrderCanceled $event) (src/Domains/Order/Event/Listeners/RestoreSpentPointsListener.php lines 33-58) then:
- Fetches the order row via
OrderService::get($event->orderId)— returnsnull(silent no-op) if the order no longer exists - Reads
points_spenddirectly from the order row (not from the event payload, to keep the event minimal and tolerant of future schema changes) - Guards: if
$pointsSpend <= 0or$event->customerId <= 0, returns without action - Fetches the customer via
CustomerRepository::get($event->customerId) - Calls
CustomerWriteService::update($event->customerId, ['total_points' => $balance + $pointsSpend])— adds the spent points back to the customer's balance
The listener is wired to the OrderEventDispatcher via addCanceledListener in the Order module container:
src/Domains/Order/container.php lines 166-168:
php
->call('addCanceledListener', [service(Event\Listeners\RestoreSpentPointsListener::class)])
->call('addCanceledListener', [service(Event\Listeners\RestoreCouponUsageListener::class)])
->call('addCanceledListener', [service(Event\Listeners\RestoreGiftStockOnCanceledListener::class)]);RestoreCouponUsageListener (src/Domains/Order/Event/Listeners/RestoreCouponUsageListener.php) fires from the same OrderCanceled event and decrements the is_used counter on the applied coupon code. See CF-13 Coupons for the coupon reversal path.
RestoreGiftStockOnCanceledListener (src/Domains/Order/Event/Listeners/RestoreGiftStockOnCanceledListener.php) also fires from the same OrderCanceled event, restoring promotional-gift stock (gifts.remaining) consumed by the canceled order -- unrelated to loyalty points.
Note: the OrderCanceled event and its listener fanout are isolated in individual try/catch blocks — a failure in one cancellation hook cannot affect the order status update or sibling listeners.
returnPointsToCustomers() internal flow:
- Queries
shop_orderfor the given serials, selectingpoints_spendandcustomer_id - For each order: calls
savePointsToCustomer(customer_id, points_spend)-- adds the spent points back
removeProductPointFromCustomers() internal flow:
- Joins
shop_order_basketwithshop_orderfor given serials wherepoints_added = true - Computes
SUM(item_points * qty)per order - Calls
savePointsToCustomer(customer_id, 0 - product_points)-- subtracts earned points - Sets
points_added = falseviasetOrdersMark()
Key files:
ecommercen/eshop/controllers/Adv_orders_admin.php(:417-427,:1798+:1814-1816)ecommercen/checkout/controllers/Adv_checkout.php(:2413-2425, delegating to localreturnPointsToCustomers()at:2427-2433)ecommercen/job/libraries/AdvCancelIncompleteOrders.php(:169-173, call site:195)ecommercen/libraries/Adv_loyalty.php(methods:returnPointsToCustomers,removeProductPointFromCustomers)src/Domains/Checkout/PaymentConfirmationService.php(:181-187:OrderCanceleddispatch)src/Domains/Order/Event/OrderCanceled.php(event payload:orderId,customerId,orderSerial,payway,metaData)src/Domains/Order/Event/Listeners/RestoreSpentPointsListener.php(REST cancellation points refund)src/Domains/Order/Event/Listeners/RestoreCouponUsageListener.php(REST cancellation coupon decrement — see CF-13)src/Domains/Order/Event/Listeners/RestoreGiftStockOnCanceledListener.php(REST cancellation gift-stock restore; unrelated to loyalty points)src/Domains/Order/container.php(lines 166-168: listener wiring viaaddCanceledListener)
5. Bonus Points Flows
New customer bonus (Adv_customer_model::addPointsToNewCustomer(), lines 773-784):
- During customer registration (non-guest), if
POINT_SYSTEM.IS_ENABLEDandESHOP.REWARD_NEW_CUSTOMERare both enabled - Sets
total_pointstoESHOP.POINTS_TO_NEW_CUSTOMERvalue in the insert data - Points are set directly during the INSERT, not via
savePointsToCustomer()
Birthday bonus (AdvSentMailToCustomerBirthday):
- Checks
EMAIL_FOR_BIRTHDAY.IS_ENABLEDandPOINT_SYSTEM.IS_ENABLED - Queries customers whose birthday matches today (respecting repeat interval to avoid duplicate emails)
- For each customer: calls
Adv_mailer::sentBirthdayWishes($customerId, $pointsToAdd) - Inside mailer: calls
loyalty->savePointsToCustomer($customerId, $pointsToAdd)-- adds points directly - Sends birthday email showing awarded points and their cash equivalent
- Records campaign entry to prevent duplicate awards within the repeat interval
Product review reward (Adv_product_reviews_admin):
- When admin approves a product review (sets status to 1)
- If
POINT_SYSTEM.IS_ENABLEDandECOMMERCEN_PLUS.CUSTOMER_REVIEW_REWARD_ENABLEare enabled, the award gate checksshop_product_reviews.is_points_awardedflag (backed by migrationdatabase/migrations/20260721120000_add_is_points_awarded_to_shop_product_reviews.php:13) - Batch approval (
ecommercen/eshop/controllers/Adv_product_reviews_admin.php:138-142, inside transaction at:121): iterates over selected reviews, calling$this->product_reviews_model->claimReviewPointsAward((int) $reviewId)— this call is idempotent, the claim succeeds only once per review - Single approval (
:221-222, transaction at:219): same claim-then-award guard - Award value is from
ECOMMERCEN_PLUS.CUSTOMER_REVIEW_REWARD; if claimed successfully,is_points_awardedis set totrueandloyalty->savePointsToCustomer($customerId, $rewardValue)is called
Remaining points reminder (AdvSentMailToCustomerRemainingPoints):
- Checks
POINT_SYSTEM.IS_ENABLEDandEMAIL_FOR_TOTAL_POINTS.IS_ENABLED - Queries customers where
total_points > EMAIL_FOR_TOTAL_POINTS.TOTAL_POINTSwho have not ordered withinINTERVALmonths and have not received the email withinINTERVAL_REPEATmonths - For each customer: sends remaining points email via
Adv_mailer::sentRemainingPoints()showing redeemable points and cash equivalent - Records campaign entry to prevent duplicates
6. Admin Manual Points Management
Single order (Adv_orders_admin):
addPointsToCustomer($orderSerial)-- manually awards product points for a specific order viasaveProductPointToCustomers()removePointsFromCustomer($orderSerial)-- manually removes product points viaremoveProductPointFromCustomers()
Batch operations (Adv_orders_admin):
addPointsSelected($selected)-- batch award for multiple order serialsremovePointsSelected($selected)-- batch removal for multiple order serials
Customer edit (Adv_customers_admin, line 254):
- Admin can add arbitrary points via
loyalty->savePointsToCustomer($id, $inputPoints)-- the input value is added to existing total (not a replacement)
7. Order Edit with Points Preservation
When editing an order from admin, the system optionally preserves the original item points:
Adv_orders_adminloadskeepProductPointsForEditfromPOINT_SYSTEM.ORDER_EDIT_KEEP_PRODUCT_POINTSAdv_order_model::fakeTheCart()checks this flag per product- If
keepOldPointsis enabled andprData['keepPoints']is set, the originalitem_pointsvalue is retained - Otherwise, points are recalculated from live product data
Architecture
┌─────────────────────────────────────────────────────────────────────┐
│ Storefront (Vue 2) │
│ CheckoutPage.vue: loyalty display, redeemPoints checkbox │
│ loyaltyInfo.php: customer account points display │
└──────────────────────────────┬──────────────────────────────────────┘
│
┌──────────────────────────────▼──────────────────────────────────────┐
│ Controller Layer (HMVC) │
│ │
│ Adv_order - preview loyalty data, deduct at checkout │
│ Adv_checkout - cancel/reverse points on payment failure │
│ Adv_orders_admin - cancel reversal, manual add/remove, batch │
│ Adv_customers_admin - manual points adjustment per customer │
│ Adv_product_reviews_admin - review reward points │
└──────────────────────────────┬──────────────────────────────────────┘
│
┌──────────────────────────────▼──────────────────────────────────────┐
│ Library Layer │
│ │
│ Adv_loyalty (ecommercen/libraries/) │
│ ├── calcLoyalty($customerId) → {points, cash, totalPoints}│
│ ├── calcPointsToSpend($points) → redeemable points │
│ ├── pointsToCash($points) → cash equivalent │
│ ├── removePointsFromCustomer($id) → deduct at checkout │
│ ├── savePointsToCustomer($id, $pts) → add/subtract points (DB) │
│ ├── saveProductPointToCustomers($s) → batch earn from orders │
│ ├── removeProductPointFromCustomers($s) → batch unearn │
│ ├── returnPointsToCustomers($s) → return spent points │
│ ├── getCustomerPendingPoints($id) → pending (undelivered) │
│ └── getOrderPoints($serial) → order's total points │
│ │
│ Loyalty (application/libraries/) → empty client override stub │
└──────────────────────────────┬──────────────────────────────────────┘
│
┌──────────────────────────────▼──────────────────────────────────────┐
│ Model Layer │
│ │
│ Adv_customer_model - getCustomerById, addPointsToNewCustomer │
│ getCustomersForRemainingPoints, ForBirthday │
│ Adv_order_model - create_order (stores points_spend/reward) │
│ fakeTheCart (keepOldPoints logic) │
│ Adv_product_parser_model - setPoints (display calculation) │
│ Adv_order_basket_model - item_points query │
│ Adv_mailer - sentBirthdayWishes, sentRemainingPoints │
└──────────────────────────────┬──────────────────────────────────────┘
│
┌──────────────────────────────▼──────────────────────────────────────┐
│ Cron Jobs (JobCommand) │
│ │
│ AdvAddPointsToCustomerDeliver - courier delivery → award pts │
│ AdvAddPointsToCustomerFromStore - in-store pickup → award pts │
│ AdvSentMailToCustomerBirthday - birthday bonus + email │
│ AdvSentMailToCustomerRemainingPoints - points reminder email │
│ AdvCancelIncompleteOrders - timeout cancel → return pts │
└──────────────────────────────┬──────────────────────────────────────┘
│
┌──────────────────────────────▼──────────────────────────────────────┐
│ REST Layer │
│ │
│ Customer Resource - exposes totalPoints (integer) │
│ Product Resource - exposes pointFactor (float, nullable) │
│ GET /rest/storefront-config - loyalty.spendPoints/rewardCashPerUnit │
│ POST /rest/checkout/totals - LoyaltyRedemption::resolve() preview │
│ POST /rest/checkout/place-order - LoyaltyRedemption charge + debit │
│ (src/Domains/Checkout/LoyaltyRedemption.php, #495) │
│ POST /rest/checkout/cancel-payment/{orderId} │
│ → PaymentConfirmationService::cancelPayment() │
│ → OrderCanceled event → RestoreSpentPointsListener │
│ → RestoreCouponUsageListener │
└─────────────────────────────────────────────────────────────────────┘Data Model
shop_customer (points storage)
| Column | Type | Description |
|---|---|---|
id | int (PK) | Customer ID |
total_points | int | Accumulated loyalty points balance (can go to 0 but not negative under normal operation) |
Points are modified atomically via raw SQL: SET total_points = total_points + $delta (positive to add, negative to subtract).
shop_product (point factor per product)
| Column | Type | Description |
|---|---|---|
id | int (PK) | Product ID |
point_factor | decimal(11,4) DEFAULT NULL | Per-product point multiplier. Defaults to ESHOP.DEFAULT_POINT_FACTOR when null/zero in product admin forms. Admin-editable per product. |
shop_order (order-level point data)
| Column | Type | Description |
|---|---|---|
order_serial | varchar (unique) | Order serial number, used as key in loyalty operations |
customer_id | int (FK) | Links to shop_customer.id |
points_spend | int | Number of points the customer redeemed on this order |
points_reward | decimal(11,2) | Cash discount applied from redeemed points (subtracted from total_vat) |
points_added | tinyint(1) | Whether product-earned points have been credited to the customer (0=pending, 1=credited) |
total_vat | decimal | Order total including VAT, reduced by points_reward |
status | varchar | Order status (PENDING, PAID, INVOICED, CANCELED, RETURN, etc.) |
gtstatus | varchar | Carrier tracking status text written by the tracking poller; vocabulary varies by carrier (e.g. ΠΑΡΑΔΟΜΕΝΟ, DELIVERED). Not used as a filter by the loyalty jobs. |
gtflag | tinyint(1) | Delivery terminal-state flag; set to 1 by the tracking poller when the carrier confirms delivery. The loyalty delivery job filters on gtflag = 1. |
store_id | int, nullable | If not null, order is for in-store pickup |
shop_order_basket (item-level point data)
| Column | Type | Description |
|---|---|---|
order_id | int (FK) | Links to shop_order.id |
product_code_id | int | Product code identifier |
item_points | int | Points earned for this line item (per-unit, calculated as round(price * point_factor) at order time) |
qty | int | Quantity ordered |
Earned points per order are computed as SUM(item_points * qty) across all basket items.
shop_product_reviews (review reward gate)
| Column | Type | Description |
|---|---|---|
id | int (PK) | Review ID |
is_points_awarded | tinyint(1) | Whether loyalty points have been awarded for this review (0=pending, 1=awarded). Set to true when admin approves the review and ECOMMERCEN_PLUS.CUSTOMER_REVIEW_REWARD_ENABLE is on. Ensures idempotent award — the claimReviewPointsAward() claim gate prevents duplicate awards on retry. |
registry (configuration storage)
Point system configuration is stored in the registry table with group/key pairs. See the Configuration section below for all keys.
Configuration
Core Point System Settings
| Group | Key | Type | Purpose |
|---|---|---|---|
POINT_SYSTEM | IS_ENABLED | boolean | Master toggle for the entire loyalty system |
POINT_SYSTEM | ORDER_EDIT_KEEP_PRODUCT_POINTS | boolean | When editing an order in admin, preserve original item points instead of recalculating |
ESHOP | SPEND_POINTS | int | Points threshold for redemption (e.g., 100 = must redeem in multiples of 100) |
ESHOP | REWARD_CASH | float | Cash reward per SPEND_POINTS threshold (e.g., 10 = 10 EUR per 100 points) |
ESHOP | POINT_FACTOR_TYPE | string | Which price field to use for point calculation: price, price_without_vat, original_price, or final_price |
ESHOP | DEFAULT_POINT_FACTOR | float | Default point multiplier for products without a custom point_factor |
ESHOP | REWARD_NEW_CUSTOMER | boolean | Enable welcome bonus points for new registrations |
ESHOP | POINTS_TO_NEW_CUSTOMER | int | Number of bonus points for new customers |
Email/Campaign Settings
| Group | Key | Type | Purpose |
|---|---|---|---|
EMAIL_FOR_BIRTHDAY | IS_ENABLED | boolean | Enable birthday email with bonus points |
EMAIL_FOR_BIRTHDAY | POINTS_TO_ADD | int | Points to award on customer's birthday |
EMAIL_FOR_BIRTHDAY | REPEAT | int | Months before re-sending birthday email |
EMAIL_FOR_TOTAL_POINTS | IS_ENABLED | boolean | Enable remaining points reminder email |
EMAIL_FOR_TOTAL_POINTS | TOTAL_POINTS | int | Minimum points threshold to trigger reminder |
EMAIL_FOR_TOTAL_POINTS | INTERVAL | int | Months since last order to qualify for reminder |
EMAIL_FOR_TOTAL_POINTS | INTERVAL_REPEAT | int | Months before re-sending reminder |
ECOMMERCEN_PLUS | CUSTOMER_REVIEW_REWARD_ENABLE | boolean | Enable points for approved product reviews |
ECOMMERCEN_PLUS | CUSTOMER_REVIEW_REWARD | int | Points awarded per approved review |
All settings are managed from the admin UI. Loyalty point system settings (POINT_SYSTEM, ESHOP point keys) are written by Adv_settings::subsystem_settings() (:980; writes at :989-990, read back at :1013-1014) in ecommercen/settings/controllers/Adv_settings.php. Review reward settings (ECOMMERCEN_PLUS point keys) are managed via Adv_settings::eCommercenPlus() (:2066).
Client Extension Points
Override Adv_loyalty Library
The application/libraries/Loyalty.php file is an empty class extending Adv_loyalty:
php
class Loyalty extends Adv_loyalty
{
//
}Client repos can override any method (e.g., custom calcPointsToSpend(), pointsToCash(), or saveProductPointToCustomers()) by adding logic to this file. The library is always loaded as loyalty (lowercase), so all callers use $this->loyalty->method().
Override Cron Jobs
The job classes AdvAddPointsToCustomerDeliver and AdvAddPointsToCustomerFromStore have protected methods for their query criteria:
getWhereOptions()andgetWhereNotInOptions()(deliver job)getQueryOptions()(store job)
Client repos can extend these jobs (in application/ or custom/) to customize:
- Which order statuses trigger point awarding
- Additional filtering criteria (e.g., custom delivery status values)
Admin Manual Points
Adv_customers_admin calls loyalty->savePointsToCustomer($id, $rawValue) directly, allowing admin to add any arbitrary positive or negative value to a customer's balance.
Order Edit Point Preservation
The POINT_SYSTEM.ORDER_EDIT_KEEP_PRODUCT_POINTS registry flag controls whether editing an order in admin recalculates item points from live product data or preserves the original values. When enabled and per-item keepPoints flag is set, the original item_points are retained.
Custom Domain Integration
The modern domain layer exposes total_points on Customer\Repository\Entity (read-only @property int $total_points) and point_factor on Product\Repository\Entity (@property float|null $point_factor). REST resources transform these as totalPoints and pointFactor respectively. Client repos extending the REST layer can add custom point logic via resource overrides.
Business Rules
Redemption granularity: Points can only be redeemed in exact multiples of
SPEND_POINTS. Fractional multiples are floored (e.g., 250 points with threshold 100 = 200 redeemable).Redemption is all-or-nothing: The checkout checkbox is binary -- the customer either redeems all eligible points or none. There is no partial redemption UI. Enforced identically on the REST path (#495):
redeemPointsis a boolean, andLoyaltyRedemption::resolve()always spends the whole floored balance -- a REST client cannot name a partial amount either (src/Domains/Checkout/LoyaltyRedemption.php:83-133).Deduction timing: Points are deducted from the customer balance at order submission (before payment confirmation), not at payment success. This means points are "spent" even if payment fails.
Payment failure recovery: If payment fails and the order is canceled (in checkout flow or by
AdvCancelIncompleteOrders), spent points are returned viareturnPointsToCustomers(). The REST checkout cancellation path (POST /rest/checkout/cancel-payment/{orderId}) restores spent points through theRestoreSpentPointsListeneron theOrderCanceledevent instead of callingreturnPointsToCustomers()directly.Earning timing: Points are earned only after delivery, never at order creation or payment. This prevents earning points on canceled/returned orders.
Cancellation double reversal: When an admin cancels a delivered order, both operations occur: spent points are returned AND earned points are removed. The
points_addedflag is reset tofalse.No negative balance protection: The
savePointsToCustomer()method uses rawtotal_points + $deltawithout a floor check. Under normal operation, points should not go negative, but edge cases (concurrent operations, manual adjustments) could theoretically cause this.Total includes points discount:
shop_order.total_vatis stored with the points discount already applied:total_vat = cart_total_vat + transport + delivery - points_reward + gift_packaging.Points for gifts: Gift items (from cart rules, bundles) have their
item_pointsstored inshop_order_basketlike regular items. Points earned from gifts are included in theSUM(item_points * qty)calculation.Birthday points are immediate: Unlike purchase points (which require delivery), birthday bonus points are added to the customer balance immediately when the birthday email job runs.
Review points are immediate: Review reward points are added when an admin approves the review, not when the review is submitted.
Delivery detection via gtflag: The deliver job queries
gtflag = 1(set by the tracking poller when any carrier reports delivery). Prior to #276 the job usedgtstatus = 'ΠΑΡΑΔΟΜΕΝΟ', which silently skipped carriers (e.g. GenikiV2) that write an English status string while still settinggtflag = 1.Store pickup uses INVOICED status: In-store pickup orders use
status = 'INVOICED'as the trigger for point awarding, notgtstatus, since there is no courier tracking.Redemption ceiling (Rule 17): The redeemed loyalty cash discount must be strictly less than the payable order total. An exactly-covered cart is refused with HTTP 422
loyalty_redemption_exceeds_order_total. Both REST and legacy paths enforce this identically via the boundary condition$pointsCash >= $payableBeforePoints || round($payableBeforePoints - $pointsCash, 2) <= 0.0. REST:LoyaltyRedemption::exceedsPayable()(src/Domains/Checkout/LoyaltyRedemption.php:246-248), throwssrc/Domains/Checkout/Exceptions/LoyaltyRedemptionExceedsOrderTotalException.php:38atsrc/Domains/Checkout/PlaceOrderService.php:460-463. Legacy:loyaltyRedemptionExceedsOrder()(ecommercen/helpers/loyalty_helper.php:100-112), enforced at five sites (see §2b). Any future change to the ceiling must update both implementations.Review reward points are idempotent: Product review approval awards points once via the
is_points_awardedflag onshop_product_reviews. The claim gate$this->product_reviews_model->claimReviewPointsAward((int) $reviewId)ensures that retrying approval (or concurrent approvals) will not double-award. Both batch (:138-142) and single (:221-222) paths inecommercen/eshop/controllers/Adv_product_reviews_admin.phpcall the same guard.
Tests
| Test File | Coverage |
|---|---|
tests/Unit/Domains/Checkout/LoyaltyRedemptionTest.php | Unit tests for the modern REST redemption path: resolve() maths (floor logic, cash conversion), guard conditions (guest, disabled, zero threshold), and preview() state object construction |
tests/Legacy/Eshop/AdvOrderLoyaltyRedemptionGuardTest.php | Pure-function tests of the two shared helpers — loyaltyRedemptionExceedsOrder() and loyaltyPayableBeforePoints() — with boundary cases: exact match at :121, below threshold at :130, exceeded at :138, rounding edge case at :145 |
tests/Legacy/Eshop/AdvProductReviewsAdminLoyaltyIdempotencyTest.php | Integration test of review approval with the is_points_awarded claim gate: verifies that batch and single approvals award points once per review, and that concurrent/retry attempts do not double-award |
tests/Integration/Legacy/Eshop/AdvLoyaltySavePointsToCustomerTest.php | Integration test of the legacy loyalty library's point-add/subtract operation; verifies atomic SQL increment, no negative-balance floor, and transactional rollback behavior |
Known Issues & Security Gaps
#276 (fixed in develop):
AdvAddPointsToCustomerDeliverpreviously selected delivered orders bygtstatus = 'ΠΑΡΑΔΟΜΕΝΟ'. GenikiV2 writesgtstatus = $track->Status(e.g.'DELIVERED') alongsidegtflag = 1, causing those orders to be silently skipped. Fixed by switching the selector togtflag = 1. Bothecommercen/job/libraries/AdvAddPointsToCustomerDeliver.php(getWhereOptions()) and the inline copy inapplication/controllers/Cronjob.php:addPointsToCustomerDeliver()were updated.DivisionByZeroError on zero
SPEND_POINTS:Adv_loyalty::pointsToCash()(ecommercen/libraries/Adv_loyalty.php:178-187) guards$this->rewardCash > 0 && $points > 0but never checks$this->spendPoints. When dividing by it at:187, a shop configured withESHOP.SPEND_POINTS = 0raises a PHP 8DivisionByZeroError. OnlycalcLoyalty()shields the method viacalcPointsToSpend()'s<> 0check at:161. The method is public and is a documented client-override seam, so the zero-divisor guard should be inpointsToCash()itself.Cache corruption in
getCustomersPoints():Adv_loyalty::getCustomersPoints()(ecommercen/libraries/Adv_loyalty.php:212-227) destroys its own cache on a second call. The DB read is skipped when$this->customerTotalPointsis already non-empty (line 214), but$this->customerTotalPoints = $points;is then executed unconditionally (line 226) with$pointsstill0from initialization. Cached value is overwritten with0on second invocation, even when the first call already read the correct value from the DB.Deadlock risk in batch review approval:
Adv_product_reviews_admin::approveBatch()(ecommercen/eshop/controllers/Adv_product_reviews_admin.php:104-114) locksshop_product_reviewsrows in ascending review-id order within a transaction. Inside each iteration,savePointsToCustomer()takes ashop_customerrow lock. When two concurrent batches map review ids to customers in opposite relative order, InnoDB's deadlock detector may victim one batch, causing the entire transaction to roll back and all approved reviews in that batch to lose their approval status.
Related Flows
- CF-13 Coupons --
RestoreCouponUsageListenerfires from the sameOrderCanceledevent asRestoreSpentPointsListener; couponis_usedcounter is decremented on REST checkout cancellation - CF-02 Product Detail -- product points calculation and display on PDP
- CF-06 Order Preview -- loyalty block shown at checkout with redemption checkbox
- CF-07 Order Confirmation -- points deducted at checkout, stored on order
- CF-11 Customer Account -- customer can see their total points balance
- CF-16 Product Reviews -- approved reviews can award loyalty points
- AD-03 Order Management -- admin cancel/add/remove points per order
- AD-04 Customer Management -- admin view/edit customer points
- SY-10 Loyalty Points Jobs -- point awarding cron jobs (delivery, store pickup)
- SY-12 Birthday Points Emails -- birthday bonus points cron job
- SY-24 Email Dispatch System --
Adv_mailerinternals used bysentBirthdayWishes()andsentRemainingPoints()to deliver loyalty-related emails - SY-30 Client Features Reference -- per-client loyalty point customization patterns