diff --git a/Helper/PaymentResponseHandler.php b/Helper/PaymentResponseHandler.php
index 5ef5053f19..0f17322086 100644
--- a/Helper/PaymentResponseHandler.php
+++ b/Helper/PaymentResponseHandler.php
@@ -74,10 +74,16 @@ public function __construct(
public function formatPaymentResponse(
string $resultCode,
- ?array $action = null
+ ?array $action = null,
+ ?bool $donationTokenExists = false
): array {
switch ($resultCode) {
case self::AUTHORISED:
+ return [
+ "isFinal" => true,
+ "resultCode" => $resultCode,
+ "canDonate" => $donationTokenExists
+ ];
case self::REFUSED:
case self::ERROR:
case self::POS_SUCCESS:
diff --git a/Model/Api/AdyenOrderPaymentStatus.php b/Model/Api/AdyenOrderPaymentStatus.php
index 55cc124b4a..e12d57010d 100644
--- a/Model/Api/AdyenOrderPaymentStatus.php
+++ b/Model/Api/AdyenOrderPaymentStatus.php
@@ -61,7 +61,8 @@ public function getOrderPaymentStatus(string $orderId): string
return json_encode($this->paymentResponseHandler->formatPaymentResponse(
$additionalInformation['resultCode'],
- !empty($additionalInformation['action']) ? $additionalInformation['action'] : null
+ !empty($additionalInformation['action']) ? $additionalInformation['action'] : null,
+ !empty($additionalInformation['donationToken'])
));
}
}
diff --git a/Model/Api/AdyenPaymentsDetails.php b/Model/Api/AdyenPaymentsDetails.php
index 5691a359a6..2f2996539c 100644
--- a/Model/Api/AdyenPaymentsDetails.php
+++ b/Model/Api/AdyenPaymentsDetails.php
@@ -75,7 +75,7 @@ public function initiate(string $payload, string $orderId): string
$this->paymentResponseHandler->formatPaymentResponse(
$response['resultCode'],
$response['action'] ?? null,
- $response['additionalData'] ?? null
+ !empty($response['donationToken'])
)
);
}
diff --git a/Model/Api/GuestAdyenOrderPaymentStatus.php b/Model/Api/GuestAdyenOrderPaymentStatus.php
index f652881dd2..477486714d 100644
--- a/Model/Api/GuestAdyenOrderPaymentStatus.php
+++ b/Model/Api/GuestAdyenOrderPaymentStatus.php
@@ -78,7 +78,7 @@ public function getOrderPaymentStatus(string $orderId, string $cartId): string
return json_encode($this->paymentResponseHandler->formatPaymentResponse(
$additionalInformation['resultCode'],
!empty($additionalInformation['action']) ? $additionalInformation['action'] : null,
- !empty($additionalInformation['additionalData']) ? $additionalInformation['additionalData'] : null
+ !empty($additionalInformation['donationToken'])
));
}
}
diff --git a/Model/GraphqlInputArgumentValidator.php b/Model/GraphqlInputArgumentValidator.php
new file mode 100644
index 0000000000..ec2cabe0b4
--- /dev/null
+++ b/Model/GraphqlInputArgumentValidator.php
@@ -0,0 +1,53 @@
+
+ */
+declare(strict_types=1);
+
+namespace Adyen\Payment\Model;
+
+use Magento\Framework\GraphQl\Exception\GraphQlInputException;
+
+class GraphqlInputArgumentValidator
+{
+ /**
+ * Validates GraphQl input arguments
+ *
+ * Multidimensional arrays can be validated with fields separated with a dot.
+ *
+ * @param array|null $args
+ * @param array $requiredFields
+ * @return void
+ * @throws GraphQlInputException
+ */
+ public function execute(?array $args, array $requiredFields): void
+ {
+ $missingFields = [];
+
+ foreach ($requiredFields as $field) {
+ $keys = explode('.', $field);
+ $value = $args;
+
+ foreach ($keys as $key) {
+ $value = $value[$key] ?? null;
+ }
+
+ if (empty($value)) {
+ $missingFields[] = $field;
+ }
+ }
+
+ if (!empty($missingFields)) {
+ throw new GraphQlInputException(
+ __('Required parameters "%1" are missing', implode(', ', $missingFields))
+ );
+ }
+ }
+}
diff --git a/Model/Resolver/AbstractDonationResolver.php b/Model/Resolver/AbstractDonationResolver.php
new file mode 100644
index 0000000000..1fe1a26952
--- /dev/null
+++ b/Model/Resolver/AbstractDonationResolver.php
@@ -0,0 +1,131 @@
+
+ */
+declare(strict_types=1);
+
+namespace Adyen\Payment\Model\Resolver;
+
+use Adyen\Payment\Exception\GraphQlAdyenException;
+use Adyen\Payment\Logger\AdyenLogger;
+use Adyen\Payment\Model\GraphqlInputArgumentValidator;
+use Adyen\Payment\Model\Sales\OrderRepository;
+use Exception;
+use Magento\Framework\Exception\LocalizedException;
+use Magento\Framework\Exception\NoSuchEntityException;
+use Magento\Framework\GraphQl\Config\Element\Field;
+use Magento\Framework\GraphQl\Exception\GraphQlInputException;
+use Magento\Framework\GraphQl\Query\ResolverInterface;
+use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;
+use Magento\GraphQl\Helper\Error\AggregateExceptionMessageFormatter;
+use Magento\Quote\Model\MaskedQuoteIdToQuoteIdInterface;
+use Magento\Sales\Api\Data\OrderInterface;
+
+abstract class AbstractDonationResolver implements ResolverInterface
+{
+ /**
+ * @param MaskedQuoteIdToQuoteIdInterface $maskedQuoteIdToQuoteId
+ * @param OrderRepository $orderRepository
+ * @param GraphqlInputArgumentValidator $graphqlInputArgumentValidator
+ * @param AdyenLogger $adyenLogger
+ * @param AggregateExceptionMessageFormatter $adyenGraphQlExceptionMessageFormatter
+ */
+ public function __construct(
+ protected readonly MaskedQuoteIdToQuoteIdInterface $maskedQuoteIdToQuoteId,
+ protected readonly OrderRepository $orderRepository,
+ protected readonly GraphqlInputArgumentValidator $graphqlInputArgumentValidator,
+ protected readonly AdyenLogger $adyenLogger,
+ protected readonly AggregateExceptionMessageFormatter $adyenGraphQlExceptionMessageFormatter
+ ) { }
+
+ /**
+ * @return array
+ */
+ abstract protected function getRequiredFields(): array;
+
+ /**
+ * @param OrderInterface $order
+ * @param array $args
+ * @param Field $field
+ * @param $context
+ * @param ResolveInfo $info
+ * @return array
+ * @throws GraphQlAdyenException
+ */
+ abstract protected function performOperation(
+ OrderInterface $order,
+ array $args,
+ Field $field,
+ $context,
+ ResolveInfo $info
+ ): array;
+
+ /**
+ * @return string
+ */
+ protected function getGenericErrorMessage(): string
+ {
+ return 'An error occurred while processing the donation.';
+ }
+
+ /**
+ * @param Field $field
+ * @param $context
+ * @param ResolveInfo $info
+ * @param array|null $value
+ * @param array|null $args
+ * @return array
+ * @throws GraphQlAdyenException
+ * @throws GraphQlInputException
+ */
+ public function resolve(
+ Field $field,
+ $context,
+ ResolveInfo $info,
+ ?array $value = null,
+ ?array $args = null
+ ): array {
+ $this->graphqlInputArgumentValidator->execute($args, $this->getRequiredFields());
+
+ try {
+ $quoteId = $this->maskedQuoteIdToQuoteId->execute($args['cartId']);
+ } catch (NoSuchEntityException $e) {
+ $this->adyenLogger->error(sprintf("Quote with masked ID %s not found!", $args['cartId']));
+ throw new GraphQlAdyenException(__($this->getGenericErrorMessage()));
+ }
+
+ $order = $this->orderRepository->getOrderByQuoteId($quoteId);
+
+ if (!$order) {
+ $this->adyenLogger->error(sprintf("Order for quote ID %s not found!", $quoteId));
+ throw new GraphQlAdyenException(__($this->getGenericErrorMessage()));
+ }
+
+ try {
+ return $this->performOperation($order, $args, $field, $context, $info);
+ } catch (LocalizedException $e) {
+ throw $this->adyenGraphQlExceptionMessageFormatter->getFormatted(
+ $e,
+ __($this->getGenericErrorMessage()),
+ $this->getGenericErrorMessage(),
+ $field,
+ $context,
+ $info
+ );
+ } catch (Exception $e) {
+ $this->adyenLogger->error(sprintf(
+ '%s: %s',
+ $this->getGenericErrorMessage(),
+ $e->getMessage()
+ ));
+ throw new GraphQlAdyenException(__($this->getGenericErrorMessage()));
+ }
+ }
+}
diff --git a/Model/Resolver/DonationCampaigns.php b/Model/Resolver/DonationCampaigns.php
new file mode 100644
index 0000000000..fb4cd62c3d
--- /dev/null
+++ b/Model/Resolver/DonationCampaigns.php
@@ -0,0 +1,57 @@
+
+ */
+declare(strict_types=1);
+
+namespace Adyen\Payment\Model\Resolver;
+
+use Adyen\Payment\Exception\GraphQlAdyenException;
+use Adyen\Payment\Model\Api\AdyenDonationCampaigns;
+use Magento\Framework\App\ObjectManager;
+use Magento\Framework\Exception\LocalizedException;
+use Magento\Framework\GraphQl\Config\Element\Field;
+use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;
+use Magento\Sales\Api\Data\OrderInterface;
+
+class DonationCampaigns extends AbstractDonationResolver
+{
+ /**
+ * @return array
+ */
+ protected function getRequiredFields(): array
+ {
+ return [
+ 'cartId'
+ ];
+ }
+
+ /**
+ * @param OrderInterface $order
+ * @param array $args
+ * @param Field $field
+ * @param $context
+ * @param ResolveInfo $info
+ * @return array
+ * @throws GraphQlAdyenException|LocalizedException
+ */
+ protected function performOperation(
+ OrderInterface $order,
+ array $args,
+ Field $field,
+ $context,
+ ResolveInfo $info
+ ): array {
+ $adyenDonationCampaigns = ObjectManager::getInstance()->get(AdyenDonationCampaigns::class);
+ $campaignsResponse = $adyenDonationCampaigns->getCampaigns((int) $order->getEntityId());
+
+ return ['campaignsData' => $campaignsResponse];
+ }
+}
diff --git a/Model/Resolver/Donations.php b/Model/Resolver/Donations.php
new file mode 100644
index 0000000000..b9df12cfe2
--- /dev/null
+++ b/Model/Resolver/Donations.php
@@ -0,0 +1,72 @@
+
+ */
+declare(strict_types=1);
+
+namespace Adyen\Payment\Model\Resolver;
+
+use Adyen\Payment\Exception\GraphQlAdyenException;
+use Adyen\Payment\Model\Api\AdyenDonations;
+use Magento\Framework\App\ObjectManager;
+use Magento\Framework\Exception\LocalizedException;
+use Magento\Framework\GraphQl\Config\Element\Field;
+use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;
+use Magento\Framework\Serialize\Serializer\Json;
+use Magento\Sales\Api\Data\OrderInterface;
+
+class Donations extends AbstractDonationResolver
+{
+ /**
+ * @return array
+ */
+ protected function getRequiredFields(): array
+ {
+ return [
+ 'cartId',
+ 'amount',
+ 'amount.currency',
+ 'returnUrl'
+ ];
+ }
+
+ /**
+ * @param OrderInterface $order
+ * @param array $args
+ * @param Field $field
+ * @param $context
+ * @param ResolveInfo $info
+ * @return array
+ * @throws GraphQlAdyenException|LocalizedException
+ */
+ protected function performOperation(
+ OrderInterface $order,
+ array $args,
+ Field $field,
+ $context,
+ ResolveInfo $info
+ ): array {
+ $payloadData = [
+ 'amount' => [
+ 'currency' => $args['amount']['currency'],
+ 'value' => $args['amount']['value']
+ ],
+ 'returnUrl' => $args['returnUrl']
+ ];
+
+ $jsonSerializer = ObjectManager::getInstance()->get(Json::class);
+ $payload = $jsonSerializer->serialize($payloadData);
+
+ $adyenDonations = ObjectManager::getInstance()->get(AdyenDonations::class);
+ $adyenDonations->makeDonation($payload, $order);
+
+ return ['status' => true];
+ }
+}
diff --git a/Test/Unit/Helper/PaymentResponseHandlerTest.php b/Test/Unit/Helper/PaymentResponseHandlerTest.php
index 5579bb48ba..738391bc9c 100644
--- a/Test/Unit/Helper/PaymentResponseHandlerTest.php
+++ b/Test/Unit/Helper/PaymentResponseHandlerTest.php
@@ -118,6 +118,10 @@ public function testFormatPaymentResponseForFinalResultCodes($resultCode)
"resultCode" => $resultCode
];
+ if ($resultCode === PaymentResponseHandler::AUTHORISED) {
+ $expectedResult["canDonate"] = false;
+ }
+
// Execute method of the tested class
$result = $this->paymentResponseHandler->formatPaymentResponse($resultCode);
@@ -125,6 +129,28 @@ public function testFormatPaymentResponseForFinalResultCodes($resultCode)
$this->assertEquals($expectedResult, $result);
}
+ /**
+ * @return void
+ */
+ public function testFormatPaymentResponseForAuthorisedWithDonationToken()
+ {
+ $expectedResult = [
+ "isFinal" => true,
+ "resultCode" => PaymentResponseHandler::AUTHORISED,
+ "canDonate" => true
+ ];
+
+ // Execute method of the tested class
+ $result = $this->paymentResponseHandler->formatPaymentResponse(
+ PaymentResponseHandler::AUTHORISED,
+ null,
+ true
+ );
+
+ // Assert conditions
+ $this->assertEquals($expectedResult, $result);
+ }
+
public static function dataSourceForFormatPaymentResponseActionRequiredPayments(): array
{
return [
diff --git a/Test/Unit/Model/GraphqlInputArgumentValidatorTest.php b/Test/Unit/Model/GraphqlInputArgumentValidatorTest.php
new file mode 100644
index 0000000000..d10716acf3
--- /dev/null
+++ b/Test/Unit/Model/GraphqlInputArgumentValidatorTest.php
@@ -0,0 +1,172 @@
+
+ */
+declare(strict_types=1);
+
+namespace Adyen\Payment\Test\Unit\Model;
+
+use Adyen\Payment\Model\GraphqlInputArgumentValidator;
+use Adyen\Payment\Test\Unit\AbstractAdyenTestCase;
+use Magento\Framework\GraphQl\Exception\GraphQlInputException;
+
+class GraphqlInputArgumentValidatorTest extends AbstractAdyenTestCase
+{
+ private GraphqlInputArgumentValidator $validator;
+
+ protected function setUp(): void
+ {
+ $this->validator = new GraphqlInputArgumentValidator();
+ }
+
+ /**
+ * @return void
+ * @throws GraphQlInputException
+ */
+ public function testExecuteWithAllRequiredFieldsPresent()
+ {
+ $args = [
+ 'name' => 'John',
+ 'email' => 'john@example.com'
+ ];
+ $requiredFields = ['name', 'email'];
+
+ $this->validator->execute($args, $requiredFields);
+
+ // No exception means the test passes
+ $this->assertTrue(true);
+ }
+
+ /**
+ * @return void
+ * @throws GraphQlInputException
+ */
+ public function testExecuteWithNestedRequiredFieldsPresent()
+ {
+ $args = [
+ 'payment' => [
+ 'method' => 'adyen_cc',
+ 'details' => [
+ 'brand' => 'visa'
+ ]
+ ]
+ ];
+ $requiredFields = ['payment.method', 'payment.details.brand'];
+
+ $this->validator->execute($args, $requiredFields);
+
+ $this->assertTrue(true);
+ }
+
+ /**
+ * @return void
+ */
+ public function testExecuteThrowsExceptionForMissingTopLevelField()
+ {
+ $args = [
+ 'name' => 'John'
+ ];
+ $requiredFields = ['name', 'email'];
+
+ $this->expectException(GraphQlInputException::class);
+ $this->expectExceptionMessage('email');
+
+ $this->validator->execute($args, $requiredFields);
+ }
+
+ /**
+ * @return void
+ */
+ public function testExecuteThrowsExceptionForMissingNestedField()
+ {
+ $args = [
+ 'payment' => [
+ 'method' => 'adyen_cc'
+ ]
+ ];
+ $requiredFields = ['payment.details.brand'];
+
+ $this->expectException(GraphQlInputException::class);
+ $this->expectExceptionMessage('payment.details.brand');
+
+ $this->validator->execute($args, $requiredFields);
+ }
+
+ /**
+ * @return void
+ */
+ public function testExecuteThrowsExceptionWithMultipleMissingFields()
+ {
+ $args = [
+ 'name' => 'John'
+ ];
+ $requiredFields = ['email', 'phone'];
+
+ $this->expectException(GraphQlInputException::class);
+ $this->expectExceptionMessage('email, phone');
+
+ $this->validator->execute($args, $requiredFields);
+ }
+
+ /**
+ * @return void
+ */
+ public function testExecuteThrowsExceptionForNullArgs()
+ {
+ $requiredFields = ['name'];
+
+ $this->expectException(GraphQlInputException::class);
+ $this->expectExceptionMessage('name');
+
+ $this->validator->execute(null, $requiredFields);
+ }
+
+ /**
+ * @return void
+ */
+ public function testExecuteThrowsExceptionForEmptyArgs()
+ {
+ $requiredFields = ['name'];
+
+ $this->expectException(GraphQlInputException::class);
+ $this->expectExceptionMessage('name');
+
+ $this->validator->execute([], $requiredFields);
+ }
+
+ /**
+ * @return void
+ * @throws GraphQlInputException
+ */
+ public function testExecuteWithNoRequiredFields()
+ {
+ $args = ['name' => 'John'];
+
+ $this->validator->execute($args, []);
+
+ $this->assertTrue(true);
+ }
+
+ /**
+ * @return void
+ */
+ public function testExecuteThrowsExceptionForEmptyStringValue()
+ {
+ $args = [
+ 'name' => ''
+ ];
+ $requiredFields = ['name'];
+
+ $this->expectException(GraphQlInputException::class);
+ $this->expectExceptionMessage('name');
+
+ $this->validator->execute($args, $requiredFields);
+ }
+}
diff --git a/Test/Unit/Model/Resolver/DonationCampaignsTest.php b/Test/Unit/Model/Resolver/DonationCampaignsTest.php
new file mode 100644
index 0000000000..2fed44bdb5
--- /dev/null
+++ b/Test/Unit/Model/Resolver/DonationCampaignsTest.php
@@ -0,0 +1,259 @@
+
+ */
+declare(strict_types=1);
+
+namespace Adyen\Payment\Test\Unit\Model\Resolver;
+
+use Adyen\Payment\Exception\GraphQlAdyenException;
+use Adyen\Payment\Logger\AdyenLogger;
+use Adyen\Payment\Model\Api\AdyenDonationCampaigns;
+use Adyen\Payment\Model\GraphqlInputArgumentValidator;
+use Adyen\Payment\Model\Resolver\DonationCampaigns;
+use Adyen\Payment\Model\Sales\OrderRepository;
+use Adyen\Payment\Test\Unit\AbstractAdyenTestCase;
+use Exception;
+use Magento\Framework\App\ObjectManager;
+use Magento\Framework\Exception\LocalizedException;
+use Magento\Framework\Exception\NoSuchEntityException;
+use Magento\Framework\GraphQl\Config\Element\Field;
+use Magento\Framework\GraphQl\Exception\GraphQlInputException;
+use Magento\Framework\GraphQl\Query\Resolver\ContextInterface;
+use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;
+use Magento\Framework\ObjectManagerInterface;
+use Magento\GraphQl\Helper\Error\AggregateExceptionMessageFormatter;
+use Magento\Quote\Model\MaskedQuoteIdToQuoteIdInterface;
+use Magento\Sales\Model\Order;
+use PHPUnit\Framework\MockObject\MockObject;
+
+class DonationCampaignsTest extends AbstractAdyenTestCase
+{
+ private MockObject $adyenDonationCampaignsMock;
+ private MockObject $maskedQuoteIdToQuoteIdMock;
+ private MockObject $orderRepositoryMock;
+ private MockObject $graphqlInputArgumentValidatorMock;
+ private MockObject $adyenLoggerMock;
+ private MockObject $adyenGraphQlExceptionMessageFormatterMock;
+ private MockObject $fieldMock;
+ private MockObject $contextMock;
+ private MockObject $infoMock;
+ private DonationCampaigns $donationCampaignsResolver;
+
+ protected function setUp(): void
+ {
+ $this->adyenDonationCampaignsMock = $this->createMock(AdyenDonationCampaigns::class);
+ $this->maskedQuoteIdToQuoteIdMock = $this->createMock(MaskedQuoteIdToQuoteIdInterface::class);
+ $this->orderRepositoryMock = $this->createMock(OrderRepository::class);
+ $this->graphqlInputArgumentValidatorMock = $this->createMock(GraphqlInputArgumentValidator::class);
+ $this->adyenLoggerMock = $this->createMock(AdyenLogger::class);
+ $this->adyenGraphQlExceptionMessageFormatterMock = $this->createMock(AggregateExceptionMessageFormatter::class);
+ $this->fieldMock = $this->createMock(Field::class);
+ $this->contextMock = $this->createMock(ContextInterface::class);
+ $this->infoMock = $this->createMock(ResolveInfo::class);
+
+ $objectManagerMock = $this->createMock(ObjectManagerInterface::class);
+ $objectManagerMock->method('get')->willReturnMap([
+ [AdyenDonationCampaigns::class, $this->adyenDonationCampaignsMock]
+ ]);
+ ObjectManager::setInstance($objectManagerMock);
+
+ $this->donationCampaignsResolver = new DonationCampaigns(
+ $this->maskedQuoteIdToQuoteIdMock,
+ $this->orderRepositoryMock,
+ $this->graphqlInputArgumentValidatorMock,
+ $this->adyenLoggerMock,
+ $this->adyenGraphQlExceptionMessageFormatterMock
+ );
+ }
+
+ public function testResolveSuccessful(): void
+ {
+ $args = ['cartId' => 'masked_cart_id'];
+ $quoteId = 123;
+ $orderId = 456;
+ $campaignsResponse = '{"campaigns": [{"id": "1"}]}';
+
+ $orderMock = $this->createMock(Order::class);
+ $orderMock->method('getEntityId')->willReturn($orderId);
+
+ $this->maskedQuoteIdToQuoteIdMock->method('execute')
+ ->with('masked_cart_id')
+ ->willReturn($quoteId);
+
+ $this->orderRepositoryMock->method('getOrderByQuoteId')
+ ->with($quoteId)
+ ->willReturn($orderMock);
+
+ $this->adyenDonationCampaignsMock->expects($this->once())
+ ->method('getCampaigns')
+ ->with($orderId)
+ ->willReturn($campaignsResponse);
+
+ $result = $this->donationCampaignsResolver->resolve(
+ $this->fieldMock,
+ $this->contextMock,
+ $this->infoMock,
+ null,
+ $args
+ );
+
+ $this->assertEquals(['campaignsData' => $campaignsResponse], $result);
+ }
+
+ public function testResolveThrowsExceptionForMissingRequiredFields(): void
+ {
+ $args = [];
+
+ $this->graphqlInputArgumentValidatorMock->method('execute')
+ ->willThrowException(new GraphQlInputException(__('Required parameters "cartId" are missing')));
+
+ $this->expectException(GraphQlInputException::class);
+
+ $this->donationCampaignsResolver->resolve(
+ $this->fieldMock,
+ $this->contextMock,
+ $this->infoMock,
+ null,
+ $args
+ );
+ }
+
+ public function testResolveThrowsExceptionWhenQuoteNotFound(): void
+ {
+ $args = ['cartId' => 'invalid_masked_id'];
+
+ $this->maskedQuoteIdToQuoteIdMock->method('execute')
+ ->with('invalid_masked_id')
+ ->willThrowException(new NoSuchEntityException());
+
+ $this->adyenLoggerMock->expects($this->once())->method('error');
+
+ $this->expectException(GraphQlAdyenException::class);
+
+ $this->donationCampaignsResolver->resolve(
+ $this->fieldMock,
+ $this->contextMock,
+ $this->infoMock,
+ null,
+ $args
+ );
+ }
+
+ public function testResolveThrowsExceptionWhenOrderNotFound(): void
+ {
+ $args = ['cartId' => 'masked_cart_id'];
+ $quoteId = 123;
+
+ $this->maskedQuoteIdToQuoteIdMock->method('execute')
+ ->with('masked_cart_id')
+ ->willReturn($quoteId);
+
+ $this->orderRepositoryMock->method('getOrderByQuoteId')
+ ->with($quoteId)
+ ->willReturn(null);
+
+ $this->adyenLoggerMock->expects($this->once())->method('error');
+
+ $this->expectException(GraphQlAdyenException::class);
+
+ $this->donationCampaignsResolver->resolve(
+ $this->fieldMock,
+ $this->contextMock,
+ $this->infoMock,
+ null,
+ $args
+ );
+ }
+
+ public function testResolveThrowsFormattedExceptionOnLocalizedException(): void
+ {
+ $args = ['cartId' => 'masked_cart_id'];
+ $quoteId = 123;
+ $orderId = 456;
+
+ $orderMock = $this->createMock(Order::class);
+ $orderMock->method('getEntityId')->willReturn($orderId);
+
+ $this->maskedQuoteIdToQuoteIdMock->method('execute')
+ ->with('masked_cart_id')
+ ->willReturn($quoteId);
+
+ $this->orderRepositoryMock->method('getOrderByQuoteId')
+ ->with($quoteId)
+ ->willReturn($orderMock);
+
+ $localizedException = new LocalizedException(__('Unable to retrieve donation campaigns.'));
+
+ $this->adyenDonationCampaignsMock->method('getCampaigns')
+ ->with($orderId)
+ ->willThrowException($localizedException);
+
+ $formattedException = new GraphQlInputException(__('An error occurred while processing the donation.'));
+
+ $this->adyenGraphQlExceptionMessageFormatterMock
+ ->expects($this->once())
+ ->method('getFormatted')
+ ->with(
+ $this->equalTo($localizedException),
+ $this->anything(),
+ $this->equalTo('An error occurred while processing the donation.'),
+ $this->equalTo($this->fieldMock),
+ $this->equalTo($this->contextMock),
+ $this->equalTo($this->infoMock)
+ )
+ ->willReturn($formattedException);
+
+ $this->expectException(GraphQlInputException::class);
+ $this->expectExceptionMessage('An error occurred while processing the donation.');
+
+ $this->donationCampaignsResolver->resolve(
+ $this->fieldMock,
+ $this->contextMock,
+ $this->infoMock,
+ null,
+ $args
+ );
+ }
+
+ public function testResolveThrowsAdyenExceptionOnGenericException(): void
+ {
+ $args = ['cartId' => 'masked_cart_id'];
+ $quoteId = 123;
+ $orderId = 456;
+
+ $orderMock = $this->createMock(Order::class);
+ $orderMock->method('getEntityId')->willReturn($orderId);
+
+ $this->maskedQuoteIdToQuoteIdMock->method('execute')
+ ->with('masked_cart_id')
+ ->willReturn($quoteId);
+
+ $this->orderRepositoryMock->method('getOrderByQuoteId')
+ ->with($quoteId)
+ ->willReturn($orderMock);
+
+ $this->adyenDonationCampaignsMock->method('getCampaigns')
+ ->with($orderId)
+ ->willThrowException(new Exception('Unexpected error'));
+
+ $this->adyenLoggerMock->expects($this->once())->method('error');
+
+ $this->expectException(GraphQlAdyenException::class);
+
+ $this->donationCampaignsResolver->resolve(
+ $this->fieldMock,
+ $this->contextMock,
+ $this->infoMock,
+ null,
+ $args
+ );
+ }
+}
diff --git a/Test/Unit/Model/Resolver/DonationsTest.php b/Test/Unit/Model/Resolver/DonationsTest.php
new file mode 100644
index 0000000000..547739b348
--- /dev/null
+++ b/Test/Unit/Model/Resolver/DonationsTest.php
@@ -0,0 +1,301 @@
+
+ */
+declare(strict_types=1);
+
+namespace Adyen\Payment\Test\Unit\Model\Resolver;
+
+use Adyen\Payment\Exception\GraphQlAdyenException;
+use Adyen\Payment\Logger\AdyenLogger;
+use Adyen\Payment\Model\Api\AdyenDonations;
+use Adyen\Payment\Model\GraphqlInputArgumentValidator;
+use Adyen\Payment\Model\Resolver\Donations;
+use Adyen\Payment\Model\Sales\OrderRepository;
+use Adyen\Payment\Test\Unit\AbstractAdyenTestCase;
+use Exception;
+use Magento\Framework\App\ObjectManager;
+use Magento\Framework\Exception\LocalizedException;
+use Magento\Framework\Exception\NoSuchEntityException;
+use Magento\Framework\GraphQl\Config\Element\Field;
+use Magento\Framework\GraphQl\Exception\GraphQlInputException;
+use Magento\Framework\GraphQl\Query\Resolver\ContextInterface;
+use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;
+use Magento\Framework\ObjectManagerInterface;
+use Magento\Framework\Serialize\Serializer\Json;
+use Magento\GraphQl\Helper\Error\AggregateExceptionMessageFormatter;
+use Magento\Quote\Model\MaskedQuoteIdToQuoteIdInterface;
+use Magento\Sales\Model\Order;
+use PHPUnit\Framework\MockObject\MockObject;
+
+class DonationsTest extends AbstractAdyenTestCase
+{
+ private MockObject $adyenDonationsMock;
+ private MockObject $maskedQuoteIdToQuoteIdMock;
+ private MockObject $orderRepositoryMock;
+ private MockObject $jsonSerializerMock;
+ private MockObject $graphqlInputArgumentValidatorMock;
+ private MockObject $adyenLoggerMock;
+ private MockObject $adyenGraphQlExceptionMessageFormatterMock;
+ private MockObject $fieldMock;
+ private MockObject $contextMock;
+ private MockObject $infoMock;
+ private Donations $donationsResolver;
+
+ protected function setUp(): void
+ {
+ $this->adyenDonationsMock = $this->createMock(AdyenDonations::class);
+ $this->maskedQuoteIdToQuoteIdMock = $this->createMock(MaskedQuoteIdToQuoteIdInterface::class);
+ $this->orderRepositoryMock = $this->createMock(OrderRepository::class);
+ $this->jsonSerializerMock = $this->createMock(Json::class);
+ $this->graphqlInputArgumentValidatorMock = $this->createMock(GraphqlInputArgumentValidator::class);
+ $this->adyenLoggerMock = $this->createMock(AdyenLogger::class);
+ $this->adyenGraphQlExceptionMessageFormatterMock = $this->createMock(AggregateExceptionMessageFormatter::class);
+ $this->fieldMock = $this->createMock(Field::class);
+ $this->contextMock = $this->createMock(ContextInterface::class);
+ $this->infoMock = $this->createMock(ResolveInfo::class);
+
+ $objectManagerMock = $this->createMock(ObjectManagerInterface::class);
+ $objectManagerMock->method('get')->willReturnMap([
+ [Json::class, $this->jsonSerializerMock],
+ [AdyenDonations::class, $this->adyenDonationsMock]
+ ]);
+ ObjectManager::setInstance($objectManagerMock);
+
+ $this->donationsResolver = new Donations(
+ $this->maskedQuoteIdToQuoteIdMock,
+ $this->orderRepositoryMock,
+ $this->graphqlInputArgumentValidatorMock,
+ $this->adyenLoggerMock,
+ $this->adyenGraphQlExceptionMessageFormatterMock
+ );
+ }
+
+ public function testResolveSuccessful(): void
+ {
+ $args = [
+ 'cartId' => 'masked_cart_id',
+ 'amount' => [
+ 'currency' => 'EUR',
+ 'value' => 500
+ ],
+ 'returnUrl' => 'https://example.com/return'
+ ];
+
+ $quoteId = 123;
+ $orderMock = $this->createMock(Order::class);
+ $serializedPayload = '{"amount":{"currency":"EUR","value":500},"returnUrl":"https:\/\/example.com\/return"}';
+
+ $this->maskedQuoteIdToQuoteIdMock->method('execute')
+ ->with('masked_cart_id')
+ ->willReturn($quoteId);
+
+ $this->orderRepositoryMock->method('getOrderByQuoteId')
+ ->with($quoteId)
+ ->willReturn($orderMock);
+
+ $this->jsonSerializerMock->method('serialize')
+ ->willReturn($serializedPayload);
+
+ $this->adyenDonationsMock->expects($this->once())
+ ->method('makeDonation')
+ ->with($serializedPayload, $orderMock);
+
+ $result = $this->donationsResolver->resolve(
+ $this->fieldMock,
+ $this->contextMock,
+ $this->infoMock,
+ null,
+ $args
+ );
+
+ $this->assertEquals(['status' => true], $result);
+ }
+
+ public function testResolveThrowsExceptionForMissingRequiredFields(): void
+ {
+ $args = [
+ 'cartId' => 'masked_cart_id'
+ ];
+
+ $this->graphqlInputArgumentValidatorMock->method('execute')
+ ->willThrowException(new GraphQlInputException(__('Required parameters "amount, returnUrl" are missing')));
+
+ $this->expectException(GraphQlInputException::class);
+
+ $this->donationsResolver->resolve(
+ $this->fieldMock,
+ $this->contextMock,
+ $this->infoMock,
+ null,
+ $args
+ );
+ }
+
+ public function testResolveThrowsExceptionWhenQuoteNotFound(): void
+ {
+ $args = [
+ 'cartId' => 'invalid_masked_id',
+ 'amount' => [
+ 'currency' => 'EUR',
+ 'value' => 500
+ ],
+ 'returnUrl' => 'https://example.com/return'
+ ];
+
+ $this->maskedQuoteIdToQuoteIdMock->method('execute')
+ ->with('invalid_masked_id')
+ ->willThrowException(new NoSuchEntityException());
+
+ $this->adyenLoggerMock->expects($this->once())->method('error');
+
+ $this->expectException(GraphQlAdyenException::class);
+
+ $this->donationsResolver->resolve(
+ $this->fieldMock,
+ $this->contextMock,
+ $this->infoMock,
+ null,
+ $args
+ );
+ }
+
+ public function testResolveThrowsExceptionWhenOrderNotFound(): void
+ {
+ $args = [
+ 'cartId' => 'masked_cart_id',
+ 'amount' => [
+ 'currency' => 'EUR',
+ 'value' => 500
+ ],
+ 'returnUrl' => 'https://example.com/return'
+ ];
+
+ $quoteId = 123;
+
+ $this->maskedQuoteIdToQuoteIdMock->method('execute')
+ ->with('masked_cart_id')
+ ->willReturn($quoteId);
+
+ $this->orderRepositoryMock->method('getOrderByQuoteId')
+ ->with($quoteId)
+ ->willReturn(null);
+
+ $this->adyenLoggerMock->expects($this->once())->method('error');
+
+ $this->expectException(GraphQlAdyenException::class);
+
+ $this->donationsResolver->resolve(
+ $this->fieldMock,
+ $this->contextMock,
+ $this->infoMock,
+ null,
+ $args
+ );
+ }
+
+ public function testResolveThrowsFormattedExceptionOnLocalizedException(): void
+ {
+ $args = [
+ 'cartId' => 'masked_cart_id',
+ 'amount' => [
+ 'currency' => 'EUR',
+ 'value' => 500
+ ],
+ 'returnUrl' => 'https://example.com/return'
+ ];
+
+ $quoteId = 123;
+ $orderMock = $this->createMock(Order::class);
+
+ $this->maskedQuoteIdToQuoteIdMock->method('execute')
+ ->with('masked_cart_id')
+ ->willReturn($quoteId);
+
+ $this->orderRepositoryMock->method('getOrderByQuoteId')
+ ->with($quoteId)
+ ->willReturn($orderMock);
+
+ $this->jsonSerializerMock->method('serialize')
+ ->willReturn('{}');
+
+ $localizedException = new LocalizedException(__('Donation failed!'));
+
+ $this->adyenDonationsMock->method('makeDonation')
+ ->willThrowException($localizedException);
+
+ $formattedException = new GraphQlInputException(__('An error occurred while processing the donation.'));
+
+ $this->adyenGraphQlExceptionMessageFormatterMock
+ ->expects($this->once())
+ ->method('getFormatted')
+ ->with(
+ $this->equalTo($localizedException),
+ $this->anything(),
+ $this->equalTo('An error occurred while processing the donation.'),
+ $this->equalTo($this->fieldMock),
+ $this->equalTo($this->contextMock),
+ $this->equalTo($this->infoMock)
+ )
+ ->willReturn($formattedException);
+
+ $this->expectException(GraphQlInputException::class);
+ $this->expectExceptionMessage('An error occurred while processing the donation.');
+
+ $this->donationsResolver->resolve(
+ $this->fieldMock,
+ $this->contextMock,
+ $this->infoMock,
+ null,
+ $args
+ );
+ }
+
+ public function testResolveThrowsAdyenExceptionOnGenericException(): void
+ {
+ $args = [
+ 'cartId' => 'masked_cart_id',
+ 'amount' => [
+ 'currency' => 'EUR',
+ 'value' => 500
+ ],
+ 'returnUrl' => 'https://example.com/return'
+ ];
+
+ $quoteId = 123;
+ $orderMock = $this->createMock(Order::class);
+
+ $this->maskedQuoteIdToQuoteIdMock->method('execute')
+ ->with('masked_cart_id')
+ ->willReturn($quoteId);
+
+ $this->orderRepositoryMock->method('getOrderByQuoteId')
+ ->with($quoteId)
+ ->willReturn($orderMock);
+
+ $this->jsonSerializerMock->method('serialize')
+ ->willReturn('{}');
+
+ $this->adyenDonationsMock->method('makeDonation')
+ ->willThrowException(new Exception('Unexpected error'));
+
+ $this->adyenLoggerMock->expects($this->once())->method('error');
+
+ $this->expectException(GraphQlAdyenException::class);
+
+ $this->donationsResolver->resolve(
+ $this->fieldMock,
+ $this->contextMock,
+ $this->infoMock,
+ null,
+ $args
+ );
+ }
+}
diff --git a/etc/graphql/di.xml b/etc/graphql/di.xml
index 08bc8e1a49..2da51ce653 100644
--- a/etc/graphql/di.xml
+++ b/etc/graphql/di.xml
@@ -171,4 +171,14 @@
AdyenGraphQlExceptionMessageFormatter
+
+
+ AdyenGraphQlExceptionMessageFormatter
+
+
+
+
+ AdyenGraphQlExceptionMessageFormatter
+
+
diff --git a/etc/schema.graphqls b/etc/schema.graphqls
index cce10008b2..b9a4e56bbd 100644
--- a/etc/schema.graphqls
+++ b/etc/schema.graphqls
@@ -18,6 +18,10 @@ type Query {
adyenRedeemedGiftcards (
cartId: String! @doc(description: "Cart ID for which to fetch redeemed gift cards.")
): AdyenRedeemedGiftcardsResponse @resolver(class: "Adyen\\Payment\\Model\\Resolver\\GetAdyenRedeemedGiftcards")
+
+ adyenDonationCampaigns (
+ cartId: String! @doc(description: "Cart ID associated with the donation")
+ ): AdyenDonationCampaignsResponse @resolver(class: "Adyen\\Payment\\Model\\Resolver\\DonationCampaigns")
}
type Mutation {
@@ -39,6 +43,25 @@ type Mutation {
posPayment(
cartId: String! @doc(description: "Cart ID associated with the POS payment.")
): AdyenPaymentStatus @resolver(class: "Adyen\\Payment\\Model\\Resolver\\InitiatePosPayment")
+
+ adyenDonate(
+ cartId: String! @doc(description: "Cart ID associated with the donation")
+ amount: AdyenAmountInput! @doc(description: "Donation amount")
+ returnUrl: String! @doc(description: "The URL to return to after the donation is completed in case of a redirect")
+ ): AdyenDonationStatus @resolver(class: "Adyen\\Payment\\Model\\Resolver\\Donations")
+}
+
+input AdyenAmountInput {
+ currency: String! @doc(description: "Three-letter ISO currency code, e.g. EUR.")
+ value: Int! @doc(description: "Amount in minor units, e.g. 500 for 5.00 EUR.")
+}
+
+type AdyenDonationStatus {
+ status: Boolean @doc(description: "True if the donation was processed successfully.")
+}
+
+type AdyenDonationCampaignsResponse {
+ campaignsData: String @doc(description: "JSON encoded donation campaigns data.")
}
type AdyenPaymentStatus {
@@ -46,6 +69,7 @@ type AdyenPaymentStatus {
resultCode: String @doc(description: "Current state of the order in Adyen.")
additionalData: String @doc(description: "Additional data required for the next step in the payment process.")
action: String @doc(description: "Object containing information about the payment's next step.")
+ canDonate: Boolean @doc(description: "Indicates if donations are available for this order.")
}
type AdyenPaymentMethods {