Migrating Legacy Promotions to the Promotion Engine Without Breaking Old Orders
The migration modern upgrades can no longer postpone: mapping legacy promotion types to rule-engine equivalents, why carts migrate themselves, the order recalculation cronjob with its integrity check, and what to do about orders you cannot touch.
The legacy promotions and voucher extensions were deprecated back in the 6.x era, and every modern platform version runs the Drools-based promotion engine exclusively. Why publish a migration guide in 2026? Because the long tail is real: a meaningful share of the systems now being dragged from old Hybris versions to 2211 (ahead of the on-premise maintenance cliff) still run legacy promotions, and the migration is the single gnarliest data problem in those upgrades. There was never an automated migration tool for promotions, and there never will be; the customization level made one impossible. This guide is the manual, updated with what years of these migrations taught.
The problem has three distinct parts, and mixing them up is where projects go wrong: migrating the promotion definitions (configuration work), migrating live carts (which turns out to be free), and migrating historical orders (which is the hard part, because money is attached).
First: Check the Functional Gap#
Almost every legacy promotion type has a promotion-engine equivalent: order threshold discounts, free gifts, delivery-mode changes, perfect partner variants, bundles, BOGOF, multi-buy, fixed price, percentage discounts. The one true gap that survived: stepped multi-buy cannot be expressed as a single rule. You can approximate it with one rule per step, but not encapsulate it in one promotion, and orders carrying a legacy stepped multi-buy cannot be cleanly migrated at all. Inventory your legacy promotion types against this before planning anything; a catalog of stepped multi-buys changes the strategy (and the conversation with the business about simplification).
Recreating the Definitions#
Every legacy promotion that is either currently active or attached to an order you intend to keep modifiable needs an equivalent PromotionSourceRule. Two ways to create them: by hand in Backoffice, or via ImpEx for bulk and repeatability. Name each new rule with the legacy promotion's code; nothing else links the two worlds, and the order-migration job depends on that lookup being trivial.
The ImpEx shape (conditions and actions are JSON structures referencing rule definition ids; one representative example, a 10 percent discount on a specific product):
$defaultPromoGrp = electronicsPromoGrp
INSERT_UPDATE PromotionSourceRule; code[unique=true]; priority; maxAllowedRuns; stackable[default=false]; ruleGroup(code); conditions; actions; website(Identifier)[default=$defaultPromoGrp]
; 10DiscountCanonEOS450D ; 500 ; 1 ; true ; productPromotionRuleGroup ; [{"definitionId":"y_qualifying_products","parameters":{"products_operator":{"type":"Enum(de.hybris.platform.ruledefinitions.CollectionOperator)","value":"CONTAINS_ANY"},"quantity":{"type":"java.lang.Integer","value":1},"products":{"type":"List(ItemType(Product))","value":["1382080"]}},"children":[]}] ; [{"definitionId":"y_order_entry_percentage_discount","parameters":{"value":{"type":"java.math.BigDecimal","value":10.00}}}] ;
The full palette of definitionIds covers the whole legacy catalog: y_cart_total for thresholds, y_free_gift, y_change_delivery_mode for free shipping, y_container pairs with y_partner_order_entry_fixed_price for perfect-partner and BOGOF mechanics, y_target_bundle_price for bundles and multi-buy. Build the rules in a lower environment, export them as ImpEx, and promote that file; hand-recreating rules per environment is how staging and production end up disagreeing about discounts.
While translating, apply the modeling hygiene from the promotion performance guide: legacy promotions with hundreds of directly attached products become rules conditioned on virtual categories, and legacy "promotions" that are really flat markdowns become discount rows instead of rules at all. The migration is the one chance to shed a decade of promotion debt; take it.
The Cutover Sequence#
The switch is done under scheduled downtime, because half-migrated promotion state is customer-visible:
- Close the site to customers.
- Swap the extension set in
localextensions.xml: legacy out (promotions,voucher), engine in (ruleengineservices-family,promotionengineservices, the engine Backoffice extensions). ant clean all, then a system update including the rule engine family's project data (ruleengine, ruleengineservices, ruledefinitions, droolsruleengineservices, promotionengineservices, and the relevant Backoffice extensions), which creates the essential data the engine needs.- Import the new rules and publish them.
- Verify on the closed site that carts pick up the new promotions.
- Run the order migration (below).
- Republish the correct active rule set, reopen the site.
Do it first on a production-data copy, twice: once to find the surprises, once to time the runbook.
Carts: The Free Migration#
Live carts referencing legacy promotions need no dedicated migration, and understanding why prevents you from building one. Promotion results on a cart are not authoritative; they are recomputed. When the customer returns, the cart loads from the database and recalculates; checkout recalculates again. Recalculation removes all previous promotion results and re-evaluates against the currently published rules. Provided the equivalent rules are published before the site reopens (step 4 above), every returning cart silently swaps its legacy results for engine results, and the customer sees the same discount with new plumbing. This is also the reason step 4 is not optional: reopen without the equivalents published and every returning cart loses its discount, which is a support-ticket storm with your name on it.
Orders: The Actual Problem#
Historical orders hold references to legacy promotion results, and orders are not archives; returns, refunds, cancellations, and edits all trigger recalculation. Recalculating an order whose promotion no longer exists strips the discount and changes the order total, which is a financial-integrity incident, not a bug.
Two strategies, honestly compared:
Leave old orders untouched. Zero migration effort, but every legacy-promotion order becomes read-only forever: any modification recalculates, drops the promotion, and alters the total; and once the legacy extension is fully gone, such orders will not even open cleanly. Viable only for orders old enough that no modification will ever be needed, which is why the real decision is a time window: pick the age beyond which orders are operationally dead (commonly six months to a year, driven by your return and dispute policies), archive those, and migrate the rest.
Recalculate orders onto the new rules. Keeps orders modifiable and lets you eventually sever legacy dependencies. The costs: every legacy promotion appearing on in-window orders needs its equivalent rule, and recalculation re-runs everything, including taxes, so an order whose tax rates changed since placement will change total even with a perfectly equivalent promotion. That is not a defect in your mapping; it is why the integrity check below exists.
The migration cronjob#
The proven mechanism is a one-shot cronjob that recalculates each affected order with only that order's promotions enabled. The shape:
- Select orders with legacy promotion results (optionally bounded by the time window):
"SELECT DISTINCT({pr." + PromotionResultModel.ORDER + "}) FROM {"
+ PromotionResultModel._TYPECODE + " AS pr JOIN "
+ OrderModel._TYPECODE + " AS o ON {o.pk} = {pr.order}}"
- Sanity-check the mapping first: every legacy promotion code found on those orders must resolve to a published equivalent rule (
platformRuleEngineService.getRuleForCode(code)). Decide the miss policy (abort versus log-and-skip) before running, not when it fires. - Disable all published rules. This is the counterintuitive core of the design: with the full rule set active, recalculation could apply promotions the order never had.
- Per order: enable exactly the equivalent rules for the promotions that order carried, record the pre-migration total, recalculate (promotions and taxes re-applied), compare totals. Mismatch means log the order for manual review and move on; matching means done. Disable the rules again and take the next order.
- Finish with SUCCESS only if zero orders errored, and republish the real active rule set before reopening the site, because the job's disable-everything discipline leaves the engine with almost nothing enabled: forgetting this step reopens a site with no promotions at all, which users notice with remarkable speed.
The mismatch bucket (tax changes, discontinued products, rounding) is not failure; it is the work surfacing. Size it in the rehearsal run, and prepare the customer-service guidance for what to do when someone opens one of these orders, because "it depends on company policy" is only an acceptable answer before go-live.
Endgame: Severing the Legacy Extension#
Even fully migrated, the legacy promotions extension cannot simply be deleted; platform code (commerceservices, the promotion Backoffice tooling) retained dependencies on it for years. What you control is your data's dependency: no orders referencing legacy promotion results, no un-migratable multi-step promotions lingering, everything in-window recalculated. Reach that state and the extension becomes inert baggage that platform upgrades progressively strip, instead of a landmine under your order history.
Archive the out-of-window orders on the same schedule (the data maintenance guide's retention machinery applies), and the books close on the legacy engine for good: one less deprecated subsystem, and, more to the point, an order history that can survive the next decade of recalculations.