Skip to content

Loyalty Points System ​

Flow ID: CF-32 | Module(s): eshop (Adv_loyalty library), 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-payment endpoint): both spent points (returned to customer) and earned points (removed from customer) are reversed; the REST path uses the OrderCanceled domain event and RestoreSpentPointsListener rather than calling the legacy Adv_loyalty library 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):

EndpointFieldDescription
GET /rest/customer/metotalPointsCustomer's current point balance (integer)
GET /rest/product/{id}pointFactorProduct's point multiplier (float, nullable)
GET /rest/featuresloyalty (boolean)Capability gate mirroring POINT_SYSTEM.IS_ENABLED (application/config/rest_features.php:55)
GET /rest/storefront-configloyalty.spendPoints, loyalty.rewardCashPerUnitRedemption 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/totalsredeemPoints/redeem_points requestPreview 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-orderredeemPoints/redeem_points request; 422 loyalty_redemption_exceeds_order_totalCharges 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.

  1. Adv_product_parser_model::setPoints($product) is called during product parsing
  2. Checks POINT_SYSTEM.IS_ENABLED registry flag
  3. Computes points for all four price variants: price, price_without_vat, original_price, final_price -- each as round(price * point_factor)
  4. Selects the variant matching $this->pointFactorType (loaded from ESHOP.POINT_FACTOR_TYPE) and assigns it to $product->points
  5. The point_factor column is loaded from shop_product via 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:

  1. Preview order page (Adv_order::previewOrder() / previewOrderLoyalty()):

    • Loads loyalty library, calls calcLoyalty($customerId)
    • calcLoyalty() reads shop_customer.total_points, calculates max redeemable points via calcPointsToSpend() (floor division: floor(total / SPEND_POINTS) * SPEND_POINTS), converts to cash via pointsToCash()
    • 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
  2. Vue checkout (assets/main/vue/CheckoutPage.vue):

    • Displays loyalty block with total points, redeemable points, and equivalent cash value
    • Customer checks the redeemPoints checkbox (value 1)
    • On form submit, redeemPoints=1 is POSTed
  3. Checkout view (Adv_order::checkoutView()):

    • If POINT_SYSTEM.IS_ENABLED and redeemPoints is posted, recalculates loyalty via calcLoyalty()
    • Sets orderData['points_spend'] and orderData['points_reward'] (cash equivalent)
    • Points reward is subtracted from order total: total_vat = cart_total_vat + transport + delivery - points_reward + gift_packaging
  4. Order submission (Adv_order::submittedOrder()):

    • Calls deductLoyaltyPoints($customerId, $orderData)
    • deductLoyaltyPoints() checks POINT_SYSTEM.IS_ENABLED and redeemPoints input
    • Calls loyalty->removePointsFromCustomer($customerId) which:
      • Recalculates redeemable points via calcLoyalty()
      • Calls savePointsToCustomer($customerId, 0 - points) -- executes UPDATE shop_customer SET total_points = total_points + (-points) WHERE id = $customerId
    • Resulting points_spend and points_reward are stored on shop_order
  5. Order creation (Adv_order_model::create_order()):

    • Stores points_spend and points_reward in the shop_order record
    • total_vat is reduced by points_reward

