Appearance
Gift Rules & Free Products
Flow ID: CF-14 | Module(s): eshop, Promotion domain | Complexity: High | Last Updated: 2026-07-10
Business Overview
Gift rules automatically grant free products when cart meets conditions. 13 rule types cover: specific products, vendor products, cart total, product combinations, and the special "cheapest product free" (Rule 13).
Key business behaviors:
- Evaluated on every cart render via
AdvCartResource::getGifts() - Gift quantity:
floor(validatorResult / gift_per_count), capped byremainingstock - Rule 13 special: cheapest qualifying product becomes free (no gift selection)
- Customer can choose from eligible gifts (
gift_user_choice_countlimit)
API Reference
REST Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /rest/promotion/gift | Backend | List gift rules |
| POST | /rest/promotion/gift | Backend | Create gift rule |
| GET | /rest/promotion/gift-requirement | Backend | List requirements |
| GET | /rest/promotion/gift-choice | Backend | List gift choices |
| GET | /rest/cart | Guest | Cart response includes a gifts block: the applicable free-gift rules for the current cart (earned count, multi-choice pool, requirements), computed server-side |
| POST | /rest/checkout/place-order | Guest/Customer | Accepts selectedGifts field ([{giftId, productId, qty}]); applies gift rules to the placed order |
REST Gift Flow
Placement path (#85)
The legacy 13-rule engine is reused via a GiftRuleEngine / LegacyGiftRuleEngine seam — no reimplementation (src/Domains/Checkout/Gift/GiftRuleEngine.php:12-44, LegacyGiftRuleEngine.php:13-70). The bridge loads eshop/gifts_model, product_parser_model, the shopmodule helper, and the registry library on first use.
GiftMatcher (src/Domains/Checkout/Gift/GiftMatcher.php) runs server-authoritatively. The client's selectedGifts request field only picks from the already-eligible pool, capped at the earned count — it cannot grant new gifts or exceed the earned quantity.
- Rule 13 (cheapest-free): deducts the free units from the matching paid basket rows (reducing the payable total by
giftDiscount); no separate free row is added. - Other rules: a free basket row is appended (
price=0,discount_string=GIFT,gift_id) per gift viaOrderBasketBuilder::applyGiftOutcome()(src/Domains/Checkout/OrderBasketBuilder.php:41-80).
PlaceOrderData.selectedGifts (src/Domains/Checkout/PlaceOrderData.php:64-71): normalized from [{giftId, productId, qty}, ...]; ignored for Rule-13 gifts (auto-resolved server-side).
Stock decrement (#203 — REST path)
DecrementGiftStockOnPaidListener (src/Domains/Order/Event/Listeners/DecrementGiftStockOnPaidListener.php) fires on OrderPaid (at payment-success for deferred payways; at placement for immediate/offline payways — mirroring legacy afterSuccess timing). It reads persisted basket rows for the order, sums qty per gift_id, and calls decrementRemaining().
Atomic guarded UPDATE (src/Domains/Promotion/Gift/Repository/WriteRepository.php:19-30):
sql
UPDATE gifts SET remaining = remaining - ?
WHERE id = ? AND remaining IS NOT NULL AND remaining >= ?This keeps remaining from going negative on the REST path (counter integrity). It clamps the counter only — it does not reject an already-earned gift, so a gift promised during checkout can still be over-issued under concurrency. That over-issue is an accepted tradeoff — see Known Issues.
Cart display
The cart payload (GET /rest/cart — src/Rest/Cart/Controllers/Cart.php:133-140 — and all cart mutation responses) includes a gifts block computed by CartGiftPresenter (src/Domains/Checkout/Gift/CartGiftPresenter.php).
Exposed fields (allowlist matching legacy mapGiftRuleFieldsForJson): id, ruleId, amount, giftUserChoiceCount, earnedCount, choices, requirements, image, description. Internal fields (internal_name, remaining, active, priority, date_start/end, amount_from/to) are NOT exposed.
Rule-13 choices is overridden with the computed cheapest product ids, not the empty gift_choices table. Empty cart → empty gifts block.
cart_contents snapshot note
The shop_order.cart_contents audit blob is built from PRE-gift paid rows (legacy parity). Gift rows live on shop_order_basket.gift_id, not in cart_contents.
13 Rule Types
| Rule | Validator | Trigger |
|---|---|---|
| 1 | ruleProductsValidator | Specific products in cart |
| 2 | ruleVendorsValidator | Vendor products in cart |
| 3 | ruleTotalCartValidator | Cart total in amount range |
| 4 | ruleProductsTotalCartValidator | Products + cart total |
| 5 | ruleProductsTotalMinAmountValidator | Products' total value in range |
| 6 | ruleVendorsTotalCartValidator | Vendors + cart total |
| 7 | ruleVendorsTotalMinAmountValidator | Vendors' total value in range |
| 8 | ruleVendorsTotalMinPriceValidator | Individual vendor product prices |
| 9 | ruleProductsTotalMinPriceValidator | Individual product prices |
| 10 | ruleProductsCombinationValidator | ALL required products present (minimum qty) |
| 11 | ruleProductsValidatorOneGift | Products, caps at 1 gift |
| 12 | ruleVendorsValidatorReturnOneGift | Vendors, caps at 1 gift |
| 13 | ruleProductsCheapestFreeValidator | Cheapest requirement product = free |
Rule 13 Special Handling
Doesn't use gift_choices table. Instead finds cheapest requirement product via getCheapestRequirementProductInCart(). In order pricing, paidQty = quantity - rule13GiftCount.
Save-side invariant (4.101.0): dom_gift_ID is stripped on save and historical orphan rows were purged — see AD-09 Gift Rules Admin for the full save-pipeline detail and cleanup migration.
Admin path convergence (4.101.0): Both admin order paths in Adv_orders_admin.php now delegate to filterApplicableGiftRules() — see AD-09 Gift Rules Admin for details.
Known Issues & Security Gaps
Gift over-issue under concurrent checkout — accepted tradeoff (#203). Gift eligibility is evaluated per cart render and the gift is promised to the customer throughout checkout, but eligibility and stock decrement are not one atomic transaction. Under truly concurrent checkouts a strictly-limited gift (
remaining = N) can be earned — and honored — by more than N buyers. This is accepted business behavior for now: the platform will not strip a gift already promised during checkout just to hand the last buyer an order without it. The same tradeoff applies to product stock, which is not reserved on add-to-cart either. Not scheduled for change.Counter integrity still differs by path:
- REST path —
WriteRepository::decrementRemaining()(src/Domains/Promotion/Gift/Repository/WriteRepository.php:19-30) uses an atomic guardedUPDATE gifts SET remaining = remaining - N WHERE id = ? AND remaining IS NOT NULL AND remaining >= N, soremainingnever goes negative. It clamps the counter — by design it does not reject an already-earned gift. - Legacy path —
Adv_gifts_model::updateCounter()(ecommercen/eshop/models/Adv_gifts_model.php:1192-1199) does a non-transactional read-then-write, soremainingcan transiently go negative under concurrency, self-correcting on the next evaluation (negativeremainingfails the>0filter).
- REST path —
[RESOLVED 4.101.0, commit bd187f2db] Admin order validation dropped Rule 13 gifts — see AD-09 Known Issues for the full resolution detail.
[SAVE-SIDE RESOLVED 4.101.0, commit 879423e21] Rule 13 admin form persisted
dom_gift_IDtogift_choices(never read at runtime) — see AD-09 Known Issues for the fix and cleanup migration detail.[RENDER-SIDE OPEN] Admin form still renders the gift-products picker when Rule 13 is selected — see AD-09 Known Issues for the open tracking detail.
[OPEN — client override risk] Any client repo that overrides
Adv_orders_admin::validateProductGiftSelections()and retains the old inlinearray_filterclosure will continue to drop Rule 13 gifts from admin order validation. Checkapplication/modules/eshop/controllers/Adv_orders_admin.phpin client repos for this pattern.
Client Extension Points
- Gift model override: Custom validator logic, new rule types
- Gift rules model: Rule definitions, field visibility per type
- Admin:
postDataRuleCleanupHelper()strips inapplicable fields per rule
Data Model
| Table | Purpose |
|---|---|
gifts / gifts_mui | Gift rule master + translations |
gift_requirements | Trigger conditions (option_type 1=product, 2=vendor) |
gift_choices | Products offered as gifts |
For full column-level schema details, see AD-09 Gift Rules Admin.
Related Flows
- CF-02 Product Detail — gift badges shown on product page
- CF-05 Cart Management — gifts evaluated every render
- CF-06 Order Preview — gift selection during checkout
- CF-07 Order Confirmation — gift stock decremented
- AD-09 Gift Rules Admin — admin gift rule CRUD
Wiki Guide: Detailed gift module documentation — see Gifts Module.