Skip to content

Loyalty Points System

Flow ID: CF-32 | Module(s): eshop (Adv_loyalty library), 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-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

No dedicated REST endpoints for loyalty points. Points data is exposed via existing resources:

EndpointFieldDescription
GET /rest/customer/metotalPointsCustomer's current point balance (integer)
GET /rest/product/{id}pointFactorProduct'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.

  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 (lines 579-610)

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 as JSON state
  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 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)

  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, line 410-419):

  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 1797-1816):

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

Checkout payment failure (Adv_checkout::cancelOrder(), lines 2042-2061):

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

  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 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:

  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 (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: 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/container.php (lines 133-134: 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, lines 126-131):

  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
  3. Calls loyalty->savePointsToCustomer($customerId, $rewardValue) where $rewardValue is from ECOMMERCEN_PLUS.CUSTOMER_REVIEW_REWARD

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)        │
│  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_factorfloat, nullablePer-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_rewardfloatCash 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.

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

  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.


Known Issues & Security Gaps

  • #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.