Key files:

  • ecommercen/eshop/controllers/Adv_order.php (lines 378-384, 680-687, 963-988 — refuseLoyaltyRedemptionExceedingTotal() + deductLoyaltyPoints() calls in checkout())
  • ecommercen/libraries/Adv_loyalty.php (methods: calcLoyalty, removePointsFromCustomer, savePointsToCustomer)
  • ecommercen/eshop/models/Adv_order_model.php (points_reward/points_spend handling 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:

  1. Client opt-in: the customer sends a boolean — redeemPoints or redeem_points — never an amount. Accepted by both POST /rest/checkout/totals (src/Rest/Checkout/Controllers/Checkout.php:813-817) and POST /rest/checkout/place-order via PlaceOrderData::$redeemPoints (src/Domains/Checkout/PlaceOrderData.php:45, parsed with FILTER_VALIDATE_BOOLEAN at :160-163). The earlier REST pointsSpend integer field (a client-named amount) is retired — legacy has no such parameter anywhere.
  2. 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_ENABLED off, or ESHOP.SPEND_POINTS <= 0. Otherwise points = floor(balance / SPEND_POINTS) * SPEND_POINTS and cash = (points / SPEND_POINTS) * REWARD_CASH (:321-328) — mirroring Adv_loyalty::calcPointsToSpend() / pointsToCash() (ecommercen/libraries/Adv_loyalty.php:161, :178), with the missing zero-divisor guard added.
  3. Totals preview: Checkout::totals() calls resolve() and returns pointsSpend (point count) and pointsCash (cash value) alongside the total (resolve() at Checkout.php:817; pointsSpend/pointsCash at :868-869).
  4. Charge: PlaceOrderService::placeOrder() calls the same resolve() (PlaceOrderService.php:370-373), passing null for 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) and points_reward (cash) are both written (:543-544).
  5. 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. Legacy savePointsToCustomer() has no floor at all; the GREATEST(..., 0) clamp is a deliberate, documented deviation.
  6. Ratio discovery: GET /rest/storefront-config's loyalty section (Advisable\Domains\StorefrontConfig\Loyalty\LoyaltyConfigResolver::resolve(), src/Domains/StorefrontConfig/Loyalty/LoyaltyConfigResolver.php:55-67) exposes spendPoints / rewardCashPerUnit live from Registry, with no enabled key by design (POINT_SYSTEM.IS_ENABLED is the loyalty boolean already on GET /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 :2260 and :1175)
  • ecommercen/checkout/controllers/Adv_checkout.php:2413-2425 (payment failure cancel, delegating to local returnPointsToCustomers() at :2427-2433)
  • ecommercen/eshop/models/Adv_order_model.php:428-431 (model layer refusal in create_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 path
  • src/Domains/Checkout/PlaceOrderData.php (:45, :116, :160-163) — $redeemPoints boolean + guest detection
  • src/Rest/Checkout/Controllers/Checkout.php (redeemPoints parse + resolve() :813-817, wouldExceed decision :853-857, totals() :712-877 with the loyalty part from the // Loyalty redemption (#495) comment :799 to :857) — totals preview path
  • src/Domains/Customer/Customer/Repository/WriteRepository.php (:30) — atomic decrementPoints()
  • src/Domains/StorefrontConfig/Loyalty/LoyaltyConfigResolver.php — loyalty storefront-config section

3. Earning Flow (Post-Delivery) ​

Points are awarded asynchronously by two cron jobs after order fulfillment:

Job 1: AdvAddPointsToCustomerDeliver (courier delivery)

  1. Checks POINT_SYSTEM.IS_ENABLED
  2. Queries shop_order for orders where: points_added = false, gtflag = 1 (delivery confirmed by tracking poller), store_id IS NULL, and status NOT IN ('CANCELED', 'RETURN')
  3. Collects matching order_serial values
  4. Calls loyalty->saveProductPointToCustomers($orderSerials)

Job 2: AdvAddPointsToCustomerFromStore (in-store pickup)

  1. Checks POINT_SYSTEM.IS_ENABLED
  2. Queries shop_order for orders where: points_added = false, status = 'INVOICED', store_id IS NOT NULL
  3. Collects matching order_serial values
  4. Calls loyalty->saveProductPointToCustomers($orderSerials)

saveProductPointToCustomers() internal flow:

  1. Joins shop_order_basket with shop_order for the given serials where points_added = false
  2. Computes SUM(item_points * qty) as product_points per order, grouped by order serial
  3. For each order: calls savePointsToCustomer(customer_id, product_points) -- executes UPDATE shop_customer SET total_points = total_points + product_points
  4. Marks processed orders as points_added = true via setOrdersMark()
  5. 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.php
  • ecommercen/job/libraries/AdvAddPointsToCustomerFromStore.php
  • ecommercen/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):

  1. Detects status == 'CANCELED' in order update
  2. Calls loyalty->returnPointsToCustomers([$order_serial]) -- returns the points_spend value back to customer's total_points
  3. Calls loyalty->removeProductPointFromCustomers([$order_serial]) -- removes the earned product_points from customer's total_points and sets points_added = false

Admin batch cancellation (Adv_orders_admin::setBatchCanceled(), lines 1805-1825):

  1. Same two-step reversal for each successfully canceled order serial

Checkout payment failure (Adv_checkout::cancelOrder(), lines 2413-2425):

  1. Sets order status to CANCELED
  2. 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):

  1. For each pending order that exceeds its timeout (varies by payment method)
  2. 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:

  1. Fetches the order row via OrderService::get($event->orderId) — returns null (silent no-op) if the order no longer exists
  2. Reads points_spend directly from the order row (not from the event payload, to keep the event minimal and tolerant of future schema changes)
  3. Guards: if $pointsSpend <= 0 or $event->customerId <= 0, returns without action
  4. Fetches the customer via CustomerRepository::get($event->customerId)
  5. 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:

  1. Queries shop_order for the given serials, selecting points_spend and customer_id
  2. For each order: calls savePointsToCustomer(customer_id, points_spend) -- adds the spent points back

