Appearance
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 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/promotion/gift/active | Guest | Currently-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/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 |
| GET | /rest/cart | Guest | Cart 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-order | Guest/Customer | Accepts 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 viaOrderBasketBuilder::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, addedAFTER 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 onaffected_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
ifbranch inAdv_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-stockreturnOrderStock()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 onset_status(). The call lands inAdv_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 owntrans_start()/trans_complete()transaction. Whentrans_status()comes backfalseit logs the failure vialog_message('error', ...)and returns normally rather than throwing — the rollback has already re-armedgifts_appliedby that point, so a later cancellation can still restore the units, without a throw's side effect of propagating out ofset_status()and aborting the rest of a batch cancel (Adv_order_model::setBatchCanceled(),:3174-3228).Adv_gifts_model::restoreCounter()restores with one atomicUPDATE gifts SET remaining = remaining + ? WHERE id = ? AND remaining IS NOT NULL(deliberately not the read-then-write shapeupdateCounter()uses — see Known Issues item 1). - Modern path: a new
RestoreGiftStockOnCanceledListeneronOrderCanceled(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, sinceOrderEventDispatcherswallows listener exceptions per-listener.Gift\WriteRepository::restoreRemaining()mirrors the legacy method's atomic guardedUPDATE. - Only
CANCELEDtriggers the restore —RETURNdoes not.RETURNis not dormant — a cron job writes it wherever Europharmacy is enabled (AdvSyncOrdersStatusEuropharmacy::getOrderStatusID(),ecommercen/job/libraries/AdvSyncOrdersStatusEuropharmacy.php:108-109, maps vendor code7to it; the job now returns early unless registryEUROPHARMACY.ENABLEDis truthy,:52-55, and reads its config viaConfig::fromRegistry,:35-37), butAdv_order_model::update_order()(ecommercen/eshop/models/Adv_order_model.php:957-959) only routes intoset_status(..., 'CANCELED', ...)when the incoming status isCANCELED; aRETURNwrite 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 —RETURNIS written" section. - No restore for orders placed before this deploy.
gifts_applieddefaults to0, 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-restoresproduct_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:
| Rule | Dimension | What's reported |
|---|---|---|
| 1, 2, 11, 12, 13 | quantity | Distance to the next gift_per_count tier (covers both "never earned" and "earned N, upsell to N+1" via one modulo formula) |
| 3 | amount | Strictly-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, 6 | amount OR quantity | Two-stage rules report whichever gate (cart-total or product/vendor quantity) is the actual blocker — never both |
| 5, 7 | amount | Distance to threshold on the cumulative value of the specific product/vendor requirement list (same strictly-exceed +0.01 semantics as rule 3) |
| 10 | quantity | Headline 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
| 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:41onward) 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:1636-1652) does a non-transactional read-then-write, soremainingcan transiently go negative under concurrency, self-correcting on the next evaluation (negativeremainingfails the>0filter).
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-
UPDATEshape on both paths and does not shareupdateCounter()'s read-then-write race.- 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.Stale docblock on
getActiveGiftRulesForNearMiss()— The method's docblock still documents the reverted behavior: "an empty$reqProductIdsis 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), andCartGiftNearMissPresenter::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
| 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.
Tests
| Test File | Coverage |
|---|---|
tests/Unit/Domains/Checkout/Gift/CartGiftPresenterTest.php | Cart gift block presentation |
tests/Unit/Domains/Checkout/Gift/CartGiftNearMissPresenterTest.php | Near-miss teaser computation |
tests/Unit/Domains/Checkout/Gift/GiftMatcherTest.php | Gift eligibility evaluation |
tests/Unit/Domains/Checkout/OrderBasketBuilderGiftTest.php | Gift row construction in basket |
tests/Integration/Domains/Checkout/Gift/GiftCheckoutFlowTest.php | End-to-end gift rule flows |
tests/Integration/Legacy/Eshop/AdvGiftsModelNearMissTest.php | Legacy near-miss discovery |
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.