From 71e03365d5d79787d00e64e01a9032dd1bb938fa Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Sun, 26 Jul 2026 17:15:26 +0100 Subject: [PATCH 1/8] WIP recalculation fix --- src/elements/Order.php | 11 +- tests/unit/adjusters/ShippingTest.php | 303 ++++++++++++++++++++++++++ 2 files changed, 312 insertions(+), 2 deletions(-) create mode 100644 tests/unit/adjusters/ShippingTest.php diff --git a/src/elements/Order.php b/src/elements/Order.php index e74d9e345c..8e51e72c9c 100644 --- a/src/elements/Order.php +++ b/src/elements/Order.php @@ -1782,8 +1782,15 @@ public function updateOrderPaidInformation(): void $this->trigger(self::EVENT_AFTER_ORDER_AUTHORIZED); } - // restore recalculation lock state - $this->setRecalculationMode($originalRecalculationMode); + // Restore the recalculation lock state, unless this call just completed the order. + // A completed order must never be left able to recalculate its adjustments again, so + // its mode stays locked at `RECALCULATION_MODE_NONE` rather than reverting to whatever + // mode it was in as a cart. If the order didn't complete here - e.g. a partial payment + // or authorization that didn't fully cover the total - it's still a cart, and the + // customer must still be able to edit and recalculate it, so the original mode is restored. + if (!$this->isCompleted) { + $this->setRecalculationMode($originalRecalculationMode); + } } /** diff --git a/tests/unit/adjusters/ShippingTest.php b/tests/unit/adjusters/ShippingTest.php new file mode 100644 index 0000000000..f932329cbe --- /dev/null +++ b/tests/unit/adjusters/ShippingTest.php @@ -0,0 +1,303 @@ + + */ +class ShippingTest extends Unit +{ + /** + * @var UnitTester + */ + protected UnitTester $tester; + + /** + * @var Plugin|null + */ + protected ?Plugin $pluginInstance = null; + + /** + * Toggled mid-test to simulate the third-party plugin's registration + * handler starting to match the order, then failing to on a later call. + * + * @var bool + */ + private bool $_thirdPartyMethodMatches = true; + + /** + * @var int[] Element IDs created directly by test methods (not fixtures), for cleanup. + */ + private array $_deleteElementIds = []; + + /** + * @return array + */ + public function _fixtures(): array + { + return [ + 'products' => [ + 'class' => ProductFixture::class, + ], + ]; + } + + /** + * @inheritdoc + */ + protected function _before(): void + { + parent::_before(); + + $this->pluginInstance = Plugin::getInstance(); + $this->_thirdPartyMethodMatches = true; + + // No discounts in play; keeps the test isolated from DB fixture state. + $this->pluginInstance->set('discounts', $this->make(Discounts::class, [ + 'getAllActiveDiscounts' => fn() => [], + ])); + + // Simulate a third-party plugin that registers a shipping method by + // re-evaluating live availability every time it's asked, instead of + // returning a static, persisted method. + Event::on( + ShippingMethods::class, + ShippingMethods::EVENT_REGISTER_AVAILABLE_SHIPPING_METHODS, + function(RegisterAvailableShippingMethodsEvent $event) { + $shippingMethods = $event->getShippingMethods(); + $shippingMethods->push($this->make(ShippingMethod::class, [ + // Third-party plugins have to set this themselves on the methods they + // register (e.g. Postie's `Service::registerShippingMethods()` does the + // same) - `Order::getAvailableShippingMethodOptions()` silently drops any + // `ShippingMethod` instance whose `storeId` doesn't match the order's. + 'storeId' => $event->order->storeId, + 'handle' => 'thirdPartyFlatRate', + 'name' => 'Third Party Flat Rate', + 'getIsEnabled' => true, + 'getMatchingShippingRule' => fn() => null, + 'getPriceForOrder' => fn() => 8.99, + 'matchOrder' => fn() => $this->_thirdPartyMethodMatches, + ])); + } + ); + } + + /** + * @inheritdoc + */ + protected function _after(): void + { + Event::off(ShippingMethods::class, ShippingMethods::EVENT_REGISTER_AVAILABLE_SHIPPING_METHODS); + + foreach ($this->_deleteElementIds as $elementId) { + Craft::$app->getElements()->deleteElementById($elementId, null, null, true); + } + $this->_deleteElementIds = []; + + parent::_after(); + } + + /** + * Building block: confirms `Shipping::adjust()` in isolation does exactly + * what it should when a registered method stops matching - drop the + * adjustment, no error. This is *correct* behaviour for a cart, and is + * not, by itself, the bug. + */ + public function testAdjusterDropsAdjustmentWhenMethodDoesNotMatch(): void + { + $lineItem = $this->make(LineItem::class, [ + 'id' => 1, + 'qty' => 1, + 'price' => 50, + 'getIsShippable' => true, + ]); + + $order = new Order(); + $order->shippingMethodHandle = 'thirdPartyFlatRate'; + $order->setLineItems([$lineItem]); + + $adjuster = new Shipping(); + + $firstPass = $adjuster->adjust($order); + self::assertCount(1, $firstPass, 'Shipping adjustment should be present while the method matches.'); + self::assertEquals(8.99, $firstPass[0]->amount); + + $this->_thirdPartyMethodMatches = false; + + $secondPass = $adjuster->adjust($order); + self::assertSame([], $secondPass, 'No adjustment, no exception - this part is expected.'); + } + + /** + * Confirms the fix: a real order that has already been completed *and + * paid in full* - including the third-party shipping cost - stays + * locked against recalculation, even when the third-party plugin's + * handler later stops matching. Before the fix, `recalculate()` would + * still run in `RECALCULATION_MODE_ALL` here and silently drop the + * shipping cost from a paid order. + * + * @throws Throwable + */ + public function testCompletedAndPaidOrderStaysLockedAgainstRecalculation(): void + { + // A real, saved cart order - `recalculate()` requires a saved order, + // and only a real element exercises `afterSave()`/`markAsComplete()`/ + // `updateOrderPaidInformation()` the way production code does. + $order = new Order(); + Craft::$app->getElements()->saveElement($order, false); + $this->_deleteElementIds[] = $order->id; + + $variant = Variant::find()->indexBy('sku')->all()['hct-white']; + $lineItem = $this->pluginInstance->getLineItems()->create($order, [ + 'purchasableId' => $variant->id, + 'qty' => 1, + 'note' => '', + ]); + $order->setLineItems([$lineItem]); + $order->shippingMethodHandle = 'thirdPartyFlatRate'; + + $gateway = $this->pluginInstance->getGateways()->getGatewayByHandle('dummy'); + $order->gatewayId = $gateway->id; + + // Cart is untouched, so recalculation mode defaults to `ALL` - this + // is the same state the order is in throughout a normal checkout. + self::assertEquals(Order::RECALCULATION_MODE_ALL, $order->getRecalculationMode()); + + // Checkout: the third-party method matches, its cost gets applied and persisted. + $order->recalculate(); + Craft::$app->getElements()->saveElement($order, false); + + $totalCollected = $order->getTotalPrice(); + self::assertGreaterThan(0, $order->getTotalShippingCost(), 'Sanity check: shipping cost was applied before payment.'); + + // Customer pays the full amount shown at checkout, including shipping. + $transaction = $this->pluginInstance->getTransactions()->createTransaction($order, typeOverride: TransactionRecord::TYPE_PURCHASE); + $transaction->status = TransactionRecord::STATUS_SUCCESS; + $this->pluginInstance->getTransactions()->saveTransaction($transaction); + + // The order is now completed and paid in full... + self::assertTrue($order->isCompleted); + self::assertFalse($order->hasOutstandingBalance()); + self::assertEquals($totalCollected, $order->getTotalPaid()); + + // ...and, with the fix, stays locked at `NONE` rather than being + // restored to the `ALL` mode it had as a cart. + self::assertEquals( + Order::RECALCULATION_MODE_NONE, + $order->getRecalculationMode(), + 'A completed order must stay locked against recalculation.' + ); + + // Some time later - a queue job, a webhook, a fulfillment plugin + // reacting to payment completion - something recalculates this same + // completed order again. This time the third-party plugin's + // registration handler fails to match (its own route/context check + // fails outside the checkout flow, or its live rate lookup errors). + // None of that matters now, because recalculation is locked out. + $this->_thirdPartyMethodMatches = false; + + $order->recalculate(); + + // Nothing changed: still completed, still paid, shipping cost and + // handle untouched, no "shippingMethodChanged" notice. + self::assertTrue($order->isCompleted); + self::assertEquals($totalCollected, $order->getTotalPrice()); + self::assertEquals($totalCollected, $order->getTotalPaid()); + self::assertEquals('thirdPartyFlatRate', $order->shippingMethodHandle); + self::assertFalse($order->hasNotices('shippingMethodChanged')); + } + + /** + * Confirms the fix doesn't regress the case it needs to leave alone: a + * cart that receives a payment/authorization update but does *not* + * complete as a result (e.g. a partial payment) must remain fully + * editable and recalculable, exactly as before the fix. + * + * @throws Throwable + */ + public function testUpdatingPaidInformationWithoutCompletingStaysRecalculable(): void + { + $order = new Order(); + Craft::$app->getElements()->saveElement($order, false); + $this->_deleteElementIds[] = $order->id; + + // A real, priced line item - an empty cart has a $0 total, which is + // trivially "paid in full" with no outstanding balance. That's not + // what we're testing here: this needs a genuine amount still owing. + $variant = Variant::find()->indexBy('sku')->all()['hct-white']; + $lineItem = $this->pluginInstance->getLineItems()->create($order, [ + 'purchasableId' => $variant->id, + 'qty' => 1, + 'note' => '', + ]); + $order->setLineItems([$lineItem]); + $order->recalculate(); + Craft::$app->getElements()->saveElement($order, false); + + self::assertTrue($order->hasOutstandingBalance(), 'Sanity check: the order has an amount still owing.'); + self::assertEquals(Order::RECALCULATION_MODE_ALL, $order->getRecalculationMode()); + + // Nothing paid or authorized, so this cannot complete the order - it + // still exercises the same lock/restore logic `updateOrderPaidInformation()` + // runs through on every payment/authorization update, successful or not. + $order->updateOrderPaidInformation(); + + self::assertFalse($order->isCompleted, 'Sanity check: nothing was paid, so the order has not completed.'); + self::assertEquals( + Order::RECALCULATION_MODE_ALL, + $order->getRecalculationMode(), + 'A cart that receives a payment update without completing must remain fully recalculable.' + ); + } +} From 9ce2e7d279a2f7733ceac8cf851dbdb5ced066db Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Fri, 7 Aug 2026 08:35:33 +0100 Subject: [PATCH 2/8] Further test --- src/elements/Order.php | 8 +- tests/unit/adjusters/ShippingTest.php | 246 +++++++++++++++++++------- 2 files changed, 186 insertions(+), 68 deletions(-) diff --git a/src/elements/Order.php b/src/elements/Order.php index 8e51e72c9c..21ec63db6e 100644 --- a/src/elements/Order.php +++ b/src/elements/Order.php @@ -1782,12 +1782,8 @@ public function updateOrderPaidInformation(): void $this->trigger(self::EVENT_AFTER_ORDER_AUTHORIZED); } - // Restore the recalculation lock state, unless this call just completed the order. - // A completed order must never be left able to recalculate its adjustments again, so - // its mode stays locked at `RECALCULATION_MODE_NONE` rather than reverting to whatever - // mode it was in as a cart. If the order didn't complete here - e.g. a partial payment - // or authorization that didn't fully cover the total - it's still a cart, and the - // customer must still be able to edit and recalculate it, so the original mode is restored. + // Restore the original recalculation mode, unless this call completed the order + // a completed order must stay locked at `RECALCULATION_MODE_NONE` rather than reverting to its cart mode. if (!$this->isCompleted) { $this->setRecalculationMode($originalRecalculationMode); } diff --git a/tests/unit/adjusters/ShippingTest.php b/tests/unit/adjusters/ShippingTest.php index f932329cbe..c86251a9e7 100644 --- a/tests/unit/adjusters/ShippingTest.php +++ b/tests/unit/adjusters/ShippingTest.php @@ -7,6 +7,7 @@ namespace craftcommercetests\unit\adjusters; +use Codeception\Stub\Expected; use Codeception\Test\Unit; use Craft; use craft\base\Event; @@ -21,38 +22,34 @@ use craft\commerce\services\Discounts; use craft\commerce\services\ShippingMethods; use craftcommercetests\fixtures\ProductFixture; +use ReflectionMethod; use Throwable; use UnitTester; /** * ShippingTest * - * Covers a real-world bug report: a third-party shipping method plugin - * registers its shipping methods dynamically via - * {@see ShippingMethods::EVENT_REGISTER_AVAILABLE_SHIPPING_METHODS} every - * time `getMatchingShippingMethods()` is called, rather than storing a - * static, persisted method. That, by itself, is fine - a plugin not matching - * an order is expected, ordinary behaviour for a cart. + * Covers a real-world bug: a third-party shipping method plugin registers + * its methods dynamically via + * {@see ShippingMethods::EVENT_REGISTER_AVAILABLE_SHIPPING_METHODS} on every + * call, rather than a static, persisted method. A method later failing to + * match is normal cart behaviour and not the bug on its own. * - * The actual bug was that this could happen to an order that had *already - * been completed and paid for*. `Order::updateOrderPaidInformation()` locks - * `recalculationMode` to `NONE` for the duration of marking an order - * complete, but was then unconditionally restoring whatever mode the order - * was in *before* it completed - `RECALCULATION_MODE_ALL`, since it was a - * cart a moment ago. If anything saved/recalculated that same order - * afterwards (a queue job, a webhook, a fulfillment plugin hooking a - * payment-complete event), Commerce would wipe and rebuild every adjustment - * on an order that had already been paid in full - and if the third-party - * shipping method no longer matched at that point, the shipping cost would - * silently disappear from a completed order, leaving it looking "overpaid" - * relative to what was actually collected. + * The real bug: this could happen to an order that had *already completed + * and been paid for*. `Order::updateOrderPaidInformation()` locks + * `recalculationMode` to `NONE` while completing an order, but then + * unconditionally restored whatever mode it had before - `ALL`, since it was + * a cart a moment ago. Any later save/recalculate (a queue job, webhook, or + * fulfillment plugin) would then rebuild every adjustment on an already-paid + * order, and if the third-party shipping method no longer matched, its cost + * would silently vanish, leaving the order looking overpaid. * - * Fixed by only restoring the original recalculation mode when the order - * did *not* complete as a result of the call - see + * Fixed by only restoring the original recalculation mode when the call + * didn't complete the order - see * {@see testCompletedAndPaidOrderStaysLockedAgainstRecalculation()} for the * fix, and {@see testUpdatingPaidInformationWithoutCompletingStaysRecalculable()} - * for confirmation that a cart which takes a payment without completing - * (e.g. a partial payment) remains editable/recalculable as before. + * confirming a cart that takes a payment without completing (e.g. a partial + * payment) stays editable/recalculable as before. * * @author Pixel & Tonic, Inc. */ @@ -69,8 +66,8 @@ class ShippingTest extends Unit protected ?Plugin $pluginInstance = null; /** - * Toggled mid-test to simulate the third-party plugin's registration - * handler starting to match the order, then failing to on a later call. + * Toggled mid-test to simulate the registration handler matching the + * order, then failing to on a later call. * * @var bool */ @@ -103,13 +100,13 @@ protected function _before(): void $this->pluginInstance = Plugin::getInstance(); $this->_thirdPartyMethodMatches = true; - // No discounts in play; keeps the test isolated from DB fixture state. + // No discounts in play; keeps the test isolated from fixture state. $this->pluginInstance->set('discounts', $this->make(Discounts::class, [ 'getAllActiveDiscounts' => fn() => [], ])); - // Simulate a third-party plugin that registers a shipping method by - // re-evaluating live availability every time it's asked, instead of + // Simulate a third-party plugin registering a shipping method by + // re-evaluating live availability on every call, instead of // returning a static, persisted method. Event::on( ShippingMethods::class, @@ -117,10 +114,9 @@ protected function _before(): void function(RegisterAvailableShippingMethodsEvent $event) { $shippingMethods = $event->getShippingMethods(); $shippingMethods->push($this->make(ShippingMethod::class, [ - // Third-party plugins have to set this themselves on the methods they - // register (e.g. Postie's `Service::registerShippingMethods()` does the - // same) - `Order::getAvailableShippingMethodOptions()` silently drops any - // `ShippingMethod` instance whose `storeId` doesn't match the order's. + // Plugins must set this themselves on methods they register - + // `Order::getAvailableShippingMethodOptions()` silently drops any + // `ShippingMethod` whose `storeId` doesn't match the order's. 'storeId' => $event->order->storeId, 'handle' => 'thirdPartyFlatRate', 'name' => 'Third Party Flat Rate', @@ -141,7 +137,10 @@ protected function _after(): void Event::off(ShippingMethods::class, ShippingMethods::EVENT_REGISTER_AVAILABLE_SHIPPING_METHODS); foreach ($this->_deleteElementIds as $elementId) { - Craft::$app->getElements()->deleteElementById($elementId, null, null, true); + // Pass the element type explicitly: one test saves an anonymous + // subclass of Order as a spy, whose class name in the `elements` + // table's `type` column can't be resolved back via `class_exists()`. + Craft::$app->getElements()->deleteElementById($elementId, Order::class, null, true); } $this->_deleteElementIds = []; @@ -149,9 +148,8 @@ protected function _after(): void } /** - * Building block: confirms `Shipping::adjust()` in isolation does exactly - * what it should when a registered method stops matching - drop the - * adjustment, no error. This is *correct* behaviour for a cart, and is + * Confirms `Shipping::adjust()` correctly drops the adjustment (no error) + * when a registered method stops matching. Correct cart behaviour, and * not, by itself, the bug. */ public function testAdjusterDropsAdjustmentWhenMethodDoesNotMatch(): void @@ -180,19 +178,18 @@ public function testAdjusterDropsAdjustmentWhenMethodDoesNotMatch(): void } /** - * Confirms the fix: a real order that has already been completed *and - * paid in full* - including the third-party shipping cost - stays - * locked against recalculation, even when the third-party plugin's - * handler later stops matching. Before the fix, `recalculate()` would - * still run in `RECALCULATION_MODE_ALL` here and silently drop the - * shipping cost from a paid order. + * Confirms the fix: an order already completed and paid in full - + * including the shipping cost - stays locked against recalculation, + * even after the registration handler stops matching. Before the fix, + * `recalculate()` would still run in `RECALCULATION_MODE_ALL` here and + * silently drop the shipping cost from a paid order. * * @throws Throwable */ public function testCompletedAndPaidOrderStaysLockedAgainstRecalculation(): void { - // A real, saved cart order - `recalculate()` requires a saved order, - // and only a real element exercises `afterSave()`/`markAsComplete()`/ + // A real, saved cart order - `recalculate()` requires one, and only a + // real element exercises `afterSave()`/`markAsComplete()`/ // `updateOrderPaidInformation()` the way production code does. $order = new Order(); Craft::$app->getElements()->saveElement($order, false); @@ -210,11 +207,11 @@ public function testCompletedAndPaidOrderStaysLockedAgainstRecalculation(): void $gateway = $this->pluginInstance->getGateways()->getGatewayByHandle('dummy'); $order->gatewayId = $gateway->id; - // Cart is untouched, so recalculation mode defaults to `ALL` - this - // is the same state the order is in throughout a normal checkout. + // Cart is untouched, so recalculation mode defaults to `ALL`, as + // throughout a normal checkout. self::assertEquals(Order::RECALCULATION_MODE_ALL, $order->getRecalculationMode()); - // Checkout: the third-party method matches, its cost gets applied and persisted. + // Checkout: the method matches, its cost gets applied and persisted. $order->recalculate(); Craft::$app->getElements()->saveElement($order, false); @@ -239,12 +236,10 @@ public function testCompletedAndPaidOrderStaysLockedAgainstRecalculation(): void 'A completed order must stay locked against recalculation.' ); - // Some time later - a queue job, a webhook, a fulfillment plugin - // reacting to payment completion - something recalculates this same - // completed order again. This time the third-party plugin's - // registration handler fails to match (its own route/context check - // fails outside the checkout flow, or its live rate lookup errors). - // None of that matters now, because recalculation is locked out. + // Some time later - a queue job, webhook, or fulfillment plugin - + // something recalculates this completed order again, and this time + // the registration handler fails to match. Doesn't matter now, + // since recalculation is locked out. $this->_thirdPartyMethodMatches = false; $order->recalculate(); @@ -259,10 +254,69 @@ public function testCompletedAndPaidOrderStaysLockedAgainstRecalculation(): void } /** - * Confirms the fix doesn't regress the case it needs to leave alone: a - * cart that receives a payment/authorization update but does *not* - * complete as a result (e.g. a partial payment) must remain fully - * editable and recalculable, exactly as before the fix. + * Control test: proves the bug was real by simulating the old, + * unconditional restore that `updateOrderPaidInformation()` used to do - + * manually unlocking a completed, paid order back to + * `RECALCULATION_MODE_ALL` and saving it. This isn't something the fixed + * code does; it's here to show the failure mode described in the class + * docblock actually happens, and that the other tests in this file would + * catch a regression back to it. + * + * @throws Throwable + */ + public function testManuallyUnlockingRecalculationModeOnCompletedOrderDropsShippingCost(): void + { + $order = new Order(); + Craft::$app->getElements()->saveElement($order, false); + $this->_deleteElementIds[] = $order->id; + + $variant = Variant::find()->indexBy('sku')->all()['hct-white']; + $lineItem = $this->pluginInstance->getLineItems()->create($order, [ + 'purchasableId' => $variant->id, + 'qty' => 1, + 'note' => '', + ]); + $order->setLineItems([$lineItem]); + $order->shippingMethodHandle = 'thirdPartyFlatRate'; + + $gateway = $this->pluginInstance->getGateways()->getGatewayByHandle('dummy'); + $order->gatewayId = $gateway->id; + + $order->recalculate(); + Craft::$app->getElements()->saveElement($order, false); + + $totalCollected = $order->getTotalPrice(); + self::assertGreaterThan(0, $order->getTotalShippingCost(), 'Sanity check: shipping cost was applied before payment.'); + + $transaction = $this->pluginInstance->getTransactions()->createTransaction($order, typeOverride: TransactionRecord::TYPE_PURCHASE); + $transaction->status = TransactionRecord::STATUS_SUCCESS; + $this->pluginInstance->getTransactions()->saveTransaction($transaction); + + self::assertTrue($order->isCompleted); + self::assertEquals(Order::RECALCULATION_MODE_NONE, $order->getRecalculationMode()); + + // Simulate the pre-fix bug: restore the cart's original mode after + // completion instead of staying locked at `NONE`. + $order->setRecalculationMode(Order::RECALCULATION_MODE_ALL); + + // The registration handler stops matching, then something saves the + // order - `afterSave()` unconditionally calls `recalculate()`, which + // now actually runs, since mode is `ALL` again. + $this->_thirdPartyMethodMatches = false; + Craft::$app->getElements()->saveElement($order, false); + + // The shipping cost silently disappeared, even though the order is + // still marked completed and paid - this is the bug. + self::assertTrue($order->isCompleted); + self::assertEquals(0.0, $order->getTotalShippingCost()); + self::assertLessThan($totalCollected, $order->getTotalPrice()); + self::assertGreaterThan($order->getTotalPrice(), $order->getTotalPaid(), 'Order now looks overpaid relative to its (wrongly recalculated) total.'); + } + + /** + * Confirms the fix doesn't regress the case it must leave alone: a cart + * that receives a payment/authorization update without completing (e.g. + * a partial payment) stays fully editable and recalculable, as before. * * @throws Throwable */ @@ -272,9 +326,8 @@ public function testUpdatingPaidInformationWithoutCompletingStaysRecalculable(): Craft::$app->getElements()->saveElement($order, false); $this->_deleteElementIds[] = $order->id; - // A real, priced line item - an empty cart has a $0 total, which is - // trivially "paid in full" with no outstanding balance. That's not - // what we're testing here: this needs a genuine amount still owing. + // A real, priced line item - an empty cart's $0 total is trivially + // "paid in full", but this needs a genuine amount still owing. $variant = Variant::find()->indexBy('sku')->all()['hct-white']; $lineItem = $this->pluginInstance->getLineItems()->create($order, [ 'purchasableId' => $variant->id, @@ -288,9 +341,9 @@ public function testUpdatingPaidInformationWithoutCompletingStaysRecalculable(): self::assertTrue($order->hasOutstandingBalance(), 'Sanity check: the order has an amount still owing.'); self::assertEquals(Order::RECALCULATION_MODE_ALL, $order->getRecalculationMode()); - // Nothing paid or authorized, so this cannot complete the order - it - // still exercises the same lock/restore logic `updateOrderPaidInformation()` - // runs through on every payment/authorization update, successful or not. + // Nothing paid or authorized, so this can't complete the order, but + // it still exercises the same lock/restore logic that + // `updateOrderPaidInformation()` runs on every payment update. $order->updateOrderPaidInformation(); self::assertFalse($order->isCompleted, 'Sanity check: nothing was paid, so the order has not completed.'); @@ -300,4 +353,73 @@ public function testUpdatingPaidInformationWithoutCompletingStaysRecalculable(): 'A cart that receives a payment update without completing must remain fully recalculable.' ); } + + /** + * Confirms that saving an already-completed, already-paid order again + * afterwards - as custom code might do, e.g. a controller action or + * queue job unrelated to shipping - has no adverse effect. + * `updateOrderPaidInformation()` already saves the order itself; this + * covers an *extra* save on top of that. Recalculation stays locked at + * `NONE`, so the extra save is a no-op as far as adjustments go. + * + * Also spies on `updateOrderPaidInformation()` itself, to confirm it's + * actually the successful transaction save that triggers it, rather than + * this test only happening to reproduce the same end state some other way. + * + * @throws Throwable + */ + public function testSavingCompletedOrderAgainAfterPaymentHasNoAdverseEffect(): void + { + // A spy on `updateOrderPaidInformation()`: still runs the real method + // via reflection (invoking the original, bypassing this override), + // but additionally expects to be called exactly once. `Expected::once()` + // is verified automatically when the test finishes. + $order = $this->make(Order::class, [ + 'updateOrderPaidInformation' => Expected::once(function() use (&$order) { + (new ReflectionMethod(Order::class, 'updateOrderPaidInformation'))->invoke($order); + }), + ]); + Craft::$app->getElements()->saveElement($order, false); + $this->_deleteElementIds[] = $order->id; + + $variant = Variant::find()->indexBy('sku')->all()['hct-white']; + $lineItem = $this->pluginInstance->getLineItems()->create($order, [ + 'purchasableId' => $variant->id, + 'qty' => 1, + 'note' => '', + ]); + $order->setLineItems([$lineItem]); + $order->shippingMethodHandle = 'thirdPartyFlatRate'; + + $gateway = $this->pluginInstance->getGateways()->getGatewayByHandle('dummy'); + $order->gatewayId = $gateway->id; + + $order->recalculate(); + Craft::$app->getElements()->saveElement($order, false); + + $totalCollected = $order->getTotalPrice(); + $shippingCost = $order->getTotalShippingCost(); + self::assertGreaterThan(0, $shippingCost, 'Sanity check: shipping cost was applied before payment.'); + + $transaction = $this->pluginInstance->getTransactions()->createTransaction($order, typeOverride: TransactionRecord::TYPE_PURCHASE); + $transaction->status = TransactionRecord::STATUS_SUCCESS; + $this->pluginInstance->getTransactions()->saveTransaction($transaction); + + self::assertTrue($order->isCompleted); + + // The registration handler stops matching some time later - it + // doesn't matter, because recalculation is locked out. + $this->_thirdPartyMethodMatches = false; + + // Custom code saves the already-completed, already-paid order again, + // for reasons unrelated to shipping/adjustments. + Craft::$app->getElements()->saveElement($order, false); + + self::assertTrue($order->isCompleted); + self::assertEquals(Order::RECALCULATION_MODE_NONE, $order->getRecalculationMode()); + self::assertEquals($shippingCost, $order->getTotalShippingCost(), 'Shipping cost must survive an unrelated save.'); + self::assertEquals($totalCollected, $order->getTotalPrice()); + self::assertEquals($totalCollected, $order->getTotalPaid()); + self::assertFalse($order->hasOutstandingBalance()); + } } From 48605dea25a84bb0193cab46714e381bcdbb5be3 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Mon, 10 Aug 2026 07:37:00 +0100 Subject: [PATCH 3/8] Fix test --- tests/unit/adjusters/ShippingTest.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/unit/adjusters/ShippingTest.php b/tests/unit/adjusters/ShippingTest.php index c86251a9e7..4a3906d953 100644 --- a/tests/unit/adjusters/ShippingTest.php +++ b/tests/unit/adjusters/ShippingTest.php @@ -373,8 +373,10 @@ public function testSavingCompletedOrderAgainAfterPaymentHasNoAdverseEffect(): v // A spy on `updateOrderPaidInformation()`: still runs the real method // via reflection (invoking the original, bypassing this override), // but additionally expects to be called exactly once. `Expected::once()` - // is verified automatically when the test finishes. - $order = $this->make(Order::class, [ + // is verified automatically when the test finishes. Uses `construct()` + // rather than `make()` so Order's real constructor/`init()` still runs + // (e.g. defaulting `siteId`), instead of leaving the order half-built. + $order = $this->construct(Order::class, [], [ 'updateOrderPaidInformation' => Expected::once(function() use (&$order) { (new ReflectionMethod(Order::class, 'updateOrderPaidInformation'))->invoke($order); }), From a159bfe8e1b1f6eddf9a3109d1a5a5def833de7b Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Mon, 10 Aug 2026 09:36:45 +0100 Subject: [PATCH 4/8] Tweak new test locations --- tests/unit/adjusters/ShippingTest.php | 320 +------------- .../elements/order/OrderRecalculationTest.php | 397 ++++++++++++++++++ 2 files changed, 401 insertions(+), 316 deletions(-) create mode 100644 tests/unit/elements/order/OrderRecalculationTest.php diff --git a/tests/unit/adjusters/ShippingTest.php b/tests/unit/adjusters/ShippingTest.php index 4a3906d953..08a4437169 100644 --- a/tests/unit/adjusters/ShippingTest.php +++ b/tests/unit/adjusters/ShippingTest.php @@ -7,50 +7,19 @@ namespace craftcommercetests\unit\adjusters; -use Codeception\Stub\Expected; use Codeception\Test\Unit; -use Craft; use craft\base\Event; use craft\commerce\adjusters\Shipping; use craft\commerce\elements\Order; -use craft\commerce\elements\Variant; use craft\commerce\events\RegisterAvailableShippingMethodsEvent; use craft\commerce\models\LineItem; use craft\commerce\models\ShippingMethod; -use craft\commerce\Plugin; -use craft\commerce\records\Transaction as TransactionRecord; -use craft\commerce\services\Discounts; use craft\commerce\services\ShippingMethods; -use craftcommercetests\fixtures\ProductFixture; -use ReflectionMethod; -use Throwable; use UnitTester; /** * ShippingTest * - * Covers a real-world bug: a third-party shipping method plugin registers - * its methods dynamically via - * {@see ShippingMethods::EVENT_REGISTER_AVAILABLE_SHIPPING_METHODS} on every - * call, rather than a static, persisted method. A method later failing to - * match is normal cart behaviour and not the bug on its own. - * - * The real bug: this could happen to an order that had *already completed - * and been paid for*. `Order::updateOrderPaidInformation()` locks - * `recalculationMode` to `NONE` while completing an order, but then - * unconditionally restored whatever mode it had before - `ALL`, since it was - * a cart a moment ago. Any later save/recalculate (a queue job, webhook, or - * fulfillment plugin) would then rebuild every adjustment on an already-paid - * order, and if the third-party shipping method no longer matched, its cost - * would silently vanish, leaving the order looking overpaid. - * - * Fixed by only restoring the original recalculation mode when the call - * didn't complete the order - see - * {@see testCompletedAndPaidOrderStaysLockedAgainstRecalculation()} for the - * fix, and {@see testUpdatingPaidInformationWithoutCompletingStaysRecalculable()} - * confirming a cart that takes a payment without completing (e.g. a partial - * payment) stays editable/recalculable as before. - * * @author Pixel & Tonic, Inc. */ class ShippingTest extends Unit @@ -60,11 +29,6 @@ class ShippingTest extends Unit */ protected UnitTester $tester; - /** - * @var Plugin|null - */ - protected ?Plugin $pluginInstance = null; - /** * Toggled mid-test to simulate the registration handler matching the * order, then failing to on a later call. @@ -73,23 +37,6 @@ class ShippingTest extends Unit */ private bool $_thirdPartyMethodMatches = true; - /** - * @var int[] Element IDs created directly by test methods (not fixtures), for cleanup. - */ - private array $_deleteElementIds = []; - - /** - * @return array - */ - public function _fixtures(): array - { - return [ - 'products' => [ - 'class' => ProductFixture::class, - ], - ]; - } - /** * @inheritdoc */ @@ -97,14 +44,8 @@ protected function _before(): void { parent::_before(); - $this->pluginInstance = Plugin::getInstance(); $this->_thirdPartyMethodMatches = true; - // No discounts in play; keeps the test isolated from fixture state. - $this->pluginInstance->set('discounts', $this->make(Discounts::class, [ - 'getAllActiveDiscounts' => fn() => [], - ])); - // Simulate a third-party plugin registering a shipping method by // re-evaluating live availability on every call, instead of // returning a static, persisted method. @@ -136,21 +77,16 @@ protected function _after(): void { Event::off(ShippingMethods::class, ShippingMethods::EVENT_REGISTER_AVAILABLE_SHIPPING_METHODS); - foreach ($this->_deleteElementIds as $elementId) { - // Pass the element type explicitly: one test saves an anonymous - // subclass of Order as a spy, whose class name in the `elements` - // table's `type` column can't be resolved back via `class_exists()`. - Craft::$app->getElements()->deleteElementById($elementId, Order::class, null, true); - } - $this->_deleteElementIds = []; - parent::_after(); } /** * Confirms `Shipping::adjust()` correctly drops the adjustment (no error) * when a registered method stops matching. Correct cart behaviour, and - * not, by itself, the bug. + * not, by itself, a bug - see + * {@see \craftcommercetests\unit\elements\order\OrderRecalculationTest} + * for the recalculation-lock bug this scenario was originally written to + * demonstrate. */ public function testAdjusterDropsAdjustmentWhenMethodDoesNotMatch(): void { @@ -176,252 +112,4 @@ public function testAdjusterDropsAdjustmentWhenMethodDoesNotMatch(): void $secondPass = $adjuster->adjust($order); self::assertSame([], $secondPass, 'No adjustment, no exception - this part is expected.'); } - - /** - * Confirms the fix: an order already completed and paid in full - - * including the shipping cost - stays locked against recalculation, - * even after the registration handler stops matching. Before the fix, - * `recalculate()` would still run in `RECALCULATION_MODE_ALL` here and - * silently drop the shipping cost from a paid order. - * - * @throws Throwable - */ - public function testCompletedAndPaidOrderStaysLockedAgainstRecalculation(): void - { - // A real, saved cart order - `recalculate()` requires one, and only a - // real element exercises `afterSave()`/`markAsComplete()`/ - // `updateOrderPaidInformation()` the way production code does. - $order = new Order(); - Craft::$app->getElements()->saveElement($order, false); - $this->_deleteElementIds[] = $order->id; - - $variant = Variant::find()->indexBy('sku')->all()['hct-white']; - $lineItem = $this->pluginInstance->getLineItems()->create($order, [ - 'purchasableId' => $variant->id, - 'qty' => 1, - 'note' => '', - ]); - $order->setLineItems([$lineItem]); - $order->shippingMethodHandle = 'thirdPartyFlatRate'; - - $gateway = $this->pluginInstance->getGateways()->getGatewayByHandle('dummy'); - $order->gatewayId = $gateway->id; - - // Cart is untouched, so recalculation mode defaults to `ALL`, as - // throughout a normal checkout. - self::assertEquals(Order::RECALCULATION_MODE_ALL, $order->getRecalculationMode()); - - // Checkout: the method matches, its cost gets applied and persisted. - $order->recalculate(); - Craft::$app->getElements()->saveElement($order, false); - - $totalCollected = $order->getTotalPrice(); - self::assertGreaterThan(0, $order->getTotalShippingCost(), 'Sanity check: shipping cost was applied before payment.'); - - // Customer pays the full amount shown at checkout, including shipping. - $transaction = $this->pluginInstance->getTransactions()->createTransaction($order, typeOverride: TransactionRecord::TYPE_PURCHASE); - $transaction->status = TransactionRecord::STATUS_SUCCESS; - $this->pluginInstance->getTransactions()->saveTransaction($transaction); - - // The order is now completed and paid in full... - self::assertTrue($order->isCompleted); - self::assertFalse($order->hasOutstandingBalance()); - self::assertEquals($totalCollected, $order->getTotalPaid()); - - // ...and, with the fix, stays locked at `NONE` rather than being - // restored to the `ALL` mode it had as a cart. - self::assertEquals( - Order::RECALCULATION_MODE_NONE, - $order->getRecalculationMode(), - 'A completed order must stay locked against recalculation.' - ); - - // Some time later - a queue job, webhook, or fulfillment plugin - - // something recalculates this completed order again, and this time - // the registration handler fails to match. Doesn't matter now, - // since recalculation is locked out. - $this->_thirdPartyMethodMatches = false; - - $order->recalculate(); - - // Nothing changed: still completed, still paid, shipping cost and - // handle untouched, no "shippingMethodChanged" notice. - self::assertTrue($order->isCompleted); - self::assertEquals($totalCollected, $order->getTotalPrice()); - self::assertEquals($totalCollected, $order->getTotalPaid()); - self::assertEquals('thirdPartyFlatRate', $order->shippingMethodHandle); - self::assertFalse($order->hasNotices('shippingMethodChanged')); - } - - /** - * Control test: proves the bug was real by simulating the old, - * unconditional restore that `updateOrderPaidInformation()` used to do - - * manually unlocking a completed, paid order back to - * `RECALCULATION_MODE_ALL` and saving it. This isn't something the fixed - * code does; it's here to show the failure mode described in the class - * docblock actually happens, and that the other tests in this file would - * catch a regression back to it. - * - * @throws Throwable - */ - public function testManuallyUnlockingRecalculationModeOnCompletedOrderDropsShippingCost(): void - { - $order = new Order(); - Craft::$app->getElements()->saveElement($order, false); - $this->_deleteElementIds[] = $order->id; - - $variant = Variant::find()->indexBy('sku')->all()['hct-white']; - $lineItem = $this->pluginInstance->getLineItems()->create($order, [ - 'purchasableId' => $variant->id, - 'qty' => 1, - 'note' => '', - ]); - $order->setLineItems([$lineItem]); - $order->shippingMethodHandle = 'thirdPartyFlatRate'; - - $gateway = $this->pluginInstance->getGateways()->getGatewayByHandle('dummy'); - $order->gatewayId = $gateway->id; - - $order->recalculate(); - Craft::$app->getElements()->saveElement($order, false); - - $totalCollected = $order->getTotalPrice(); - self::assertGreaterThan(0, $order->getTotalShippingCost(), 'Sanity check: shipping cost was applied before payment.'); - - $transaction = $this->pluginInstance->getTransactions()->createTransaction($order, typeOverride: TransactionRecord::TYPE_PURCHASE); - $transaction->status = TransactionRecord::STATUS_SUCCESS; - $this->pluginInstance->getTransactions()->saveTransaction($transaction); - - self::assertTrue($order->isCompleted); - self::assertEquals(Order::RECALCULATION_MODE_NONE, $order->getRecalculationMode()); - - // Simulate the pre-fix bug: restore the cart's original mode after - // completion instead of staying locked at `NONE`. - $order->setRecalculationMode(Order::RECALCULATION_MODE_ALL); - - // The registration handler stops matching, then something saves the - // order - `afterSave()` unconditionally calls `recalculate()`, which - // now actually runs, since mode is `ALL` again. - $this->_thirdPartyMethodMatches = false; - Craft::$app->getElements()->saveElement($order, false); - - // The shipping cost silently disappeared, even though the order is - // still marked completed and paid - this is the bug. - self::assertTrue($order->isCompleted); - self::assertEquals(0.0, $order->getTotalShippingCost()); - self::assertLessThan($totalCollected, $order->getTotalPrice()); - self::assertGreaterThan($order->getTotalPrice(), $order->getTotalPaid(), 'Order now looks overpaid relative to its (wrongly recalculated) total.'); - } - - /** - * Confirms the fix doesn't regress the case it must leave alone: a cart - * that receives a payment/authorization update without completing (e.g. - * a partial payment) stays fully editable and recalculable, as before. - * - * @throws Throwable - */ - public function testUpdatingPaidInformationWithoutCompletingStaysRecalculable(): void - { - $order = new Order(); - Craft::$app->getElements()->saveElement($order, false); - $this->_deleteElementIds[] = $order->id; - - // A real, priced line item - an empty cart's $0 total is trivially - // "paid in full", but this needs a genuine amount still owing. - $variant = Variant::find()->indexBy('sku')->all()['hct-white']; - $lineItem = $this->pluginInstance->getLineItems()->create($order, [ - 'purchasableId' => $variant->id, - 'qty' => 1, - 'note' => '', - ]); - $order->setLineItems([$lineItem]); - $order->recalculate(); - Craft::$app->getElements()->saveElement($order, false); - - self::assertTrue($order->hasOutstandingBalance(), 'Sanity check: the order has an amount still owing.'); - self::assertEquals(Order::RECALCULATION_MODE_ALL, $order->getRecalculationMode()); - - // Nothing paid or authorized, so this can't complete the order, but - // it still exercises the same lock/restore logic that - // `updateOrderPaidInformation()` runs on every payment update. - $order->updateOrderPaidInformation(); - - self::assertFalse($order->isCompleted, 'Sanity check: nothing was paid, so the order has not completed.'); - self::assertEquals( - Order::RECALCULATION_MODE_ALL, - $order->getRecalculationMode(), - 'A cart that receives a payment update without completing must remain fully recalculable.' - ); - } - - /** - * Confirms that saving an already-completed, already-paid order again - * afterwards - as custom code might do, e.g. a controller action or - * queue job unrelated to shipping - has no adverse effect. - * `updateOrderPaidInformation()` already saves the order itself; this - * covers an *extra* save on top of that. Recalculation stays locked at - * `NONE`, so the extra save is a no-op as far as adjustments go. - * - * Also spies on `updateOrderPaidInformation()` itself, to confirm it's - * actually the successful transaction save that triggers it, rather than - * this test only happening to reproduce the same end state some other way. - * - * @throws Throwable - */ - public function testSavingCompletedOrderAgainAfterPaymentHasNoAdverseEffect(): void - { - // A spy on `updateOrderPaidInformation()`: still runs the real method - // via reflection (invoking the original, bypassing this override), - // but additionally expects to be called exactly once. `Expected::once()` - // is verified automatically when the test finishes. Uses `construct()` - // rather than `make()` so Order's real constructor/`init()` still runs - // (e.g. defaulting `siteId`), instead of leaving the order half-built. - $order = $this->construct(Order::class, [], [ - 'updateOrderPaidInformation' => Expected::once(function() use (&$order) { - (new ReflectionMethod(Order::class, 'updateOrderPaidInformation'))->invoke($order); - }), - ]); - Craft::$app->getElements()->saveElement($order, false); - $this->_deleteElementIds[] = $order->id; - - $variant = Variant::find()->indexBy('sku')->all()['hct-white']; - $lineItem = $this->pluginInstance->getLineItems()->create($order, [ - 'purchasableId' => $variant->id, - 'qty' => 1, - 'note' => '', - ]); - $order->setLineItems([$lineItem]); - $order->shippingMethodHandle = 'thirdPartyFlatRate'; - - $gateway = $this->pluginInstance->getGateways()->getGatewayByHandle('dummy'); - $order->gatewayId = $gateway->id; - - $order->recalculate(); - Craft::$app->getElements()->saveElement($order, false); - - $totalCollected = $order->getTotalPrice(); - $shippingCost = $order->getTotalShippingCost(); - self::assertGreaterThan(0, $shippingCost, 'Sanity check: shipping cost was applied before payment.'); - - $transaction = $this->pluginInstance->getTransactions()->createTransaction($order, typeOverride: TransactionRecord::TYPE_PURCHASE); - $transaction->status = TransactionRecord::STATUS_SUCCESS; - $this->pluginInstance->getTransactions()->saveTransaction($transaction); - - self::assertTrue($order->isCompleted); - - // The registration handler stops matching some time later - it - // doesn't matter, because recalculation is locked out. - $this->_thirdPartyMethodMatches = false; - - // Custom code saves the already-completed, already-paid order again, - // for reasons unrelated to shipping/adjustments. - Craft::$app->getElements()->saveElement($order, false); - - self::assertTrue($order->isCompleted); - self::assertEquals(Order::RECALCULATION_MODE_NONE, $order->getRecalculationMode()); - self::assertEquals($shippingCost, $order->getTotalShippingCost(), 'Shipping cost must survive an unrelated save.'); - self::assertEquals($totalCollected, $order->getTotalPrice()); - self::assertEquals($totalCollected, $order->getTotalPaid()); - self::assertFalse($order->hasOutstandingBalance()); - } } diff --git a/tests/unit/elements/order/OrderRecalculationTest.php b/tests/unit/elements/order/OrderRecalculationTest.php new file mode 100644 index 0000000000..d653fd42cd --- /dev/null +++ b/tests/unit/elements/order/OrderRecalculationTest.php @@ -0,0 +1,397 @@ + + */ +class OrderRecalculationTest extends Unit +{ + /** + * @var UnitTester + */ + protected UnitTester $tester; + + /** + * @var Plugin|null + */ + protected ?Plugin $pluginInstance = null; + + /** + * Toggled mid-test to simulate the registration handler matching the + * order, then failing to on a later call. + * + * @var bool + */ + private bool $_thirdPartyMethodMatches = true; + + /** + * @var int[] Element IDs created directly by test methods (not fixtures), for cleanup. + */ + private array $_deleteElementIds = []; + + /** + * @return array + */ + public function _fixtures(): array + { + return [ + 'products' => [ + 'class' => ProductFixture::class, + ], + ]; + } + + /** + * @inheritdoc + */ + protected function _before(): void + { + parent::_before(); + + $this->pluginInstance = Plugin::getInstance(); + $this->_thirdPartyMethodMatches = true; + + // No discounts in play; keeps the test isolated from fixture state. + $this->pluginInstance->set('discounts', $this->make(Discounts::class, [ + 'getAllActiveDiscounts' => fn() => [], + ])); + + // Simulate a third-party plugin registering a shipping method by + // re-evaluating live availability on every call, instead of + // returning a static, persisted method. + Event::on( + ShippingMethods::class, + ShippingMethods::EVENT_REGISTER_AVAILABLE_SHIPPING_METHODS, + function(RegisterAvailableShippingMethodsEvent $event) { + $shippingMethods = $event->getShippingMethods(); + $shippingMethods->push($this->make(ShippingMethod::class, [ + // Plugins must set this themselves on methods they register - + // `Order::getAvailableShippingMethodOptions()` silently drops any + // `ShippingMethod` whose `storeId` doesn't match the order's. + 'storeId' => $event->order->storeId, + 'handle' => 'thirdPartyFlatRate', + 'name' => 'Third Party Flat Rate', + 'getIsEnabled' => true, + 'getMatchingShippingRule' => fn() => null, + 'getPriceForOrder' => fn() => 8.99, + 'matchOrder' => fn() => $this->_thirdPartyMethodMatches, + ])); + } + ); + } + + /** + * @inheritdoc + */ + protected function _after(): void + { + Event::off(ShippingMethods::class, ShippingMethods::EVENT_REGISTER_AVAILABLE_SHIPPING_METHODS); + + foreach ($this->_deleteElementIds as $elementId) { + // Pass the element type explicitly: one test saves an anonymous + // subclass of Order as a spy, whose class name in the `elements` + // table's `type` column can't be resolved back via `class_exists()`. + Craft::$app->getElements()->deleteElementById($elementId, Order::class, null, true); + } + $this->_deleteElementIds = []; + + parent::_after(); + } + + /** + * Confirms the fix: an order already completed and paid in full - + * including the shipping cost - stays locked against recalculation, + * even after the registration handler stops matching. Before the fix, + * `recalculate()` would still run in `RECALCULATION_MODE_ALL` here and + * silently drop the shipping cost from a paid order. + * + * @throws Throwable + */ + public function testCompletedAndPaidOrderStaysLockedAgainstRecalculation(): void + { + // A real, saved cart order - `recalculate()` requires one, and only a + // real element exercises `afterSave()`/`markAsComplete()`/ + // `updateOrderPaidInformation()` the way production code does. + $order = new Order(); + Craft::$app->getElements()->saveElement($order, false); + $this->_deleteElementIds[] = $order->id; + + $variant = Variant::find()->indexBy('sku')->all()['hct-white']; + $lineItem = $this->pluginInstance->getLineItems()->create($order, [ + 'purchasableId' => $variant->id, + 'qty' => 1, + 'note' => '', + ]); + $order->setLineItems([$lineItem]); + $order->shippingMethodHandle = 'thirdPartyFlatRate'; + + $gateway = $this->pluginInstance->getGateways()->getGatewayByHandle('dummy'); + $order->gatewayId = $gateway->id; + + // Cart is untouched, so recalculation mode defaults to `ALL`, as + // throughout a normal checkout. + self::assertEquals(Order::RECALCULATION_MODE_ALL, $order->getRecalculationMode()); + + // Checkout: the method matches, its cost gets applied and persisted. + $order->recalculate(); + Craft::$app->getElements()->saveElement($order, false); + + $totalCollected = $order->getTotalPrice(); + self::assertGreaterThan(0, $order->getTotalShippingCost(), 'Sanity check: shipping cost was applied before payment.'); + + // Customer pays the full amount shown at checkout, including shipping. + $transaction = $this->pluginInstance->getTransactions()->createTransaction($order, typeOverride: TransactionRecord::TYPE_PURCHASE); + $transaction->status = TransactionRecord::STATUS_SUCCESS; + $this->pluginInstance->getTransactions()->saveTransaction($transaction); + + // The order is now completed and paid in full... + self::assertTrue($order->isCompleted); + self::assertFalse($order->hasOutstandingBalance()); + self::assertEquals($totalCollected, $order->getTotalPaid()); + + // ...and, with the fix, stays locked at `NONE` rather than being + // restored to the `ALL` mode it had as a cart. + self::assertEquals( + Order::RECALCULATION_MODE_NONE, + $order->getRecalculationMode(), + 'A completed order must stay locked against recalculation.' + ); + + // Some time later - a queue job, webhook, or fulfillment plugin - + // something recalculates this completed order again, and this time + // the registration handler fails to match. Doesn't matter now, + // since recalculation is locked out. + $this->_thirdPartyMethodMatches = false; + + $order->recalculate(); + + // Nothing changed: still completed, still paid, shipping cost and + // handle untouched, no "shippingMethodChanged" notice. + self::assertTrue($order->isCompleted); + self::assertEquals($totalCollected, $order->getTotalPrice()); + self::assertEquals($totalCollected, $order->getTotalPaid()); + self::assertEquals('thirdPartyFlatRate', $order->shippingMethodHandle); + self::assertFalse($order->hasNotices('shippingMethodChanged')); + } + + /** + * Control test: proves the bug was real by simulating the old, + * unconditional restore that `updateOrderPaidInformation()` used to do - + * manually unlocking a completed, paid order back to + * `RECALCULATION_MODE_ALL` and saving it. This isn't something the fixed + * code does; it's here to show the failure mode described in the class + * docblock actually happens, and that the other tests in this file would + * catch a regression back to it. + * + * @throws Throwable + */ + public function testManuallyUnlockingRecalculationModeOnCompletedOrderDropsShippingCost(): void + { + $order = new Order(); + Craft::$app->getElements()->saveElement($order, false); + $this->_deleteElementIds[] = $order->id; + + $variant = Variant::find()->indexBy('sku')->all()['hct-white']; + $lineItem = $this->pluginInstance->getLineItems()->create($order, [ + 'purchasableId' => $variant->id, + 'qty' => 1, + 'note' => '', + ]); + $order->setLineItems([$lineItem]); + $order->shippingMethodHandle = 'thirdPartyFlatRate'; + + $gateway = $this->pluginInstance->getGateways()->getGatewayByHandle('dummy'); + $order->gatewayId = $gateway->id; + + $order->recalculate(); + Craft::$app->getElements()->saveElement($order, false); + + $totalCollected = $order->getTotalPrice(); + self::assertGreaterThan(0, $order->getTotalShippingCost(), 'Sanity check: shipping cost was applied before payment.'); + + $transaction = $this->pluginInstance->getTransactions()->createTransaction($order, typeOverride: TransactionRecord::TYPE_PURCHASE); + $transaction->status = TransactionRecord::STATUS_SUCCESS; + $this->pluginInstance->getTransactions()->saveTransaction($transaction); + + self::assertTrue($order->isCompleted); + self::assertEquals(Order::RECALCULATION_MODE_NONE, $order->getRecalculationMode()); + + // Simulate the pre-fix bug: restore the cart's original mode after + // completion instead of staying locked at `NONE`. + $order->setRecalculationMode(Order::RECALCULATION_MODE_ALL); + + // The registration handler stops matching, then something saves the + // order - `afterSave()` unconditionally calls `recalculate()`, which + // now actually runs, since mode is `ALL` again. + $this->_thirdPartyMethodMatches = false; + Craft::$app->getElements()->saveElement($order, false); + + // The shipping cost silently disappeared, even though the order is + // still marked completed and paid - this is the bug. + self::assertTrue($order->isCompleted); + self::assertEquals(0.0, $order->getTotalShippingCost()); + self::assertLessThan($totalCollected, $order->getTotalPrice()); + self::assertGreaterThan($order->getTotalPrice(), $order->getTotalPaid(), 'Order now looks overpaid relative to its (wrongly recalculated) total.'); + } + + /** + * Confirms the fix doesn't regress the case it must leave alone: a cart + * that receives a payment/authorization update without completing (e.g. + * a partial payment) stays fully editable and recalculable, as before. + * + * @throws Throwable + */ + public function testUpdatingPaidInformationWithoutCompletingStaysRecalculable(): void + { + $order = new Order(); + Craft::$app->getElements()->saveElement($order, false); + $this->_deleteElementIds[] = $order->id; + + // A real, priced line item - an empty cart's $0 total is trivially + // "paid in full", but this needs a genuine amount still owing. + $variant = Variant::find()->indexBy('sku')->all()['hct-white']; + $lineItem = $this->pluginInstance->getLineItems()->create($order, [ + 'purchasableId' => $variant->id, + 'qty' => 1, + 'note' => '', + ]); + $order->setLineItems([$lineItem]); + $order->recalculate(); + Craft::$app->getElements()->saveElement($order, false); + + self::assertTrue($order->hasOutstandingBalance(), 'Sanity check: the order has an amount still owing.'); + self::assertEquals(Order::RECALCULATION_MODE_ALL, $order->getRecalculationMode()); + + // Nothing paid or authorized, so this can't complete the order, but + // it still exercises the same lock/restore logic that + // `updateOrderPaidInformation()` runs on every payment update. + $order->updateOrderPaidInformation(); + + self::assertFalse($order->isCompleted, 'Sanity check: nothing was paid, so the order has not completed.'); + self::assertEquals( + Order::RECALCULATION_MODE_ALL, + $order->getRecalculationMode(), + 'A cart that receives a payment update without completing must remain fully recalculable.' + ); + } + + /** + * Confirms that saving an already-completed, already-paid order again + * afterwards - as custom code might do, e.g. a controller action or + * queue job unrelated to shipping - has no adverse effect. + * `updateOrderPaidInformation()` already saves the order itself; this + * covers an *extra* save on top of that. Recalculation stays locked at + * `NONE`, so the extra save is a no-op as far as adjustments go. + * + * Also spies on `updateOrderPaidInformation()` itself, to confirm it's + * actually the successful transaction save that triggers it, rather than + * this test only happening to reproduce the same end state some other way. + * + * @throws Throwable + */ + public function testSavingCompletedOrderAgainAfterPaymentHasNoAdverseEffect(): void + { + // A spy on `updateOrderPaidInformation()`: still runs the real method + // via reflection (invoking the original, bypassing this override), + // but additionally expects to be called exactly once. `Expected::once()` + // is verified automatically when the test finishes. Uses `construct()` + // rather than `make()` so Order's real constructor/`init()` still runs + // (e.g. defaulting `siteId`), instead of leaving the order half-built. + $order = $this->construct(Order::class, [], [ + 'updateOrderPaidInformation' => Expected::once(function() use (&$order) { + (new ReflectionMethod(Order::class, 'updateOrderPaidInformation'))->invoke($order); + }), + ]); + Craft::$app->getElements()->saveElement($order, false); + $this->_deleteElementIds[] = $order->id; + + $variant = Variant::find()->indexBy('sku')->all()['hct-white']; + $lineItem = $this->pluginInstance->getLineItems()->create($order, [ + 'purchasableId' => $variant->id, + 'qty' => 1, + 'note' => '', + ]); + $order->setLineItems([$lineItem]); + $order->shippingMethodHandle = 'thirdPartyFlatRate'; + + $gateway = $this->pluginInstance->getGateways()->getGatewayByHandle('dummy'); + $order->gatewayId = $gateway->id; + + $order->recalculate(); + Craft::$app->getElements()->saveElement($order, false); + + $totalCollected = $order->getTotalPrice(); + $shippingCost = $order->getTotalShippingCost(); + self::assertGreaterThan(0, $shippingCost, 'Sanity check: shipping cost was applied before payment.'); + + $transaction = $this->pluginInstance->getTransactions()->createTransaction($order, typeOverride: TransactionRecord::TYPE_PURCHASE); + $transaction->status = TransactionRecord::STATUS_SUCCESS; + $this->pluginInstance->getTransactions()->saveTransaction($transaction); + + self::assertTrue($order->isCompleted); + + // The registration handler stops matching some time later - it + // doesn't matter, because recalculation is locked out. + $this->_thirdPartyMethodMatches = false; + + // Custom code saves the already-completed, already-paid order again, + // for reasons unrelated to shipping/adjustments. + Craft::$app->getElements()->saveElement($order, false); + + self::assertTrue($order->isCompleted); + self::assertEquals(Order::RECALCULATION_MODE_NONE, $order->getRecalculationMode()); + self::assertEquals($shippingCost, $order->getTotalShippingCost(), 'Shipping cost must survive an unrelated save.'); + self::assertEquals($totalCollected, $order->getTotalPrice()); + self::assertEquals($totalCollected, $order->getTotalPaid()); + self::assertFalse($order->hasOutstandingBalance()); + } +} From 6c97efd2d163454cf04181cedba44f799abbf6bf Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Mon, 10 Aug 2026 15:35:56 +0100 Subject: [PATCH 5/8] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tests/unit/adjusters/ShippingTest.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/unit/adjusters/ShippingTest.php b/tests/unit/adjusters/ShippingTest.php index 08a4437169..9cb8b05970 100644 --- a/tests/unit/adjusters/ShippingTest.php +++ b/tests/unit/adjusters/ShippingTest.php @@ -62,10 +62,9 @@ function(RegisterAvailableShippingMethodsEvent $event) { 'handle' => 'thirdPartyFlatRate', 'name' => 'Third Party Flat Rate', 'getIsEnabled' => true, - 'getMatchingShippingRule' => fn() => null, - 'getPriceForOrder' => fn() => 8.99, - 'matchOrder' => fn() => $this->_thirdPartyMethodMatches, - ])); + 'getMatchingShippingRule' => fn(Order $order) => null, + 'getPriceForOrder' => fn(Order $order) => 8.99, + 'matchOrder' => fn(Order $order) => $this->_thirdPartyMethodMatches, } ); } From 54111570c2a756e11644687f56a7b541f5b65b4c Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Mon, 10 Aug 2026 15:36:14 +0100 Subject: [PATCH 6/8] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tests/unit/elements/order/OrderRecalculationTest.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/unit/elements/order/OrderRecalculationTest.php b/tests/unit/elements/order/OrderRecalculationTest.php index d653fd42cd..efb6bfea7b 100644 --- a/tests/unit/elements/order/OrderRecalculationTest.php +++ b/tests/unit/elements/order/OrderRecalculationTest.php @@ -121,10 +121,9 @@ function(RegisterAvailableShippingMethodsEvent $event) { 'handle' => 'thirdPartyFlatRate', 'name' => 'Third Party Flat Rate', 'getIsEnabled' => true, - 'getMatchingShippingRule' => fn() => null, - 'getPriceForOrder' => fn() => 8.99, - 'matchOrder' => fn() => $this->_thirdPartyMethodMatches, - ])); + 'getMatchingShippingRule' => fn(Order $order) => null, + 'getPriceForOrder' => fn(Order $order) => 8.99, + 'matchOrder' => fn(Order $order) => $this->_thirdPartyMethodMatches, } ); } From 051618f16606ff0b6e5a350fd44523d02efc5a8f Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Mon, 10 Aug 2026 15:40:17 +0100 Subject: [PATCH 7/8] bad syntax --- tests/unit/adjusters/ShippingTest.php | 1 + tests/unit/elements/order/OrderRecalculationTest.php | 1 + 2 files changed, 2 insertions(+) diff --git a/tests/unit/adjusters/ShippingTest.php b/tests/unit/adjusters/ShippingTest.php index 9cb8b05970..fc67962c78 100644 --- a/tests/unit/adjusters/ShippingTest.php +++ b/tests/unit/adjusters/ShippingTest.php @@ -65,6 +65,7 @@ function(RegisterAvailableShippingMethodsEvent $event) { 'getMatchingShippingRule' => fn(Order $order) => null, 'getPriceForOrder' => fn(Order $order) => 8.99, 'matchOrder' => fn(Order $order) => $this->_thirdPartyMethodMatches, + ])); } ); } diff --git a/tests/unit/elements/order/OrderRecalculationTest.php b/tests/unit/elements/order/OrderRecalculationTest.php index efb6bfea7b..d182910094 100644 --- a/tests/unit/elements/order/OrderRecalculationTest.php +++ b/tests/unit/elements/order/OrderRecalculationTest.php @@ -124,6 +124,7 @@ function(RegisterAvailableShippingMethodsEvent $event) { 'getMatchingShippingRule' => fn(Order $order) => null, 'getPriceForOrder' => fn(Order $order) => 8.99, 'matchOrder' => fn(Order $order) => $this->_thirdPartyMethodMatches, + ])); } ); } From 6f6ab3c3bb58d48675101b471fd25dd8e509105e Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Mon, 10 Aug 2026 15:46:05 +0100 Subject: [PATCH 8/8] changelog item [ci skip] --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97f9f3f94f..02181f77ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Release Notes for Craft Commerce +## Unreleased + +- Fixed a bug where completed orders could unintentionally have their recalculation mode set to "all". ([#4342](https://github.com/craftcms/commerce/issues/4342)) + ## 5.7.1 - 2026-07-22 - Fixed a bug where guest customers couldn’t load credentialed carts with a valid token. ([#4225](https://github.com/craftcms/commerce/issues/4225))