magenxcommerce / module-price-history-graph-ql
Tracks a rolling daily snapshot of each product's final price and exposes the lowest price over a configurable look-back window (default 30 days) over GraphQL as `ProductInterface.price_history`, plus a `price_history_enabled` store-config flag. This is the EU Omnibus Directive (Art. 6a of Directive
Package info
github.com/magenxcommerce/module-price-history-graph-ql
Type:magento2-module
pkg:composer/magenxcommerce/module-price-history-graph-ql
Requires
- php: ~8.3.0||~8.4.0||~8.5.0
- magento/framework: *
- magento/module-catalog: *
- magento/module-catalog-graph-ql: *
- magento/module-customer: *
- magento/module-store: *
- magento/module-store-graph-ql: *
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.
Tracks a rolling price history per product and exposes the lowest price over a configurable look-back window (default 30 days) over GraphQL. This is the EU Omnibus Directive (Art. 6a of Directive 98/6/EC) prior price: the lowest price applied during at least the 30 days before a price reduction, which a trader must display whenever a reduction is announced.
Stock Magento only exposes point-in-time prices, so nothing records what a
product cost in the past. This module adds that record and surfaces it, without
touching any core module (the same companion-module split as DealGraphQl /
BestSellerGraphQl).
How it works
- Daily snapshot cron
magenx_price_history_snapshot(Cron/SnapshotPrices.php) copies each product's current price from the native price indexcatalog_product_index_priceintomagenx_price_history, one row per (product, website, customer group, day). The copy is walked inentity_idbatches rather than one statement, so a large catalog doesn't turn the nightly run into a single long transaction.- It captures
IF(final_price > 0, final_price, min_price):- Simple products →
final_price(post special-price + catalog-rule), the authoritative single-unit selling price — notmin_price(which would fold in tier / quantity-break prices). - Composite parents (configurable / bundle / grouped) have
final_price = 0in the index — the parent carries no own price and the storefront showsmin_price(the cheapest child's "from" price). Capturingfinal_pricethere stored €0.00, so the Omnibus disclosure rendered "Lowest price in the last 30 days: €0.00". These now capturemin_price, matching the displayed figure. A selected variant still gets its own precise history (children are simple products with their own index rows). - Genuinely free products (both
0) are skipped rather than storing a0.
- Simple products →
- All
(website, customer_group)rows are captured, because groups can be shown different prices and the prior price must match what that customer saw. - Re-runs on the same day keep the lowest value seen
(
ON DUPLICATE KEY UPDATE price = LEAST(...)), so an intraday markdown isn't lost. - The snapshot is skipped entirely while the feature is disabled in every scope, so a default install never accumulates rows nothing reads. Pruning still runs, so an existing table drains after the feature is switched off.
- Rows older than
window_days + 10are pruned (batched), wherewindow_daysis the longest window configured in any scope — a store on a 90-day window keeps 100 days of history even if the default scope is still 30.
- It captures
ProductInterface.price_historyis a batch resolver — a whole grid's products are resolved with oneMIN(price) ... GROUP BY product_idquery, so the field is safe on listing grids (category / search / carousels), not just the PDP. It returns{ lowest_price, currency, days, since }in the store's display currency, ornullwhen disabled or no history exists. TheMINignores non-positive rows (price > 0), so a legacy€0.00snapshot (a configurable parent captured before themin_pricefix) can't drag the result to€0.00— such a product resolves tonulluntil real prices accumulate. The field is also selected on each configurable variant product, so choosing a swatch on the PDP updates the prior-price disclosure to that specific child's history.StoreConfig.price_history_enabledmirrors the admin toggle.
{
products(filter: { sku: { eq: "24-MB01" } }) {
items {
price_history { lowest_price currency days since }
}
}
}
Configuration
Stores → Configuration → Magenx → Price History (EU Omnibus)
(magenx_price_history/...):
- Enable Prior-Price Tracking (
general/enabled, default off). - Look-back Window (days) (
general/window_days, default 30 — the EU minimum). - Snapshot Schedule (
cron/schedule, default0 3 * * *).
Operational notes
- Warm-up. The window fills in over the first N days the cron runs — a fresh install has no history, and the lowest price is only the minimum of what has been captured so far. Enable the module and let the cron run.
- Run after the price reindex. The snapshot is only as fresh as the last product-price reindex. If the price indexer runs on schedule, keep the snapshot cron (default 03:00) after the indexer's window.
- Indexer Dimensions mode. When the product-price indexer is dimension-based,
Magento refreshes the base
catalog_product_index_priceonly on full reindex; the cron reads that base table intentionally, so ensure it is populated. - Table growth. ≈
products × websites × customer_groups × (window_days + 10)rows; the prune keeps it flat. Size the DB accordingly. - Timezone. The snapshot day and the resolver's look-back cutoff are both
anchored to the default scope's timezone (
scopeDate(0)), so the 30-day window is exact at day boundaries. Anchoring matters because cron runs in the admin scope while the resolver runs in a store scope, and store views may each carry a differentgeneral/locale/timezone.
Verification
php -l Cron/SnapshotPrices.php # (and the other Model/*.php) bin/magento setup:upgrade # creates magenx_price_history + unique key + indexes bin/magento cron:run --group=default # then query price_history over GraphQL and confirm a 24-item grid issues a # single MIN(...) GROUP BY product_id query (no per-card fan-out).