Skip to content
Open
101 changes: 79 additions & 22 deletions app/code/Magento/QuoteGraphQl/Model/Resolver/CartPrices.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php
/**
* Copyright 2019 Adobe
* Copyright 2025 Adobe
* All Rights Reserved.
*/
declare(strict_types=1);
Expand All @@ -13,13 +13,20 @@
use Magento\Framework\GraphQl\Config\Element\Field;
use Magento\Framework\GraphQl\Query\ResolverInterface;
use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;
use Magento\Framework\Api\DataObjectHelper;
use Magento\Framework\Api\ExtensibleDataInterface;
use Magento\Quote\Api\Data\TotalsInterface as QuoteTotalsInterface;
use Magento\Quote\Api\Data\TotalsInterfaceFactory;
use Magento\Quote\Model\Quote;
use Magento\Quote\Model\Quote\Address\Total;
use Magento\Quote\Model\Cart\Totals as CartTotals;
use Magento\QuoteGraphQl\Model\Cart\TotalsCollector;
use Magento\Store\Model\ScopeInterface;

/**
* @inheritdoc
*
* @SuppressWarnings(PHPMD.CouplingBetweenObjects)
*/
class CartPrices implements ResolverInterface
{
Expand All @@ -28,17 +35,26 @@ class CartPrices implements ResolverInterface
*/
private $totalsCollector;

/**
* @var string
*/
private const QUERY_TYPE = 'query';

/**
* @var ScopeConfigInterface
*/
private ScopeConfigInterface $scopeConfig;

/**
* @param TotalsCollector $totalsCollector
* @param TotalsInterfaceFactory $totalsFactory
* @param DataObjectHelper $dataObjectHelper
* @param ScopeConfigInterface|null $scopeConfig
*/
public function __construct(
TotalsCollector $totalsCollector,
private TotalsInterfaceFactory $totalsFactory,
private DataObjectHelper $dataObjectHelper,
?ScopeConfigInterface $scopeConfig = null
) {
$this->totalsCollector = $totalsCollector;
Expand All @@ -56,16 +72,30 @@ public function resolve(Field $field, $context, ResolveInfo $info, ?array $value

/** @var Quote $quote */
$quote = $value['model'];
/**
* To calculate a right discount value
* before calculate totals
* need to reset Cart Fixed Rules in the quote
*/
$quote->setCartFixedRules([]);
$cartTotals = $this->totalsCollector->collectQuoteTotals($quote);
$currency = $quote->getQuoteCurrencyCode();

$appliedTaxes = $this->getAppliedTaxes($cartTotals, $currency);
if (!$quote->isVirtual() && $info->operation->operation == self::QUERY_TYPE) {
$addressTotalsData = $quote->getShippingAddress()->getData();
unset($addressTotalsData[ExtensibleDataInterface::EXTENSION_ATTRIBUTES_KEY]);
$cartTotals = $this->totalsFactory->create();
$this->dataObjectHelper->populateWithArray(
$cartTotals,
$addressTotalsData,
QuoteTotalsInterface::class
);

$appliedTaxes = $this->getAppliedTaxes($quote->getShippingAddress(), $currency);
} else {
/**
* To calculate a right discount value
* before calculate totals
* need to reset Cart Fixed Rules in the quote
*/
$quote->setCartFixedRules([]);
$cartTotals = $this->totalsCollector->collectQuoteTotals($quote);
$appliedTaxes = $this->getAppliedTaxes($cartTotals, $currency);
}

$grandTotal = $cartTotals->getGrandTotal();

$totalAppliedTaxes = 0;
Expand All @@ -92,14 +122,19 @@ public function resolve(Field $field, $context, ResolveInfo $info, ?array $value
/**
* Returns taxes applied to the current quote
*
* @param Total $total
* @param \Magento\Quote\Model\Quote\Address|Total $addressOrTotals
* @param string $currency
* @return array
* @throws \InvalidArgumentException
*/
private function getAppliedTaxes(Total $total, string $currency): array
private function getAppliedTaxes($addressOrTotals, string $currency): array
{
if (!$addressOrTotals instanceof Total && !$addressOrTotals instanceof \Magento\Quote\Model\Quote\Address) {
throw new \InvalidArgumentException('Unsupported totals type: ' . get_class($addressOrTotals));
}

$appliedTaxesData = [];
$appliedTaxes = $total->getAppliedTaxes();
$appliedTaxes = $addressOrTotals->getAppliedTaxes();

if (empty($appliedTaxes)) {
return $appliedTaxesData;
Expand Down Expand Up @@ -133,37 +168,59 @@ private function getAppliedTaxes(Total $total, string $currency): array
/**
* Returns information about an applied discount
*
* @param Total $total
* @param Total|CartTotals $totals
* @param string $currency
* @return array|null
* @throws \InvalidArgumentException
*/
private function getDiscount(Total $total, string $currency)
private function getDiscount($totals, string $currency)
{
if ($total->getDiscountAmount() === 0) {
$this->validateTotalsInstance($totals);

if ($totals->getDiscountAmount() === 0) {
return null;
}
return [
'label' => $total->getDiscountDescription() !== null ? explode(', ', $total->getDiscountDescription()) : [],
'amount' => ['value' => $total->getDiscountAmount(), 'currency' => $currency]
'label' => $totals->getDiscountDescription() !== null ?
explode(', ', $totals->getDiscountDescription()) : [],
'amount' => ['value' => $totals->getDiscountAmount(), 'currency' => $currency]
];
}

/**
* Get Subtotal with discount excluding tax.
*
* @param Total $cartTotals
* @param Total|CartTotals $totals
* @return float
* @throws \InvalidArgumentException
*/
private function getSubtotalWithDiscountExcludingTax(Total $cartTotals): float
private function getSubtotalWithDiscountExcludingTax($totals): float
{
$this->validateTotalsInstance($totals);

$discountIncludeTax = $this->scopeConfig->getValue(
'tax/calculation/discount_tax',
ScopeInterface::SCOPE_STORE
) ?? 0;
$discountExclTax = $discountIncludeTax ?
$cartTotals->getDiscountAmount() + $cartTotals->getDiscountTaxCompensationAmount() :
$cartTotals->getDiscountAmount();
$totals->getDiscountAmount() + $totals->getDiscountTaxCompensationAmount() :
$totals->getDiscountAmount();

return $cartTotals->getSubtotal() + $discountExclTax;
return $totals->getSubtotal() + $discountExclTax;
}

/**
* Validates the provided totals instance to ensure it is of a supported type.
*
* @param Total|CartTotals $totals
* @return void
* @throws \InvalidArgumentException If the provided totals instance is of an unsupported type.
*/
private function validateTotalsInstance($totals)
{

if (!$totals instanceof Total && !$totals instanceof CartTotals) {
throw new \InvalidArgumentException('Unsupported totals type: ' . get_class($totals));
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php
/**
* Copyright 2021 Adobe
* Copyright 2025 Adobe
* All Rights Reserved.
*/
declare(strict_types=1);
Expand All @@ -11,16 +11,23 @@
use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\GraphQl\Config\Element\Field;
use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;
use Magento\Framework\Api\DataObjectHelper;
use Magento\GraphQl\Model\Query\Context;
use Magento\Quote\Api\Data\TotalsInterface;
use Magento\Quote\Api\Data\TotalsInterfaceFactory;
use Magento\Quote\Model\Quote;
use Magento\Quote\Model\Quote\Address;
use Magento\Quote\Model\Quote\Address\Total;
use Magento\QuoteGraphQl\Model\Cart\TotalsCollector;
use Magento\QuoteGraphQl\Model\Resolver\CartPrices;
use GraphQL\Language\AST\OperationDefinitionNode;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\MockObject\MockObject;

/**
* @see CartPrices
*
* @SuppressWarnings(PHPMD.CouplingBetweenObjects)
*/
class CartPricesTest extends TestCase
{
Expand Down Expand Up @@ -49,6 +56,11 @@ class CartPricesTest extends TestCase
*/
private ResolveInfo $resolveInfoMock;

/**
* @var DataObjectHelper|MockObject
*/
private DataObjectHelper $dataObjectHelperMock;

/**
* @var Context|MockObject
*/
Expand All @@ -64,6 +76,16 @@ class CartPricesTest extends TestCase
*/
private Total $totalMock;

/**
* @var TotalsInterfaceFactory|MockObject
*/
private $totalsFactoryMock;

/**
* @var Address|MockObject
*/
private $shippingAddressMock;

/**
* @var array
*/
Expand All @@ -72,13 +94,31 @@ class CartPricesTest extends TestCase
protected function setUp(): void
{
$this->totalsCollectorMock = $this->createMock(TotalsCollector::class);
$this->dataObjectHelperMock = $this->createMock(DataObjectHelper::class);
$this->totalsFactoryMock = $this->getMockBuilder(TotalsInterfaceFactory::class)
->disableOriginalConstructor()
->onlyMethods(['create'])
->addMethods(
[
'getSubtotal',
'getSubtotalInclTax',
'getGrandTotal',
'getDiscountTaxCompensationAmount',
'getDiscountAmount',
'getDiscountDescription',
'getAppliedTaxes'
]
)
->getMock();
$this->scopeConfigMock = $this->createMock(ScopeConfigInterface::class);
$this->fieldMock = $this->createMock(Field::class);
$this->resolveInfoMock = $this->createMock(ResolveInfo::class);
$this->resolveInfoMock->operation = new OperationDefinitionNode([]);
$this->contextMock = $this->createMock(Context::class);
$this->quoteMock = $this->getMockBuilder(Quote::class)
->disableOriginalConstructor()
->addMethods(['getQuoteCurrencyCode'])
->onlyMethods(['isVirtual', 'getShippingAddress'])
->getMock();
$this->totalMock = $this->getMockBuilder(Total::class)
->disableOriginalConstructor()
Expand All @@ -96,6 +136,8 @@ protected function setUp(): void
->getMock();
$this->cartPrices = new CartPrices(
$this->totalsCollectorMock,
$this->totalsFactoryMock,
$this->dataObjectHelperMock,
$this->scopeConfigMock
);
}
Expand All @@ -107,7 +149,70 @@ public function testResolveWithoutModelInValueParameter(): void
$this->cartPrices->resolve($this->fieldMock, $this->contextMock, $this->resolveInfoMock, $this->valueMock);
}

public function testResolve(): void
public function testResolveQuery(): void
{
$this->resolveInfoMock->operation->operation = 'query';

$this->shippingAddressMock = $this->getMockBuilder(Address::class)
->disableOriginalConstructor()
->onlyMethods(['getData'])
->getMock();

$this->shippingAddressMock->expects($this->any())
->method('getData')
->willReturn([]);

$this->quoteMock
->expects($this->once())
->method('isVirtual')
->willReturn(0);

$this->quoteMock
->expects($this->any())
->method('getShippingAddress')
->willReturn($this->shippingAddressMock);

$this->dataObjectHelperMock->expects($this->once())
->method('populateWithArray')
->with(
$this->identicalTo($this->totalMock),
[],
TotalsInterface::class
);

$this->totalsFactoryMock
->expects($this->once())
->method('create')
->willReturn($this->totalMock);

$this->resolve();
}

public function testResolveQueryVirtual(): void
{
$this->quoteMock
->expects($this->once())
->method('isVirtual')
->willReturn(1);

$this->totalMock
->expects($this->once())
->method('getAppliedTaxes');

$this->resolve();
}
public function testResolveMutation(): void
{
$this->resolveInfoMock->operation->operation = 'mutation';

$this->totalMock
->expects($this->once())
->method('getAppliedTaxes');

$this->resolve();
}

private function resolve(): void
{
$this->valueMock = ['model' => $this->quoteMock];
$this->quoteMock
Expand All @@ -126,9 +231,6 @@ public function testResolve(): void
$this->totalMock
->method('getDiscountDescription')
->willReturn('Discount Description');
$this->totalMock
->expects($this->once())
->method('getAppliedTaxes');
$this->scopeConfigMock
->expects($this->once())
->method('getValue')
Expand Down