Skip to content
CH SCShop classcommerce · advanced · ~150 min · 9 steps

How to debug Drupal Commerce price and promotion problems

The order refresh pipeline, adjustments, promotion evaluation order, and compatibility — how to find out exactly why a total is wrong instead of guessing at configuration.

August 10, 2026 · by Dane Petersen

“The discount is coming out wrong” is the most common advanced Commerce complaint, and the least useful sentence to debug from. Drupal Commerce totals aren’t a number — they’re the output of a pipeline, and a wrong total means some stage of that pipeline did something you didn’t expect. This lesson teaches the pipeline itself, and the inspection techniques that turn “wrong somehow” into “this promotion, this stage, this reason.” It pairs with the promotion math essay, which covers why stacked percentages surprise humans; this is the engineering underneath.

You’ll want drush, a local copy of the store, and a test order that reproduces the problem. Never debug pricing on production — you’ll be creating and mangling orders freely.

Learn the order refresh pipeline first

Every time a draft order is loaded, Commerce may refresh it: recalculate every price from scratch. The sequence: each order item’s unit price is recalculated (by price resolvers), then order processors run in priority order — promotions apply here, as do fees and taxes — each one adding adjustments to items or to the order. The total you see is subtotal + adjustments, nothing more. Internalize two consequences: a wrong total is always either a wrong unit price or a wrong adjustment, and anything you hand-set on a draft order can be silently overwritten on the next refresh — which explains a whole category of “it changed by itself” reports.

Reproduce it in one cart, then freeze the variables

Build the smallest cart that shows the wrong number: one product if possible, the exact coupon, the same customer role (promotions can be role-conditional). Note the expected total and the actual total. Then stop touching configuration — from here on you’re observing, not twiddling. Every config change mid-diagnosis resets your evidence.

Dump the adjustments — the total’s itemized receipt

The single most useful move in Commerce debugging:

drush php:eval "
\$order = \Drupal\commerce_order\Entity\Order::load(ORDER_ID);
foreach (\$order->getItems() as \$item) {
  echo \$item->label() . ' unit: ' . \$item->getUnitPrice() . PHP_EOL;
  foreach (\$item->getAdjustments() as \$adj) {
    echo '  [' . \$adj->getType() . '] ' . \$adj->getLabel() . ': '
      . \$adj->getAmount() . ' (source: ' . \$adj->getSourceId() . ')' . PHP_EOL;
  }
}
foreach (\$order->getAdjustments() as \$adj) {
  echo 'ORDER [' . \$adj->getType() . '] ' . \$adj->getLabel() . ': '
    . \$adj->getAmount() . ' (source: ' . \$adj->getSourceId() . ')' . PHP_EOL;
}
echo 'TOTAL: ' . \$order->getTotalPrice() . PHP_EOL;
"

This prints the receipt the checkout never shows: every adjustment, its type (promotion, fee, tax, custom), and — the gold — getSourceId(), which for promotions is the promotion entity ID. The wrong number is now attributable: either an adjustment you didn’t expect exists, an adjustment you expected is missing, or the amounts are individually right and your expectation of how they combine is wrong (sequential application — the 28% problem).

If an expected promotion is missing: walk its gates in order

A promotion applies only if every gate passes, and they fail silently. Check in this order, because it’s cheapest-first: status (enabled?), dates (start and end, in the store timezone), usage limits (total and per-customer — commerce_promotion_usage table tells the truth), order type and store (multi-store sites bite here), conditions (product/category/customer-role/order-total — remember condition operator AND/OR), coupon validity if it’s coupon-driven (exists, active, its own usage limits), and finally compatibility.

drush sql:query "SELECT promotion_id, usage_count FROM commerce_promotion_usage WHERE promotion_id = X"

Understand compatibility and ordering — the silent killers

Promotions evaluate in a defined order (weight on the promotion listing), and each carries a compatibility policy — roughly, whether it tolerates other promotions. Two failure patterns account for most mysteries. First: a promotion marked incompatible-with-others wins the evaluation slot and everything after it never runs — so the promotion you’re staring at is fine, and the culprit is a different promotion entirely, often an auto-apply one added months later. Second: ordering changes math — $10-off-then-20%-off and 20%-off-then-$10-off produce different totals, so a reordering that seemed cosmetic changes real prices. When a total changed “on its own,” diff the promotion list against what changed that week:

drush sql:query "SELECT promotion_id, status, changed FROM commerce_promotion_field_data ORDER BY changed DESC LIMIT 10"

If the unit price itself is wrong: suspect resolvers

When the base price is wrong before any promotion touches it, promotions are innocent — a price resolver is speaking. Stock Commerce resolves from the variation’s price field (and price lists if you use them), but contrib and custom modules register resolvers with priorities, and the highest priority wins silently. Inventory the suspects:

grep -rn "commerce_price.price_resolver" web/modules/custom web/modules/contrib --include="*.services.yml"

Anything custom in that list is the first interview. Classic symptoms: currency-dependent wrongness, role-dependent wrongness on the unit price, or a price that reverts on refresh because a resolver recomputes what someone hand-edited.

Trace a refresh when you need the full movie

When receipts and gates haven’t cracked it, watch the pipeline run. Xdebug with a breakpoint in the promotion order processor is the thorough path. The fast path is a temporary log inside a custom order processor registered at a late priority, dumping the adjustment state — crude, effective, deleted afterward. While tracing, remember the refresh triggers: order load in a draft state, cart page views, checkout steps. A “sometimes wrong” total is usually a refresh-timing problem — the order was viewed between a config change and checkout completion.

Write the regression test before you fix it

Advanced stores earn kernel tests. Before correcting the config or code, write the test that encodes the expected math:

$order = $this->createOrderWith(['PRODUCT-SKU' => 1]);
$this->applyCoupon($order, 'SUMMER10');
$this->assertEquals(new Price('72.00', 'USD'), $order->getTotalPrice());

(Sketch — build on commerce_order’s test traits.) The point: promotion bugs recur, because promotions keep being added by people who weren’t present for this debugging session. The test is the session’s memory. Untested work is a rumor — priced work doubly so.

Verify the fix like a customer, then re-arm the monitoring

Fix applied: run the real cart again — same products, same coupon, same role — and confirm the receipt via the adjustment dump, not just the checkout screen. Then place one live test transaction end to end, because staging and production configs drift. If this store is on a maintenance plan with commerce monitoring, add the failing combination to the monthly sanity checks: the combination that broke once is the one that breaks again. And if you got here because the store quietly lost a promotion for weeks and nobody noticed — that’s the argument for watching the promotion stack professionally stated better than any sales page could.

That's the lesson. Back to the shop for more — or if this is the chore your organization never gets to,that's literally what we're for.