removeProductPointFromCustomers() internal flow:

  1. Joins shop_order_basket with shop_order for given serials where points_added = true
  2. Computes SUM(item_points * qty) per order
  3. Calls savePointsToCustomer(customer_id, 0 - product_points) -- subtracts earned points
  4. Sets points_added = false via setOrdersMark()

Key files:

  • ecommercen/eshop/controllers/Adv_orders_admin.php (:417-427, :1798 + :1814-1816)
  • ecommercen/checkout/controllers/Adv_checkout.php (:2413-2425, delegating to local returnPointsToCustomers() 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: OrderCanceled dispatch)
  • 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 via addCanceledListener)

5. Bonus Points Flows ​

New customer bonus (Adv_customer_model::addPointsToNewCustomer(), lines 773-784):

  1. During customer registration (non-guest), if POINT_SYSTEM.IS_ENABLED and ESHOP.REWARD_NEW_CUSTOMER are both enabled
  2. Sets total_points to ESHOP.POINTS_TO_NEW_CUSTOMER value in the insert data
  3. Points are set directly during the INSERT, not via savePointsToCustomer()

Birthday bonus (AdvSentMailToCustomerBirthday):

  1. Checks EMAIL_FOR_BIRTHDAY.IS_ENABLED and POINT_SYSTEM.IS_ENABLED
  2. Queries customers whose birthday matches today (respecting repeat interval to avoid duplicate emails)
  3. For each customer: calls Adv_mailer::sentBirthdayWishes($customerId, $pointsToAdd)
  4. Inside mailer: calls loyalty->savePointsToCustomer($customerId, $pointsToAdd) -- adds points directly
  5. Sends birthday email showing awarded points and their cash equivalent
  6. Records campaign entry to prevent duplicate awards within the repeat interval

Product review reward (Adv_product_reviews_admin):

  1. When admin approves a product review (sets status to 1)
  2. If POINT_SYSTEM.IS_ENABLED and ECOMMERCEN_PLUS.CUSTOMER_REVIEW_REWARD_ENABLE are enabled, the award gate checks shop_product_reviews.is_points_awarded flag (backed by migration database/migrations/20260721120000_add_is_points_awarded_to_shop_product_reviews.php:13)
  3. 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
  4. Single approval (:221-222, transaction at :219): same claim-then-award guard
  5. Award value is from ECOMMERCEN_PLUS.CUSTOMER_REVIEW_REWARD; if claimed successfully, is_points_awarded is set to true and loyalty->savePointsToCustomer($customerId, $rewardValue) is called

Remaining points reminder (AdvSentMailToCustomerRemainingPoints):

  1. Checks POINT_SYSTEM.IS_ENABLED and EMAIL_FOR_TOTAL_POINTS.IS_ENABLED
  2. Queries customers where total_points > EMAIL_FOR_TOTAL_POINTS.TOTAL_POINTS who have not ordered within INTERVAL months and have not received the email within INTERVAL_REPEAT months
  3. For each customer: sends remaining points email via Adv_mailer::sentRemainingPoints() showing redeemable points and cash equivalent
  4. 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 via saveProductPointToCustomers()
  • removePointsFromCustomer($orderSerial) -- manually removes product points via removeProductPointFromCustomers()

