Appearance
Loyalty Points System
Flow ID: CF-32 | Module(s): eshop (
Adv_loyaltylibrary), job (AdvAddPointsToCustomerDeliver,AdvAddPointsToCustomerFromStore,AdvSentMailToCustomerBirthday,AdvSentMailToCustomerRemainingPoints) | Complexity: High | Last Updated: 2026-06-04
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
No dedicated REST endpoints for loyalty points. Points data is exposed via existing resources:
| 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) |
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.
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 (lines 579-610)
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 as JSON state
- 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 368-374, 666-673, 938-952)ecommercen/libraries/Adv_loyalty.php(methods:calcLoyalty,removePointsFromCustomer,savePointsToCustomer)ecommercen/eshop/models/Adv_order_model.php(lines 370-380, 655-663)
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, line 410-419):
- 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 1797-1816):
- Same two-step reversal for each successfully canceled order serial
Checkout payment failure (Adv_checkout::cancelOrder(), lines 2042-2061):
- 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. When a customer cancels a payment via the REST endpoint, or a payment gateway webhook delivers a cancellation signal, PaymentConfirmationService::cancelPayment() dispatches an OrderCanceled event via OrderEventDispatcher::dispatchCanceled().
src/Domains/Checkout/PaymentConfirmationService.php lines 162-168:
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 133-134:
php
->call('addCanceledListener', [service(Event\Listeners\RestoreSpentPointsListener::class)])
->call('addCanceledListener', [service(Event\Listeners\RestoreCouponUsageListener::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.
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(lines 410-419, 1797-1816)ecommercen/checkout/controllers/Adv_checkout.php(lines 2042-2061)ecommercen/job/libraries/AdvCancelIncompleteOrders.php(lines 125-131)application/controllers/Cronjob.php(lines 277-284)ecommercen/libraries/Adv_loyalty.php(methods:returnPointsToCustomers,removeProductPointFromCustomers)src/Domains/Checkout/PaymentConfirmationService.php(lines 162-168: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/container.php(lines 133-134: 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, lines 126-131):
- When admin approves a product review (sets status to 1)
- If
POINT_SYSTEM.IS_ENABLEDandECOMMERCEN_PLUS.CUSTOMER_REVIEW_REWARD_ENABLEare enabled - Calls
loyalty->savePointsToCustomer($customerId, $rewardValue)where$rewardValueis fromECOMMERCEN_PLUS.CUSTOMER_REVIEW_REWARD
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) │
│ 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 | float, nullable | 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 | float | 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.
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 Admin > Settings > Loyalty (Adv_settings::loyalty(), line 834) and Admin > Settings > E-commerceN Plus (Adv_settings::e_commercen_plus(), line 1766).
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.
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.
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.
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