Skip to content

Gift Rules & Free Products ​

Flow ID: CF-14 | Module(s): eshop, Promotion domain | Complexity: High | Last Updated: 2026-09-29

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/promotion/gift/activeGuestCurrently-active gift rules a shopper can earn right now — closed 20-field payload (id, ruleId, isPromo, image, giftImage, description, choices, amountFrom, amountTo, giftPerCount, url, promoImage, extraProductImage, extraVendorImage, requirementOptionType, requirementName, requirementImage, requirementSlug, giftName, giftSlug), optional ?filter[product]=<id> and ?filter[isPromo]=<0|1>, no sorts (#646)
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
GET/rest/cartGuestCart response also includes a giftsNearMiss block (#446): gift rules not yet earned (or not yet earned at the next tier), with the amount/quantity still needed to qualify — powers the "add €X more to earn your free gift" storefront teaser
POST/rest/checkout/place-orderGuest/CustomerAccepts selectedGifts field ([{giftId, productId, qty}]); applies gift rules to the placed order

REST Gift Flow ​

Guest-readable active gift rules (#646) ​

GET /rest/promotion/gift/active (and its (\w{2})/ locale-prefixed twin) is the one gift endpoint with auth: guest — every other endpoint in the table above stays backend (src/Rest/Promotion/Controllers/ActiveGift.php, namespace Advisable\Rest\Promotion\Controllers). It projects the rules currently earnable by the full legacy definition — inside the date window, active = 1, stock remaining, and at least one in-stock gift choice (rule 13 exempt, since its pool is computed from the cart) — built so a headless storefront can render the "current gift offers" block the legacy homepage shows via getActiveGiftRules() (see CF-26 Home Page).

Not full homepage parity by itself. The legacy homepage also renders homeSubContent()'s static blocks, and in this repo neither home_gifts_slider_text nor xtra-gifts-link-homepage exists — $config['homeSubContent'] = [] (application/config/main.php:147). A client fork that declares those keys gets them through /rest/cms/subcontent instead; the WeCare storefront consumer reports (issue #646, comment #issuecomment-5600724049) that v4-wecare does, consuming them in home-gifts-slider.php:84 and gift_rules_slider.php:123 — unverified here, since that fork isn't in this checkout. Making the gift rules renderable does not, on its own, reproduce that surrounding block — see docs/decisions/646-active-gift-rules.md for the full scoping.

Placement path (#85) ​

The legacy 13-rule engine is reused via a GiftRuleEngine / LegacyGiftRuleEngine seam — no reimplementation (src/Domains/Checkout/Gift/GiftRuleEngine.php:12-65, LegacyGiftRuleEngine.php:13-84). 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:97-155, docblock :67-96), specifically the row construction at :189-195.

PlaceOrderData.selectedGifts (property at src/Domains/Checkout/PlaceOrderData.php:86, hydration :174-176): normalized from [{giftId, productId, qty}, ...] via normalizeSelectedGifts() (:195-220); 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:41 onward):

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.

decrementRemaining() (unreleased) returns bool rather than void: true means this gift's stock is correctly accounted for — either the guarded UPDATE actually decremented a row, or the gift is unlimited (remaining IS NULL) and had nothing to decrement. false means the opposite — the counter is exhausted, the gift row no longer exists, or $by was non-positive. Collapsing this back to affected_rows() > 0 is the bug this contract avoids: an unlimited gift in the basket would then also report false. DecrementGiftStockOnPaidListener filters basket lines with qty <= 0 out of its per-gift aggregation before any of them reach decrementRemaining() (src/Domains/Order/Event/Listeners/DecrementGiftStockOnPaidListener.php:30-33) — such a line asks for nothing to be decremented, so it must not veto the marker for gifts on the same order that genuinely were. The order's gift stock is marked consumed (see below) only when every decrement that was attempted returned true; an order whose only gift line is qty-0 is still left unmarked, since nothing was attempted for it. A partial or failed decrement among the lines that were attempted still leaves the order unmarked, so a later cancellation cannot credit back units that were never actually deducted.

Stock restore on cancellation (#688) ​

Order cancellation restores gifts.remaining for limited gifts on both paths, gated on a new persisted marker rather than on order status — status and is_paid cannot reliably discriminate "this order's gift stock is currently consumed" across the three independent decrement sites (REST OrderPaid, legacy checkout completion, admin/POS order creation).

  • Marker: shop_order.gifts_applied (TINYINT(1) NOT NULL DEFAULT 0, added AFTER points_added). Set when a gift decrement lands (see above); cleared by the restore via a compare-and-swap (UPDATE shop_order SET gifts_applied = 0 WHERE id = ? AND gifts_applied = 1, guarded on affected_rows() > 0). The same column supplies both the restore's precondition and its once-only idempotency: a repeat cancel, or two concurrent cancels, restore exactly once.
  • Legacy path: the restore is a new sibling if branch in Adv_order_model::set_status() (ecommercen/eshop/models/Adv_order_model.php:1830-1832), gated on !$ignoreStock && $stockMode === '+' && $status === 'CANCELED' — placed directly beside the pre-existing product-stock returnOrderStock() branch (:1803-1805) but with an added $status === 'CANCELED' condition, since stock mode alone doesn't discriminate CANCELED from a hypothetical RETURN write — no changes at any of the ~7 legacy cancellation entry points, all of which already converge on set_status(). The call lands in Adv_order_model::restoreOrderGifts() (ecommercen/eshop/models/Adv_order_model.php:1031-1073), which — like the modern listener — claims the marker before reading the basket, inside its own trans_start()/trans_complete() transaction. When trans_status() comes back false it logs the failure via log_message('error', ...) and returns normally rather than throwing — the rollback has already re-armed gifts_applied by that point, so a later cancellation can still restore the units, without a throw's side effect of propagating out of set_status() and aborting the rest of a batch cancel (Adv_order_model::setBatchCanceled(), :3174-3228). Adv_gifts_model::restoreCounter() restores with one atomic UPDATE gifts SET remaining = remaining + ? WHERE id = ? AND remaining IS NOT NULL (deliberately not the read-then-write shape updateCounter() uses — see Known Issues item 1).
  • Modern path: a new RestoreGiftStockOnCanceledListener on OrderCanceled (src/Domains/Order/Event/Listeners/RestoreGiftStockOnCanceledListener.php), registered alongside the existing coupon/points cancel listeners. It claims the marker first, inside its own transaction, before reading the basket — a mid-restore exception rolls back and re-arms the marker rather than leaking the stock silently, since OrderEventDispatcher swallows listener exceptions per-listener. Gift\WriteRepository::restoreRemaining() mirrors the legacy method's atomic guarded UPDATE.
  • Only CANCELED triggers the restore — RETURN does not. RETURN is not dormant — a cron job writes it wherever Europharmacy is enabled (AdvSyncOrdersStatusEuropharmacy::getOrderStatusID(), ecommercen/job/libraries/AdvSyncOrdersStatusEuropharmacy.php:108-109, maps vendor code 7 to it; the job now returns early unless registry EUROPHARMACY.ENABLED is truthy, :52-55, and reads its config via Config::fromRegistry, :35-37), but Adv_order_model::update_order() (ecommercen/eshop/models/Adv_order_model.php:957-959) only routes into set_status(..., 'CANCELED', ...) when the incoming status is CANCELED; a RETURN write just updates the status column and stops, so a returned order restores nothing today, product stock included. Making gift stock the sole exception would be inconsistent in the merchant's favor — that gap is real and knowingly out of scope here; see the decision record's "Correction — RETURN IS written" section.
  • No restore for orders placed before this deploy. gifts_applied defaults to 0, so cancelling a historical order restores nothing for it — deliberate, not a gap; see the decision record for why a backfill patcher was rejected.
  • Unaffected by this change: the pre-existing product-stock double-restore in set_status() (returnOrderStock() has no idempotency guard of its own, so a repeat cancel still double-restores product_codes.stock). Only the gift-stock restore above is guarded and idempotent.

Admin clone/edit orders decrement the same gift set the basket carries. The admin "edit order" screen is clone-and-cancel, not an in-place edit: it carries previously-awarded gifts forward via a hidden old_product_gifts POST field alongside any newly-selected product_gifts. Adv_order_model::create_order_admin() builds the clone's basket from mergeOldWithNewGifts($giftArrNew, $giftArrOld) (ecommercen/eshop/models/Adv_order_model.php:2173-2177) and marks/decrements off that same merged array (:2403-2404) — not off the newly-selected gifts alone. Driving the two off different inputs was a HIGH-severity defect fixed during PR #260 review: decrementing only the new selection while the basket held the merge let cancelling the clone restore more units than were ever deducted for it, or leave a merge containing only carried-forward gifts undecremented and unmarked entirely.

Why the merged decrement is correct, restated after a second PR #260 review round. The first fix's code comment justified the merged decrement by claiming it "nets to zero" against the source order's cancellation — that premise does not hold on every path, so the reasoning (not the fix) was corrected. The merged set is right for a reason that holds regardless of the source order's fate: whatever the clone's basket carries is exactly what gets decremented for it, so the clone is self-consistent on its own terms, and its own later cancellation restores precisely what was deducted for it. Where the source genuinely was not cancelled — setBatchCanceled() skips several cases outright (already CANCELED, a non-paybybank PENDING order, a PAID + paybybank order, or a card-gateway payway) and edit() only refuses the paybybank+PAID combination up front — two live orders really do hold the same gift stock, which is accurate accounting for two live orders, not double-counting. That failed cancel is no longer silent: Adv_orders_admin::edit() now inspects setBatchCanceled()'s return and flashes an admin-visible error instead of discarding it (see Stock restore on cancellation above).

One residual, stated rather than hidden. A historical source order — gifts_applied = 0, guaranteed to exist since there is no backfill (see above) — cancels cleanly through setBatchCanceled() but restores nothing, because the compare-and-swap never wins for it. Its unit is never returned even though the clone decrements again, so one unit is lost per such edit. This follows from the no-backfill decision, not from this call site, and it is in the safe direction — stock is under-reported, never inflated.

Full design and rejected alternatives: docs/decisions/688-restore-gift-stock-on-cancel.md.

Cart display ​

The cart payload (GET /rest/cart — src/Rest/Cart/Controllers/Cart.php:162-166 — and all cart mutation responses) includes a gifts block computed by CartGiftPresenter (src/Domains/Checkout/Gift/CartGiftPresenter.php), with OpenAPI schema in the same controller's OA\Get attribute (Cart.php:212-261; gifts property at :242, giftsNearMiss at :248).

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.

Near-miss teaser (giftsNearMiss, #446) ​

Sibling block to gifts, computed by CartGiftNearMissPresenter (src/Domains/Checkout/Gift/CartGiftNearMissPresenter.php) and returned as a separate top-level giftsNearMiss key on GET /rest/cart — it does not modify the existing gifts schema or its allowlist test.

Like gifts, an empty cart is a no-op: CartGiftNearMissPresenter short-circuits to [] for an empty cart, and the engine (Adv_gifts_model::getNearMissGiftsForProductsInCart()) enforces the same guard. Surfacing near-miss rows on an empty cart (the "spend €X more before you've added anything" headline) was the original design but was reverted (PR #80 review): the loosened discovery predicate below admits every amount-based rule (3/4/5/6/7) on an empty cart, each reporting its full threshold as "remaining" — pure teaser noise. No near-miss row is shown until the customer actually has something in their cart.

Discovery-gap fix: for a non-empty cart, the earned-path discovery query (Adv_gifts_model::getActiveGiftRulesForRequiredProducts()) filters candidate rows with amount_from < cartTotalWithVat — this actively excludes a pure cart-total rule (rule 3) the cart hasn't reached yet, i.e. exactly the near-miss scenario. A new discovery method, getActiveGiftRulesForNearMiss(), uses a loosened predicate (amount_from > 0, regardless of cart progress) so not-yet-reached amount rules are still surfaced. Both methods share their SQL construction via a buildActiveGiftRulesSql() helper — the earned path's behavior is unchanged.

Rule-by-rule near-miss computability:

RuleDimensionWhat's reported
1, 2, 11, 12, 13quantityDistance to the next gift_per_count tier (covers both "never earned" and "earned N, upsell to N+1" via one modulo formula)
3amountStrictly-exceed distance (amount_from + 0.01) - cartTotalWithVat, floored at 0.01 — a cart resting exactly on amount_from has not earned the gift yet, so it still reports 0.01, not 0; excluded once the cart strictly exceeds amount_from (earned) or once the amount_to window has passed
4, 6amount OR quantityTwo-stage rules report whichever gate (cart-total or product/vendor quantity) is the actual blocker — never both
5, 7amountDistance to threshold on the cumulative value of the specific product/vendor requirement list (same strictly-exceed +0.01 semantics as rule 3)
10quantityHeadline remainingQuantity = distance for the binding (lagging) product to the shared next combination tier, one gift_per_count step above the minimum qty across the required products (a non-binding product already at/above that tier adds nothing). A per-required-product breakdown is computed internally to derive the headline but, like all internal fields, is not exposed — only remainingQuantity crosses the boundary
8, 9—Omitted (known v1 gap) — these check each cart item's own unit price against a bracket, not a cumulative spend, so no single "add €X more" number applies

Exposed fields (allowlist, mirrors gifts' discipline): id, ruleId, remainingAmount, remainingQuantity, giftUserChoiceCount, choices, requirements, image, description. As with gifts, internal_name, active, priority, remaining (stock), date_start/date_end and the raw amount_from/amount_to are never exposed — only the pre-computed remainingAmount/remainingQuantity deltas cross the boundary. Rows with remaining stock = 0 are excluded (inherited from the same active/date/stock filter the earned path uses).

Legacy pre-gift cart ordering (no REST equivalent) ​

shop_order.cart_contents has never been a real column (#605) — for either checkout path, nothing has ever persisted it, and there is no migration or initial.sql entry for it. What is real, on the legacy path only, is an in-memory ordering subtlety worth knowing: Adv_order_model::create_order() parses the cart via baseParseCartContents() (Adv_order_model.php:318-322) before gift matching runs, and hands that pre-gift array through as the cart_contents field of $order. processOrder() unset()s that field before the order INSERT (:1165 — it was never going to be written either way) and instead unserializes it back (getCartFromUnserializedContents(), :1207), then calls adjustCartForRule13Gifts() (:1218) to deduct the free units before building shop_order_basket rows. Reordering that — matching gifts before parsing the cart, or building basket rows before the Rule-13 deduction — would double- or under-deduct. Gift rows themselves live on shop_order_basket.gift_id, never in the transient cart_contents array.

REST checkout (PlaceOrderService::placeOrder()) has no equivalent stage to order: it builds paid basket rows directly via OrderBasketBuilder::buildBasketRows() (step 3b) and applies the gift outcome via applyGiftOutcome() (step 3c) with no intermediate serialize/unserialize round-trip — see CF-06 Order Preview.


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 path — WriteRepository::decrementRemaining() (src/Domains/Promotion/Gift/Repository/WriteRepository.php:41 onward) 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 path — Adv_gifts_model::updateCounter() (ecommercen/eshop/models/Adv_gifts_model.php:1636-1652) 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).

    This entry is scoped to the award-time decrement only. It does not cover order cancellation: the stock restore added by #688 (see Stock restore on cancellation) uses the atomic single-UPDATE shape on both paths and does not share updateCounter()'s read-then-write race.

  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.

  6. Stale docblock on getActiveGiftRulesForNearMiss() — The method's docblock still documents the reverted behavior: "an empty $reqProductIds is intentionally NOT short-circuited… which is the desired 'spend €X to earn a gift' empty-cart headline" (ecommercen/eshop/models/Adv_gifts_model.php:486-489). However, the PR #80 revert added empty-cart guards at all three consuming layers: getNearMissGiftsForProductsInCart() (:807-809), LegacyGiftRuleEngine::getNearMissGiftsForProductsInCart() (src/Domains/Checkout/Gift/LegacyGiftRuleEngine.php:51-53), and CartGiftNearMissPresenter::present() (src/Domains/Checkout/Gift/CartGiftNearMissPresenter.php:58-60). The comment documents behavior the codebase deliberately prevents.


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.

Tests ​

Test FileCoverage
tests/Unit/Domains/Checkout/Gift/CartGiftPresenterTest.phpCart gift block presentation
tests/Unit/Domains/Checkout/Gift/CartGiftNearMissPresenterTest.phpNear-miss teaser computation
tests/Unit/Domains/Checkout/Gift/GiftMatcherTest.phpGift eligibility evaluation
tests/Unit/Domains/Checkout/OrderBasketBuilderGiftTest.phpGift row construction in basket
tests/Integration/Domains/Checkout/Gift/GiftCheckoutFlowTest.phpEnd-to-end gift rule flows
tests/Integration/Legacy/Eshop/AdvGiftsModelNearMissTest.phpLegacy near-miss discovery

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