Batch operations (Adv_orders_admin):

  • addPointsSelected($selected) -- batch award for multiple order serials
  • removePointsSelected($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:

  1. Adv_orders_admin loads keepProductPointsForEdit from POINT_SYSTEM.ORDER_EDIT_KEEP_PRODUCT_POINTS
  2. Adv_order_model::fakeTheCart() checks this flag per product
  3. If keepOldPoints is enabled and prData['keepPoints'] is set, the original item_points value is retained
  4. 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) ​

ColumnTypeDescription
idint (PK)Customer ID
total_pointsintAccumulated 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) ​

ColumnTypeDescription
idint (PK)Product ID
point_factordecimal(11,4) DEFAULT NULLPer-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) ​

ColumnTypeDescription
order_serialvarchar (unique)Order serial number, used as key in loyalty operations
customer_idint (FK)Links to shop_customer.id
points_spendintNumber of points the customer redeemed on this order
points_rewarddecimal(11,2)Cash discount applied from redeemed points (subtracted from total_vat)
points_addedtinyint(1)Whether product-earned points have been credited to the customer (0=pending, 1=credited)
total_vatdecimalOrder total including VAT, reduced by points_reward
statusvarcharOrder status (PENDING, PAID, INVOICED, CANCELED, RETURN, etc.)
gtstatusvarcharCarrier tracking status text written by the tracking poller; vocabulary varies by carrier (e.g. ΠΑΡΑΔΟΜΕΝΟ, DELIVERED). Not used as a filter by the loyalty jobs.
gtflagtinyint(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_idint, nullableIf not null, order is for in-store pickup

shop_order_basket (item-level point data) ​

ColumnTypeDescription
order_idint (FK)Links to shop_order.id
product_code_idintProduct code identifier
item_pointsintPoints earned for this line item (per-unit, calculated as round(price * point_factor) at order time)
qtyintQuantity ordered

Earned points per order are computed as SUM(item_points * qty) across all basket items.

shop_product_reviews (review reward gate) ​

ColumnTypeDescription
idint (PK)Review ID
is_points_awardedtinyint(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 ​

GroupKeyTypePurpose
POINT_SYSTEMIS_ENABLEDbooleanMaster toggle for the entire loyalty system
POINT_SYSTEMORDER_EDIT_KEEP_PRODUCT_POINTSbooleanWhen editing an order in admin, preserve original item points instead of recalculating
ESHOPSPEND_POINTSintPoints threshold for redemption (e.g., 100 = must redeem in multiples of 100)
ESHOPREWARD_CASHfloatCash reward per SPEND_POINTS threshold (e.g., 10 = 10 EUR per 100 points)
ESHOPPOINT_FACTOR_TYPEstringWhich price field to use for point calculation: price, price_without_vat, original_price, or final_price
ESHOPDEFAULT_POINT_FACTORfloatDefault point multiplier for products without a custom point_factor
ESHOPREWARD_NEW_CUSTOMERbooleanEnable welcome bonus points for new registrations
ESHOPPOINTS_TO_NEW_CUSTOMERintNumber of bonus points for new customers

Email/Campaign Settings ​

GroupKeyTypePurpose
EMAIL_FOR_BIRTHDAYIS_ENABLEDbooleanEnable birthday email with bonus points
EMAIL_FOR_BIRTHDAYPOINTS_TO_ADDintPoints to award on customer's birthday
EMAIL_FOR_BIRTHDAYREPEATintMonths before re-sending birthday email
EMAIL_FOR_TOTAL_POINTSIS_ENABLEDbooleanEnable remaining points reminder email
EMAIL_FOR_TOTAL_POINTSTOTAL_POINTSintMinimum points threshold to trigger reminder
EMAIL_FOR_TOTAL_POINTSINTERVALintMonths since last order to qualify for reminder
EMAIL_FOR_TOTAL_POINTSINTERVAL_REPEATintMonths before re-sending reminder
ECOMMERCEN_PLUSCUSTOMER_REVIEW_REWARD_ENABLEbooleanEnable points for approved product reviews
ECOMMERCEN_PLUSCUSTOMER_REVIEW_REWARDintPoints 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() and getWhereNotInOptions() (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 ​

  1. 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).

  2. 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): redeemPoints is a boolean, and LoyaltyRedemption::resolve() always spends the whole floored balance -- a REST client cannot name a partial amount either (src/Domains/Checkout/LoyaltyRedemption.php:83-133).

  3. 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.

  4. Payment failure recovery: If payment fails and the order is canceled (in checkout flow or by AdvCancelIncompleteOrders), spent points are returned via returnPointsToCustomers(). The REST checkout cancellation path (POST /rest/checkout/cancel-payment/{orderId}) restores spent points through the RestoreSpentPointsListener on the OrderCanceled event instead of calling returnPointsToCustomers() directly.

  5. Earning timing: Points are earned only after delivery, never at order creation or payment. This prevents earning points on canceled/returned orders.

  6. Cancellation double reversal: When an admin cancels a delivered order, both operations occur: spent points are returned AND earned points are removed. The points_added flag is reset to false.

  7. No negative balance protection: The savePointsToCustomer() method uses raw total_points + $delta without a floor check. Under normal operation, points should not go negative, but edge cases (concurrent operations, manual adjustments) could theoretically cause this.

  8. Total includes points discount: shop_order.total_vat is stored with the points discount already applied: total_vat = cart_total_vat + transport + delivery - points_reward + gift_packaging.

  9. Points for gifts: Gift items (from cart rules, bundles) have their item_points stored in shop_order_basket like regular items. Points earned from gifts are included in the SUM(item_points * qty) calculation.

  10. 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.

  11. Review points are immediate: Review reward points are added when an admin approves the review, not when the review is submitted.

  12. 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 used gtstatus = 'ΠΑΡΑΔΟΜΕΝΟ', which silently skipped carriers (e.g. GenikiV2) that write an English status string while still setting gtflag = 1.

  13. Store pickup uses INVOICED status: In-store pickup orders use status = 'INVOICED' as the trigger for point awarding, not gtstatus, since there is no courier tracking.

  14. 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), throws src/Domains/Checkout/Exceptions/LoyaltyRedemptionExceedsOrderTotalException.php:38 at src/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.

  15. Review reward points are idempotent: Product review approval awards points once via the is_points_awarded flag on shop_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 in ecommercen/eshop/controllers/Adv_product_reviews_admin.php call the same guard.


