diff --git a/app/code/Magento/SalesRule/Helper/CartFixedDiscount.php b/app/code/Magento/SalesRule/Helper/CartFixedDiscount.php index a5287e57b3e7..a217e5a83b64 100644 --- a/app/code/Magento/SalesRule/Helper/CartFixedDiscount.php +++ b/app/code/Magento/SalesRule/Helper/CartFixedDiscount.php @@ -275,6 +275,92 @@ public function getAvailableDiscountAmount( return $availableDiscountAmount; } + /** + * Initial cart-fixed balance for shipping discount processing. + * Single: remaining after item allocation on quote. + * Multi: full rule amount (legacy). + * + * @param Quote $quote + * @param Rule $rule + * @param string|int $ruleId + * @param bool $isMultiShipping + * @return float + */ + public function getCartFixedShippingRuleBalance( + Quote $quote, + Rule $rule, + $ruleId, + bool $isMultiShipping + ): float { + if ($isMultiShipping) { + return (float) $rule->getDiscountAmount(); + } + $quoteCartRules = $quote->getCartFixedRules(); + if (is_array($quoteCartRules) && array_key_exists($ruleId, $quoteCartRules)) { + return (float) $quoteCartRules[$ruleId]; + } + return (float) $rule->getDiscountAmount(); + } + + /** + * Single-shipping only: remaining balance capped by shipping discount tax basis. + * + * @param Quote $quote + * @param float $availableRuleBalance + * @param float $shippingAmountForDiscount + * @param float $baseShippingAmountForDiscount + * @param float $appliedShippingDiscount + * @param float $baseAppliedShippingDiscount + * @return array{0: float, 1: float} [quote currency, base currency] + */ + public function calculateSingleShippingCartFixedDiscount( + Quote $quote, + float $availableRuleBalance, + float $shippingAmountForDiscount, + float $baseShippingAmountForDiscount, + float $appliedShippingDiscount, + float $baseAppliedShippingDiscount + ): array { + if ($availableRuleBalance <= 0.0) { + return [0.0, 0.0]; + } + + $baseDiscountAmount = max( + 0.0, + min( + $availableRuleBalance, + $baseShippingAmountForDiscount - $baseAppliedShippingDiscount + ) + ); + $discountAmount = max( + 0.0, + min( + (float) $this->priceCurrency->convert($baseDiscountAmount, $quote->getStore()), + $shippingAmountForDiscount - $appliedShippingDiscount + ) + ); + + return [$discountAmount, $baseDiscountAmount]; + } + + /** + * Sync remaining cart-fixed balance back onto the quote (single shipping only). + * + * @param Quote $quote + * @param string|int $ruleId + * @param float $remaining + * @return void + */ + public function syncQuoteCartFixedRuleBalance(Quote $quote, $ruleId, float $remaining): void + { + $quoteCartRules = $quote->getCartFixedRules(); + if (!is_array($quoteCartRules)) { + $quoteCartRules = []; + } + $quoteCartRules[$ruleId] = $remaining; + $quote->setCartFixedRules($quoteCartRules); + } + /** * Get configuration setting "Apply Discount On Prices Including Tax" value * diff --git a/app/code/Magento/SalesRule/Model/Validator.php b/app/code/Magento/SalesRule/Model/Validator.php index 93f7d7a712f0..f66413afc113 100644 --- a/app/code/Magento/SalesRule/Model/Validator.php +++ b/app/code/Magento/SalesRule/Model/Validator.php @@ -33,6 +33,7 @@ * @method int getCustomerGroupId() * @method Validator setCustomerGroupId($id) * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) */ class Validator extends \Magento\Framework\Model\AbstractModel implements ResetAfterRequestInterface { @@ -480,42 +481,13 @@ public function processShippingAmount(Address $address) $baseDiscountAmount = $rule->getDiscountAmount(); break; case Rule::CART_FIXED_ACTION: - $cartRules = $address->getCartFixedRules(); - $quoteAmount = $this->priceCurrency->convert($rule->getDiscountAmount(), $quote->getStore()); - $isAppliedToShipping = (int) $rule->getApplyToShipping(); - $ruleId = $rule->getId() ?? ''; - if (!isset($cartRules[$ruleId])) { - $cartRules[$ruleId] = $rule->getDiscountAmount(); - } - if ($cartRules[$ruleId] > 0) { - $shippingQuoteAmount = (float) $address->getShippingAmount(); - $quoteBaseSubtotal = (float) $quote->getBaseSubtotal(); - $isMultiShipping = $this->cartFixedDiscountHelper->checkMultiShippingQuote($quote); - if ($isAppliedToShipping) { - $quoteBaseSubtotal = ($quote->getIsMultiShipping() && $isMultiShipping) ? - $this->cartFixedDiscountHelper->getQuoteTotalsForMultiShipping($quote) : - $this->cartFixedDiscountHelper->getQuoteTotalsForRegularShipping( - $address, - $quoteBaseSubtotal, - $shippingQuoteAmount - ); - $discountAmount = $this->cartFixedDiscountHelper-> - getShippingDiscountAmount( - $rule, - $shippingQuoteAmount, - $quoteBaseSubtotal - ); - $baseDiscountAmount = $discountAmount; - } else { - $discountAmount = min($shippingQuoteAmount, $quoteAmount); - $baseDiscountAmount = min( - $baseShippingAmount - $address->getBaseShippingDiscountAmount(), - $cartRules[$ruleId] - ); - } - $cartRules[$ruleId] -= $baseDiscountAmount; - } - $address->setCartFixedRules($cartRules); + [$discountAmount, $baseDiscountAmount] = $this->processCartFixedShippingAmount( + $address, + $quote, + $rule, + (float) $shippingAmount, + (float) $baseShippingAmount + ); break; case Rule::BUY_X_GET_Y_ACTION: $allQtyDiscount = $this->getDiscountQtyAllItemsBuyXGetYAction($quote, $rule); @@ -562,6 +534,93 @@ public function processShippingAmount(Address $address) return $this; } + /** + * Apply cart-fixed rule discount to shipping for one address. + * + * Single shipping: remaining quote balance capped by shipping tax-basis amounts. + * Multi shipping: legacy proportional share of full rule amount. + * + * @param Address $address + * @param Quote $quote + * @param Rule $rule + * @param float $shippingAmount Shipping amount for discount (quote currency) + * @param float $baseShippingAmount Shipping amount for discount (base currency) + * @return array{0: float, 1: float} [quote currency discount, base currency discount] + */ + private function processCartFixedShippingAmount( + Address $address, + Quote $quote, + Rule $rule, + float $shippingAmount, + float $baseShippingAmount + ): array { + $discountAmount = 0.0; + $baseDiscountAmount = 0.0; + $cartRules = $address->getCartFixedRules(); + if (!is_array($cartRules)) { + $cartRules = []; + } + $quoteAmount = $this->priceCurrency->convert($rule->getDiscountAmount(), $quote->getStore()); + $isAppliedToShipping = (int) $rule->getApplyToShipping(); + $ruleId = $rule->getId() ?? ''; + $isMultiShipping = $quote->getIsMultiShipping() + && $this->cartFixedDiscountHelper->checkMultiShippingQuote($quote); + + if (!isset($cartRules[$ruleId])) { + $cartRules[$ruleId] = $this->cartFixedDiscountHelper->getCartFixedShippingRuleBalance( + $quote, + $rule, + $ruleId, + $isMultiShipping + ); + } + if ($cartRules[$ruleId] > 0) { + $shippingQuoteAmount = (float) $address->getShippingAmount(); + if ($isAppliedToShipping) { + if ($isMultiShipping) { + // HEAD multi path: proportional share of full rule. + $quoteBaseSubtotal = $this->cartFixedDiscountHelper + ->getQuoteTotalsForMultiShipping($quote); + $discountAmount = $this->cartFixedDiscountHelper->getShippingDiscountAmount( + $rule, + $shippingQuoteAmount, + $quoteBaseSubtotal + ); + $baseDiscountAmount = $discountAmount; + } else { + // Single-ship: remaining balance + tax-basis cap. + [$discountAmount, $baseDiscountAmount] = $this->cartFixedDiscountHelper + ->calculateSingleShippingCartFixedDiscount( + $quote, + (float) $cartRules[$ruleId], + $shippingAmount, + $baseShippingAmount, + (float) $address->getShippingDiscountAmount(), + (float) $address->getBaseShippingDiscountAmount() + ); + } + } else { + // HEAD else branch (dead when apply-to-shipping is filtered above; keep for BC). + $discountAmount = min($shippingQuoteAmount, $quoteAmount); + $baseDiscountAmount = min( + $baseShippingAmount - $address->getBaseShippingDiscountAmount(), + $cartRules[$ruleId] + ); + } + $cartRules[$ruleId] -= $baseDiscountAmount; + } + $address->setCartFixedRules($cartRules); + if (!$isMultiShipping) { + $this->cartFixedDiscountHelper->syncQuoteCartFixedRuleBalance( + $quote, + $ruleId, + (float) ($cartRules[$ruleId] ?? 0) + ); + } + + return [(float) $discountAmount, (float) $baseDiscountAmount]; + } + /** * Calculate quote totals for each rule and save results * diff --git a/app/code/Magento/SalesRule/Test/Unit/Helper/CartFixedDiscountTest.php b/app/code/Magento/SalesRule/Test/Unit/Helper/CartFixedDiscountTest.php index 9f50fc084688..9c2bd64d835c 100644 --- a/app/code/Magento/SalesRule/Test/Unit/Helper/CartFixedDiscountTest.php +++ b/app/code/Magento/SalesRule/Test/Unit/Helper/CartFixedDiscountTest.php @@ -9,14 +9,24 @@ use Magento\Framework\App\Config\ScopeConfigInterface; use Magento\Framework\Pricing\PriceCurrencyInterface; +use Magento\Framework\TestFramework\Unit\Helper\MockCreationTrait; use Magento\Quote\Model\Cart\ShippingMethodConverter; +use Magento\Quote\Model\Quote; use Magento\SalesRule\Helper\CartFixedDiscount; use Magento\SalesRule\Model\DeltaPriceRound; -use PHPUnit\Framework\TestCase; +use Magento\SalesRule\Model\Rule; +use Magento\Store\Model\Store; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +/** + * Unit tests for cart-fixed discount helpers (single-ship remaining/cap + multi balance seed). + */ class CartFixedDiscountTest extends TestCase { + use MockCreationTrait; + /** * @var DeltaPriceRound|MockObject */ @@ -37,6 +47,11 @@ class CartFixedDiscountTest extends TestCase */ private ScopeConfigInterface $scopeConfig; + /** + * @var CartFixedDiscount + */ + private CartFixedDiscount $cartFixedDiscount; + /** * @inheritDoc */ @@ -45,9 +60,18 @@ protected function setUp(): void parent::setUp(); $this->deltaPriceRound = $this->createMock(DeltaPriceRound::class); - $this->priceCurrency = $this->createMock(PriceCurrencyInterface::class); + $this->priceCurrency = $this->createPartialMockWithReflection( + \Magento\Directory\Model\PriceCurrency::class, + ['convert', 'roundPrice'] + ); $this->shippingMethodConverter = $this->createMock(ShippingMethodConverter::class); $this->scopeConfig = $this->createMock(ScopeConfigInterface::class); + $this->cartFixedDiscount = new CartFixedDiscount( + $this->deltaPriceRound, + $this->priceCurrency, + $this->shippingMethodConverter, + $this->scopeConfig + ); } /** @@ -55,34 +79,203 @@ protected function setUp(): void */ public function testGetDiscountedAmountProportionally(): void { - $ruleDiscount = 5; - $qty = 2.0; - $baseItemPrice = 10.0; - $baseItemDiscountAmount = 0.0; - $baseRuleTotalsDiscount = 10; - $discountType = 'fixed'; - $expected = 5.0; - - $cartFixedDiscount = new CartFixedDiscount( - $this->deltaPriceRound, - $this->priceCurrency, - $this->shippingMethodConverter, - $this->scopeConfig - ); $this->deltaPriceRound->expects($this->once()) ->method('round') - ->with(5, $discountType) - ->willReturn($expected); + ->with(5, 'fixed') + ->willReturn(5.0); + $this->assertSame( + 5.0, + $this->cartFixedDiscount->getDiscountedAmountProportionally(5, 2.0, 10.0, 0.0, 10, 'fixed') + ); + } + + /** + * Single shipping seeds balance from quote remaining after item allocation. + * + * @return void + */ + public function testGetCartFixedShippingRuleBalanceSingleUsesQuoteRemaining(): void + { + $rule = $this->createPartialMockWithReflection(Rule::class, ['getDiscountAmount']); + $rule->method('getDiscountAmount')->willReturn(56.0); + $quote = $this->createPartialMockWithReflection(Quote::class, ['getCartFixedRules']); + $quote->method('getCartFixedRules')->willReturn([7 => 5.0]); + + $this->assertSame( + 5.0, + $this->cartFixedDiscount->getCartFixedShippingRuleBalance($quote, $rule, 7, false) + ); + } + + /** + * Single shipping falls back to full rule amount when quote has no remaining key. + * + * @return void + */ + public function testGetCartFixedShippingRuleBalanceSingleFallsBackToRuleAmount(): void + { + $rule = $this->createPartialMockWithReflection(Rule::class, ['getDiscountAmount']); + $rule->method('getDiscountAmount')->willReturn(56.0); + $quote = $this->createPartialMockWithReflection(Quote::class, ['getCartFixedRules']); + $quote->method('getCartFixedRules')->willReturn([]); + + $this->assertSame( + 56.0, + $this->cartFixedDiscount->getCartFixedShippingRuleBalance($quote, $rule, 7, false) + ); + } + + /** + * Multi shipping always seeds address balance with the full rule amount (legacy). + * + * @return void + */ + public function testGetCartFixedShippingRuleBalanceMultiUsesFullRule(): void + { + $rule = $this->createPartialMockWithReflection(Rule::class, ['getDiscountAmount']); + $rule->method('getDiscountAmount')->willReturn(56.0); + $quote = $this->createPartialMockWithReflection(Quote::class, ['getCartFixedRules']); + $quote->method('getCartFixedRules')->willReturn([7 => 5.0]); + $this->assertSame( - $expected, - $cartFixedDiscount->getDiscountedAmountProportionally( - $ruleDiscount, - $qty, - $baseItemPrice, - $baseItemDiscountAmount, - $baseRuleTotalsDiscount, - $discountType - ) + 56.0, + $this->cartFixedDiscount->getCartFixedShippingRuleBalance($quote, $rule, 7, true) + ); + } + + /** + * Single-ship cart-fixed shipping discount uses remaining balance and shipping cap. + * + * @param float $availableRuleBalance + * @param float $shippingAmountForDiscount + * @param float $baseShippingAmountForDiscount + * @param float $appliedShippingDiscount + * @param float $baseAppliedShippingDiscount + * @param float $expectedQuoteAmount + * @param float $expectedBaseAmount + * @return void + */ + #[DataProvider('calculateSingleShippingCartFixedDiscountDataProvider')] + public function testCalculateSingleShippingCartFixedDiscountUsesRemainingAndCaps( + float $availableRuleBalance, + float $shippingAmountForDiscount, + float $baseShippingAmountForDiscount, + float $appliedShippingDiscount, + float $baseAppliedShippingDiscount, + float $expectedQuoteAmount, + float $expectedBaseAmount + ): void { + $store = $this->createMock(Store::class); + $quote = $this->createPartialMockWithReflection(Quote::class, ['getStore']); + $quote->method('getStore')->willReturn($store); + + // Identity convert for unit math (quote currency == base). + $this->priceCurrency->method('convert')->willReturnCallback(static function ($amount) { + return $amount; + }); + + [$amount, $baseAmount] = $this->cartFixedDiscount->calculateSingleShippingCartFixedDiscount( + $quote, + $availableRuleBalance, + $shippingAmountForDiscount, + $baseShippingAmountForDiscount, + $appliedShippingDiscount, + $baseAppliedShippingDiscount + ); + + $this->assertSame($expectedQuoteAmount, $amount); + $this->assertSame($expectedBaseAmount, $baseAmount); + } + + /** + * @return array> + */ + public static function calculateSingleShippingCartFixedDiscountDataProvider(): array + { + return [ + 'remaining equals shipping' => [ + 5.0, + 5.0, + 5.0, + 0.0, + 0.0, + 5.0, + 5.0, + ], + 'remaining larger than shipping is capped' => [ + 10.0, + 5.0, + 5.0, + 0.0, + 0.0, + 5.0, + 5.0, + ], + 'remaining reduced by already applied shipping discount' => [ + 5.0, + 5.0, + 5.0, + 2.0, + 2.0, + 3.0, + 3.0, + ], + 'clamps to zero when applied exceeds shipping' => [ + 5.0, + 5.0, + 5.0, + 6.0, + 6.0, + 0.0, + 0.0, + ], + 'zero remaining yields zero discount' => [ + 0.0, + 5.0, + 5.0, + 0.0, + 0.0, + 0.0, + 0.0, + ], + ]; + } + + /** + * Sync writes remaining cart-fixed balance onto the quote ledger. + * + * @return void + */ + public function testSyncQuoteCartFixedRuleBalance(): void + { + $quote = $this->createPartialMockWithReflection( + Quote::class, + ['getCartFixedRules', 'setCartFixedRules'] ); + $quote->method('getCartFixedRules')->willReturn([7 => 10.0]); + $quote->expects($this->once()) + ->method('setCartFixedRules') + ->with([7 => 3.0]); + + $this->cartFixedDiscount->syncQuoteCartFixedRuleBalance($quote, 7, 3.0); + } + + /** + * Sync initializes quote cart fixed rules when storage is missing/non-array. + * + * @return void + */ + public function testSyncQuoteCartFixedRuleBalanceWhenRulesMissing(): void + { + $quote = $this->createPartialMockWithReflection( + Quote::class, + ['getCartFixedRules', 'setCartFixedRules'] + ); + $quote->method('getCartFixedRules')->willReturn(null); + $quote->expects($this->once()) + ->method('setCartFixedRules') + ->with([11 => 4.5]); + + $this->cartFixedDiscount->syncQuoteCartFixedRuleBalance($quote, 11, 4.5); } } diff --git a/app/code/Magento/SalesRule/Test/Unit/Model/ValidatorTest.php b/app/code/Magento/SalesRule/Test/Unit/Model/ValidatorTest.php index b2266033c603..c5ede855922f 100644 --- a/app/code/Magento/SalesRule/Test/Unit/Model/ValidatorTest.php +++ b/app/code/Magento/SalesRule/Test/Unit/Model/ValidatorTest.php @@ -125,7 +125,10 @@ protected function setUp(): void 'getCustomAttributesCodes', 'getShippingAmountForDiscount', 'getBaseShippingAmountForDiscount', - 'setCartFixedRules' + 'getShippingAmount', + 'getBaseShippingAmount', + 'getCartFixedRules', + 'setCartFixedRules', ] ); @@ -161,7 +164,11 @@ protected function setUp(): void 'getQuoteTotalsForMultiShipping', 'getQuoteTotalsForRegularShipping', 'getBaseRuleTotals', - 'getAvailableDiscountAmount']) + 'getAvailableDiscountAmount', + 'getCartFixedShippingRuleBalance', + 'calculateSingleShippingCartFixedDiscount', + 'syncQuoteCartFixedRuleBalance', + ]) ->disableOriginalConstructor() ->getMock(); /** @var Validator|MockObject $validator */ @@ -705,6 +712,7 @@ public static function dataProviderForFullShippingDiscount(): array * @param float $quoteBaseSubTotal * * @return Address|MockObject + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ protected function setupAddressMock( float $shippingAmount = 0.0, @@ -720,7 +728,10 @@ protected function setupAddressMock( 'getExtensionAttributes', 'isVirtual', 'setAppliedRuleIds', - 'getBaseSubtotal' + 'getBaseSubtotal', + 'getCartFixedRules', + 'setCartFixedRules', + 'getIsMultiShipping' ] ); $cartExtensionMock = $this->createPartialMockWithReflection( @@ -742,6 +753,15 @@ protected function setupAddressMock( $quoteMock->method('getBaseSubtotal') ->willReturn($quoteBaseSubTotal); + $quoteMock->method('getCartFixedRules') + ->willReturn([]); + + $quoteMock->method('setCartFixedRules') + ->willReturnSelf(); + + $quoteMock->method('getIsMultiShipping') + ->willReturn(false); + $this->cartFixedDiscountHelper ->method('getQuoteTotalsForRegularShipping') ->willReturn($quoteBaseSubTotal); @@ -750,6 +770,26 @@ protected function setupAddressMock( ->method('getShippingDiscountAmount') ->willReturn($shippingAmount); + // Single-ship path: dual gate is false when either flag is false. + $this->cartFixedDiscountHelper + ->method('checkMultiShippingQuote') + ->willReturn(false); + + $this->cartFixedDiscountHelper + ->method('getCartFixedShippingRuleBalance') + ->willReturnCallback(static function (...$args) { + /** @var Rule $rule */ + $rule = $args[1]; + return (float) $rule->getDiscountAmount(); + }); + + $this->cartFixedDiscountHelper + ->method('calculateSingleShippingCartFixedDiscount') + ->willReturn([$shippingAmount, $shippingAmount]); + + $this->cartFixedDiscountHelper + ->method('syncQuoteCartFixedRuleBalance'); + $quoteMock->method('getExtensionAttributes') ->willReturn($cartExtensionMock); @@ -759,6 +799,18 @@ protected function setupAddressMock( $this->addressMock->method('getBaseShippingAmountForDiscount') ->willReturn($shippingAmount); + $this->addressMock->method('getShippingAmount') + ->willReturn($shippingAmount); + + $this->addressMock->method('getBaseShippingAmount') + ->willReturn($shippingAmount); + + $this->addressMock->method('getCartFixedRules') + ->willReturn([]); + + $this->addressMock->method('setCartFixedRules') + ->willReturnSelf(); + $this->addressMock->method('getQuote') ->willReturn($quoteMock); @@ -861,4 +913,221 @@ public static function dataProviderNonShippingRuleStopProcessing(): array 'no stop processing allows subsequent rules' => [false, true, 10.0], ]; } + + /** + * Cart-fixed single shipping: seed remaining, cap via helper, sync quote ledger. + * + * @return void + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + public function testProcessShippingAmountCartFixedSingleShippingUsesRemainingHelper(): void + { + $shippingAmount = 5.0; + $remainingBalance = 5.0; + $ruleId = 7; + $discountQuote = 5.0; + $discountBase = 5.0; + + $ruleMock = $this->createPartialMockWithReflection( + Rule::class, + ['getApplyToShipping', 'getDiscountAmount', 'getSimpleAction', 'getId', 'getRuleId'] + ); + $ruleMock->method('getApplyToShipping')->willReturn(true); + $ruleMock->method('getDiscountAmount')->willReturn(56.0); + $ruleMock->method('getSimpleAction')->willReturn(Rule::CART_FIXED_ACTION); + $ruleMock->method('getId')->willReturn($ruleId); + $ruleMock->method('getRuleId')->willReturn($ruleId); + + $iterator = new \ArrayIterator([$ruleMock]); + $this->ruleCollection->method('getIterator')->willReturn($iterator); + $this->utility->method('canProcessRule')->willReturn(true); + + $this->priceCurrency->method('convert')->willReturn(56.0); + $this->priceCurrency->method('roundPrice') + ->willReturnCallback(static function ($price) { + return round((float) $price, 2); + }); + + $storeMock = $this->createMock(Store::class); + $quoteMock = $this->createPartialMockWithReflection( + Quote::class, + [ + 'getStore', + 'getExtensionAttributes', + 'isVirtual', + 'setAppliedRuleIds', + 'getBaseSubtotal', + 'getCartFixedRules', + 'setCartFixedRules', + 'getIsMultiShipping', + ] + ); + $quoteMock->method('getStore')->willReturn($storeMock); + $quoteMock->method('setAppliedRuleIds')->willReturnSelf(); + $quoteMock->method('isVirtual')->willReturn(false); + $quoteMock->method('getBaseSubtotal')->willReturn(51.0); + $quoteMock->method('getCartFixedRules')->willReturn([$ruleId => $remainingBalance]); + $quoteMock->method('setCartFixedRules')->willReturnSelf(); + $quoteMock->method('getIsMultiShipping')->willReturn(false); + $quoteMock->method('getExtensionAttributes')->willReturn(null); + + $this->addressMock->method('getQuote')->willReturn($quoteMock); + $this->addressMock->method('getCustomAttributesCodes')->willReturn([]); + $this->addressMock->method('getShippingAmountForDiscount')->willReturn($shippingAmount); + $this->addressMock->method('getBaseShippingAmountForDiscount')->willReturn($shippingAmount); + $this->addressMock->method('getShippingAmount')->willReturn($shippingAmount); + $this->addressMock->method('getBaseShippingAmount')->willReturn($shippingAmount); + $this->addressMock->method('getCartFixedRules')->willReturn([]); + $this->addressMock->method('setCartFixedRules')->willReturnSelf(); + + // Dual gate short-circuits when getIsMultiShipping() is false, so + // checkMultiShippingQuote may not be invoked; stub only if it is. + $this->cartFixedDiscountHelper + ->method('checkMultiShippingQuote') + ->willReturn(false); + + $this->cartFixedDiscountHelper + ->expects($this->once()) + ->method('getCartFixedShippingRuleBalance') + ->with($quoteMock, $ruleMock, $ruleId, false) + ->willReturn($remainingBalance); + + $this->cartFixedDiscountHelper + ->expects($this->once()) + ->method('calculateSingleShippingCartFixedDiscount') + ->with( + $quoteMock, + $remainingBalance, + $shippingAmount, + $shippingAmount, + 0.0, + 0.0 + ) + ->willReturn([$discountQuote, $discountBase]); + + $this->cartFixedDiscountHelper + ->expects($this->once()) + ->method('syncQuoteCartFixedRuleBalance') + ->with($quoteMock, $ruleId, 0.0); + + // Multi proportional helpers must not be used on the single-ship path. + $this->cartFixedDiscountHelper + ->expects($this->never()) + ->method('getQuoteTotalsForMultiShipping'); + $this->cartFixedDiscountHelper + ->expects($this->never()) + ->method('getShippingDiscountAmount'); + + $this->model->init( + $this->model->getWebsiteId(), + $this->model->getCustomerGroupId(), + $this->model->getCouponCode() + ); + + $this->assertInstanceOf(Validator::class, $this->model->processShippingAmount($this->addressMock)); + } + + /** + * Cart-fixed multi shipping keeps HEAD proportional helpers (not single-ship calculator). + * + * @return void + */ + public function testProcessShippingAmountCartFixedMultiShippingUsesLegacyProportional(): void + { + $shippingAmount = 5.0; + $ruleId = 9; + $ruleDiscount = 30.0; + $proportionalDiscount = 3.75; + + $ruleMock = $this->createPartialMockWithReflection( + Rule::class, + ['getApplyToShipping', 'getDiscountAmount', 'getSimpleAction', 'getId', 'getRuleId'] + ); + $ruleMock->method('getApplyToShipping')->willReturn(true); + $ruleMock->method('getDiscountAmount')->willReturn($ruleDiscount); + $ruleMock->method('getSimpleAction')->willReturn(Rule::CART_FIXED_ACTION); + $ruleMock->method('getId')->willReturn($ruleId); + $ruleMock->method('getRuleId')->willReturn($ruleId); + + $iterator = new \ArrayIterator([$ruleMock]); + $this->ruleCollection->method('getIterator')->willReturn($iterator); + $this->utility->method('canProcessRule')->willReturn(true); + + $this->priceCurrency->method('convert')->willReturn($ruleDiscount); + $this->priceCurrency->method('roundPrice') + ->willReturnCallback(static function ($price) { + return round((float) $price, 2); + }); + + $storeMock = $this->createMock(Store::class); + $quoteMock = $this->createPartialMockWithReflection( + Quote::class, + [ + 'getStore', + 'getExtensionAttributes', + 'isVirtual', + 'setAppliedRuleIds', + 'getBaseSubtotal', + 'getCartFixedRules', + 'setCartFixedRules', + 'getIsMultiShipping', + ] + ); + $quoteMock->method('getStore')->willReturn($storeMock); + $quoteMock->method('setAppliedRuleIds')->willReturnSelf(); + $quoteMock->method('isVirtual')->willReturn(false); + $quoteMock->method('getBaseSubtotal')->willReturn(30.0); + $quoteMock->method('getCartFixedRules')->willReturn([]); + $quoteMock->method('setCartFixedRules')->willReturnSelf(); + $quoteMock->method('getIsMultiShipping')->willReturn(true); + $quoteMock->method('getExtensionAttributes')->willReturn(null); + + $this->addressMock->method('getQuote')->willReturn($quoteMock); + $this->addressMock->method('getCustomAttributesCodes')->willReturn([]); + $this->addressMock->method('getShippingAmountForDiscount')->willReturn($shippingAmount); + $this->addressMock->method('getBaseShippingAmountForDiscount')->willReturn($shippingAmount); + $this->addressMock->method('getShippingAmount')->willReturn($shippingAmount); + $this->addressMock->method('getBaseShippingAmount')->willReturn($shippingAmount); + $this->addressMock->method('getCartFixedRules')->willReturn([]); + $this->addressMock->method('setCartFixedRules')->willReturnSelf(); + + $this->cartFixedDiscountHelper + ->expects($this->atLeastOnce()) + ->method('checkMultiShippingQuote') + ->with($quoteMock) + ->willReturn(true); + + $this->cartFixedDiscountHelper + ->expects($this->once()) + ->method('getCartFixedShippingRuleBalance') + ->with($quoteMock, $ruleMock, $ruleId, true) + ->willReturn($ruleDiscount); + + $this->cartFixedDiscountHelper + ->expects($this->once()) + ->method('getQuoteTotalsForMultiShipping') + ->with($quoteMock) + ->willReturn(40.0); + + $this->cartFixedDiscountHelper + ->expects($this->once()) + ->method('getShippingDiscountAmount') + ->willReturn($proportionalDiscount); + + // Single-ship calculator and quote sync must not run on multi path. + $this->cartFixedDiscountHelper + ->expects($this->never()) + ->method('calculateSingleShippingCartFixedDiscount'); + $this->cartFixedDiscountHelper + ->expects($this->never()) + ->method('syncQuoteCartFixedRuleBalance'); + + $this->model->init( + $this->model->getWebsiteId(), + $this->model->getCustomerGroupId(), + $this->model->getCouponCode() + ); + + $this->assertInstanceOf(Validator::class, $this->model->processShippingAmount($this->addressMock)); + } } diff --git a/dev/tests/integration/testsuite/Magento/SalesRule/Model/Rule/Action/Discount/CartFixedShippingTaxTest.php b/dev/tests/integration/testsuite/Magento/SalesRule/Model/Rule/Action/Discount/CartFixedShippingTaxTest.php new file mode 100644 index 000000000000..3b224df6ee29 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/SalesRule/Model/Rule/Action/Discount/CartFixedShippingTaxTest.php @@ -0,0 +1,672 @@ +quoteRepository = $objectManager->get(CartRepositoryInterface::class); + $this->couponManagement = $objectManager->get(CouponManagementInterface::class); + } + + /** + * The bug path: coupon applied before shipping is set (incl tax, discount on incl). + * + * Expected: full rule −56, shipping discount 5.00 (incl VAT), grand total 0. + * Buggy path used excl shipping basis → shipping discount 4.17 and residual 0.83. + */ + #[ + DbIsolation(true), + Config('tax/classes/shipping_tax_class', '2', 'store', 'default'), + Config('tax/calculation/price_includes_tax', '1', 'store', 'default'), + Config('tax/calculation/based_on', 'shipping', 'store', 'default'), + Config('tax/calculation/shipping_includes_tax', '1', 'store', 'default'), + Config('tax/calculation/discount_tax', '1', 'store', 'default'), + Config('tax/calculation/apply_after_discount', '0', 'store', 'default'), + Config('tax/calculation/cross_border_trade_enabled', '1', 'store', 'default'), + Config('carriers/flatrate/active', '1', 'store', 'default'), + Config('carriers/flatrate/price', '5', 'store', 'default'), + Config('carriers/flatrate/type', 'O', 'store', 'default'), + DataFixture( + TaxRateFixture::class, + ['tax_country_id' => 'US', 'tax_region_id' => 0, 'tax_postcode' => '*', 'rate' => 20], + 'taxRate' + ), + DataFixture( + TaxRuleFixture::class, + [ + 'customer_tax_class_ids' => [3], + 'product_tax_class_ids' => [2], + 'tax_rate_ids' => ['$taxRate.id$'], + ] + ), + DataFixture(ProductFixture::class, ['price' => 51, 'tax_class_id' => 2], 'product'), + DataFixture( + RuleFixture::class, + [ + 'simple_action' => Rule::CART_FIXED_ACTION, + 'discount_amount' => 56, + 'apply_to_shipping' => 1, + 'stop_rules_processing' => 0, + 'coupon_code' => self::COUPON_CODE, + ], + 'rule' + ), + DataFixture(GuestCartFixture::class, as: 'cart'), + DataFixture( + AddProductToCartFixture::class, + ['cart_id' => '$cart.id$', 'product_id' => '$product.id$', 'qty' => 1] + ), + ] + public function testCartFixedApplyToShippingCouponBeforeShippingInclTax(): void + { + $quote = $this->getCartQuote(); + $this->couponManagement->set((int) $quote->getId(), self::COUPON_CODE); + $quote = $this->getCartQuote(); + $this->assignCaliforniaShipping($quote); + $quote->setTotalsCollectedFlag(false); + $quote->collectTotals(); + $this->quoteRepository->save($quote); + $quote = $this->getCartQuote(); + $address = $quote->getShippingAddress(); + + $shippingDiscount = (float) $address->getShippingDiscountAmount(); + + $this->assertGreaterThan(0.0, (float) $address->getTaxAmount(), 'Expected tax to apply'); + $this->assertEqualsWithDelta(0.0, (float) $quote->getGrandTotal(), self::EPSILON); + $this->assertEqualsWithDelta(-56.0, (float) $address->getDiscountAmount(), self::EPSILON); + // Explicit regression: must cover shipping incl VAT (5), not excl (4.17). + $this->assertEqualsWithDelta(5.0, $shippingDiscount, self::EPSILON); + $this->assertNotEqualsWithDelta( + 4.17, + $shippingDiscount, + self::EPSILON, + 'Shipping discount must not use excl-tax basis when discount_tax=1' + ); + } + + /** + * Control path: shipping method first, then coupon — same finals as coupon-first. + */ + #[ + DbIsolation(true), + Config('tax/classes/shipping_tax_class', '2', 'store', 'default'), + Config('tax/calculation/price_includes_tax', '1', 'store', 'default'), + Config('tax/calculation/based_on', 'shipping', 'store', 'default'), + Config('tax/calculation/shipping_includes_tax', '1', 'store', 'default'), + Config('tax/calculation/discount_tax', '1', 'store', 'default'), + Config('tax/calculation/apply_after_discount', '0', 'store', 'default'), + Config('tax/calculation/cross_border_trade_enabled', '1', 'store', 'default'), + Config('carriers/flatrate/active', '1', 'store', 'default'), + Config('carriers/flatrate/price', '5', 'store', 'default'), + Config('carriers/flatrate/type', 'O', 'store', 'default'), + DataFixture( + TaxRateFixture::class, + ['tax_country_id' => 'US', 'tax_region_id' => 0, 'tax_postcode' => '*', 'rate' => 20], + 'taxRate' + ), + DataFixture( + TaxRuleFixture::class, + [ + 'customer_tax_class_ids' => [3], + 'product_tax_class_ids' => [2], + 'tax_rate_ids' => ['$taxRate.id$'], + ] + ), + DataFixture(ProductFixture::class, ['price' => 51, 'tax_class_id' => 2], 'product'), + DataFixture( + RuleFixture::class, + [ + 'simple_action' => Rule::CART_FIXED_ACTION, + 'discount_amount' => 56, + 'apply_to_shipping' => 1, + 'stop_rules_processing' => 0, + 'coupon_code' => self::COUPON_CODE, + ], + 'rule' + ), + DataFixture(GuestCartFixture::class, as: 'cart'), + DataFixture( + AddProductToCartFixture::class, + ['cart_id' => '$cart.id$', 'product_id' => '$product.id$', 'qty' => 1] + ), + ] + public function testCartFixedApplyToShippingShippingBeforeCouponInclTax(): void + { + $quote = $this->getCartQuote(); + $this->assignCaliforniaShipping($quote); + $quote->collectTotals(); + $this->quoteRepository->save($quote); + $this->couponManagement->set((int) $quote->getId(), self::COUPON_CODE); + $quote = $this->getCartQuote(); + $address = $quote->getShippingAddress(); + + $shippingDiscount = (float) $address->getShippingDiscountAmount(); + + $this->assertEqualsWithDelta(0.0, (float) $quote->getGrandTotal(), self::EPSILON); + $this->assertEqualsWithDelta(-56.0, (float) $address->getDiscountAmount(), self::EPSILON); + $this->assertEqualsWithDelta(5.0, $shippingDiscount, self::EPSILON); + $this->assertNotEqualsWithDelta(4.17, $shippingDiscount, self::EPSILON); + } + + /** + * Cart estimate style. + * + * Models: guest cart with coupon, then TotalsInformationManagement::calculate with + * destination + flatrate (shipping estimation / "estimate shipping and tax" style + * recollect without a full checkout place-order flow). + */ + #[ + DbIsolation(true), + Config('tax/classes/shipping_tax_class', '2', 'store', 'default'), + Config('tax/calculation/price_includes_tax', '1', 'store', 'default'), + Config('tax/calculation/based_on', 'shipping', 'store', 'default'), + Config('tax/calculation/shipping_includes_tax', '1', 'store', 'default'), + Config('tax/calculation/discount_tax', '1', 'store', 'default'), + Config('tax/calculation/apply_after_discount', '0', 'store', 'default'), + Config('tax/calculation/cross_border_trade_enabled', '1', 'store', 'default'), + Config('carriers/flatrate/active', '1', 'store', 'default'), + Config('carriers/flatrate/price', '5', 'store', 'default'), + Config('carriers/flatrate/type', 'O', 'store', 'default'), + DataFixture( + TaxRateFixture::class, + ['tax_country_id' => 'US', 'tax_region_id' => 0, 'tax_postcode' => '*', 'rate' => 20], + 'taxRate' + ), + DataFixture( + TaxRuleFixture::class, + [ + 'customer_tax_class_ids' => [3], + 'product_tax_class_ids' => [2], + 'tax_rate_ids' => ['$taxRate.id$'], + ] + ), + DataFixture(ProductFixture::class, ['price' => 51, 'tax_class_id' => 2], 'product'), + DataFixture( + RuleFixture::class, + [ + 'simple_action' => Rule::CART_FIXED_ACTION, + 'discount_amount' => 56, + 'apply_to_shipping' => 1, + 'stop_rules_processing' => 0, + 'coupon_code' => self::COUPON_CODE, + ], + 'rule' + ), + DataFixture(GuestCartFixture::class, as: 'cart'), + DataFixture( + AddProductToCartFixture::class, + ['cart_id' => '$cart.id$', 'product_id' => '$product.id$', 'qty' => 1] + ), + ] + public function testTicket32468CartEstimateStyle(): void + { + $quote = $this->getCartQuote(); + $cartId = (int) $quote->getId(); + $this->couponManagement->set($cartId, self::COUPON_CODE); + + $objectManager = Bootstrap::getObjectManager(); + /** @var Address $address */ + $address = $objectManager->get(AddressFactory::class)->create(); + $address->setAddressType(Address::ADDRESS_TYPE_SHIPPING) + ->setCountryId('US') + ->setRegionId(12) + ->setRegion('California') + ->setPostcode('90210') + ->setCity('Los Angeles'); + $addressInformation = $objectManager->create( + TotalsInformationInterface::class, + [ + 'data' => [ + 'address' => $address, + 'shipping_method_code' => 'flatrate', + 'shipping_carrier_code' => 'flatrate', + ], + ] + ); + + /** @var TotalsInformationManagement $totalsManagement */ + $totalsManagement = $objectManager->get(TotalsInformationManagement::class); + $totals = $totalsManagement->calculate($cartId, $addressInformation); + + $this->assertEqualsWithDelta(0.0, (float) $totals->getGrandTotal(), self::EPSILON); + $this->assertEqualsWithDelta(-56.0, (float) $totals->getDiscountAmount(), self::EPSILON); + + // Persist path: re-load quote after estimate-style collect and assert shipping share. + $quote = $this->getCartQuote(); + $shippingDiscount = (float) $quote->getShippingAddress()->getShippingDiscountAmount(); + $this->assertEqualsWithDelta(5.0, $shippingDiscount, self::EPSILON); + $this->assertNotEqualsWithDelta(4.17, $shippingDiscount, self::EPSILON); + } + + /** + * Tax applied after discount (apply_after_discount=1) still fully covers 51+5 with rule 56. + * + * Ticket notes both apply_after_discount 0 and 1 reproduce the shipping VAT miss without the fix. + */ + #[ + DbIsolation(true), + Config('tax/classes/shipping_tax_class', '2', 'store', 'default'), + Config('tax/calculation/price_includes_tax', '1', 'store', 'default'), + Config('tax/calculation/based_on', 'shipping', 'store', 'default'), + Config('tax/calculation/shipping_includes_tax', '1', 'store', 'default'), + Config('tax/calculation/discount_tax', '1', 'store', 'default'), + Config('tax/calculation/apply_after_discount', '1', 'store', 'default'), + Config('tax/calculation/cross_border_trade_enabled', '1', 'store', 'default'), + Config('carriers/flatrate/active', '1', 'store', 'default'), + Config('carriers/flatrate/price', '5', 'store', 'default'), + Config('carriers/flatrate/type', 'O', 'store', 'default'), + DataFixture( + TaxRateFixture::class, + ['tax_country_id' => 'US', 'tax_region_id' => 0, 'tax_postcode' => '*', 'rate' => 20], + 'taxRate' + ), + DataFixture( + TaxRuleFixture::class, + [ + 'customer_tax_class_ids' => [3], + 'product_tax_class_ids' => [2], + 'tax_rate_ids' => ['$taxRate.id$'], + ] + ), + DataFixture(ProductFixture::class, ['price' => 51, 'tax_class_id' => 2], 'product'), + DataFixture( + RuleFixture::class, + [ + 'simple_action' => Rule::CART_FIXED_ACTION, + 'discount_amount' => 56, + 'apply_to_shipping' => 1, + 'stop_rules_processing' => 0, + 'coupon_code' => self::COUPON_CODE, + ], + 'rule' + ), + DataFixture(GuestCartFixture::class, as: 'cart'), + DataFixture( + AddProductToCartFixture::class, + ['cart_id' => '$cart.id$', 'product_id' => '$product.id$', 'qty' => 1] + ), + ] + public function testCartFixedApplyToShippingCouponBeforeShippingTaxAfterDiscount(): void + { + $quote = $this->getCartQuote(); + $this->couponManagement->set((int) $quote->getId(), self::COUPON_CODE); + $quote = $this->getCartQuote(); + $this->assignCaliforniaShipping($quote); + $quote->setTotalsCollectedFlag(false); + $quote->collectTotals(); + $this->quoteRepository->save($quote); + $quote = $this->getCartQuote(); + $address = $quote->getShippingAddress(); + + $this->assertEqualsWithDelta(0.0, (float) $quote->getGrandTotal(), self::EPSILON); + $this->assertEqualsWithDelta(-56.0, (float) $address->getDiscountAmount(), self::EPSILON); + $this->assertEqualsWithDelta(5.0, (float) $address->getShippingDiscountAmount(), self::EPSILON); + } + + /** + * Discount on excl tax (discount_tax=0): shipping discount uses excl shipping basis. + * + * Expected: shipping disc ≈ 4.17 (5/1.20), not full 5.00 VAT-inclusive cover. + * Total discount is limited by excl-tax item+ship basis (≈ 46.67), not rule amount 56. + */ + #[ + DbIsolation(true), + Config('tax/classes/shipping_tax_class', '2', 'store', 'default'), + Config('tax/calculation/price_includes_tax', '1', 'store', 'default'), + Config('tax/calculation/based_on', 'shipping', 'store', 'default'), + Config('tax/calculation/shipping_includes_tax', '1', 'store', 'default'), + Config('tax/calculation/discount_tax', '0', 'store', 'default'), + Config('tax/calculation/apply_after_discount', '0', 'store', 'default'), + Config('tax/calculation/cross_border_trade_enabled', '1', 'store', 'default'), + Config('carriers/flatrate/active', '1', 'store', 'default'), + Config('carriers/flatrate/price', '5', 'store', 'default'), + Config('carriers/flatrate/type', 'O', 'store', 'default'), + DataFixture( + TaxRateFixture::class, + ['tax_country_id' => 'US', 'tax_region_id' => 0, 'tax_postcode' => '*', 'rate' => 20], + 'taxRate' + ), + DataFixture( + TaxRuleFixture::class, + [ + 'customer_tax_class_ids' => [3], + 'product_tax_class_ids' => [2], + 'tax_rate_ids' => ['$taxRate.id$'], + ] + ), + DataFixture(ProductFixture::class, ['price' => 51, 'tax_class_id' => 2], 'product'), + DataFixture( + RuleFixture::class, + [ + 'simple_action' => Rule::CART_FIXED_ACTION, + 'discount_amount' => 56, + 'apply_to_shipping' => 1, + 'stop_rules_processing' => 0, + 'coupon_code' => self::COUPON_CODE, + ], + 'rule' + ), + DataFixture(GuestCartFixture::class, as: 'cart'), + DataFixture( + AddProductToCartFixture::class, + ['cart_id' => '$cart.id$', 'product_id' => '$product.id$', 'qty' => 1] + ), + ] + public function testCartFixedApplyToShippingDiscountOnExclTax(): void + { + $quote = $this->getCartQuote(); + $this->couponManagement->set((int) $quote->getId(), self::COUPON_CODE); + $quote = $this->getCartQuote(); + $this->assignCaliforniaShipping($quote); + $quote->setTotalsCollectedFlag(false); + $quote->collectTotals(); + $this->quoteRepository->save($quote); + $quote = $this->getCartQuote(); + $address = $quote->getShippingAddress(); + + $this->assertGreaterThan(0.0, (float) $address->getTaxAmount(), 'Tax must apply for excl-discount assertions'); + // Shipping amount stored excl when shipping_includes_tax display path resolves excl basis. + $this->assertEqualsWithDelta(4.17, (float) $address->getShippingAmount(), self::EPSILON); + $this->assertEqualsWithDelta( + 4.17, + (float) $address->getShippingDiscountAmount(), + self::EPSILON, + 'With discount_tax=0 shipping discount must not cover full incl-tax 5.00' + ); + $this->assertEqualsWithDelta(-46.67, (float) $address->getDiscountAmount(), self::EPSILON); + // Must not exceed rule amount even when tax basis is excl. + $this->assertLessThanOrEqual(56.0 + self::EPSILON, abs((float) $address->getDiscountAmount())); + } + + /** + * Partial remaining: rule 53 on items+ship basis 56 → shipping share ≈ 53 * 5/56 ≈ 4.73. + */ + #[ + DbIsolation(true), + Config('tax/classes/shipping_tax_class', '2', 'store', 'default'), + Config('tax/calculation/price_includes_tax', '1', 'store', 'default'), + Config('tax/calculation/based_on', 'shipping', 'store', 'default'), + Config('tax/calculation/shipping_includes_tax', '1', 'store', 'default'), + Config('tax/calculation/discount_tax', '1', 'store', 'default'), + Config('tax/calculation/apply_after_discount', '0', 'store', 'default'), + Config('tax/calculation/cross_border_trade_enabled', '1', 'store', 'default'), + Config('carriers/flatrate/active', '1', 'store', 'default'), + Config('carriers/flatrate/price', '5', 'store', 'default'), + Config('carriers/flatrate/type', 'O', 'store', 'default'), + DataFixture( + TaxRateFixture::class, + ['tax_country_id' => 'US', 'tax_region_id' => 0, 'tax_postcode' => '*', 'rate' => 20], + 'taxRate' + ), + DataFixture( + TaxRuleFixture::class, + [ + 'customer_tax_class_ids' => [3], + 'product_tax_class_ids' => [2], + 'tax_rate_ids' => ['$taxRate.id$'], + ] + ), + DataFixture(ProductFixture::class, ['price' => 51, 'tax_class_id' => 2], 'product'), + DataFixture( + RuleFixture::class, + [ + 'simple_action' => Rule::CART_FIXED_ACTION, + 'discount_amount' => 53, + 'apply_to_shipping' => 1, + 'stop_rules_processing' => 0, + 'coupon_code' => self::COUPON_CODE, + ], + 'rule' + ), + DataFixture(GuestCartFixture::class, as: 'cart'), + DataFixture( + AddProductToCartFixture::class, + ['cart_id' => '$cart.id$', 'product_id' => '$product.id$', 'qty' => 1] + ), + ] + public function testCartFixedPartialRemainingAppliedToShipping(): void + { + $quote = $this->getCartQuote(); + $this->assignCaliforniaShipping($quote); + $quote->collectTotals(); + $this->quoteRepository->save($quote); + $this->couponManagement->set((int) $quote->getId(), self::COUPON_CODE); + $quote = $this->getCartQuote(); + $address = $quote->getShippingAddress(); + + // Fixed 53 with items+ship basis 56 → shipping share ≈ 53 * 5/56 ≈ 4.73 + $this->assertEqualsWithDelta(4.73, (float) $address->getShippingDiscountAmount(), self::EPSILON); + $this->assertEqualsWithDelta(-53.0, (float) $address->getDiscountAmount(), self::EPSILON); + $this->assertLessThanOrEqual(53.0 + self::EPSILON, abs((float) $address->getDiscountAmount())); + } + + /** + * Money invariant: cart-fixed total discount never exceeds the rule amount. + * + * Rule 100 with product 51 + ship 5 (incl basis 56): discount must be ≤ 100 and + * practically capped by cart value (≈ 56), shipping still fully covered at 5.00. + */ + #[ + DbIsolation(true), + Config('tax/classes/shipping_tax_class', '2', 'store', 'default'), + Config('tax/calculation/price_includes_tax', '1', 'store', 'default'), + Config('tax/calculation/based_on', 'shipping', 'store', 'default'), + Config('tax/calculation/shipping_includes_tax', '1', 'store', 'default'), + Config('tax/calculation/discount_tax', '1', 'store', 'default'), + Config('tax/calculation/apply_after_discount', '0', 'store', 'default'), + Config('tax/calculation/cross_border_trade_enabled', '1', 'store', 'default'), + Config('carriers/flatrate/active', '1', 'store', 'default'), + Config('carriers/flatrate/price', '5', 'store', 'default'), + Config('carriers/flatrate/type', 'O', 'store', 'default'), + DataFixture( + TaxRateFixture::class, + ['tax_country_id' => 'US', 'tax_region_id' => 0, 'tax_postcode' => '*', 'rate' => 20], + 'taxRate' + ), + DataFixture( + TaxRuleFixture::class, + [ + 'customer_tax_class_ids' => [3], + 'product_tax_class_ids' => [2], + 'tax_rate_ids' => ['$taxRate.id$'], + ] + ), + DataFixture(ProductFixture::class, ['price' => 51, 'tax_class_id' => 2], 'product'), + DataFixture( + RuleFixture::class, + [ + 'simple_action' => Rule::CART_FIXED_ACTION, + 'discount_amount' => 100, + 'apply_to_shipping' => 1, + 'stop_rules_processing' => 0, + 'coupon_code' => self::COUPON_CODE, + ], + 'rule' + ), + DataFixture(GuestCartFixture::class, as: 'cart'), + DataFixture( + AddProductToCartFixture::class, + ['cart_id' => '$cart.id$', 'product_id' => '$product.id$', 'qty' => 1] + ), + ] + public function testCartFixedDiscountNeverExceedsRuleAmount(): void + { + $ruleAmount = 100.0; + $quote = $this->getCartQuote(); + $this->couponManagement->set((int) $quote->getId(), self::COUPON_CODE); + $quote = $this->getCartQuote(); + $this->assignCaliforniaShipping($quote); + $quote->setTotalsCollectedFlag(false); + $quote->collectTotals(); + $this->quoteRepository->save($quote); + $quote = $this->getCartQuote(); + $address = $quote->getShippingAddress(); + + $discountAmount = abs((float) $address->getDiscountAmount()); + $shippingDiscount = (float) $address->getShippingDiscountAmount(); + + $this->assertLessThanOrEqual( + $ruleAmount + self::EPSILON, + $discountAmount, + 'Cart-fixed total discount must never exceed the rule amount' + ); + // Cart basis is 51 + 5 = 56; oversize rule must not invent extra discount. + $this->assertLessThanOrEqual(56.0 + self::EPSILON, $discountAmount); + $this->assertEqualsWithDelta(5.0, $shippingDiscount, self::EPSILON); + $this->assertEqualsWithDelta(0.0, (float) $quote->getGrandTotal(), self::EPSILON); + $this->assertEqualsWithDelta(-56.0, (float) $address->getDiscountAmount(), self::EPSILON); + } + + /** + * Catalog excl tax, shipping incl tax, discount on incl tax (stable mixed config). + */ + #[ + DbIsolation(true), + Config('tax/classes/shipping_tax_class', '2', 'store', 'default'), + Config('tax/calculation/price_includes_tax', '0', 'store', 'default'), + Config('tax/calculation/based_on', 'shipping', 'store', 'default'), + Config('tax/calculation/shipping_includes_tax', '1', 'store', 'default'), + Config('tax/calculation/discount_tax', '1', 'store', 'default'), + Config('tax/calculation/apply_after_discount', '0', 'store', 'default'), + Config('tax/calculation/cross_border_trade_enabled', '1', 'store', 'default'), + Config('carriers/flatrate/active', '1', 'store', 'default'), + Config('carriers/flatrate/price', '5', 'store', 'default'), + Config('carriers/flatrate/type', 'O', 'store', 'default'), + DataFixture( + TaxRateFixture::class, + ['tax_country_id' => 'US', 'tax_region_id' => 0, 'tax_postcode' => '*', 'rate' => 20], + 'taxRate' + ), + DataFixture( + TaxRuleFixture::class, + [ + 'customer_tax_class_ids' => [3], + 'product_tax_class_ids' => [2], + 'tax_rate_ids' => ['$taxRate.id$'], + ] + ), + DataFixture(ProductFixture::class, ['price' => 42.5, 'tax_class_id' => 2], 'product'), + DataFixture( + RuleFixture::class, + [ + 'simple_action' => Rule::CART_FIXED_ACTION, + 'discount_amount' => 56, + 'apply_to_shipping' => 1, + 'stop_rules_processing' => 0, + 'coupon_code' => self::COUPON_CODE, + ], + 'rule' + ), + DataFixture(GuestCartFixture::class, as: 'cart'), + DataFixture( + AddProductToCartFixture::class, + ['cart_id' => '$cart.id$', 'product_id' => '$product.id$', 'qty' => 1] + ), + ] + public function testCartFixedMixedCatalogExclShippingInclDiscountIncl(): void + { + $quote = $this->getCartQuote(); + $this->couponManagement->set((int) $quote->getId(), self::COUPON_CODE); + $quote = $this->getCartQuote(); + $this->assignCaliforniaShipping($quote); + $quote->setTotalsCollectedFlag(false); + $quote->collectTotals(); + $this->quoteRepository->save($quote); + $quote = $this->getCartQuote(); + $address = $quote->getShippingAddress(); + + $this->assertEqualsWithDelta(0.0, (float) $quote->getGrandTotal(), self::EPSILON); + $this->assertEqualsWithDelta(-56.0, (float) $address->getDiscountAmount(), self::EPSILON); + $this->assertEqualsWithDelta(5.0, (float) $address->getShippingDiscountAmount(), self::EPSILON); + $this->assertLessThanOrEqual(56.0 + self::EPSILON, abs((float) $address->getDiscountAmount())); + } + + private function getCartQuote(): Quote + { + $cart = DataFixtureStorageManager::getStorage()->get('cart'); + return $this->quoteRepository->get((int) $cart->getId()); + } + + private function assignCaliforniaShipping(Quote $quote): void + { + $addressData = [ + 'firstname' => 'Test', + 'lastname' => 'User', + 'street' => ['123 Test St'], + 'city' => 'Los Angeles', + 'region' => 'CA', + 'region_id' => 12, + 'postcode' => '90210', + 'country_id' => 'US', + 'telephone' => '5555555555', + 'email' => 'cart-fixed-shipping-tax@example.com', + ]; + $quote->getBillingAddress()->addData($addressData); + $quote->getShippingAddress() + ->addData($addressData) + ->setCollectShippingRates(true) + ->setShippingMethod('flatrate_flatrate') + ->setSameAsBilling(1); + } +}