All Areas at a Glance: How the 32 Building Blocks Work Together
All Areas at a Glance: How the 32 Building Blocks Work Together
~8 Min. Lesezeit Zuletzt aktualisiert am August 9, 2026
Eleven blocks, 96 chapters, 32 individual module areas - each explained in its own chapter, each anchored in its own small slice of the points ledger, the reward entity, or a single attribute. What's easy to lose in that separate view: none of these building blocks stands alone. A single purchase runs through nearly all 32 areas within a fraction of a second - from observing the order placement to a GraphQL response weeks later, when the same customer checks their points balance through a headless frontend. This chapter retraces exactly that path.
The path of a single purchase
The interplay becomes clearest by following one customer: they buy something, earn points, see them in their account, later redeem them for a reward - and a cron job cleans up if they never do.
Data flow: one purchase from order placement to display
Purchase completed
|
v
sales_order_place_after (global event, chapters 29/30)
|-- AwardPointsOnOrderPlaced (chapter 30)
| |-- PointsCalculator::calculatePoints() (chapter 5)
| |-- CategoryBonusResolver::resolveForProduct() (chapter 27)
| `-- PointsLedgerRepository::save() -> TYPE_EARN (chapter 6)
| `-- loyalty_points_balance updated (chapters 21/30)
| `-- LoyaltyTierBackend::beforeSave() -> tier recalculated (26)
| `-- EVENT_TIER_CHANGED dispatched (chapter 35)
|-- RedeemPointsOnOrderPlaced (chapters 63/67, if points were redeemed)
`-- CreditPurchasedPointsPackageOnOrderPlaced (chapter 76, points-package purchase)
Displaying the balance (four paths, one value)
|-- ViewModel PointsBalance -> account dashboard/widget (chapters 48/52/56)
|-- CustomerData PointsBalance -> mini-cart/checkout (chapter 84)
|-- REST GET /V1/loyalty/points/mine (chapter 80)
`-- GraphQL loyaltyPointsSummary (chapter 82)
Redeeming a reward
RewardCatalog/Redeem controller, REST, GraphQL (chapters 49/50/81/83)
`-- RewardRedemptionManagement::redeem() -> TYPE_REDEEM (chapter 81)
Checkout integration
|-- ApplyPointsRedemptionToTotalsPlugin -> discount (chapter 39)
|-- PointsRedemptionFacade payment method -> 100% edge case (chapters 62-65)
`-- FreeShippingByPoints shipping method -> free shipping (chapters 66-69)
Expiry
ExpirePoints cron job, daily -> TYPE_EXPIRE, tier recalculated (chapters 32-34)1. Earning points: observer → PointsCalculator → ledger
The globally registered observer AwardPointsOnOrderPlaced (chapter 30) reacts to sales_order_place_after (chapter 29) and delegates the actual math entirely to PointsCalculator::calculatePoints() (chapter 5) - taking into account the product multiplier loyalty_points_multiplier (chapter 19) and, via CategoryBonusResolver::resolveForProduct() (chapter 27), the highest active category bonus loyalty_bonus_category (chapter 20). The result lands twice: as loyalty_points_earned on the order and order item (chapter 23) and as a TYPE_EARN entry in the ledger through PointsLedgerRepositoryInterface::save() (chapter 6). If the customer instead bought a points-package product (chapters 71-78), a third, independent observer runs on the same event: CreditPurchasedPointsPackageOnOrderPlaced (chapter 76) - deliberately kept separate so its idempotency guard can't interfere with the other one.
2. Updating the balance and the loyalty tier
Both observers write loyalty_points_balance exclusively through CustomerRepositoryInterface::getCustomAttribute()/setCustomAttribute() (chapters 21/30), never through raw getData()/setData(). Every single save runs through LoyaltyTierBackend::beforeSave() (chapter 26), which automatically recalculates the loyalty tier from the new balance - using the same bronze/silver/gold thresholds PointsCalculator knows internally (chapter 5). If the tier actually changes, the backend model dispatches the module's own EVENT_TIER_CHANGED (chapter 35) - an extension point chapter 102 revisits.
3. Displaying the balance: four paths, one value
The account page and the widget read through the PointsBalance view model (chapter 48, wired up in chapters 51/52/56), the mini-cart through the CustomerData section loyalty_points (chapter 84), external clients through GET /V1/loyalty/points/mine (chapter 80), and headless frontends through the GraphQL query loyaltyPointsSummary (chapter 82). REST and GraphQL even share the exact same implementation, PointsManagementInterface (chapter 80, reused unchanged in chapter 82) - four surfaces, a single read path.
4. Redeeming a reward
The storefront reward catalog and redeem controller (chapters 49/50) as well as REST and GraphQL redemption (chapters 81/83) all call the exact same method: RewardRedemptionManagementInterface::redeem() (chapter 81) - one implementation, three surfaces. It books a TYPE_REDEEM ledger entry and reduces the balance through the same CustomerRepositoryInterface path used for earning, so LoyaltyTierBackend automatically recalculates the tier here too.
5. Using points at checkout: discount, payment method, shipping method
The default case is a discount: ApplyPointsRedemptionToTotalsPlugin (chapter 39) reduces grand_total directly in the checkout totals. The custom payment method mironsoft_loyalty_points (chapters 62-65) only kicks in for the edge case where points cover the entire amount; the shipping method FreeShippingByPoints (chapters 66-69) trades points for free shipping. Only once the order is actually placed does RedeemPointsOnOrderPlaced (chapters 63/67) book the real ledger entries - the discount shown in the cart remains a pure preview until then, unbooked, to avoid double execution.
6. Expiry and cleanup
The daily ExpirePoints cron job (chapters 32-34) books TYPE_EXPIRE entries using the same should-be-vs-is reconciliation pattern that ReversePointsOnCreditmemoSave (chapter 31) also uses for TYPE_ADJUST reversals on credit memos - and again reduces loyalty_points_balance, again with automatic tier recalculation.
The 32 areas in seven groups
Instead of the raw list from the specification, here are the same 32 areas, grouped by theme - a map for your own practice:
- Data model & base infrastructure: model (chapter 4), EAV entity (10-18), custom EAV attributes (13-14), cache (chapter 8), configuration type (chapter 88).
- Attributes on existing entities: product (19), category (20), customer (21), company (22), sales (23).
- Business logic & automation: observer/event (29-31, 35-36), cron group (32), cron job (33), plugin (38-39), preference/rewrite (40-42), helper (44).
- Frontend & administration: controller (45, 50), router (46), block (47), view model (48), widget (55-57), Page Builder content type (58-60).
- Checkout integration: payment method (62-65), shipping method (66-69), product type (71-78).
- Interfaces: API (79-81), GraphQL endpoint (82-83), customer (section) data (84).
- Configuration, language & quality: system/config/setting (7), console command (9), language (89-90), unit test (91-95).
Tipp: Two classes show up in practically every one of these groups: PointsLedgerRepositoryInterface (chapter 6) and PointsCalculator (chapter 5). They're the module's spine - all the other 30 areas ultimately just provide different inputs and outputs for these two classes.
With this overview in mind, chapter 98 turns to the question of how this entire construction actually loads correctly through module.xml.