magenxcommerce / module-deal-graph-ql
Extends Magento Catalog Price Rules with a merchandising deal label + type (deal / first-order) and exposes them over GraphQL: a `deals` query, a per-product `deal` badge (label, type, authoritative deal price from the rule, schedule/countdown) for grids and the PDP, and a `deals_enabled` store-conf
Package info
github.com/magenxcommerce/module-deal-graph-ql
Type:magento2-module
pkg:composer/magenxcommerce/module-deal-graph-ql
Requires
- php: ~8.3.0||~8.4.0||~8.5.0
- magento/framework: *
- magento/module-backend: *
- magento/module-catalog: *
- magento/module-catalog-graph-ql: *
- magento/module-catalog-rule: *
- magento/module-config: *
- magento/module-customer: *
- magento/module-graph-ql: *
- magento/module-quote: *
- magento/module-quote-graph-ql: *
- magento/module-sales: *
- magento/module-sales-rule: *
- magento/module-store: *
- magento/module-store-graph-ql: *
- magento/module-ui: *
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Built with AI assistance. This module is built with significant use of Claude Code, under active human review and guidance. It is open source and in active development. AI assistance does not lower the engineering bar: every change must meet the same correctness, security, documentation and maintainability requirements as any other contribution.
No warranty. We are not responsible for any incompatibility. By installing this software, you accept responsibility for its security, upgrades, patches and monitoring.
Turns Magento Catalog Price Rules into storefront deals and exposes them over GraphQL. Stock Magento already has everything a deal needs — product conditions, a discount action, a schedule, website + customer-group scope and a priority — in a Catalog Price Rule; what it lacks is a label, a type, and any GraphQL to read it back for a headless storefront. This module adds exactly that, without touching any core module and without a second place to configure deals: you set the discount and the deal label on the same rule.
The idea
A "deal" is any active Catalog Price Rule that has a Magenx deal label. The rule remains the single source of truth:
- Conditions / actions / pricing — the rule's own product conditions and
discount action. Magento already indexes which products the rule applies to
(
catalogrule_product) and computes the discounted price, so this module never re-implements matching or pricing:deal_priceis the product's actual rule-aware final price (what the cart charges),regular_priceits pre-rule price. - Schedule — the rule's From/To dates drive the storefront countdown.
- Scope / priority — the rule's websites, customer groups and priority decide who sees the deal and which rule wins when several match a product.
On top of the rule this module appends a small fieldset to the Catalog Price Rule edit form: a Deal Badge Label, optional Per-Store-View Badge Labels (for language-specific text — see below), and a Deal Type (deal / first order). Deal is the generic type — its free-text label, conditions and schedule live on the rule, so it covers flash sales and special offers without a dedicated type; First Order is distinct because it drives the customer-group automation below. Leave the label empty and the rule is an ordinary price rule with no deal presentation.
What it adds
deals(type: DealType, pageSize: Int): Deals— products currently on a labelled rule for the request's website + customer group, optionally filtered to products matched by a rule of oneDealType. EachDealItemresolves a fullProductInterface; select itsdealfield for the metadata. All items are hydrated by ONE product collection load (a batch resolver selecting only the requested attributes, like core related/upsell lists), and a product not visible in the catalog — disabled, a configurable's simple child, off the website — resolves tonull.ProductInterface.deal: DealInfo— the winning deal matched to a product (ornull):label,type,discount_type,discount_value,regular_price,deal_price,percent_off,currency,starts_at,ends_at. The lookup is bounded and request-cached: the labelled rules are read once per request (a handful of rows), and when a store has none — the common case — the field short circuits tonullwith no further query at all. Otherwise the field, a batch resolver, matches every product of a query branch (a grid, a related/upsell list) againstcatalogrule_productin ONE query on itsproduct_idindex, with hits and misses cached, so — unlike an EAV fan-out — it is safe to select on listing grids (the "DEAL" badge renders on category/search cards, not only the PDP). Thedealsquery warms that cache for the whole page in one query, and never pulls more rows thanpageSize.StoreConfig.deals_enabled— mirrors the enable flag (viaextendedConfigData) so the storefront can hide the Deals menu link / badges without a second round trip.
Per-store-view (language) badge labels
The badge label is translatable. The Deal Badge Label is the default/fallback
(and the switch that makes a rule a deal — leave it empty and the rule is an
ordinary price rule). Under it, Per-Store-View Badge Labels is a repeatable
grid where you add one row per store view to override the text for that language
(e.g. SALE for the English view, ANGEBOT for German). A store view with no row
falls back to the default label. The per-product deal resolver picks the
override for the request's store view (DealProvider::resolveLabel), so the badge
reads in the shopper's language on the PDP, grids and cart.
How the fields are stored
Three nullable columns are added to the core catalogrule table via
db_schema.xml: magenx_deal_label (default label) and magenx_deal_type
(scalars), plus magenx_deal_label_store — a text column holding a JSON list
of {store_id, label} overrides. The fields are added to the rule's edit form
declaratively through the view/adminhtml/ui_component/catalog_rule_form.xml UI
component (the Catalog Price Rule form is a UI component, not a legacy
Data\Form, so this is the correct hook — a _prepareForm/afterSetForm block
plugin would never render); the overrides use a dynamicRows grid with a
store-view select (Model/Config/Source/StoreView).
On save the Rule::loadPost plugin (Plugin/PersistDealFields) copies the
scalars across and JSON-encodes the overrides grid (core loadPost maps only
conditions/actions, so the custom fields need it). The scalars prefill straight
from the rule collection on edit; the JSON overrides column is decoded back into
dynamicRows records by Plugin/PrefillDealLabelStore (an afterGetData plugin
on the form's Magento\CatalogRule\Model\Rule\DataProvider).
First-order deals (customer-group automation)
The first_order deal type is only a label unless something tracks who is a
first-time customer. Magento gates a Catalog Price Rule by customer group, so
this module can make a first-order deal real by moving customers between groups —
no third-party module, no cron:
- On registration a new customer is placed in a dedicated first-order
group. This hooks
AccountManagementInterface::createAccount, the one seam every channel funnels through — storefront, GraphQLcreateCustomer, andMagenx_SocialLoginGraphQl— so it covers them all. An explicit non-default registration group (e.g. Wholesale) is never overridden. - On their first order (
sales_model_service_quote_submit_success, which fires for GraphQLplaceOrdertoo) they are moved back to the default group. This runs after totals are collected, so the first order keeps the discount; only later carts lose it.
A Catalog Price Rule scoped to the first-order group and labelled with Deal Type = First Order then shows its badge/price only until the customer's first order — after which the group flips and, because catalog-rule prices are pre-indexed per group, the badge disappears on the next request with no reindex.
Setup:
- Create a customer group, e.g. New Customers (same tax class as General).
- Stores → Configuration → Magenx → Deals → First-Order Deal: enable it, set the first-order group, the group after first order (General), and the default registration group (General).
- Create a Catalog Price Rule scoped to the New Customers group with the discount + Deal Label + Deal Type = First Order; apply it.
Caveats: guests can't be in a group, so this targets shoppers who register before ordering (a guest who registers at checkout won't see it — their cart was priced as a guest). A cancelled/refunded order does not re-add the group. Existing zero-order customers are not back-filled (forward-only). Off by default.
Gift deals (free gift with a Cart Price Rule)
A Cart Price Rule with a Free Gift SKU (and optional Free Gift
Quantity, default 1) adds that product to the cart at price 0 while the rule
applies, and removes it again when the rule stops applying — the subtotal drops,
the qualifying product is removed, the coupon is taken off. The rule owns the
conditions, schedule, website/customer-group scope and coupon; this module never
evaluates conditions itself, it reads the quote's applied_rule_ids after Magento
has collected the totals.
- How it runs. A
beforeSaveplugin onCartRepositoryInterfacesyncs the gift lines on every cart save (adding, updating or removing items, applying a coupon, setting an address — GraphQL, REST and Luma alike), so the gift line is saved in the same write. With gift deals off, or no rule carrying a gift SKU, the plugin returns before doing anything. A gift failure (unknown SKU, out of stock, a configurable product) is logged and skipped; it never fails the cart. - The gift line. An ordinary quote item with the
magenx_deal_gift_ruleoption (the rule id) and a custom price of 0, so it is its own line even when the same product is also bought, and it reaches the order as a 0-priced line. Only simple, virtual and downloadable products can be gifts. Its quantity is the rule's: a changed quantity is put back on the next save. - Removable. If the shopper removes the gift, the rule id is recorded on the
quote (
quote.magenx_declined_gift_rules) and the gift is not re-added to that cart. - Rule setup. Leave the rule's Actions conditions empty (so it applies to the cart) and give it the discount you want, or none. Give it a store label — that is the text the storefront shows, e.g. "Free tote bag with orders over €50".
- GraphQL.
CartItemInterface.deal_gift { rule_id label }marks the gift line.giftDealslists the gift rules live now without a coupon for the request's website + customer group (coupon rules are never advertised), each with its giftproduct,gift_qty,labelandends_at.
Off by default: Stores → Configuration → Magenx → Deals → Gift Deals. Turning it off removes any gift still in a cart on that cart's next change.
Requirements
- The Catalog Price Rule index must have run (rule save reindexes it; or
bin/magento cron:run/ indexer). Until thencatalogrule_productis empty and no deals resolve — the module degrades to an empty list, never an error. - Because the lookup is per website + customer group, a deal only shows to the groups/websites its rule targets — consistent with the price the storefront displays.
Configuration
- Stores → Configuration → Magenx → Deals → General: Enable Deals; Maximum
Products on the Deals Page (default 48). When disabled the
dealsquery returns empty anddealresolves tonull, so the feature is fully inert. - Stores → Configuration → Magenx → Deals → First-Order Deal: the customer-group automation described above (off by default).
- Marketing → Catalog Price Rules → (a rule) → Deal Display: set the label + type to turn that rule into a deal.
- Stores → Configuration → Magenx → Deals → Gift Deals: enable gift deals (off by default).
- Marketing → Cart Price Rules → (a rule) → Free Gift SKU / Quantity: turn that rule into a gift deal.
Install
bin/magento module:enable Magenx_DealGraphQl
bin/magento setup:upgrade
bin/magento setup:di:compile # production mode