Tests ​

Test FileCoverage
tests/Unit/Domains/Checkout/LoyaltyRedemptionTest.phpUnit 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.phpPure-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.phpIntegration 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.phpIntegration 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 ​

  1. #276 (fixed in develop): AdvAddPointsToCustomerDeliver previously selected delivered orders by gtstatus = 'ΠΑΡΑΔΟΜΕΝΟ'. GenikiV2 writes gtstatus = $track->Status (e.g. 'DELIVERED') alongside gtflag = 1, causing those orders to be silently skipped. Fixed by switching the selector to gtflag = 1. Both ecommercen/job/libraries/AdvAddPointsToCustomerDeliver.php (getWhereOptions()) and the inline copy in application/controllers/Cronjob.php:addPointsToCustomerDeliver() were updated.

  2. DivisionByZeroError on zero SPEND_POINTS: Adv_loyalty::pointsToCash() (ecommercen/libraries/Adv_loyalty.php:178-187) guards $this->rewardCash > 0 && $points > 0 but never checks $this->spendPoints. When dividing by it at :187, a shop configured with ESHOP.SPEND_POINTS = 0 raises a PHP 8 DivisionByZeroError. Only calcLoyalty() shields the method via calcPointsToSpend()'s <> 0 check at :161. The method is public and is a documented client-override seam, so the zero-divisor guard should be in pointsToCash() itself.

  3. 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->customerTotalPoints is already non-empty (line 214), but $this->customerTotalPoints = $points; is then executed unconditionally (line 226) with $points still 0 from initialization. Cached value is overwritten with 0 on second invocation, even when the first call already read the correct value from the DB.

  4. Deadlock risk in batch review approval: Adv_product_reviews_admin::approveBatch() (ecommercen/eshop/controllers/Adv_product_reviews_admin.php:104-114) locks shop_product_reviews rows in ascending review-id order within a transaction. Inside each iteration, savePointsToCustomer() takes a shop_customer row 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.