Skip to content

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 by remaining stock
  • Rule 13 special: cheapest qualifying product becomes free (no gift selection)
  • Customer can choose from eligible gifts (gift_user_choice_count limit)

API Reference

REST Endpoints

MethodPathAuthDescription
GET/rest/promotion/giftBackendList gift rules
POST/rest/promotion/giftBackendCreate gift rule
GET/rest/promotion/gift-requirementBackendList requirements
GET/rest/promotion/gift-choiceBackendList gift choices
GET/rest/cartGuestCart 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-orderGuest/CustomerAccepts 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 via OrderBasketBuilder::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/cartsrc/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

RuleValidatorTrigger
1ruleProductsValidatorSpecific products in cart
2ruleVendorsValidatorVendor products in cart
3ruleTotalCartValidatorCart total in amount range
4ruleProductsTotalCartValidatorProducts + cart total
5ruleProductsTotalMinAmountValidatorProducts' total value in range
6ruleVendorsTotalCartValidatorVendors + cart total
7ruleVendorsTotalMinAmountValidatorVendors' total value in range
8ruleVendorsTotalMinPriceValidatorIndividual vendor product prices
9ruleProductsTotalMinPriceValidatorIndividual product prices
10ruleProductsCombinationValidatorALL required products present (minimum qty)
11ruleProductsValidatorOneGiftProducts, caps at 1 gift
12ruleVendorsValidatorReturnOneGiftVendors, caps at 1 gift
13ruleProductsCheapestFreeValidatorCheapest 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

  1. 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 pathWriteRepository::decrementRemaining() (src/Domains/Promotion/Gift/Repository/WriteRepository.php:19-30) uses an atomic guarded UPDATE gifts SET remaining = remaining - N WHERE id = ? AND remaining IS NOT NULL AND remaining >= N, so remaining never goes negative. It clamps the counter — by design it does not reject an already-earned gift.
    • Legacy pathAdv_gifts_model::updateCounter() (ecommercen/eshop/models/Adv_gifts_model.php:1192-1199) does a non-transactional read-then-write, so remaining can transiently go negative under concurrency, self-correcting on the next evaluation (negative remaining fails the >0 filter).
  2. [RESOLVED 4.101.0, commit bd187f2db] Admin order validation dropped Rule 13 gifts — see AD-09 Known Issues for the full resolution detail.

  3. [SAVE-SIDE RESOLVED 4.101.0, commit 879423e21] Rule 13 admin form persisted dom_gift_ID to gift_choices (never read at runtime) — see AD-09 Known Issues for the fix and cleanup migration detail.

  4. [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.

  5. [OPEN — client override risk] Any client repo that overrides Adv_orders_admin::validateProductGiftSelections() and retains the old inline array_filter closure will continue to drop Rule 13 gifts from admin order validation. Check application/modules/eshop/controllers/Adv_orders_admin.php in 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

TablePurpose
gifts / gifts_muiGift rule master + translations
gift_requirementsTrigger conditions (option_type 1=product, 2=vendor)
gift_choicesProducts offered as gifts

For full column-level schema details, see AD-09 Gift Rules Admin.

Wiki Guide: Detailed gift module documentation — see Gifts Module.