From 6f2ed6562518ad3dbeddf6d03a0d116fbe56d967 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Ka=C5=82u=C5=BCny?= Date: Tue, 4 Mar 2025 09:53:16 +0100 Subject: [PATCH 001/320] SQNETS-55: add payment info to order admin view --- Block/Info/Nexi.php | 72 ++++++++++++++++++++ Gateway/Config/Config.php | 2 + etc/di.xml | 2 +- view/adminhtml/templates/info/checkout.phtml | 8 +++ 4 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 Block/Info/Nexi.php create mode 100644 view/adminhtml/templates/info/checkout.phtml diff --git a/Block/Info/Nexi.php b/Block/Info/Nexi.php new file mode 100644 index 00000000..83523849 --- /dev/null +++ b/Block/Info/Nexi.php @@ -0,0 +1,72 @@ +_scopeConfig->getValue( + Config::NEXI_LOGO, + \Magento\Store\Model\ScopeInterface::SCOPE_STORE + ); + } + + /** + * Get payment method title. + * + * @return mixed + */ + public function getPaymentMethodTitle() + { + return $this->_scopeConfig->getValue( + Config::NEXI_TITLE, + \Magento\Store\Model\ScopeInterface::SCOPE_STORE + ); + } + + /** + * Get payment selected method. + * + * @return string + * @throws \Magento\Framework\Exception\LocalizedException + */ + public function getPaymentSelectedMethod(): string + { + $order = $this->orderRepository->get($this->getInfo()->getOrder()->getId()); + + return $order->getPayment()->getAdditionalInformation(self::SELECTED_PATMENT_METHOD); + } +} diff --git a/Gateway/Config/Config.php b/Gateway/Config/Config.php index fd74f6da..e1704305 100644 --- a/Gateway/Config/Config.php +++ b/Gateway/Config/Config.php @@ -9,6 +9,8 @@ class Config extends MagentoConfig { public const CODE = 'nexi'; + public const NEXI_LOGO = 'payment/nexi/logo'; + public const NEXI_TITLE = 'payment/nexi/title'; /** * Config constructor. diff --git a/etc/di.xml b/etc/di.xml index fcc48462..4d8a566e 100644 --- a/etc/di.xml +++ b/etc/di.xml @@ -6,7 +6,7 @@ Nexi\Checkout\Gateway\Config\Config::CODE Magento\Payment\Block\Form - Magento\Payment\Block\Form + Nexi\Checkout\Block\Info\Nexi NexiValueHandlerPool Magento\Payment\Gateway\Validator\ValidatorPool NexiCommandPool diff --git a/view/adminhtml/templates/info/checkout.phtml b/view/adminhtml/templates/info/checkout.phtml new file mode 100644 index 00000000..28120e22 --- /dev/null +++ b/view/adminhtml/templates/info/checkout.phtml @@ -0,0 +1,8 @@ + + +

getPaymentMethodTitle() ? $block->getPaymentMethodTitle() : '' ?>

+ +getChildHtml()?> +Payment method: getPaymentSelectedMethod() ? $block->getPaymentSelectedMethod() : '()' ?> From d7482afa4cf409308115458fcaaf5339d7691743 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Ka=C5=82u=C5=BCny?= Date: Tue, 4 Mar 2025 09:53:29 +0100 Subject: [PATCH 002/320] SQNETS-55: add Nexi logo to display --- etc/config.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/etc/config.xml b/etc/config.xml index af3f785a..f9310bb2 100644 --- a/etc/config.xml +++ b/etc/config.xml @@ -4,6 +4,7 @@ NexiFacade Nexi Payments + https://www.pikpng.com/pngl/m/247-2474711_nexi-nexi-logo-png-clipart.png authorize_capture 0 0 From d9614d3f738093c52c225d00d8dd0a0b98b4ea95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Ka=C5=82u=C5=BCny?= Date: Tue, 4 Mar 2025 09:54:43 +0100 Subject: [PATCH 003/320] SQNETS-55: update template --- view/adminhtml/templates/info/checkout.phtml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/view/adminhtml/templates/info/checkout.phtml b/view/adminhtml/templates/info/checkout.phtml index 28120e22..ab54d30e 100644 --- a/view/adminhtml/templates/info/checkout.phtml +++ b/view/adminhtml/templates/info/checkout.phtml @@ -2,7 +2,7 @@ /** @var $block \Nexi\Checkout\Block\Info\Nexi */ ?> -

getPaymentMethodTitle() ? $block->getPaymentMethodTitle() : '' ?>

+

getPaymentMethodTitle() ? $block->getPaymentMethodTitle() : '' ?>

getChildHtml()?> -Payment method: getPaymentSelectedMethod() ? $block->getPaymentSelectedMethod() : '()' ?> +

Payment method: getPaymentSelectedMethod() ? $block->getPaymentSelectedMethod() : '()' ?>

From a1e45265a09ce1a47cfb3867885dc2d79f34c49b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Ka=C5=82u=C5=BCny?= Date: Tue, 4 Mar 2025 11:23:49 +0100 Subject: [PATCH 004/320] SQNETS-56: create plugin to provide payment data into payment section --- .../Data/PaymentMethodCustomerOrderInfo.php | 26 +++++++++++++++++++ etc/frontend/di.xml | 5 ++++ 2 files changed, 31 insertions(+) create mode 100644 Plugin/Order/Data/PaymentMethodCustomerOrderInfo.php diff --git a/Plugin/Order/Data/PaymentMethodCustomerOrderInfo.php b/Plugin/Order/Data/PaymentMethodCustomerOrderInfo.php new file mode 100644 index 00000000..d0245537 --- /dev/null +++ b/Plugin/Order/Data/PaymentMethodCustomerOrderInfo.php @@ -0,0 +1,26 @@ +getOrder()->getPayment()->getMethod() === Config::CODE) { + return $subject->getOrder()->getPayment()->getAdditionalInformation()['method_title'] + . ' (' + . $subject->getOrder()->getPayment()->getAdditionalInformation()['selected_payment_method'] + . ')'; + } else { + return $subject->getChildHtml('payment_info'); + } + } +} diff --git a/etc/frontend/di.xml b/etc/frontend/di.xml index c1d044c3..54a5b9a3 100644 --- a/etc/frontend/di.xml +++ b/etc/frontend/di.xml @@ -11,4 +11,9 @@
+ + + + + From b8f48f732f0ac5190a1fd34d4cae889e2ba906fd Mon Sep 17 00:00:00 2001 From: "konrad.konieczny" Date: Fri, 7 Mar 2025 13:44:33 +0100 Subject: [PATCH 005/320] Add Nexi payment integration with embedded checkout options --- .gitignore | 7 + Api/PaymentInitializeInterface.php | 19 +++ Gateway/Command/Initialize.php | 9 ++ Gateway/Config/Config.php | 11 ++ Gateway/Handler/CreatePayment.php | 5 +- Gateway/Http/Client.php | 45 +++++- .../Request/CreatePaymentRequestBuilder.php | 138 +++++++++++------- Gateway/Request/UpdateOrderRequestBuilder.php | 53 +++++++ LICENSE | 21 --- Model/PaymentInitialize.php | 71 +++++++++ Model/Ui/ConfigProvider.php | 1 + Plugin/UpdatePayment.php | 63 ++++++++ README.md | 2 - etc/config.xml | 1 + etc/csp_whitelist.xml | 15 ++ etc/di.xml | 17 ++- etc/payment.xml | 0 etc/webapi.xml | 13 +- view/frontend/web/js/sdk/loader.js | 37 +++++ .../payment/method-renderer/nexi-method.js | 115 ++++++++++++++- .../web/js/view/payment/nexi-payment.js | 0 view/frontend/web/template/payment/nexi.html | 31 ++-- 22 files changed, 575 insertions(+), 99 deletions(-) create mode 100644 .gitignore create mode 100644 Api/PaymentInitializeInterface.php mode change 100755 => 100644 Gateway/Command/Initialize.php create mode 100644 Gateway/Request/UpdateOrderRequestBuilder.php create mode 100644 Model/PaymentInitialize.php create mode 100644 Plugin/UpdatePayment.php create mode 100644 etc/csp_whitelist.xml mode change 100755 => 100644 etc/payment.xml create mode 100644 view/frontend/web/js/sdk/loader.js mode change 100755 => 100644 view/frontend/web/js/view/payment/method-renderer/nexi-method.js mode change 100755 => 100644 view/frontend/web/js/view/payment/nexi-payment.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..58786aac --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ + +# Playwright +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/Api/PaymentInitializeInterface.php b/Api/PaymentInitializeInterface.php new file mode 100644 index 00000000..fe91bd6a --- /dev/null +++ b/Api/PaymentInitializeInterface.php @@ -0,0 +1,19 @@ +isPaymentAlreadyCreated($payment)) { + return null; + } + try { $commandPool = $this->commandManagerPool->get(Config::CODE); $result = $commandPool->executeByCode( @@ -81,4 +85,9 @@ public function cratePayment(PaymentDataObjectInterface $payment): ?ResultInterf return $result; } + + private function isPaymentAlreadyCreated(PaymentDataObjectInterface $payment) + { + return $payment->getPayment()->getAdditionalInformation('payment_id'); + } } diff --git a/Gateway/Config/Config.php b/Gateway/Config/Config.php index 18c26a27..e0700286 100644 --- a/Gateway/Config/Config.php +++ b/Gateway/Config/Config.php @@ -5,6 +5,7 @@ use Magento\Framework\App\Config\ScopeConfigInterface; use Magento\Payment\Gateway\Config\Config as MagentoConfig; use Nexi\Checkout\Model\Config\Source\Environment; +use NexiCheckout\Model\Request\Payment\IntegrationTypeEnum; class Config extends MagentoConfig { @@ -107,6 +108,16 @@ public function getIntegrationType(): string return $this->getValue('integration_type'); } + /** + * Check integration type + * + * @return bool + */ + public function isEmbedded(): bool + { + return $this->getIntegrationType() == IntegrationTypeEnum::EmbeddedCheckout->name; + } + /** * Get webhook secret * diff --git a/Gateway/Handler/CreatePayment.php b/Gateway/Handler/CreatePayment.php index 1555d89b..2e80452f 100644 --- a/Gateway/Handler/CreatePayment.php +++ b/Gateway/Handler/CreatePayment.php @@ -3,6 +3,7 @@ namespace Nexi\Checkout\Gateway\Handler; use Magento\Payment\Gateway\Helper\SubjectReader; +use NexiCheckout\Model\Result\Payment\PaymentWithHostedCheckoutResult; class CreatePayment implements \Magento\Payment\Gateway\Response\HandlerInterface { @@ -20,6 +21,8 @@ public function handle(array $handlingSubject, array $response) $response = reset($response); $payment->setAdditionalInformation('payment_id', $response->getPaymentId()); - $payment->setAdditionalInformation('redirect_url', $response->getHostedPaymentPageUrl()); + if ($response instanceof PaymentWithHostedCheckoutResult) { + $payment->setAdditionalInformation('redirect_url', $response->getHostedPaymentPageUrl()); + } } } diff --git a/Gateway/Http/Client.php b/Gateway/Http/Client.php index 36dc6d17..8d74c022 100644 --- a/Gateway/Http/Client.php +++ b/Gateway/Http/Client.php @@ -11,10 +11,14 @@ use NexiCheckout\Factory\PaymentApiFactory; use NexiCheckout\Model\Shared\JsonDeserializeInterface; use Psr\Log\LoggerInterface; +use ReflectionException; +use function PHPUnit\Framework\isNull; class Client implements ClientInterface { + private ?string $requestHash = null; + /** * Class constructor * @@ -42,19 +46,15 @@ public function placeRequest(TransferInterface $transferObject): array try { $paymentApi = $this->getPaymentApi(); $nexiMethod = $transferObject->getUri(); - $this->logger->debug( - 'Nexi method: ' . $nexiMethod . PHP_EOL . - 'Nexi request: ' . json_encode($transferObject->getBody()) - ); + $this->logRequest($nexiMethod, $transferObject); if (is_array($transferObject->getBody())) { $response = $paymentApi->$nexiMethod(...$transferObject->getBody()); } else { $response = $paymentApi->$nexiMethod($transferObject->getBody()); } - $this->logger->debug( - 'Nexi response: ' . $this->getResponseData($response) - ); + $this->logResponse($response); + } catch (PaymentApiException|\Exception $e) { $this->logger->error($e->getMessage()); throw new LocalizedException(__('An error occurred during the payment process. Please try again later.')); @@ -69,7 +69,7 @@ public function placeRequest(TransferInterface $transferObject): array * @param JsonDeserializeInterface $response * * @return string|false - * @throws \ReflectionException + * @throws ReflectionException */ public function getResponseData($response): string|false { @@ -100,4 +100,33 @@ public function getPaymentApi(): PaymentApi $this->config->isLiveMode() ); } + + /** + * @param $response + * + * @return void + * @throws ReflectionException + */ + public function logResponse($response): void + { + if ($response instanceof JsonDeserializeInterface) { + $this->logger->debug( + 'Nexi response: ' . $this->getResponseData($response) + ); + } + } + + /** + * @param string $nexiMethod + * @param TransferInterface $transferObject + * + * @return void + */ + public function logRequest(string $nexiMethod, TransferInterface $transferObject): void + { + $this->logger->debug( + 'Nexi method: ' . $nexiMethod . PHP_EOL . + 'Nexi request: ' . json_encode($transferObject->getBody()) + ); + } } diff --git a/Gateway/Request/CreatePaymentRequestBuilder.php b/Gateway/Request/CreatePaymentRequestBuilder.php index cb97ff02..3d700cb5 100644 --- a/Gateway/Request/CreatePaymentRequestBuilder.php +++ b/Gateway/Request/CreatePaymentRequestBuilder.php @@ -7,6 +7,7 @@ use Magento\Framework\Exception\NoSuchEntityException; use Magento\Framework\UrlInterface; use Magento\Payment\Gateway\Request\BuilderInterface; +use Magento\Quote\Model\Quote; use Magento\Sales\Model\Order; use Nexi\Checkout\Gateway\Config\Config; use NexiCheckout\Model\Request\Item; @@ -45,13 +46,17 @@ public function __construct( */ public function build(array $buildSubject): array { - /** @var Order $order */ - $order = $buildSubject['payment']->getPayment()->getOrder(); + /** @var Order $paymentSubject */ + $paymentSubject = $buildSubject['payment']->getPayment()->getOrder(); + + if (!$paymentSubject) { + $paymentSubject = $buildSubject['payment']->getPayment()->getQuote(); + } return [ - 'nexi_method' => 'createPayment', + 'nexi_method' => $this->isEmbedded() ? 'createEmbeddedPayment' : 'createHostedPayment', 'body' => [ - 'payment' => $this->buildPayment($order), + 'payment' => $this->buildPayment($paymentSubject), ] ]; } @@ -76,7 +81,7 @@ public function buildOrder($order): Payment\Order * * @return Order\Item|array */ - private function buildItems(Order $order): Order\Item|array + public function buildItems($order): Order\Item|array { /** @var Order\Item $items */ foreach ($order->getAllVisibleItems() as $item) { @@ -85,7 +90,8 @@ private function buildItems(Order $order): Order\Item|array quantity : (int)$item->getQtyOrdered(), unit : 'pcs', unitPrice : (int)($item->getPrice() * 100), - grossTotalAmount: (int)($item->getRowTotalInclTax() * 100), + grossTotalAmount: (int)($item->getRowTotalInclTax() * 100) - (int)($item->getDiscountAmount() * 100), // TODO: calculate discount tax amount based on tax calculation method + netTotalAmount : (int)($item->getRowTotal() * 100), reference : $item->getSku(), taxRate : (int)($item->getTaxPercent() * 100), @@ -93,17 +99,17 @@ private function buildItems(Order $order): Order\Item|array ); } - if ($order->getShippingAmount()) { + if ($order->getShippingAddress()->getShippingInclTax() ) { $items[] = new Item( - name : $order->getShippingDescription(), + name : $order->getShippingAddress()->getShippingDescription(), quantity : 1, unit : 'pcs', - unitPrice : (int)($order->getShippingAmount() * 100), - grossTotalAmount: (int)($order->getShippingInclTax() * 100), - netTotalAmount : (int)($order->getShippingAmount() * 100), - reference : $order->getShippingMethod(), - taxRate : (int)($order->getTaxAmount() / $order->getGrandTotal() * 100), - taxAmount : (int)($order->getShippingTaxAmount() * 100), + unitPrice : (int)($order->getShippingAddress()->getShippingAmount() * 100), + grossTotalAmount: (int)($order->getShippingAddress()->getShippingInclTax() * 100), + netTotalAmount : (int)($order->getShippingAddress()->getShippingAmount() * 100), + reference : $order->getShippingAddress()->getShippingMethod(), + taxRate : (int)($order->getShippingAddress()->getTaxAmount() / $order->getShippingAddress()->getGrandTotal() * 100), + taxAmount : (int)($order->getShippingAddress()->getShippingTaxAmount() * 100), ); } @@ -111,12 +117,14 @@ private function buildItems(Order $order): Order\Item|array } /** - * @param Order $order + * Build payment object for a request + * + * @param Order|Quote $order * * @return Payment * @throws NoSuchEntityException */ - private function buildPayment(Order $order): Payment + private function buildPayment(Order|\Magento\Quote\Model\Quote $order): Payment { return new Payment( order : $this->buildOrder($order), @@ -126,15 +134,15 @@ private function buildPayment(Order $order): Payment } /** - * @return array + * TODO: added all for now, we need to check which is actually needed * - * added all for now, we need to check wh + * @return array */ public function buildWebhooks(): array { $webhooks = []; foreach (EventNameEnum::cases() as $eventName) { - $baseUrl = $this->url->getBaseUrl(); + $baseUrl = "https://a556-91-217-18-69.ngrok-free.app"; $webhooks[] = new Payment\Webhook( eventName : $eventName->value, url : $baseUrl . self::NEXI_PAYMENT_WEBHOOK_PATH, @@ -146,45 +154,30 @@ public function buildWebhooks(): array } /** - * @param Order $order + * Build Checkout request object + * + * @param Order|Quote $salesObject * * @return HostedCheckout|EmbeddedCheckout * @throws NoSuchEntityException */ - public function buildCheckout(Order $order): HostedCheckout|EmbeddedCheckout + public function buildCheckout(Quote|Order $salesObject): HostedCheckout|EmbeddedCheckout { - if ($this->config->getIntegrationType() == IntegrationTypeEnum::EmbeddedCheckout) { - return new EmbeddedCheckout( - url : $this->url->getUrl('nexi/checkout/success'), - termsUrl : $this->config->getPaymentsTermsAndConditionsUrl(), - merchantTermsUrl: $this->config->getWebshopTermsAndConditionsUrl(), - consumer : $this->buildConsumer($order), - ); - } - - return new HostedCheckout( - returnUrl : $this->url->getUrl('nexi/hpp/returnaction'), - cancelUrl : $this->url->getUrl('nexi/hpp/cancelaction'), - termsUrl : $this->config->getWebshopTermsAndConditionsUrl(), - consumer : $this->buildConsumer($order), - isAutoCharge : $this->config->getPaymentAction() == 'authorize_capture', - merchantHandlesConsumerData: $this->config->getMerchantHandlesConsumerData(), - countryCode : $this->countryInformationAcquirer->getCountryInfo( - $this->config->getCountryCode() - )->getThreeLetterAbbreviation(), - ); + return $this->isEmbedded() ? + $this->buildEmbeddedCheckout($salesObject) : + $this->buildHostedCheckout($salesObject); } - private function buildConsumer(Order $order): Consumer + private function buildConsumer($order): Consumer { return new Consumer( - email : $order->getCustomerEmail(), + email : $order->getBillingAddress()->getEmail(), reference : $order->getCustomerId(), shippingAddress: new Address( - addressLine1: $order->getShippingAddress()->getStreetLine(1), - addressLine2: $order->getShippingAddress()->getStreetLine(2), - postalCode : $order->getShippingAddress()->getPostcode(), - city : $order->getShippingAddress()->getCity(), + addressLine1: $order->getBillingAddress()->getStreetLine(1), + addressLine2: $order->getBillingAddress()->getStreetLine(2), + postalCode : $order->getBillingAddress()->getPostcode(), + city : $order->getBillingAddress()->getCity(), country : $this->countryInformationAcquirer->getCountryInfo( $this->config->getCountryCode() )->getThreeLetterAbbreviation(), @@ -200,9 +193,56 @@ private function buildConsumer(Order $order): Consumer ), privatePerson : new PrivatePerson( - firstName: $order->getCustomerFirstname(), - lastName : $order->getCustomerLastname(), + firstName: $order->getBillingAddress()->getFirstname(), + lastName : $order->getBillingAddress()->getLastname(), ) ); } + + /** + * Check integration type + * + * @return bool + */ + public function isEmbedded(): bool + { + return $this->config->getIntegrationType() == IntegrationTypeEnum::EmbeddedCheckout->name; + } + + /** + * @param Quote|Order $salesObject + * + * @return EmbeddedCheckout + */ + public function buildEmbeddedCheckout(Quote|Order $salesObject): EmbeddedCheckout + { + return new EmbeddedCheckout( + url : $this->url->getUrl('nexi/checkout/success'), + termsUrl : $this->config->getPaymentsTermsAndConditionsUrl(), +// consumer : $this->buildConsumer($salesObject), + isAutoCharge : $this->config->getPaymentAction() == 'authorize_capture', + merchantHandlesConsumerData: $this->config->getMerchantHandlesConsumerData(), + ); + } + + /** + * @param Quote|Order $salesObject + * + * @return HostedCheckout + * @throws NoSuchEntityException + */ + public function buildHostedCheckout(Quote|Order $salesObject): HostedCheckout + { + return new HostedCheckout( + returnUrl : $this->url->getUrl('nexi/hpp/returnaction'), + cancelUrl : $this->url->getUrl('nexi/hpp/cancelaction'), + termsUrl : $this->config->getWebshopTermsAndConditionsUrl(), + consumer : $this->buildConsumer($salesObject), + isAutoCharge : $this->config->getPaymentAction() == 'authorize_capture', + merchantHandlesConsumerData: $this->config->getMerchantHandlesConsumerData(), + countryCode : $this->countryInformationAcquirer->getCountryInfo( + $this->config->getCountryCode() + )->getThreeLetterAbbreviation(), + ); + } } diff --git a/Gateway/Request/UpdateOrderRequestBuilder.php b/Gateway/Request/UpdateOrderRequestBuilder.php new file mode 100644 index 00000000..dfcff624 --- /dev/null +++ b/Gateway/Request/UpdateOrderRequestBuilder.php @@ -0,0 +1,53 @@ +getPayment()?->getOrder(); + + if (!$paymentSubject) { + $paymentSubject = $buildSubject['payment']?->getPayment()?->getQuote(); + } + + if (!$paymentSubject) { + $paymentSubject = $buildSubject['payment']?->getQuote(); + } + + return [ + 'nexi_method' => 'updatePaymentOrder', + 'body' => [ + 'paymentId' => $paymentSubject->getPayment()->getAdditionalInformation('payment_id'), + 'updateOrder' => new UpdateOrder( + amount : (int)($paymentSubject->getGrandTotal() * 100), + items : $this->createPaymentRequestBuilder->buildItems($paymentSubject), + shipping : new UpdateOrder\Shipping(costSpecified: true), + paymentMethods: [], + ) + ] + ]; + } +} diff --git a/LICENSE b/LICENSE index 299e946c..e69de29b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 Solteq - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/Model/PaymentInitialize.php b/Model/PaymentInitialize.php new file mode 100644 index 00000000..c389949c --- /dev/null +++ b/Model/PaymentInitialize.php @@ -0,0 +1,71 @@ +quoteIdMaskFactory->create()->load($cartId, 'masked_id'); + + $quote = $this->quoteRepository->get($quoteIdMask->getQuoteId()); + $paymentMethod = $quote->getPayment(); + if (!$paymentMethod) { + throw new LocalizedException(__('No payment method found for the quote')); + } + if ($paymentMethod->getAdditionalInformation('payment_id')) { + + } else { + $paymentData = $this->paymentDataObjectFactory->create($paymentMethod); + $cratePayment = $this->initializeCommand->cratePayment($paymentData); + $quote->setData('no_payment_update_flag', true); + $this->quoteRepository->save($quote); + } + + return match ($integrationType) { + IntegrationTypeEnum::HostedPaymentPage->name => json_encode( + [ + 'redirect_url' => $cratePayment['body']['payment']['checkout']['url'] + ] + ), + IntegrationTypeEnum::EmbeddedCheckout->name => json_encode( + [ + 'paymentId' => $paymentMethod->getAdditionalInformation('payment_id'), + 'checkoutKey' => $this->config->getCheckoutKey() + ] + ), + default => throw new LocalizedException(__('Invalid integration type')) + }; + } catch (\Exception $e) { + throw new LocalizedException(__('Could not initialize payment: %1', $e->getMessage())); + } + } +} diff --git a/Model/Ui/ConfigProvider.php b/Model/Ui/ConfigProvider.php index d3b39844..e3095871 100644 --- a/Model/Ui/ConfigProvider.php +++ b/Model/Ui/ConfigProvider.php @@ -39,6 +39,7 @@ public function getConfig() 'environment' => $this->config->getEnvironment(), 'label' => $this->paymentHelper->getMethodInstance(Config::CODE)->getTitle(), 'integrationType' => $this->config->getIntegrationType(), + 'checkoutKey' => $this->config->getCheckoutKey() ] ] ]; diff --git a/Plugin/UpdatePayment.php b/Plugin/UpdatePayment.php new file mode 100644 index 00000000..6d99994a --- /dev/null +++ b/Plugin/UpdatePayment.php @@ -0,0 +1,63 @@ +config->isEmbedded() || $object->getData('no_payment_update_flag')) { + + return $result; + } + + //send information to the payment gateway + $quote = $object; + $payment = $quote->getPayment(); + $paymentMethod = $payment->getMethod(); + if ($paymentMethod !== Config::CODE) { + return $result; + } + + $paymentId = $payment->getAdditionalInformation('payment_id'); + if (!$paymentId) { + return $result; + } + + try { + $commandPool = $this->commandManagerPool->get(Config::CODE); + $result = $commandPool->executeByCode( + commandCode: 'update_order', + arguments : ['payment' => $payment,] + ); + } catch (\Exception $e) { + $this->logger->error($e->getMessage()); + throw new LocalizedException(__('An error occurred during the payment process. Please try again later.')); + } + + return $result; + } +} diff --git a/README.md b/README.md index bfaf0f76..e69de29b 100644 --- a/README.md +++ b/README.md @@ -1,2 +0,0 @@ -# magento-nexi-checkout -Nexi payment module for Adobe Commerce (Magento 2) diff --git a/etc/config.xml b/etc/config.xml index af3f785a..71095217 100644 --- a/etc/config.xml +++ b/etc/config.xml @@ -34,6 +34,7 @@ cc_type,cc_number,avsPostalCodeResponseCode,avsStreetAddressResponseCode,cvvResponseCode,processorAuthorizationCode,processorResponseCode,processorResponseText,liabilityShifted,liabilityShiftPossible nexi_group + HostedPaymentPage 1 diff --git a/etc/csp_whitelist.xml b/etc/csp_whitelist.xml new file mode 100644 index 00000000..ae9b1d51 --- /dev/null +++ b/etc/csp_whitelist.xml @@ -0,0 +1,15 @@ + + + + + + *.dibspayment.eu + + + + + *.dibspayment.eu + + + + diff --git a/etc/di.xml b/etc/di.xml index bf0df1de..0a33ea91 100644 --- a/etc/di.xml +++ b/etc/di.xml @@ -69,7 +69,7 @@ NexiCommandRefund - + NexiCommandUpdateOrder @@ -87,6 +87,14 @@ + + + Nexi\Checkout\Gateway\Request\UpdateOrderRequestBuilder + Nexi\Checkout\Gateway\Http\TransferFactory + Nexi\Checkout\Gateway\Http\Client + + + Nexi\Checkout\Gateway\Request\CaptureRequestBuilder @@ -204,4 +212,11 @@ Magento\Checkout\Model\Session\Proxy + + + + + diff --git a/etc/payment.xml b/etc/payment.xml old mode 100755 new mode 100644 diff --git a/etc/webapi.xml b/etc/webapi.xml index 3fa88589..b4b26690 100644 --- a/etc/webapi.xml +++ b/etc/webapi.xml @@ -1,5 +1,16 @@ - + + + + + + + + + + + + diff --git a/view/frontend/web/js/sdk/loader.js b/view/frontend/web/js/sdk/loader.js new file mode 100644 index 00000000..9c5aa56a --- /dev/null +++ b/view/frontend/web/js/sdk/loader.js @@ -0,0 +1,37 @@ +define([ + 'jquery', + 'Magento_Checkout/js/model/full-screen-loader' +], function ($, fullScreenLoader) { + 'use strict'; + + return { + loadSdk: function (isTestMode) { + return new Promise((resolve, reject) => { + if (window.Dibs?.Checkout) { + resolve(); + return; + } + + const sdkUrl = isTestMode + ? 'https://test.checkout.dibspayment.eu/v1/checkout.js?v=1' + : 'https://checkout.dibspayment.eu/v1/checkout.js?v=1'; + + fullScreenLoader.startLoader(); + + const script = document.createElement('script'); + script.src = sdkUrl; + script.async = true; + script.onload = () => { + fullScreenLoader.stopLoader(); + resolve(); + }; + script.onerror = () => { + fullScreenLoader.stopLoader(); + reject(new Error('Failed to load Nexi Checkout SDK')); + }; + + document.head.appendChild(script); + }); + } + }; +}); diff --git a/view/frontend/web/js/view/payment/method-renderer/nexi-method.js b/view/frontend/web/js/view/payment/method-renderer/nexi-method.js old mode 100755 new mode 100644 index c43fdadd..d914c684 --- a/view/frontend/web/js/view/payment/method-renderer/nexi-method.js +++ b/view/frontend/web/js/view/payment/method-renderer/nexi-method.js @@ -13,14 +13,16 @@ define( 'Magento_Checkout/js/model/url-builder', 'mage/url', 'Magento_Checkout/js/model/full-screen-loader', + 'Magento_Checkout/js/model/error-processor', 'Magento_Customer/js/model/customer', 'Magento_Checkout/js/checkout-data', 'Magento_Checkout/js/model/totals', 'Magento_Ui/js/model/messageList', 'mage/translate', - 'Magento_Ui/js/modal/modal' + 'Magento_Ui/js/modal/modal', + 'Nexi_Checkout/js/sdk/loader' ], - function (ko, $, _, storage, Component, placeOrderAction, selectPaymentMethodAction, additionalValidators, quote, getTotalsAction, urlBuilder, url, fullScreenLoader, customer, checkoutData, totals, messageList, $t, modal) { + function (ko, $, _, storage, Component, placeOrderAction, selectPaymentMethodAction, additionalValidators, quote, getTotalsAction, urlBuilder, url, fullScreenLoader, errorProcessor, customer, checkoutData, totals, messageList, $t, modal, sdkLoader) { 'use strict'; return Component.extend({ @@ -28,10 +30,31 @@ define( template: 'Nexi_Checkout/payment/nexi', config: window.checkoutConfig.payment.nexi }, + isEmbedded: ko.observable(false), + dibsCheckout: ko.observable(false), + + initialize: function () { + this._super(); + if (this.config.integrationType === 'EmbeddedCheckout') { + this.isEmbedded(true); + } + if (this.isEmbedded()) { + this.renderEmbeddedCheckout(); + } + // Subscribe to changes in quote totals + quote.totals.subscribe(function (quote) { + // Reload Nexi checkout on quote change + console.log('Quote totals changed...', quote); + if (this.dibsCheckout()) { + this.dibsCheckout().thawCheckout(); + } + }, this); + + }, placeOrder: function (data, event) { let placeOrder = placeOrderAction(this.getData(), false, this.messageContainer); - $.when(placeOrder).done(function (response) { + return $.when(placeOrder).done(function (response) { this.afterPlaceOrder(response); }.bind(this)); }, @@ -42,6 +65,92 @@ define( window.location.href = redirectUrl; } } + }, + selectPaymentMethod: function () { + this._super(); + + }, + renderEmbeddedCheckout: async function () { + try { + await sdkLoader.loadSdk(this.config.environment === 'test'); + const response = await this.initializeNexiPayment(); + if (response.paymentId) { + let checkoutOptions = { + checkoutKey: response.checkoutKey, + paymentId: response.paymentId, + containerId: "nexi-checkout-container", + language: "en-GB", + theme: { + buttonRadius: "5px" + } + }; + this.dibsCheckout(new Dibs.Checkout(checkoutOptions)); + + this.dibsCheckout().on('payment-completed', async function () { + await this.placeOrder(); + window.location.href = url.build('checkout/onepage/success'); + }.bind(this)); + + this.dibsCheckout().on('pay-initialized', function (paymentId) { + console.log('DEBUG: Payment initialized with ID:', paymentId); + //TODO: validate with backend + this.dibsCheckout().send('payment-order-finalized', true); + }.bind(this)); + } + } catch (error) { + console.error('Error loading Nexi SDK or initializing payment:', error); + } + + sdkLoader.loadSdk(this.config.environment === 'test') + .then(() => { + console.log('Nexi SDK loaded successfully'); + this.initializeNexiPayment() + .catch(function (error) { + console.error('Error loading Nexi SDK or initializing payment:', error); + }); + }) + .catch(x => { + console.error('Error loading Nexi SDK:', x); + }); + + }, + initializeNexiPayment() { + const payload = { + cartId: quote.getQuoteId(), + paymentMethod: { + method: this.getCode() + }, + integrationType: this.config.integrationType + }; + + const serviceUrl = customer.isLoggedIn() + ? urlBuilder.createUrl('/nexi/carts/mine/payment-initialize', {}) + : urlBuilder.createUrl('/nexi/guest-carts/:quoteId/payment-initialize', { + quoteId: quote.getQuoteId() + }); + + fullScreenLoader.startLoader(); + + return new Promise((resolve, reject) => { + storage.post( + serviceUrl, + JSON.stringify(payload) + ).done(function (response) { + resolve(JSON.parse(response)); + }).fail(function (response) { + errorProcessor.process(response, this.messageContainer); + let redirectURL = response.getResponseHeader('errorRedirectAction'); + + if (redirectURL) { + setTimeout(function () { + errorProcessor.redirectTo(redirectURL); + }, 3000); + } + reject(response); + }).always(function () { + fullScreenLoader.stopLoader(); + }); + }); } }); } diff --git a/view/frontend/web/js/view/payment/nexi-payment.js b/view/frontend/web/js/view/payment/nexi-payment.js old mode 100755 new mode 100644 diff --git a/view/frontend/web/template/payment/nexi.html b/view/frontend/web/template/payment/nexi.html index b20743a3..1169c4c1 100644 --- a/view/frontend/web/template/payment/nexi.html +++ b/view/frontend/web/template/payment/nexi.html @@ -6,6 +6,9 @@ data-bind="attr: {'id': getCode()}, value: getCode(), checked: isChecked, click: selectPaymentMethod, visible: isRadioButtonVisible()"/> + +
+
@@ -16,19 +19,21 @@
-
- -
+ +
+ +
+
From 0588694bff607f55757e6e04e553234b86f39048 Mon Sep 17 00:00:00 2001 From: "konrad.konieczny" Date: Fri, 14 Mar 2025 17:13:11 +0100 Subject: [PATCH 006/320] Enhance Nexi payment integration with embedded checkout support and improved error logging --- Model/Ui/ConfigProvider.php | 9 +- Plugin/GuestPaymentInformationManagement.php | 2 +- Plugin/PaymentInformationManagement.php | 2 +- view/frontend/layout/checkout_index_index.xml | 2 +- view/frontend/web/js/dibs-checkout.js | 448 ++++++++++++++++++ .../web/js/view/payment/initialize-payment.js | 52 ++ .../payment/method-renderer/nexi-method.js | 142 ++---- .../web/js/view/payment/render-embedded.js | 50 ++ view/frontend/web/template/payment/nexi.html | 26 +- 9 files changed, 620 insertions(+), 113 deletions(-) create mode 100644 view/frontend/web/js/dibs-checkout.js create mode 100644 view/frontend/web/js/view/payment/initialize-payment.js create mode 100644 view/frontend/web/js/view/payment/render-embedded.js diff --git a/Model/Ui/ConfigProvider.php b/Model/Ui/ConfigProvider.php index e3095871..18f87d08 100644 --- a/Model/Ui/ConfigProvider.php +++ b/Model/Ui/ConfigProvider.php @@ -32,16 +32,21 @@ public function getConfig() return []; } - return [ + $config = [ 'payment' => [ Config::CODE => [ 'isActive' => $this->config->isActive(), 'environment' => $this->config->getEnvironment(), 'label' => $this->paymentHelper->getMethodInstance(Config::CODE)->getTitle(), 'integrationType' => $this->config->getIntegrationType(), - 'checkoutKey' => $this->config->getCheckoutKey() ] ] ]; + + if ($this->config->isEmbedded()) { + $config['payment'][Config::CODE]['checkoutKey'] = $this->config->getCheckoutKey(); + } + + return $config; } } diff --git a/Plugin/GuestPaymentInformationManagement.php b/Plugin/GuestPaymentInformationManagement.php index 195072e9..7facb2c8 100644 --- a/Plugin/GuestPaymentInformationManagement.php +++ b/Plugin/GuestPaymentInformationManagement.php @@ -34,7 +34,7 @@ public function afterSavePaymentInformationAndPlaceOrder( $result = json_encode(['result' => $result, 'redirect_url' => $redirectUrl]); } } catch (Exception $e) { - $this->logger->error($e->getMessage() . ' ' . $e->getTraceAsString()); + $this->logger->error($e->getMessage() . ' GuestPaymentInformationManagement.php' . $e->getTraceAsString()); } return $result; diff --git a/Plugin/PaymentInformationManagement.php b/Plugin/PaymentInformationManagement.php index 22c2abe9..398676ea 100644 --- a/Plugin/PaymentInformationManagement.php +++ b/Plugin/PaymentInformationManagement.php @@ -35,7 +35,7 @@ public function afterSavePaymentInformationAndPlaceOrder( $result = json_encode(['result' => $result, 'redirect_url' => $redirectUrl]); } } catch (Exception $e) { - $this->logger->error($e->getMessage() . ' ' . $e->getTraceAsString()); + $this->logger->error($e->getMessage() . ' PaymentInformationManagement.php' . $e->getTraceAsString()); } return $result; diff --git a/view/frontend/layout/checkout_index_index.xml b/view/frontend/layout/checkout_index_index.xml index 023f2cf0..f14f8d3f 100644 --- a/view/frontend/layout/checkout_index_index.xml +++ b/view/frontend/layout/checkout_index_index.xml @@ -17,7 +17,7 @@ - + Nexi_Checkout/js/view/payment/nexi-payment diff --git a/view/frontend/web/js/dibs-checkout.js b/view/frontend/web/js/dibs-checkout.js new file mode 100644 index 00000000..f1c47829 --- /dev/null +++ b/view/frontend/web/js/dibs-checkout.js @@ -0,0 +1,448 @@ +!function () { + "use strict"; + let e = function (e) { + return e.CategoryBasedCheckout = "CategoryBasedCheckout", e.NexiRebranding = "NexiRebranding", e.ApplePay = "ApplePay", e.GooglePay = "GooglePay", e.Klarna = "Klarna", e.DoubleClick = "DoubleClick", e.PaymentCompletedLogging = "PaymentCompletedLogging", e.ClearSavedPayments = "ClearSavedPayments", e.RemoveRememberMe = "RemoveRememberMe", e.B2CWillNotBeRecognizedWithSSN = "B2CWillNotBeRecognizedWithSSN", e.SelectCardOnApplePayCancellation = "SelectCardOnApplePayCancellation", e.MobilePayCorrectPaymentType = "MobilePayCorrectPaymentType", e.KlarnaB2B = "KlarnaB2B", e.ApplePayUseMerchantCountry = "ApplePayUseMerchantCountry", e.ApplePayOnCancelDontAbort = "ApplePayOnCancelDontAbort", e.GooglePayUseAuthorizationFlow = "GooglePayUseAuthorizationFlow", e.GooglePayDynamicPriceUpdate = "GooglePayDynamicPriceUpdate", e.DisableConsumerLogin = "DisableConsumerLogin", e.EnableVippsForSweden = "EnableVippsForSweden", e + }({}); + var t; + const n = ("string" == typeof (s = "false") ? "true" === s.toLowerCase() : s || !1) || !1; + var s; + null == (t = document) || null == (t = t.currentScript) || t.src; + const i = "https://test.checkout.dibspayment.eu", o = parseInt("13"); + let a, r; + !function (t) { + class s { + constructor(e, t) { + this.eventType = e, this.data = t + } + + toJson() { + return JSON.stringify(this) + } + } + + t.Checkout = class { + constructor(e) { + this.iFrameDibsContainerId = "dibs-checkout-content", this.iFrameDefaultContainerId = "nets-checkout-content", this.iFrameContentStyleClass = "dibs-checkout-wrapper", this.iFrameId = "nets-checkout-iframe", this.applePayJSId = "applepayjs", this.endPointDomain = void 0, this.iFrameSrc = void 0, this.styleSheetSrc = void 0, this.paymentFailed = !1, this.isApplePayEnabled = !1, this.applePaySession = void 0, this.applePayPaymentRequest = void 0, this.allowedShippingCountries = void 0, this.featureToggles = void 0, this.onPaymentCompletedEvent = void 0, this.onPaymentCancelledEvent = void 0, this.onPayInitializedEvent = void 0, this.onAddressChangedEvent = void 0, this.onApplepayContactUpdatedEvent = void 0, this.checkoutInitialized = !1, this.isThemeSetExplicitly = !1, this.options = void 0, this.setupMessageListeners = e => { + if (!this.checkMsgSafe(e)) return; + try { + JSON.parse(e.data) + } catch (e) { + return void this.consoleLog(e) + } + const t = JSON.parse(e.data); + switch (this.consoleDebug(`Event received: ${t.eventType}, ${JSON.stringify(t.data)}`), t.eventType) { + case"featureTogglesChanged": + this.featureToggles = t.data; + break; + case"checkoutInitialized": + this.checkoutInitialized || (this.checkoutInitialized = !0, this.consoleLog("checkoutInitialized"), this.publishUnsentMessagesToCheckout(), this.postThemeToCheckout(), this.sendIframeSizing()); + break; + case"goto3DS": + this.goto3DS(t.data); + break; + case"payInitialized": + this.onPayInitializedEvent ? this.onPayInitializedEvent(t.data) : (this.consoleLog("PaymentInitialized not handled by merchant"), this.sendPaymentOrderFinalizedEvent(!0)); + break; + case"paymentSuccess": + this.onPaymentCompletedEvent(t.data); + break; + case"paymentCancelled": + this.onPaymentCancelledEvent(t.data); + break; + case"resize": + this.resizeIFrame(t.data); + break; + case"addressChanged": + this.onAddressChangedEvent ? this.onAddressChangedEvent(t.data) : this.postMessage(new s("addressChangedNotHandled")); + break; + case"removePaymentFailedQueryParameter": + this.removePaymentFailedQueryParameter(); + break; + case"inceptionIframeInitialized": + this.inceptionIframeInitialized(); + break; + case"getIsApplePaySupportedOnCurrentDevice": + this.getIsApplePaySupportedOnCurrentDevice(); + break; + case"applePayClicked": + this.applePayClicked(t.data); + break; + case"applePaySessionValidated": + this.onReceivedMerchantSession(t.data); + break; + case"applePayPaymentComplete": + this.onApplePayPaymentComplete(t.data); + break; + case"setAllowedShippingCountries": + this.onSetAllowedShippingCountries(t.data); + break; + default: + const n = t.eventType; + this.consoleLog(`unknown event ${n} ${JSON.stringify(e.data)}`) + } + }, this.setupResizeListeners = () => { + const e = new s("resize"); + this.postMessage(e) + }, this.unsentMessages = [], this.options = e, this.init() + } + + on(e, t) { + if (!t) throw new Error(`No function was supplied in the second argument. Please supply the function you want to be called on the ${e} event`); + if ("pay-initialized" === e) this.onPayInitializedEvent = t; else if ("payment-completed" === e) this.onPaymentCompletedEvent = t; else if ("payment-cancelled" === e) this.onPaymentCancelledEvent = t; else if ("address-changed" === e) this.onAddressChangedEvent = t; else if ("applepay-contact-updated" === e) this.onApplepayContactUpdatedEvent = t; else { + const t = e; + this.consoleLog(`${t} is not a valid public event name.`) + } + } + + send(e, t) { + if ("payment-order-finalized" === e) { + const e = t || !1; + this.sendPaymentOrderFinalizedEvent(e) + } else "payment-cancel-initiated" === e && this.postMessage(new s("cancelPayment")) + } + + freezeCheckout() { + this.postMessage(new s("freezeCheckout")) + } + + thawCheckout() { + this.postMessage(new s("thawCheckout")) + } + + setTheme(e) { + this.isThemeSetExplicitly = !0, this.postMessage(new s("setTheme", e)) + } + + setLanguage(e) { + this.postMessage(new s("setLanguage", e)) + } + + completeApplePayShippingContactUpdate(e) { + if (this.isApplePayEnabled && this.applePaySession && this.applePayPaymentRequest) try { + const t = this.applePayPaymentRequest.total; + if (!e) { + this.consoleLog("Does not support this operation. Undefined amount specified."); + const e = new ApplePayError("unknown", void 0, " Undefined amount specified."); + return void this.applePaySession.completeShippingContactSelection({newTotal: t, errors: [e]}) + } + if ("string" != typeof e && "number" != typeof e) { + this.consoleLog("Does not support this operation. Wrong argument type provided."); + const e = new ApplePayError("unknown", void 0, "Wrong argument type provided."); + return void this.applePaySession.completeShippingContactSelection({newTotal: t, errors: [e]}) + } + "number" == typeof e && (e = String(e)), this.consoleLog(`Apple pay order amount update with ${e}`); + const n = new s("updateApplePayOrderAmount", e); + this.postMessage(n); + const {label: i, type: o} = this.applePayPaymentRequest.total, a = Number(e) / 100; + this.applePaySession.completeShippingContactSelection({ + newTotal: { + amount: String(a), + label: i, + type: o + } + }) + } catch (e) { + this.consoleError(e, "Error in completeShippingMethodSelection for ApplePay") + } else this.consoleLog("Does not support this operation. ApplePay is disabled.") + } + + cleanup() { + this.removeListeners() + } + + init() { + var e, t, n, s, o, a; + const r = null == (e = this.options) ? void 0 : e.checkoutKey, + p = null == (t = this.options) ? void 0 : t.paymentId, l = this; + if (this.options.containerId || (this.options.containerId = document.getElementById(this.iFrameDefaultContainerId) ? this.iFrameDefaultContainerId : this.iFrameDibsContainerId), !this.isThemeSet() && i && r && p) { + const e = new XMLHttpRequest; + e.addEventListener("load", (function () { + if (200 === this.status && !l.isThemeSetExplicitly) { + const e = JSON.parse(this.responseText); + l.options.theme = e, l.postThemeToCheckout() + } + })), e.open("GET", `${i}/api/v1/theming/checkout`), e.setRequestHeader("CheckoutKey", r), e.setRequestHeader("PaymentId", p), e.send() + } + this.paymentFailed = "true" === this.getQueryStringParameter("paymentFailed", window.location.href), this.endPointDomain = "https://test.checkout.dibspayment.eu", this.iFrameSrc = `${this.endPointDomain}/v1/?checkoutKey=${null == (n = this.options) ? void 0 : n.checkoutKey}&paymentId=${null == (s = this.options) ? void 0 : s.paymentId}`, null != (o = this.options) && o.partnerMerchantNumber && (this.iFrameSrc += `&partnerMerchantNumber=${this.options.partnerMerchantNumber}`), null != (a = this.options) && a.language && (this.iFrameSrc += `&language=${this.options.language}`), this.paymentFailed && (this.iFrameSrc += `&paymentFailed=${this.paymentFailed}`), this.styleSheetSrc = `${this.endPointDomain}/v1/assets/css/checkout.css`, this.setListeners(); + const h = document.getElementsByTagName("head")[0]; + this.addStyleSheet(h), this.addMainIFrame() + } + + isWindowOnTopLevel() { + try { + return window.top.location.href, !0 + } catch (e) { + return !1 + } + } + + isThemeSet() { + var e; + return !(null == (e = this.options) || !e.theme) && Object.keys(this.options.theme).length > 0 + } + + inceptionIframeInitialized() { + if (this.isWindowOnTopLevel()) { + var e; + const t = this.getIFrameHeight(), n = null == (e = window.top) ? void 0 : e.innerHeight; + if (n && t > n) { + this.resizeIFrame(n); + const e = new s("scrollIntoView", n); + this.postMessage(e) + } + } + } + + getErrorMsg(e, t) { + let n = t; + return "string" == typeof e ? n = `${t} ${e}` : e instanceof Error && (n = `${t} ${e.message}`), n + } + + loadApplePayJs(e) { + const t = document.getElementById(this.applePayJSId); + if (!t) { + const t = document.createElement("script"); + t.src = "https://applepay.cdn-apple.com/jsapi/v1/apple-pay-sdk.js", t.id = this.applePayJSId, t.defer = !0, t.onload = () => { + this.consoleLog("Loaded applepay js script"), e() + }, t.onerror = () => { + this.consoleLog("Apple Pay SDK cannot be loaded", !0) + }, document.body.appendChild(t) + } + t && e && e() + } + + getIsApplePaySupportedOnCurrentDevice() { + this.loadApplePayJs((() => { + try { + const e = window.ApplePaySession; + if (e) { + const t = e.supportsVersion(o), n = e && e.canMakePayments(); + t || this.consoleLog("Does not support applepay version : " + o, !0), n || this.consoleLog("Cannot make Apple payments", !0), this.isApplePayEnabled = e && n && t; + const i = new s("setIsApplePaySupportedOnCurrentDevice", this.isApplePayEnabled); + this.consoleLog("Apple pay enabled : " + this.isApplePayEnabled), this.postMessage(i) + } else { + this.consoleLog("Empty applepay session on the window", !0); + const e = new s("setIsApplePaySupportedOnCurrentDevice", !1); + this.postMessage(e) + } + } catch (e) { + this.consoleError(e, "Something went wrong. Apple pay disabled."); + const t = new s("setIsApplePaySupportedOnCurrentDevice", !1); + this.postMessage(t) + } + })) + } + + isFeatureToggleEnabled(e) { + var t, n; + return null != (t = null == (n = this.featureToggles) || null == (n = n.find((t => t.name === e))) ? void 0 : n.isEnabled) && t + } + + applePayClicked(e) { + try { + this.applePayPaymentRequest = e, this.applePaySession = new window.ApplePaySession(o, e), this.applePaySession.onvalidatemerchant = this.getOnValidateMerchant(), this.applePaySession.onpaymentauthorized = this.getOnPaymentAuthorized(), this.applePaySession.oncancel = this.getOnCancel(), this.applePaySession.onshippingcontactselected = this.getOnShippingContactSelected(), this.applePaySession.begin() + } catch (e) { + this.consoleError(e, "Apple pay clicked. Something went wrong.") + } + } + + abortApplePay() { + try { + this.applePaySession && this.applePaySession.abort() + } catch (e) { + this.consoleError(e, "Apple pay abort. Something went wrong.") + } + } + + getOnCancel() { + return t => { + const n = new s("onApplePayWasCanceled"); + this.postMessage(n), this.isFeatureToggleEnabled(e.ApplePayOnCancelDontAbort) ? this.consoleLog("Apple pay cancelled.") : this.abortApplePay() + } + } + + getOnShippingContactSelected() { + return e => { + var t, n; + const s = this.applePayPaymentRequest.total, + i = null == e || null == (t = e.shippingContact) ? void 0 : t.countryCode; + if (!i) { + const e = new ApplePayError("addressUnserviceable", "country"); + return e.message = "Country is missing in shipping address", void this.applePaySession.completeShippingContactSelection({ + newTotal: s, + errors: [e] + }) + } + if ((null == (n = this.allowedShippingCountries) ? void 0 : n.length) > 0 && !this.allowedShippingCountries.includes(i)) { + const e = new ApplePayError("addressUnserviceable", "country"); + return e.message = "Country specified in shipping address is not supported", void this.applePaySession.completeShippingContactSelection({ + newTotal: s, + errors: [e] + }) + } + if (this.onApplepayContactUpdatedEvent) return void this.onApplepayContactUpdatedEvent({ + postalCode: e.shippingContact.postalCode, + countryCode: e.shippingContact.countryCode + }); + const o = new ApplePayError("unknown", void 0); + o.message = "Applepay contact update handler missing.", this.applePaySession.completeShippingContactSelection({ + newTotal: s, + errors: [o] + }) + } + } + + getOnPaymentAuthorized() { + return e => { + var t, n, i, o; + if ((e => null == e || 0 === e.length || "{}" === JSON.stringify(e))(null == e || null == (t = e.payment) ? void 0 : t.token)) return this.consoleLog(`Apple Pay ${"object" == typeof (null == e ? void 0 : e.payment) && 0 === Object.keys(null == e ? void 0 : e.payment).length ? "payment" : "token"} is missing`, !0), void this.applePaySession.completePayment({ + status: ApplePaySession.STATUS_FAILURE, + errors: [new ApplePayError("unknown", void 0, "Payment data is empty")] + }); + if (null != (n = e.payment.shippingContact) && n.countryCode && (null == (i = this.allowedShippingCountries) ? void 0 : i.length) > 0 && !this.allowedShippingCountries.includes(null == (o = e.payment.shippingContact) ? void 0 : o.countryCode)) return this.consoleLog("Country specified in shipping address is not supported", !0), void this.applePaySession.completePayment({ + status: ApplePaySession.STATUS_FAILURE, + errors: [new ApplePayError("addressUnserviceable", "country", "Country specified in shipping address is not supported")] + }); + const a = new s("authorizeApplePay", e.payment); + this.postMessage(a) + } + } + + getOnValidateMerchant() { + return e => { + const t = new s("validateApplePaySession"); + this.postMessage(t) + } + } + + onReceivedMerchantSession(e) { + try { + this.applePaySession.completeMerchantValidation(e) + } catch (e) { + this.consoleError(e, "Something went wrong while validating merchant session"), this.abortApplePay() + } + } + + onApplePayPaymentComplete(e) { + try { + const t = {status: Boolean(e).valueOf() ? ApplePaySession.STATUS_SUCCESS : ApplePaySession.STATUS_FAILURE}; + this.applePaySession.completePayment(t) + } catch (e) { + this.consoleError(e, "Something went wrong while completing AppleyPay payment."), this.abortApplePay() + } + } + + onSetAllowedShippingCountries(e) { + this.allowedShippingCountries = e + } + + addStyleSheet(e) { + const t = document.createElement("link"); + t.rel = "stylesheet", t.type = "text/css", t.href = this.styleSheetSrc, e.appendChild(t), this.consoleLog("Added stylesheet script " + t.href) + } + + addMainIFrame() { + const e = document.createElement("iframe"); + e.id = this.iFrameId, e.src = this.iFrameSrc, e.referrerPolicy = "strict-origin-when-cross-origin", e.allow = "payment *"; + const t = document.getElementById(this.options.containerId); + null !== t && (t.setAttribute("class", this.iFrameContentStyleClass), t.appendChild(e), this.consoleLog("Added main IFrame script to " + this.options.containerId)), e.onload = () => { + this.consoleLog("iframe ready") + }, e.allowTransparency = "true", e.frameBorder = "0", e.scrolling = "no" + } + + postThemeToCheckout() { + this.options.theme && this.setTheme(this.options.theme) + } + + goto3DS(e) { + const t = e, n = document.createElement("div"); + n.style.display = "none", n.innerHTML = t.form, document.body.appendChild(n); + const s = document.getElementById(t.formId); + null !== s && s.submit() + } + + resizeIFrame(e) { + const t = `${e}px`; + this.getIframe().height = t + } + + sendIframeSizing() { + const e = new s("initialIframeSize", this.getIFrameSize()); + this.postMessage(e) + } + + getIFrameSize() { + const e = this.getIframe(), {offsetWidth: t, offsetHeight: n} = e; + return {width: t, height: n} + } + + getIFrameHeight() { + const e = this.getIframe().height; + return parseInt(e.split("px")[0]) || 0 + } + + sendPaymentOrderFinalizedEvent(e) { + const t = new s("paymentOrderFinalized", e); + this.postMessage(t) + } + + removeListeners() { + window.removeEventListener("message", this.setupMessageListeners), window.removeEventListener("resize", this.setupResizeListeners) + } + + setListeners() { + window.addEventListener("message", this.setupMessageListeners, !1), window.addEventListener("resize", this.setupResizeListeners) + } + + removePaymentFailedQueryParameter() { + const e = new URLSearchParams(window.location.search), t = "paymentFailed"; + if (e.has(t)) { + e.delete(t); + const n = e.toString(), s = `${location.origin}${location.pathname}?${n}`; + window.history.replaceState(void 0, document.title, s) + } + } + + checkMsgSafe(e) { + const t = e.origin; + return void 0 === t ? (this.consoleDebug(`Checkout: unknown origin ${JSON.stringify(t)} (${JSON.stringify(e)}, ${JSON.stringify(e.data)})`), !1) : !(e.data && "react-devtools-bridge" === e.data.source || t !== this.endPointDomain && (this.consoleDebug(`Checkout: unknown origin ${JSON.stringify(t)} (${JSON.stringify(e)}, ${JSON.stringify(e.data)})`), 1)) + } + + getQueryStringParameter(e, t) { + if (t = t || "", 0 === (e = e || "").length || 0 === t.length) return ""; + const n = new RegExp("[?&]" + e + "=([^&#]*)", "i").exec(t); + return n ? n[1] : "" + } + + consoleDebug(e) { + n && console.debug(e) + } + + consoleLog(e, t) { + t ? this.postMessage(new s("logErrorMessage", e)) : n && console.log(e) + } + + consoleError(e, t) { + const {name: i, stack: o} = e, a = this.getErrorMsg(e, t); + this.postMessage(new s("logErrorMessage", {name: i, message: a, stack: o})), n && console.error(a) + } + + getIframe() { + return document.getElementById(this.iFrameId) + } + + postMessage(e) { + const t = this.getIframe(); + null != t && t.contentWindow && this.checkoutInitialized ? t.contentWindow.postMessage(null == e ? void 0 : e.toJson(), this.endPointDomain) : e && this.unsentMessages.push(e) + } + + publishUnsentMessagesToCheckout() { + const e = this.getIframe(); + for (; this.unsentMessages.length;) { + var t, n; + null == e || null == (t = e.contentWindow) || t.postMessage(null == (n = this.unsentMessages.shift()) ? void 0 : n.toJson(), this.endPointDomain) + } + } + }, window.Nets = a + }(a || (a = {})), r || (r = {}), a.Checkout, window.Dibs = a +}(); diff --git a/view/frontend/web/js/view/payment/initialize-payment.js b/view/frontend/web/js/view/payment/initialize-payment.js new file mode 100644 index 00000000..14ace4e1 --- /dev/null +++ b/view/frontend/web/js/view/payment/initialize-payment.js @@ -0,0 +1,52 @@ +define( + [ + 'mage/storage', + 'Magento_Checkout/js/model/url-builder', + 'Magento_Checkout/js/model/quote', + 'Magento_Checkout/js/model/full-screen-loader', + 'Magento_Checkout/js/model/error-processor', + 'Magento_Customer/js/model/customer' + ], + function (storage, urlBuilder, quote, fullScreenLoader, errorProcessor, customer) { + 'use strict'; + + return function () { + const payload = { + cartId: quote.getQuoteId(), + paymentMethod: { + method: this.getCode() + }, + integrationType: this.config.integrationType + }; + + const serviceUrl = customer.isLoggedIn() + ? urlBuilder.createUrl('/nexi/carts/mine/payment-initialize', {}) + : urlBuilder.createUrl('/nexi/guest-carts/:quoteId/payment-initialize', { + quoteId: quote.getQuoteId() + }); + + fullScreenLoader.startLoader(); + + return new Promise((resolve, reject) => { + storage.post( + serviceUrl, + JSON.stringify(payload) + ).done(function (response) { + resolve(JSON.parse(response)); + }).fail(function (response) { + errorProcessor.process(response, this.messageContainer); + let redirectURL = response.getResponseHeader('errorRedirectAction'); + + if (redirectURL) { + setTimeout(function () { + errorProcessor.redirectTo(redirectURL); + }, 3000); + } + reject(response); + }).always(function () { + fullScreenLoader.stopLoader(); + }); + }); + }; + } +); diff --git a/view/frontend/web/js/view/payment/method-renderer/nexi-method.js b/view/frontend/web/js/view/payment/method-renderer/nexi-method.js index d914c684..6754ae68 100644 --- a/view/frontend/web/js/view/payment/method-renderer/nexi-method.js +++ b/view/frontend/web/js/view/payment/method-renderer/nexi-method.js @@ -20,36 +20,60 @@ define( 'Magento_Ui/js/model/messageList', 'mage/translate', 'Magento_Ui/js/modal/modal', - 'Nexi_Checkout/js/sdk/loader' + 'Nexi_Checkout/js/sdk/loader', + 'Nexi_Checkout/js/view/payment/initialize-payment', + 'Nexi_Checkout/js/view/payment/render-embedded' ], - function (ko, $, _, storage, Component, placeOrderAction, selectPaymentMethodAction, additionalValidators, quote, getTotalsAction, urlBuilder, url, fullScreenLoader, errorProcessor, customer, checkoutData, totals, messageList, $t, modal, sdkLoader) { + function ( + ko, + $, + _, + storage, + Component, + placeOrderAction, + selectPaymentMethodAction, + additionalValidators, + quote, + getTotalsAction, + urlBuilder, + url, + fullScreenLoader, + errorProcessor, + customer, + checkoutData, + totals, + messageList, + $t, + modal, + sdkLoader, + initializeCartPayment, + renderEmbeddedCheckout + ) { 'use strict'; return Component.extend({ defaults: { template: 'Nexi_Checkout/payment/nexi', - config: window.checkoutConfig.payment.nexi + config: window.checkoutConfig.payment.nexi, }, isEmbedded: ko.observable(false), dibsCheckout: ko.observable(false), + isHosted: function () { + return !this.isEmbedded(); + }, initialize: function () { this._super(); if (this.config.integrationType === 'EmbeddedCheckout') { this.isEmbedded(true); } - if (this.isEmbedded()) { - this.renderEmbeddedCheckout(); - } - // Subscribe to changes in quote totals - quote.totals.subscribe(function (quote) { - // Reload Nexi checkout on quote change - console.log('Quote totals changed...', quote); - if (this.dibsCheckout()) { - this.dibsCheckout().thawCheckout(); - } - }, this); + if (this.isActive()) { + renderEmbeddedCheckout.call(this); + } + }, + isActive: function () { + return this.getCode() === this.isChecked(); }, placeOrder: function (data, event) { let placeOrder = placeOrderAction(this.getData(), false, this.messageContainer); @@ -68,89 +92,17 @@ define( }, selectPaymentMethod: function () { this._super(); - - }, - renderEmbeddedCheckout: async function () { - try { - await sdkLoader.loadSdk(this.config.environment === 'test'); - const response = await this.initializeNexiPayment(); - if (response.paymentId) { - let checkoutOptions = { - checkoutKey: response.checkoutKey, - paymentId: response.paymentId, - containerId: "nexi-checkout-container", - language: "en-GB", - theme: { - buttonRadius: "5px" - } - }; - this.dibsCheckout(new Dibs.Checkout(checkoutOptions)); - - this.dibsCheckout().on('payment-completed', async function () { - await this.placeOrder(); - window.location.href = url.build('checkout/onepage/success'); - }.bind(this)); - - this.dibsCheckout().on('pay-initialized', function (paymentId) { - console.log('DEBUG: Payment initialized with ID:', paymentId); - //TODO: validate with backend - this.dibsCheckout().send('payment-order-finalized', true); - }.bind(this)); + renderEmbeddedCheckout.call(this); + // Subscribe to changes in quote totals + quote.totals.subscribe(function (quote) { + // Reload Nexi checkout on quote change + console.log('Quote totals changed...', quote); + if (this.dibsCheckout()) { + this.dibsCheckout().thawCheckout(); } - } catch (error) { - console.error('Error loading Nexi SDK or initializing payment:', error); - } - - sdkLoader.loadSdk(this.config.environment === 'test') - .then(() => { - console.log('Nexi SDK loaded successfully'); - this.initializeNexiPayment() - .catch(function (error) { - console.error('Error loading Nexi SDK or initializing payment:', error); - }); - }) - .catch(x => { - console.error('Error loading Nexi SDK:', x); - }); - - }, - initializeNexiPayment() { - const payload = { - cartId: quote.getQuoteId(), - paymentMethod: { - method: this.getCode() - }, - integrationType: this.config.integrationType - }; - - const serviceUrl = customer.isLoggedIn() - ? urlBuilder.createUrl('/nexi/carts/mine/payment-initialize', {}) - : urlBuilder.createUrl('/nexi/guest-carts/:quoteId/payment-initialize', { - quoteId: quote.getQuoteId() - }); - - fullScreenLoader.startLoader(); - - return new Promise((resolve, reject) => { - storage.post( - serviceUrl, - JSON.stringify(payload) - ).done(function (response) { - resolve(JSON.parse(response)); - }).fail(function (response) { - errorProcessor.process(response, this.messageContainer); - let redirectURL = response.getResponseHeader('errorRedirectAction'); + }, this); - if (redirectURL) { - setTimeout(function () { - errorProcessor.redirectTo(redirectURL); - }, 3000); - } - reject(response); - }).always(function () { - fullScreenLoader.stopLoader(); - }); - }); + return true; } }); } diff --git a/view/frontend/web/js/view/payment/render-embedded.js b/view/frontend/web/js/view/payment/render-embedded.js new file mode 100644 index 00000000..5df5df07 --- /dev/null +++ b/view/frontend/web/js/view/payment/render-embedded.js @@ -0,0 +1,50 @@ +define( + [ + 'Nexi_Checkout/js/sdk/loader', + 'Nexi_Checkout/js/view/payment/initialize-payment', + 'mage/url' + ], + function (sdkLoader, initializeCartPayment, url) { + 'use strict'; + + return async function () { + try { + await sdkLoader.loadSdk(this.config.environment === 'test'); + + //clear checkout container + document.getElementById("nexi-checkout-container").innerHTML = ""; + + const response = await initializeCartPayment.call(this); + if (response.paymentId) { + let checkoutOptions = { + checkoutKey: response.checkoutKey, + paymentId: response.paymentId, + containerId: "nexi-checkout-container", + language: "en-GB", + theme: { + buttonRadius: "5px" + } + }; + this.dibsCheckout(new Dibs.Checkout(checkoutOptions)); + + // add this as a global variable for debugging + window.dibsCheckout = this.dibsCheckout; + console.log('DEBUG: Dibs Checkout initialized as global `window.dibsCheckout` :', this.dibsCheckout()); + + this.dibsCheckout().on('payment-completed', async function () { + window.location.href = url.build('checkout/onepage/success'); + }.bind(this)); + + this.dibsCheckout().on('pay-initialized', async function (paymentId) { + //TODO: validate with backend + await this.placeOrder(); + console.log('DEBUG: Payment initialized with ID:', paymentId); + this.dibsCheckout().send('payment-order-finalized', true); + }.bind(this)); + } + } catch (error) { + console.error('Error loading Nexi SDK or initializing payment:', error); + } + }; + } +); diff --git a/view/frontend/web/template/payment/nexi.html b/view/frontend/web/template/payment/nexi.html index 1169c4c1..d442dccd 100644 --- a/view/frontend/web/template/payment/nexi.html +++ b/view/frontend/web/template/payment/nexi.html @@ -4,36 +4,36 @@ name="payment[method]" class="radio" data-bind="attr: {'id': getCode()}, value: getCode(), checked: isChecked, click: selectPaymentMethod, visible: isRadioButtonVisible()"/> + -
-
+ +
+
- +
- -
- -
- + disabled> + + +
From 76eba342f96612f485b1c8722079472ca922a42a Mon Sep 17 00:00:00 2001 From: "konrad.konieczny" Date: Mon, 17 Mar 2025 12:40:53 +0100 Subject: [PATCH 007/320] add comment --- Gateway/Handler/CreatePayment.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Gateway/Handler/CreatePayment.php b/Gateway/Handler/CreatePayment.php index 2e80452f..e932c96c 100644 --- a/Gateway/Handler/CreatePayment.php +++ b/Gateway/Handler/CreatePayment.php @@ -13,6 +13,9 @@ public function __construct( ) { } + /** + * Handle response + */ public function handle(array $handlingSubject, array $response) { $paymentDO = $this->subjectReader->readPayment($handlingSubject); From 6fdc4568253ca77abbc39a463e26d706b0bbe6a6 Mon Sep 17 00:00:00 2001 From: Konrad Konieczny Date: Mon, 17 Mar 2025 19:13:46 +0100 Subject: [PATCH 008/320] fizes regarding to pr comments --- Controller/Hpp/CancelAction.php | 2 +- Controller/Hpp/ReturnAction.php | 1 + Gateway/Command/Initialize.php | 29 ++++++++++--------- .../Request/CreatePaymentRequestBuilder.php | 2 +- 4 files changed, 18 insertions(+), 16 deletions(-) diff --git a/Controller/Hpp/CancelAction.php b/Controller/Hpp/CancelAction.php index 8bc82ac2..d6d3985e 100644 --- a/Controller/Hpp/CancelAction.php +++ b/Controller/Hpp/CancelAction.php @@ -42,7 +42,7 @@ public function execute(): ResultInterface $this->messageManager->addNoticeMessage(__('The payment has been canceled.')); } catch (\Exception $e) { $logId = uniqid(); - $this->logger->critical($logId . ' - ' . $e->getMessage() . ' - ' . $e->getTraceAsString()); + $this->logger->critical($logId . ' - ' . $e->getMessage(), $e); $this->messageManager->addErrorMessage( __( 'An error occurred during the payment process. Please try again later.' . diff --git a/Controller/Hpp/ReturnAction.php b/Controller/Hpp/ReturnAction.php index c7cef3e7..59eb05bf 100644 --- a/Controller/Hpp/ReturnAction.php +++ b/Controller/Hpp/ReturnAction.php @@ -131,6 +131,7 @@ public function execute(): ResultInterface __('Nexi Payment charged successfully. Payment ID: %1', $paymentId) ); $order->addRelatedObject($invoice); + $order->setCanSendNewEmailFlag(true); $this->orderRepository->save($order); } diff --git a/Gateway/Command/Initialize.php b/Gateway/Command/Initialize.php index c6e5ef55..3de11976 100644 --- a/Gateway/Command/Initialize.php +++ b/Gateway/Command/Initialize.php @@ -21,9 +21,9 @@ class Initialize implements CommandInterface * @param LoggerInterface $logger */ public function __construct( - private readonly SubjectReader $subjectReader, + private readonly SubjectReader $subjectReader, private readonly CommandManagerPoolInterface $commandManagerPool, - private readonly LoggerInterface $logger + private readonly LoggerInterface $logger ) { } @@ -32,10 +32,9 @@ public function __construct( * * @param array $commandSubject * - * @return $this * @throws LocalizedException */ - public function execute(array $commandSubject): static + public function execute(array $commandSubject) { /** @var PaymentDataObjectInterface $payment */ $paymentData = $this->subjectReader->readPayment($commandSubject); @@ -51,11 +50,8 @@ public function execute(array $commandSubject): static $stateObject->setIsNotified(false); $stateObject->setState(Order::STATE_NEW); $stateObject->setStatus('pending'); - $stateObject->setIsNotified(false); $this->cratePayment($paymentData); - - return $this; } /** @@ -63,10 +59,10 @@ public function execute(array $commandSubject): static * * @param PaymentDataObjectInterface $payment * - * @return ResultInterface|null + * @return void * @throws LocalizedException */ - public function cratePayment(PaymentDataObjectInterface $payment): ?ResultInterface + public function cratePayment(PaymentDataObjectInterface $payment) { if ($this->isPaymentAlreadyCreated($payment)) { return null; @@ -74,7 +70,7 @@ public function cratePayment(PaymentDataObjectInterface $payment): ?ResultInterf try { $commandPool = $this->commandManagerPool->get(Config::CODE); - $result = $commandPool->executeByCode( + $commandPool->executeByCode( commandCode: 'create_payment', arguments : ['payment' => $payment,] ); @@ -82,12 +78,17 @@ public function cratePayment(PaymentDataObjectInterface $payment): ?ResultInterf $this->logger->error($e->getMessage()); throw new LocalizedException(__('An error occurred during the payment process. Please try again later.')); } - - return $result; } - private function isPaymentAlreadyCreated(PaymentDataObjectInterface $payment) + /** + * Check if payment is already created + * + * @param PaymentDataObjectInterface $payment + * + * @return bool + */ + private function isPaymentAlreadyCreated(PaymentDataObjectInterface $payment): bool { - return $payment->getPayment()->getAdditionalInformation('payment_id'); + return (bool)$payment->getPayment()->getAdditionalInformation('payment_id'); } } diff --git a/Gateway/Request/CreatePaymentRequestBuilder.php b/Gateway/Request/CreatePaymentRequestBuilder.php index 3d700cb5..d31f8463 100644 --- a/Gateway/Request/CreatePaymentRequestBuilder.php +++ b/Gateway/Request/CreatePaymentRequestBuilder.php @@ -142,7 +142,7 @@ public function buildWebhooks(): array { $webhooks = []; foreach (EventNameEnum::cases() as $eventName) { - $baseUrl = "https://a556-91-217-18-69.ngrok-free.app"; + $baseUrl = "https://cf28-91-217-18-69.ngrok-free.app"; $webhooks[] = new Payment\Webhook( eventName : $eventName->value, url : $baseUrl . self::NEXI_PAYMENT_WEBHOOK_PATH, From 1712f0b9cc2e6c7086d9f62f4725f2c5cb34f6a8 Mon Sep 17 00:00:00 2001 From: Konrad Konieczny Date: Wed, 19 Mar 2025 11:29:34 +0100 Subject: [PATCH 009/320] add unit tests --- .../System/Config/TestConnection.php | 3 +- Controller/Hpp/CancelAction.php | 7 +- Controller/Hpp/ReturnAction.php | 21 ++- Plugin/PaymentInformationManagement.php | 9 +- .../System/Config/TestConnectionTest.php | 108 +++++++++++++++ Test/Unit/Controller/Hpp/CancelActionTest.php | 73 ++++++++++ Test/Unit/Controller/Hpp/ReturnActionTest.php | 129 ++++++++++++++++++ 7 files changed, 339 insertions(+), 11 deletions(-) create mode 100644 Test/Unit/Controller/Adminhtml/System/Config/TestConnectionTest.php create mode 100644 Test/Unit/Controller/Hpp/CancelActionTest.php create mode 100644 Test/Unit/Controller/Hpp/ReturnActionTest.php diff --git a/Controller/Adminhtml/System/Config/TestConnection.php b/Controller/Adminhtml/System/Config/TestConnection.php index 20b808a4..252e724b 100644 --- a/Controller/Adminhtml/System/Config/TestConnection.php +++ b/Controller/Adminhtml/System/Config/TestConnection.php @@ -69,7 +69,8 @@ public function execute() $result['success'] = true; } else { $message = $e->getMessage(); - $result['errorMessage'] = $this->tagFilter->filter($message) . ' ' + $filter = $this->tagFilter->filter($message); + $result['errorMessage'] = $filter . ($filter ? ' ' : '') . __('Please check your API key and environment.'); } } diff --git a/Controller/Hpp/CancelAction.php b/Controller/Hpp/CancelAction.php index d6d3985e..031d924a 100644 --- a/Controller/Hpp/CancelAction.php +++ b/Controller/Hpp/CancelAction.php @@ -42,7 +42,12 @@ public function execute(): ResultInterface $this->messageManager->addNoticeMessage(__('The payment has been canceled.')); } catch (\Exception $e) { $logId = uniqid(); - $this->logger->critical($logId . ' - ' . $e->getMessage(), $e); + $this->logger->critical( + $logId . ' - ' . $e->getMessage(), + [ + 'exception' => $e + ] + ); $this->messageManager->addErrorMessage( __( 'An error occurred during the payment process. Please try again later.' . diff --git a/Controller/Hpp/ReturnAction.php b/Controller/Hpp/ReturnAction.php index 59eb05bf..dc54eb40 100644 --- a/Controller/Hpp/ReturnAction.php +++ b/Controller/Hpp/ReturnAction.php @@ -14,9 +14,7 @@ use Magento\Framework\UrlInterface; use Magento\Payment\Model\MethodInterface; use Magento\Sales\Api\Data\TransactionInterface; -use Magento\Sales\Api\TransactionRepositoryInterface; use Magento\Sales\Model\Order; -use Magento\Sales\Model\Order\Payment\Transaction; use Magento\Sales\Model\OrderRepository; use Nexi\Checkout\Gateway\Config\Config; use Nexi\Checkout\Model\Transaction\Builder; @@ -71,12 +69,12 @@ public function execute(): ResultInterface try { if ($order->getPayment()->getAdditionalInformation('payment_id') != $this->request->getParam('paymentid')) { - throw new LocalizedException(__('Payment ID does not match.')); + throw new \Exception(__('Payment ID does not match.')); } if ($order->getState() != Order::STATE_NEW) { $this->messageManager->addNoticeMessage(__('Payment already processed')); - throw new LocalizedException(__('Payment already processed')); + throw new \Exception(__('Payment already processed')); } $paymentAction = $this->config->getPaymentAction(); @@ -142,9 +140,8 @@ public function execute(): ResultInterface __('An error occurred during the payment process. Please try again later.') ); - return $this->resultRedirectFactory->create()->setUrl( - $this->url->getUrl('checkout/cart/index', ['_secure' => true]) - ); + + return $this->getCartRedirect(); } return $this->getSuccessRedirect(); @@ -174,4 +171,14 @@ public function getSuccessRedirect(): Redirect $this->url->getUrl('checkout/onepage/success', ['_secure' => true]) ); } + + /** + * @return Redirect + */ + public function getCartRedirect(): Redirect + { + return $this->resultRedirectFactory->create()->setUrl( + $this->url->getUrl('checkout/cart/index', ['_secure' => true]) + ); + } } diff --git a/Plugin/PaymentInformationManagement.php b/Plugin/PaymentInformationManagement.php index 398676ea..4651d23d 100644 --- a/Plugin/PaymentInformationManagement.php +++ b/Plugin/PaymentInformationManagement.php @@ -35,14 +35,19 @@ public function afterSavePaymentInformationAndPlaceOrder( $result = json_encode(['result' => $result, 'redirect_url' => $redirectUrl]); } } catch (Exception $e) { - $this->logger->error($e->getMessage() . ' PaymentInformationManagement.php' . $e->getTraceAsString()); + $this->logger->error( + $e->getMessage(), + [ + 'trace' => $e->getTraceAsString() + ] + ); } return $result; } /** - * @return string[] + * Get redirect URL from payment additional information */ private function getRedirectUrl() { diff --git a/Test/Unit/Controller/Adminhtml/System/Config/TestConnectionTest.php b/Test/Unit/Controller/Adminhtml/System/Config/TestConnectionTest.php new file mode 100644 index 00000000..82fe5f7e --- /dev/null +++ b/Test/Unit/Controller/Adminhtml/System/Config/TestConnectionTest.php @@ -0,0 +1,108 @@ +createMock(Context::class); + $this->requestMock = $this->createMock(RequestInterface::class); + $this->jsonFactoryMock = $this->createMock(JsonFactory::class); + $this->jsonMock = $this->createMock(Json::class); + $this->paymentApiFactoryMock = $this->createMock(PaymentApiFactory::class); + $this->configMock = $this->createMock(Config::class); + $stripTagsMock = $this->createMock(StripTags::class); + + $contextMock->method('getRequest')->willReturn($this->requestMock); + $this->jsonFactoryMock->method('create')->willReturn($this->jsonMock); + + $this->controller = $objectManager->getObject( + TestConnection::class, + [ + 'context' => $contextMock, + 'resultJsonFactory' => $this->jsonFactoryMock, + 'tagFilter' => $stripTagsMock, + 'paymentApiFactory' => $this->paymentApiFactoryMock, + 'config' => $this->configMock + ] + ); + } + + public function testExecuteSuccess() + { + $this->requestMock->method('getParams')->willReturn([ + 'api_key' => 'valid_api_key', + 'environment' => Environment::LIVE + ]); + + $apiMock = $this->createMock(PaymentApi::class); + $this->paymentApiFactoryMock->method('create')->willReturn($apiMock); + $apiMock->method('retrievePayment')->willThrowException(new \Exception('should be in guid format')); + + $this->jsonMock->expects($this->once()) + ->method('setData') + ->with(['success' => true, 'errorMessage' => '']) + ->willReturnSelf(); + + $this->controller->execute(); + } + + public function testExecuteFailure() + { + $this->requestMock->method('getParams')->willReturn([ + 'api_key' => 'invalid_api_key', + 'environment' => Environment::LIVE + ]); + + $apiMock = $this->createMock(PaymentApi::class); + $this->paymentApiFactoryMock->method('create')->willReturn($apiMock); + $apiMock->method('retrievePayment')->willThrowException(new \Exception('Invalid API key')); + + $this->jsonMock->expects($this->once()) + ->method('setData') + ->with([ + 'success' => false, + 'errorMessage' => 'Please check your API key and environment.' + ]) + ->willReturnSelf(); + + $this->controller->execute(); + } +} diff --git a/Test/Unit/Controller/Hpp/CancelActionTest.php b/Test/Unit/Controller/Hpp/CancelActionTest.php new file mode 100644 index 00000000..298302c1 --- /dev/null +++ b/Test/Unit/Controller/Hpp/CancelActionTest.php @@ -0,0 +1,73 @@ +redirectFactoryMock = $this->createMock(RedirectFactory::class); + $this->redirectMock = $this->createMock(Redirect::class); + $this->urlMock = $this->createMock(UrlInterface::class); + $this->loggerMock = $this->createMock(LoggerInterface::class); + $this->checkoutSessionMock = $this->createMock(Session::class); + $this->messageManagerMock = $this->createMock(ManagerInterface::class); + + $this->redirectFactoryMock->method('create')->willReturn($this->redirectMock); + + $this->controller = new CancelAction( + $this->redirectFactoryMock, + $this->urlMock, + $this->loggerMock, + $this->checkoutSessionMock, + $this->messageManagerMock + ); + } + + public function testExecuteSuccess() + { + $this->checkoutSessionMock->expects($this->once())->method('restoreQuote'); + $this->messageManagerMock->expects($this->once())->method('addNoticeMessage') + ->with(__('The payment has been canceled.')); + $this->urlMock->method('getUrl')->willReturn('checkout/cart/index'); + $this->redirectMock->expects($this->once())->method('setUrl')->with('checkout/cart/index')->willReturnSelf(); + + $result = $this->controller->execute(); + $this->assertSame($this->redirectMock, $result); + } + + public function testExecuteException() + { + $this->checkoutSessionMock->method('restoreQuote')->willThrowException(new \Exception('Error restoring quote')); + $this->messageManagerMock->expects($this->once())->method('addErrorMessage') + ->with($this->callback(function ($message) { + return str_contains((string) $message, 'An error occurred during the payment process. Please try again later.'); + })); + $this->loggerMock->expects($this->once())->method('critical'); + $this->urlMock->method('getUrl')->willReturn('checkout/cart/index'); + $this->redirectMock->expects($this->once())->method('setUrl')->with('checkout/cart/index')->willReturnSelf(); + + $result = $this->controller->execute(); + $this->assertSame($this->redirectMock, $result); + } +} diff --git a/Test/Unit/Controller/Hpp/ReturnActionTest.php b/Test/Unit/Controller/Hpp/ReturnActionTest.php new file mode 100644 index 00000000..8d576cb8 --- /dev/null +++ b/Test/Unit/Controller/Hpp/ReturnActionTest.php @@ -0,0 +1,129 @@ +redirectFactory = $this->getMockBuilder(RedirectFactory::class) + ->onlyMethods(['create']) + ->addMethods(['setUrl']) + ->disableOriginalConstructor() + ->getMock(); + $this->request = $this->createMock(\Magento\Framework\App\Request\Http::class); + $this->url = $this->createMock(UrlInterface::class); + $this->checkoutSession = $this->createMock(Session::class); + $this->config = $this->createMock(Config::class); + $this->transactionBuilder = $this->createMock(Builder::class); + $this->orderRepository = $this->createMock(OrderRepository::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->messageManager = $this->createMock(ManagerInterface::class); + $this->client = $this->createMock(Client::class); + $this->payment = $this->getMockBuilder(Order\Payment::class) + ->onlyMethods(['getAdditionalInformation']) + ->disableOriginalConstructor() + ->getMock(); + + $this->order = $this->getMockBuilder(Order::class) + ->onlyMethods(['getPayment', 'getState', 'setState', 'getStatus', 'setStatus', 'addCommentToStatusHistory']) + ->disableOriginalConstructor() + ->getMock(); + $this->order->method('getPayment')->willReturn($this->payment); + $this->checkoutSession->method('getLastRealOrder')->willReturn($this->order); + + $redirect = $this->createMock(Redirect::class); + $redirect->method('setUrl')->willReturnSelf(); + $this->redirectFactory->method('create')->willReturn($redirect); + $this->url->method('getUrl')->willReturn('checkout/onepage/success'); + + $this->controller = new ReturnAction( + $this->redirectFactory, + $this->request, + $this->url, + $this->checkoutSession, + $this->config, + $this->transactionBuilder, + $this->orderRepository, + $this->logger, + $this->messageManager, + $this->client + ); + } + + public function testExecutePaymentIdMismatch() + { + $this->request->method('getParam')->with('paymentid')->willReturn('wrong_id'); + $this->order->method('getPayment')->willReturn($this->payment); + + $result = $this->controller->execute(); + $this->assertInstanceOf(Redirect::class, $result); + } + + public function testExecutePaymentAlreadyProcessed() + { + $this->order->method('getState')->willReturn(Order::STATE_PROCESSING); + $this->messageManager->expects($this->once())->method('addNoticeMessage'); + $this->request->method('getParam')->with('paymentid')->willReturn('correct_id'); + $this->payment->method('getAdditionalInformation')->willReturn('correct_id'); + + $result = $this->controller->execute(); + $this->assertInstanceOf(Redirect::class, $result); + } + + public function testExecuteSuccessRedirect() + { + $this->order->method('getState')->willReturn(Order::STATE_NEW); + $this->order->method('setState')->willReturnSelf(); + $this->order->method('setStatus')->willReturnSelf(); + $this->order->method('addCommentToStatusHistory')->willReturnSelf(); + + $this->request->method('getParam')->with('paymentid')->willReturn('correct_id'); + $this->payment->method('getAdditionalInformation')->willReturn('correct_id'); + + $transactionMock = $this->getMockBuilder(Order\Payment\Transaction::class) + ->onlyMethods([ + 'setIsClosed', + 'setTransactionId', + 'setParentId', + 'setParentTxnId', + 'getTransactionId', + 'getTxnId' + ]) + ->disableOriginalConstructor() + ->getMock(); + + $this->transactionBuilder->method('build')->willReturn($transactionMock); + $result = $this->controller->execute(); + $this->assertInstanceOf(Redirect::class, $result); + } +} From 771d6cbd458b7a5d5ee1b0251310ebaf3e23f34b Mon Sep 17 00:00:00 2001 From: Konrad Konieczny Date: Wed, 19 Mar 2025 17:01:30 +0100 Subject: [PATCH 010/320] fixes code review results, reformatting --- .../System/Config/TestConnection.php | 2 +- .../System/Config/TestConnection.php | 5 +- Controller/Hpp/CancelAction.php | 16 +++--- Controller/Hpp/ReturnAction.php | 22 ++++--- Gateway/Config/Config.php | 2 +- Gateway/Handler/Capture.php | 13 ++--- Gateway/Handler/CreatePayment.php | 15 +++-- Gateway/Handler/RefundCharge.php | 18 ++++-- Gateway/Http/Client.php | 15 ++--- .../Request/CreatePaymentRequestBuilder.php | 7 ++- Gateway/Response/Handler.php | 14 ----- Plugin/PaymentInformationManagement.php | 6 ++ .../System/Config/TestConnectionTest.php | 40 ++++++------- Test/Unit/Controller/Hpp/CancelActionTest.php | 49 +++++++++++----- Test/Unit/Controller/Hpp/ReturnActionTest.php | 57 +++++++++++++------ Test/Unit/EnvironmentTest.php | 3 +- composer.json | 3 +- etc/di.xml | 6 -- 18 files changed, 159 insertions(+), 134 deletions(-) delete mode 100644 Gateway/Response/Handler.php diff --git a/Block/Adminhtml/System/Config/TestConnection.php b/Block/Adminhtml/System/Config/TestConnection.php index be199ef8..2db1b952 100644 --- a/Block/Adminhtml/System/Config/TestConnection.php +++ b/Block/Adminhtml/System/Config/TestConnection.php @@ -60,7 +60,7 @@ protected function _getElementHtml(AbstractElement $element) 'button_label' => __($originalData['button_label']), 'html_id' => $element->getHtmlId(), 'ajax_url' => $this->_urlBuilder->getUrl('nexi/system_config/testconnection'), - 'field_mapping' => str_replace('"', '\\"', json_encode($this->getFieldMapping())) + 'field_mapping' => str_replace('"', '\\"', \Laminas\Json\Json::encode($this->getFieldMapping())) ] ); diff --git a/Controller/Adminhtml/System/Config/TestConnection.php b/Controller/Adminhtml/System/Config/TestConnection.php index 252e724b..fbbb0aa6 100644 --- a/Controller/Adminhtml/System/Config/TestConnection.php +++ b/Controller/Adminhtml/System/Config/TestConnection.php @@ -9,7 +9,6 @@ use Magento\Framework\App\Action\HttpPostActionInterface; use Magento\Framework\Controller\Result\Json; use Magento\Framework\Controller\Result\JsonFactory; -use Magento\Framework\Exception\LocalizedException; use Magento\Framework\Filter\StripTags; use Nexi\Checkout\Gateway\Config\Config; use Nexi\Checkout\Model\Config\Source\Environment; @@ -54,13 +53,13 @@ public function execute() 'errorMessage' => '', ]; $options = $this->getRequest()->getParams(); - if ($options['api_key'] == '******') { + if ($options['api_key'] === '******') { $options['api_key'] = $this->config->getApiKey(); } try { $api = $this->paymentApiFactory->create( secretKey : $options['api_key'], - isLiveMode: $options['environment'] == Environment::LIVE + isLiveMode: $options['environment'] === Environment::LIVE ); $result = $api->retrievePayment(self::NOT_GUID_PAYMENT_ID); diff --git a/Controller/Hpp/CancelAction.php b/Controller/Hpp/CancelAction.php index 031d924a..96c6faf3 100644 --- a/Controller/Hpp/CancelAction.php +++ b/Controller/Hpp/CancelAction.php @@ -6,7 +6,6 @@ use Magento\Framework\App\ActionInterface; use Magento\Framework\Controller\Result\RedirectFactory; use Magento\Framework\Controller\ResultInterface; -use Magento\Framework\Exception\LocalizedException; use Magento\Framework\Message\ManagerInterface; use Magento\Framework\UrlInterface; use Psr\Log\LoggerInterface; @@ -37,23 +36,22 @@ public function __construct( */ public function execute(): ResultInterface { + $paymentId = null; try { + $paymentId = $this->checkoutSession->getQuote()->getPayment()->getAdditionalInformation('payment_id'); $this->checkoutSession->restoreQuote(); $this->messageManager->addNoticeMessage(__('The payment has been canceled.')); } catch (\Exception $e) { - $logId = uniqid(); $this->logger->critical( - $logId . ' - ' . $e->getMessage(), + $e->getMessage(), [ - 'exception' => $e + 'exception_trace' => $e->getTraceAsString(), + 'nexi_payment_id' => $paymentId ] ); + $this->messageManager->addErrorMessage( - __( - 'An error occurred during the payment process. Please try again later.' . - 'Log ID: %1', - $logId - ) + __('An error occurred during the payment process. Please try again later.') ); } diff --git a/Controller/Hpp/ReturnAction.php b/Controller/Hpp/ReturnAction.php index dc54eb40..2886483a 100644 --- a/Controller/Hpp/ReturnAction.php +++ b/Controller/Hpp/ReturnAction.php @@ -9,7 +9,6 @@ use Magento\Framework\Controller\Result\Redirect; use Magento\Framework\Controller\Result\RedirectFactory; use Magento\Framework\Controller\ResultInterface; -use Magento\Framework\Exception\LocalizedException; use Magento\Framework\Message\ManagerInterface; use Magento\Framework\UrlInterface; use Magento\Payment\Model\MethodInterface; @@ -61,10 +60,13 @@ public function execute(): ResultInterface { $order = $this->checkoutSession->getLastRealOrder(); - $this->logger->debug( - 'ReturnAction request: ' . json_encode($this->request->getParams()) - . ' - Order ID: ' . $order->getIncrementId() - . 'http referrer: ' . $this->request->getServer('HTTP_REFERER') + $this->logger->info( + 'ReturnAction request.', + [ + 'params' => json_encode($this->request->getParams()), + 'order_id' => $order->getIncrementId(), + 'http_referrer' => $this->request->getServer('HTTP_REFERER') + ] ); try { @@ -101,13 +103,10 @@ public function execute(): ResultInterface $paymentDetails = $this->getPaymentDetails($paymentId); if ($paymentDetails->getPayment()->getStatus() == PaymentStatusEnum::RESERVED) { - $this->messageManager->addNoticeMessage(__('Payment reserved, but not charged yet.')); - $this->logger->notice('Payment reserved, but not charged yet. Redirecting to success page.'); - return $this->getSuccessRedirect(); } elseif ($paymentDetails->getPayment()->getStatus() == PaymentStatusEnum::CHARGED) { $order->setState(Order::STATE_PROCESSING)->setStatus(Order::STATE_PROCESSING); - $chargeTxnId = $paymentDetails->getPayment()->getCharges()[0]->getChargeId(); + $chargeTxnId = $paymentDetails->getPayment()->getCharges()[0]->getChargeId(); $this->transactionBuilder ->build( $chargeTxnId, @@ -134,13 +133,12 @@ public function execute(): ResultInterface $this->orderRepository->save($order); } } - } catch (Exception $e) { - $this->logger->error($e->getMessage() . ' - ' . $e->getTraceAsString()); + } catch (PaymentApiException|Exception $e) { + $this->logger->error($e->getMessage(), ['exception_trace' => $e->getTraceAsString()]); $this->messageManager->addErrorMessage( __('An error occurred during the payment process. Please try again later.') ); - return $this->getCartRedirect(); } diff --git a/Gateway/Config/Config.php b/Gateway/Config/Config.php index e0700286..61b53756 100644 --- a/Gateway/Config/Config.php +++ b/Gateway/Config/Config.php @@ -115,7 +115,7 @@ public function getIntegrationType(): string */ public function isEmbedded(): bool { - return $this->getIntegrationType() == IntegrationTypeEnum::EmbeddedCheckout->name; + return $this->getIntegrationType() === IntegrationTypeEnum::EmbeddedCheckout->name; } /** diff --git a/Gateway/Handler/Capture.php b/Gateway/Handler/Capture.php index 28b83129..bd5d07cf 100644 --- a/Gateway/Handler/Capture.php +++ b/Gateway/Handler/Capture.php @@ -10,8 +10,6 @@ class Capture implements \Magento\Payment\Gateway\Response\HandlerInterface { /** - * Constructor - * * @param SubjectReader $subjectReader */ public function __construct( @@ -20,12 +18,7 @@ public function __construct( } /** - * Handle response - * - * @param array $handlingSubject - * @param array $response - * - * @return void + * @inheritDoc */ public function handle(array $handlingSubject, array $response) { @@ -35,6 +28,10 @@ public function handle(array $handlingSubject, array $response) /** @var ChargeResult[] $response */ $chargeResult = reset($response); + if (!$chargeResult instanceof ChargeResult) { + return; + } + $chargeId = $chargeResult->getChargeId(); $payment->setAdditionalInformation('charge_id', $chargeId); diff --git a/Gateway/Handler/CreatePayment.php b/Gateway/Handler/CreatePayment.php index e932c96c..f2e59291 100644 --- a/Gateway/Handler/CreatePayment.php +++ b/Gateway/Handler/CreatePayment.php @@ -3,9 +3,11 @@ namespace Nexi\Checkout\Gateway\Handler; use Magento\Payment\Gateway\Helper\SubjectReader; +use Magento\Payment\Gateway\Response\HandlerInterface; use NexiCheckout\Model\Result\Payment\PaymentWithHostedCheckoutResult; +use NexiCheckout\Model\Result\PaymentResult; -class CreatePayment implements \Magento\Payment\Gateway\Response\HandlerInterface +class CreatePayment implements HandlerInterface { public function __construct( @@ -20,12 +22,13 @@ public function handle(array $handlingSubject, array $response) { $paymentDO = $this->subjectReader->readPayment($handlingSubject); $payment = $paymentDO->getPayment(); + $paymentResult = reset($response); + if ($paymentResult instanceof PaymentResult) { + $payment->setAdditionalInformation('payment_id', $paymentResult->getPaymentId()); - $response = reset($response); - - $payment->setAdditionalInformation('payment_id', $response->getPaymentId()); - if ($response instanceof PaymentWithHostedCheckoutResult) { - $payment->setAdditionalInformation('redirect_url', $response->getHostedPaymentPageUrl()); + if ($paymentResult instanceof PaymentWithHostedCheckoutResult) { + $payment->setAdditionalInformation('redirect_url', $paymentResult->getHostedPaymentPageUrl()); + } } } } diff --git a/Gateway/Handler/RefundCharge.php b/Gateway/Handler/RefundCharge.php index ec697fab..480764fb 100644 --- a/Gateway/Handler/RefundCharge.php +++ b/Gateway/Handler/RefundCharge.php @@ -7,21 +7,27 @@ class RefundCharge implements \Magento\Payment\Gateway\Response\HandlerInterface { - + /** + * @param SubjectReader $subjectReader + */ public function __construct( private readonly SubjectReader $subjectReader ) { } + /** + * @inheritDoc + */ public function handle(array $handlingSubject, array $response) { $paymentDO = $this->subjectReader->readPayment($handlingSubject); $payment = $paymentDO->getPayment(); + $refundChargeResult = reset($response); - /** @var RefundChargeResult $response */ - $response = reset($response); - - $payment->setLastTransId($response->getRefundId()); - $payment->setTransactionId($response->getRefundId()); + /** @var RefundChargeResult $refundChargeResult */ + if ($refundChargeResult instanceof RefundChargeResult) { + $payment->setLastTransId($refundChargeResult->getRefundId()); + $payment->setTransactionId($refundChargeResult->getRefundId()); + } } } diff --git a/Gateway/Http/Client.php b/Gateway/Http/Client.php index 8d74c022..0a541bdd 100644 --- a/Gateway/Http/Client.php +++ b/Gateway/Http/Client.php @@ -16,12 +16,7 @@ class Client implements ClientInterface { - - private ?string $requestHash = null; - /** - * Class constructor - * * @param PaymentApiFactory $paymentApiFactory * @param Config $config * @param LoggerInterface $logger @@ -52,9 +47,7 @@ public function placeRequest(TransferInterface $transferObject): array } else { $response = $paymentApi->$nexiMethod($transferObject->getBody()); } - $this->logResponse($response); - } catch (PaymentApiException|\Exception $e) { $this->logger->error($e->getMessage()); throw new LocalizedException(__('An error occurred during the payment process. Please try again later.')); @@ -71,7 +64,7 @@ public function placeRequest(TransferInterface $transferObject): array * @return string|false * @throws ReflectionException */ - public function getResponseData($response): string|false + public function getResponseData(JsonDeserializeInterface $response): string|false { $responseData = []; @@ -102,12 +95,14 @@ public function getPaymentApi(): PaymentApi } /** - * @param $response + * Log response + * + * @param ?JsonDeserializeInterface $response * * @return void * @throws ReflectionException */ - public function logResponse($response): void + public function logResponse(?JsonDeserializeInterface $response): void { if ($response instanceof JsonDeserializeInterface) { $this->logger->debug( diff --git a/Gateway/Request/CreatePaymentRequestBuilder.php b/Gateway/Request/CreatePaymentRequestBuilder.php index d31f8463..5b32fb31 100644 --- a/Gateway/Request/CreatePaymentRequestBuilder.php +++ b/Gateway/Request/CreatePaymentRequestBuilder.php @@ -142,7 +142,7 @@ public function buildWebhooks(): array { $webhooks = []; foreach (EventNameEnum::cases() as $eventName) { - $baseUrl = "https://cf28-91-217-18-69.ngrok-free.app"; + $baseUrl = $this->url->getBaseUrl(); $webhooks[] = new Payment\Webhook( eventName : $eventName->value, url : $baseUrl . self::NEXI_PAYMENT_WEBHOOK_PATH, @@ -206,10 +206,13 @@ private function buildConsumer($order): Consumer */ public function isEmbedded(): bool { - return $this->config->getIntegrationType() == IntegrationTypeEnum::EmbeddedCheckout->name; + return $this->config->getIntegrationType() === IntegrationTypeEnum::EmbeddedCheckout->name; } /** + * Build Embedded Checkout request object + * TODO: add consumer data (save email on saving shipping address) + * * @param Quote|Order $salesObject * * @return EmbeddedCheckout diff --git a/Gateway/Response/Handler.php b/Gateway/Response/Handler.php deleted file mode 100644 index 392a334f..00000000 --- a/Gateway/Response/Handler.php +++ /dev/null @@ -1,14 +0,0 @@ -requestMock->method('getParams')->willReturn([ - 'api_key' => 'valid_api_key', - 'environment' => Environment::LIVE - ]); + $this->requestMock->method('getParams') + ->willReturn( + [ + 'api_key' => 'valid_api_key', + 'environment' => Environment::LIVE + ] + ); $apiMock = $this->createMock(PaymentApi::class); - $this->paymentApiFactoryMock->method('create')->willReturn($apiMock); - $apiMock->method('retrievePayment')->willThrowException(new \Exception('should be in guid format')); + $this->paymentApiFactoryMock->method('create') + ->willReturn($apiMock); + $apiMock->method('retrievePayment') + ->willThrowException(new \Exception('should be in guid format')); $this->jsonMock->expects($this->once()) ->method('setData') @@ -86,10 +79,13 @@ public function testExecuteSuccess() public function testExecuteFailure() { - $this->requestMock->method('getParams')->willReturn([ - 'api_key' => 'invalid_api_key', - 'environment' => Environment::LIVE - ]); + $this->requestMock->method('getParams') + ->willReturn( + [ + 'api_key' => 'invalid_api_key', + 'environment' => Environment::LIVE + ] + ); $apiMock = $this->createMock(PaymentApi::class); $this->paymentApiFactoryMock->method('create')->willReturn($apiMock); diff --git a/Test/Unit/Controller/Hpp/CancelActionTest.php b/Test/Unit/Controller/Hpp/CancelActionTest.php index 298302c1..59f4dcf9 100644 --- a/Test/Unit/Controller/Hpp/CancelActionTest.php +++ b/Test/Unit/Controller/Hpp/CancelActionTest.php @@ -10,7 +10,6 @@ use Magento\Framework\Message\ManagerInterface; use Magento\Framework\UrlInterface; use Nexi\Checkout\Controller\Hpp\CancelAction; -use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; @@ -23,18 +22,24 @@ class CancelActionTest extends TestCase private $loggerMock; private $checkoutSessionMock; private $messageManagerMock; + private $quoteMock; protected function setUp(): void { - $this->redirectFactoryMock = $this->createMock(RedirectFactory::class); $this->redirectMock = $this->createMock(Redirect::class); + $this->redirectFactoryMock = $this->createMock(RedirectFactory::class); + $this->redirectFactoryMock->method('create') + ->willReturn($this->redirectMock); $this->urlMock = $this->createMock(UrlInterface::class); $this->loggerMock = $this->createMock(LoggerInterface::class); $this->checkoutSessionMock = $this->createMock(Session::class); + $this->quoteMock = $this->createMock(\Magento\Quote\Model\Quote::class); + $this->checkoutSessionMock->method('getQuote') + ->willReturn($this->quoteMock); + $this->quoteMock->method('getPayment') + ->willReturn($this->createMock(\Magento\Quote\Model\Quote\Payment::class)); $this->messageManagerMock = $this->createMock(ManagerInterface::class); - $this->redirectFactoryMock->method('create')->willReturn($this->redirectMock); - $this->controller = new CancelAction( $this->redirectFactoryMock, $this->urlMock, @@ -46,11 +51,17 @@ protected function setUp(): void public function testExecuteSuccess() { - $this->checkoutSessionMock->expects($this->once())->method('restoreQuote'); - $this->messageManagerMock->expects($this->once())->method('addNoticeMessage') + $this->checkoutSessionMock->expects($this->once()) + ->method('restoreQuote'); + $this->messageManagerMock->expects($this->once()) + ->method('addNoticeMessage') ->with(__('The payment has been canceled.')); - $this->urlMock->method('getUrl')->willReturn('checkout/cart/index'); - $this->redirectMock->expects($this->once())->method('setUrl')->with('checkout/cart/index')->willReturnSelf(); + $this->urlMock->method('getUrl') + ->willReturn('checkout/cart/index'); + $this->redirectMock->expects($this->once()) + ->method('setUrl') + ->with('checkout/cart/index') + ->willReturnSelf(); $result = $this->controller->execute(); $this->assertSame($this->redirectMock, $result); @@ -58,14 +69,24 @@ public function testExecuteSuccess() public function testExecuteException() { - $this->checkoutSessionMock->method('restoreQuote')->willThrowException(new \Exception('Error restoring quote')); - $this->messageManagerMock->expects($this->once())->method('addErrorMessage') + $this->checkoutSessionMock->method('restoreQuote') + ->willThrowException(new \Exception('Error restoring quote')); + $this->messageManagerMock->expects($this->once()) + ->method('addErrorMessage') ->with($this->callback(function ($message) { - return str_contains((string) $message, 'An error occurred during the payment process. Please try again later.'); + return str_contains( + (string) $message, + 'An error occurred during the payment process. Please try again later.' + ); })); - $this->loggerMock->expects($this->once())->method('critical'); - $this->urlMock->method('getUrl')->willReturn('checkout/cart/index'); - $this->redirectMock->expects($this->once())->method('setUrl')->with('checkout/cart/index')->willReturnSelf(); + $this->loggerMock->expects($this->once()) + ->method('critical'); + $this->urlMock->method('getUrl') + ->willReturn('checkout/cart/index'); + $this->redirectMock->expects($this->once()) + ->method('setUrl') + ->with('checkout/cart/index') + ->willReturnSelf(); $result = $this->controller->execute(); $this->assertSame($this->redirectMock, $result); diff --git a/Test/Unit/Controller/Hpp/ReturnActionTest.php b/Test/Unit/Controller/Hpp/ReturnActionTest.php index 8d576cb8..e1c98035 100644 --- a/Test/Unit/Controller/Hpp/ReturnActionTest.php +++ b/Test/Unit/Controller/Hpp/ReturnActionTest.php @@ -58,13 +58,18 @@ protected function setUp(): void ->onlyMethods(['getPayment', 'getState', 'setState', 'getStatus', 'setStatus', 'addCommentToStatusHistory']) ->disableOriginalConstructor() ->getMock(); - $this->order->method('getPayment')->willReturn($this->payment); - $this->checkoutSession->method('getLastRealOrder')->willReturn($this->order); + $this->order->method('getPayment') + ->willReturn($this->payment); + $this->checkoutSession->method('getLastRealOrder') + ->willReturn($this->order); $redirect = $this->createMock(Redirect::class); - $redirect->method('setUrl')->willReturnSelf(); - $this->redirectFactory->method('create')->willReturn($redirect); - $this->url->method('getUrl')->willReturn('checkout/onepage/success'); + $redirect->method('setUrl') + ->willReturnSelf(); + $this->redirectFactory->method('create') + ->willReturn($redirect); + $this->url->method('getUrl') + ->willReturn('checkout/onepage/success'); $this->controller = new ReturnAction( $this->redirectFactory, @@ -82,8 +87,11 @@ protected function setUp(): void public function testExecutePaymentIdMismatch() { - $this->request->method('getParam')->with('paymentid')->willReturn('wrong_id'); - $this->order->method('getPayment')->willReturn($this->payment); + $this->request->method('getParam') + ->with('paymentid') + ->willReturn('wrong_id'); + $this->order->method('getPayment') + ->willReturn($this->payment); $result = $this->controller->execute(); $this->assertInstanceOf(Redirect::class, $result); @@ -91,10 +99,15 @@ public function testExecutePaymentIdMismatch() public function testExecutePaymentAlreadyProcessed() { - $this->order->method('getState')->willReturn(Order::STATE_PROCESSING); - $this->messageManager->expects($this->once())->method('addNoticeMessage'); - $this->request->method('getParam')->with('paymentid')->willReturn('correct_id'); - $this->payment->method('getAdditionalInformation')->willReturn('correct_id'); + $this->order->method('getState') + ->willReturn(Order::STATE_PROCESSING); + $this->messageManager->expects($this->once()) + ->method('addNoticeMessage'); + $this->request->method('getParam') + ->with('paymentid') + ->willReturn('correct_id'); + $this->payment->method('getAdditionalInformation') + ->willReturn('correct_id'); $result = $this->controller->execute(); $this->assertInstanceOf(Redirect::class, $result); @@ -102,13 +115,19 @@ public function testExecutePaymentAlreadyProcessed() public function testExecuteSuccessRedirect() { - $this->order->method('getState')->willReturn(Order::STATE_NEW); - $this->order->method('setState')->willReturnSelf(); - $this->order->method('setStatus')->willReturnSelf(); - $this->order->method('addCommentToStatusHistory')->willReturnSelf(); + $this->order->method('getState') + ->willReturn(Order::STATE_NEW); + $this->order->method('setState') + ->willReturnSelf(); + $this->order->method('setStatus') + ->willReturnSelf(); + $this->order->method('addCommentToStatusHistory') + ->willReturnSelf(); - $this->request->method('getParam')->with('paymentid')->willReturn('correct_id'); - $this->payment->method('getAdditionalInformation')->willReturn('correct_id'); + $this->request->method('getParam')->with('paymentid') + ->willReturn('correct_id'); + $this->payment->method('getAdditionalInformation') + ->willReturn('correct_id'); $transactionMock = $this->getMockBuilder(Order\Payment\Transaction::class) ->onlyMethods([ @@ -122,7 +141,9 @@ public function testExecuteSuccessRedirect() ->disableOriginalConstructor() ->getMock(); - $this->transactionBuilder->method('build')->willReturn($transactionMock); + $this->transactionBuilder->method('build') + ->willReturn($transactionMock); + $result = $this->controller->execute(); $this->assertInstanceOf(Redirect::class, $result); } diff --git a/Test/Unit/EnvironmentTest.php b/Test/Unit/EnvironmentTest.php index 3aaaf0df..0f2e58dc 100644 --- a/Test/Unit/EnvironmentTest.php +++ b/Test/Unit/EnvironmentTest.php @@ -13,6 +13,7 @@ public function testToOptionArray() $this->assertEquals([ ['value' => 'test', 'label' => __('Test')], ['value' => 'live', 'label' => __('Live')] - ], $environment->toOptionArray()); + ], + $environment->toOptionArray()); } } diff --git a/composer.json b/composer.json index 7049a0e5..b0509f5d 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,8 @@ "nexi-checkout/php-payment-sdk": "*", "magento/framework": ">=102.0.0", "ext-curl": "*", - "nesbot/carbon": "^2.57.0" + "nesbot/carbon": "^2.57.0", + "ext-mbstring": "*" }, "require-dev": { "magento/magento-coding-standard": "*", diff --git a/etc/di.xml b/etc/di.xml index 0a33ea91..47fdd67f 100644 --- a/etc/di.xml +++ b/etc/di.xml @@ -119,12 +119,6 @@ - - - NexiConfig - - - GuzzleHttp\Client From aced2288544865777a69205afc8c01720f5e5649 Mon Sep 17 00:00:00 2001 From: Konrad Konieczny Date: Thu, 20 Mar 2025 13:27:49 +0100 Subject: [PATCH 011/320] Refactor payment handling to improve parameter clarity and enhance type safety --- Gateway/Handler/CreatePayment.php | 6 ++++ .../Request/CreatePaymentRequestBuilder.php | 30 +++++++++++-------- Model/Transaction/Builder.php | 7 ++--- 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/Gateway/Handler/CreatePayment.php b/Gateway/Handler/CreatePayment.php index f2e59291..f69014e0 100644 --- a/Gateway/Handler/CreatePayment.php +++ b/Gateway/Handler/CreatePayment.php @@ -10,6 +10,9 @@ class CreatePayment implements HandlerInterface { + /** + * @param SubjectReader $subjectReader + */ public function __construct( private readonly SubjectReader $subjectReader ) { @@ -17,6 +20,9 @@ public function __construct( /** * Handle response + * + * @param array $handlingSubject + * @param array $response */ public function handle(array $handlingSubject, array $response) { diff --git a/Gateway/Request/CreatePaymentRequestBuilder.php b/Gateway/Request/CreatePaymentRequestBuilder.php index 5b32fb31..ae8dc293 100644 --- a/Gateway/Request/CreatePaymentRequestBuilder.php +++ b/Gateway/Request/CreatePaymentRequestBuilder.php @@ -77,14 +77,14 @@ public function buildOrder($order): Payment\Order } /** - * @param Order $order + * @param Order $paymentSubject * * @return Order\Item|array */ - public function buildItems($order): Order\Item|array + public function buildItems(Order|Quote $paymentSubject): Order\Item|array { /** @var Order\Item $items */ - foreach ($order->getAllVisibleItems() as $item) { + foreach ($paymentSubject->getAllVisibleItems() as $item) { $items[] = new Item( name : $item->getName(), quantity : (int)$item->getQtyOrdered(), @@ -99,17 +99,23 @@ public function buildItems($order): Order\Item|array ); } - if ($order->getShippingAddress()->getShippingInclTax() ) { + if ($paymentSubject instanceof Order) { + $shippingInfoHolder = $paymentSubject; + } else { + $shippingInfoHolder = $paymentSubject->getShippingAddress(); + } + + if ($shippingInfoHolder->getShippingInclTax() ) { $items[] = new Item( - name : $order->getShippingAddress()->getShippingDescription(), + name : $shippingInfoHolder->getShippingDescription(), quantity : 1, unit : 'pcs', - unitPrice : (int)($order->getShippingAddress()->getShippingAmount() * 100), - grossTotalAmount: (int)($order->getShippingAddress()->getShippingInclTax() * 100), - netTotalAmount : (int)($order->getShippingAddress()->getShippingAmount() * 100), - reference : $order->getShippingAddress()->getShippingMethod(), - taxRate : (int)($order->getShippingAddress()->getTaxAmount() / $order->getShippingAddress()->getGrandTotal() * 100), - taxAmount : (int)($order->getShippingAddress()->getShippingTaxAmount() * 100), + unitPrice : (int)($shippingInfoHolder->getShippingAmount() * 100), + grossTotalAmount: (int)($shippingInfoHolder->getShippingInclTax() * 100), + netTotalAmount : (int)($shippingInfoHolder->getShippingAmount() * 100), + reference : $shippingInfoHolder->getShippingMethod(), + taxRate : (int)($shippingInfoHolder->getTaxAmount() / $shippingInfoHolder->getGrandTotal() * 100), + taxAmount : (int)($shippingInfoHolder->getShippingTaxAmount() * 100), ); } @@ -124,7 +130,7 @@ public function buildItems($order): Order\Item|array * @return Payment * @throws NoSuchEntityException */ - private function buildPayment(Order|\Magento\Quote\Model\Quote $order): Payment + private function buildPayment(Order|Quote $order): Payment { return new Payment( order : $this->buildOrder($order), diff --git a/Model/Transaction/Builder.php b/Model/Transaction/Builder.php index 660f7a6d..1a3a2186 100644 --- a/Model/Transaction/Builder.php +++ b/Model/Transaction/Builder.php @@ -4,12 +4,12 @@ use Magento\Sales\Api\Data\TransactionInterface; use Magento\Sales\Model\Order; +use Magento\Sales\Model\Order\Payment\Transaction; use Magento\Sales\Model\Order\Payment\Transaction\BuilderInterface; use Nexi\Checkout\Gateway\Config\Config; class Builder { - /** * Constructor * @@ -25,6 +25,7 @@ public function __construct( /** * Build transaction * + * @param $transactionId * @param Order $order * @param mixed $transactionData * @param null $action @@ -36,9 +37,7 @@ public function build($transactionId, Order $order, $transactionData, $action ): return $this->transactionBuilder->setOrder($order) ->setPayment($order->getPayment()) ->setTransactionId($transactionId) - ->setAdditionalInformation( - [\Magento\Sales\Model\Order\Payment\Transaction::RAW_DETAILS => $transactionData] - ) + ->setAdditionalInformation([Transaction::RAW_DETAILS => $transactionData]) ->setFailSafe(true) ->setMessage('Payment transaction - return action.') ->build($action ?: $this->config->getPaymentAction()); From 6dd65c98966dea7ca5f98fb404e2046c7d9b9303 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Ka=C5=82u=C5=BCny?= Date: Thu, 27 Mar 2025 09:16:41 +0100 Subject: [PATCH 012/320] SQNETS-56: cleanup Webhook controller --- Controller/Payment/Webhook.php | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Controller/Payment/Webhook.php b/Controller/Payment/Webhook.php index 8e6bc671..e60974ff 100644 --- a/Controller/Payment/Webhook.php +++ b/Controller/Payment/Webhook.php @@ -39,11 +39,15 @@ public function execute() ->setBody('Unauthorized'); } - $this->webhookHandler->handle($this->getRequest()->getParam('event')); - // TODO: Implement webhook logic here - $this->logger->info('Webhook called: ' . json_encode($this->getRequest()->getContent())); + try { + $this->webhookHandler->handle(json_decode($this->getRequest()->getContent(), true)); - $this->_response->setHttpResponseCode(200); + $this->logger->info('Webhook called: ' . json_encode($this->getRequest()->getContent())); + $this->_response->setHttpResponseCode(200); + } catch (\Exception $e) { + $this->logger->critical($e); + throw new Exception(__($e->getMessage())); + } } From b49394a03ac92a1313c523a4d86774c841de3918 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Ka=C5=82u=C5=BCny?= Date: Thu, 27 Mar 2025 09:17:13 +0100 Subject: [PATCH 013/320] SQNETS-56: improve webhook handler --- Gateway/Handler/WebhookHandler.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Gateway/Handler/WebhookHandler.php b/Gateway/Handler/WebhookHandler.php index 3907d955..882a7d8a 100644 --- a/Gateway/Handler/WebhookHandler.php +++ b/Gateway/Handler/WebhookHandler.php @@ -22,6 +22,12 @@ public function __construct( */ public function handle($response) { - $this->webhookHandlers[$response]->processWebhook($response); + try { + if (in_array($response['event'], $this->webhookHandlers)) { + $this->webhookHandlers[$response['event']]->processWebhook($response['data']); + } + } catch (\Exception $e) { + throw new \Exception($e->getMessage()); + } } } From cb5ad4b2ab32f1245bf040426e446919bf0910de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Ka=C5=82u=C5=BCny?= Date: Thu, 27 Mar 2025 09:17:33 +0100 Subject: [PATCH 014/320] SQNETS-56: update webhooks models --- Model/Webhook/PaymentChargeCreated.php | 30 ++++++++++++-- Model/Webhook/PaymentCreated.php | 18 ++++++--- Model/Webhook/PaymentRefundCompleted.php | 43 ++++++++++++++------- Model/Webhook/PaymentReservationCreated.php | 26 ++++++++----- 4 files changed, 85 insertions(+), 32 deletions(-) diff --git a/Model/Webhook/PaymentChargeCreated.php b/Model/Webhook/PaymentChargeCreated.php index 90f64949..0b6fcdb4 100644 --- a/Model/Webhook/PaymentChargeCreated.php +++ b/Model/Webhook/PaymentChargeCreated.php @@ -13,6 +13,13 @@ class PaymentChargeCreated { + /** + * PaymentChargeCreated constructor. + * + * @param OrderRepositoryInterface $orderRepository + * @param WebhookDataLoader $webhookDataLoader + * @param Builder $transactionBuilder + */ public function __construct( private OrderRepositoryInterface $orderRepository, private WebhookDataLoader $webhookDataLoader, @@ -20,16 +27,31 @@ public function __construct( ) { } - public function processWebhook() + /** + * ProcessWebhook function for 'payment.charge.created.v2' event. + * + * @param $responseData + * @return void + * @throws \Magento\Framework\Exception\LocalizedException + */ + public function processWebhook($responseData) { - $params = json_decode('{"id":"312ecc6aaa5241a28a890b2e76ef8c93","timestamp":"2025-02-24T13:58:29.1396+00:00","merchantNumber":100065206,"event":"payment.charge.created.v2","data":{"chargeId":"312ecc6aaa5241a28a890b2e76ef8c93","orderItems":[{"grossTotalAmount":5280,"name":"Orestes Yoga Pant ","netTotalAmount":5280,"quantity":1.0,"reference":"MP10-36-Green","taxRate":0,"taxAmount":0,"unit":"pcs","unitPrice":5280},{"grossTotalAmount":0,"name":"Orestes Yoga Pant -36-Green","netTotalAmount":0,"quantity":1.0,"reference":"MP10-36-Green","taxRate":0,"taxAmount":0,"unit":"pcs","unitPrice":0},{"grossTotalAmount":500,"name":"Flat Rate - Fixed","netTotalAmount":500,"quantity":1.0,"reference":"flatrate_flatrate","taxRate":0,"taxAmount":0,"unit":"pcs","unitPrice":500}],"paymentMethod":"Visa","paymentType":"CARD","amount":{"amount":5780,"currency":"EUR"},"paymentId":"f369621ef1b149b5b90b65504506eb75"}}', true); - $order = $this->webhookDataLoader->loadOrderByPaymentId($params['data']['paymentId']); + $order = $this->webhookDataLoader->loadOrderByPaymentId($responseData['paymentId']); - $this->processOrder($order, $params['data']['paymentId'], $params['data']['chargeId']); + $this->processOrder($order, $responseData['paymentId'], $responseData['chargeId']); $this->orderRepository->save($order); } + /** + * ProcessOrder function. + * + * @param $order + * @param $paymentId + * @param $chargeTxnId + * @return void + * @throws \Magento\Checkout\Exception + */ private function processOrder($order, $paymentId, $chargeTxnId): void { $transaction = $this->webhookDataLoader->loadTransactionByPaymentId($paymentId); diff --git a/Model/Webhook/PaymentCreated.php b/Model/Webhook/PaymentCreated.php index f86014df..4b481cc4 100644 --- a/Model/Webhook/PaymentCreated.php +++ b/Model/Webhook/PaymentCreated.php @@ -15,6 +15,13 @@ class PaymentCreated { + /** + * PaymentCreated constructor. + * + * @param Builder $transactionBuilder + * @param OrderRepositoryInterface $orderRepository + * @param WebhookDataLoader $webhookDataLoader + */ public function __construct( private Builder $transactionBuilder, private OrderRepositoryInterface $orderRepository, @@ -23,19 +30,18 @@ public function __construct( } /** - * PaymentCreated webhook service. + * ProcessWebhook function for 'payment.created' event. * - * @param $response + * @param $responseData * @return void * @throws Exception * @throws LocalizedException */ - public function processWebhook($response): void + public function processWebhook($responseData): void { - $params = json_decode('{"id":"685dc0ca3c034c8d8ac78e88a577870a","merchantId":100065206,"timestamp":"2025-02-24T13:57:49.2851+00:00","event":"payment.created","data":{"order":{"amount":{"amount":5780,"currency":"EUR"},"reference":"000000020","orderItems":[{"grossTotalAmount":5280,"name":"Orestes Yoga Pant ","netTotalAmount":5280,"quantity":1.0,"reference":"MP10-36-Green","taxRate":0,"taxAmount":0,"unit":"pcs","unitPrice":5280},{"grossTotalAmount":0,"name":"Orestes Yoga Pant -36-Green","netTotalAmount":0,"quantity":1.0,"reference":"MP10-36-Green","taxRate":0,"taxAmount":0,"unit":"pcs","unitPrice":0},{"grossTotalAmount":500,"name":"Flat Rate - Fixed","netTotalAmount":500,"quantity":1.0,"reference":"flatrate_flatrate","taxRate":0,"taxAmount":0,"unit":"pcs","unitPrice":500}]},"paymentId":"f369621ef1b149b5b90b65504506eb75"}}', true); + $order = $this->webhookDataLoader->loadOrderByPaymentId($responseData['paymentId']); - $order = $this->webhookDataLoader->loadOrderByPaymentId($params['data']['paymentId']); - $this->processOrder($order, $params['data']['paymentId']); + $this->processOrder($order, $responseData['paymentId']); $this->orderRepository->save($order); } diff --git a/Model/Webhook/PaymentRefundCompleted.php b/Model/Webhook/PaymentRefundCompleted.php index 16acbac9..32476319 100644 --- a/Model/Webhook/PaymentRefundCompleted.php +++ b/Model/Webhook/PaymentRefundCompleted.php @@ -4,32 +4,49 @@ namespace Nexi\Checkout\Model\Webhook; - +use Magento\Framework\Exception\LocalizedException; use Magento\Sales\Api\Data\TransactionInterface; use Nexi\Checkout\Model\Transaction\Builder; use Nexi\Checkout\Model\Webhook\Data\WebhookDataLoader; class PaymentRefundCompleted { + /** + * PaymentRefundCompleted contructor. + * + * @param WebhookDataLoader $webhookDataLoader + * @param Builder $transactionBuilder + */ public function __construct( private WebhookDataLoader $webhookDataLoader, private Builder $transactionBuilder ) { } - public function processWebhook($response) + /** + * ProcessWebhook function for 'payment.refund.completed' event. + * + * @param $responseData + * @return void + * @throws LocalizedException + */ + public function processWebhook($responseData) { - $params = json_decode('{"id":"b16aadd52c574dedb8a3242e94ba6261","merchantId":100065206,"timestamp":"2025-03-03T14:54:26.4382+00:00","event":"payment.refund.completed","data":{"refundId":"ae4a07ad84d349acb373f777d29cfa53","reconciliationReference":"RRhncQ0LJITpbvV4LW9FXDSVR","amount":{"amount":4700,"currency":"EUR"},"paymentId":"9d0f058350e84981a9502fd31d8a512f"}}', true); - $order = $this->webhookDataLoader->loadOrderByPaymentId($params['data']['paymentId']); + try { + $order = $this->webhookDataLoader->loadOrderByPaymentId($responseData['paymentId']); + + $chargeRefundTransaction = $this->transactionBuilder + ->build( + $responseData['refundId'], + $order, + [ + 'payment_id' => $responseData['paymentId'] + ], + TransactionInterface::TYPE_REFUND + )->setParentTxnId($responseData['paymentId']); + } catch (\Exception $e) { + throw new LocalizedException(__($e->getMessage())); + } - $chargeRefundTransaction = $this->transactionBuilder - ->build( - $params['data']['refundId'], - $order, - [ - 'payment_id' => $params['data']['paymentId'] - ], - TransactionInterface::TYPE_REFUND - )->setParentTxnId($params['data']['paymentId']); } } diff --git a/Model/Webhook/PaymentReservationCreated.php b/Model/Webhook/PaymentReservationCreated.php index db2f4262..4f36b85a 100644 --- a/Model/Webhook/PaymentReservationCreated.php +++ b/Model/Webhook/PaymentReservationCreated.php @@ -3,13 +3,18 @@ namespace Nexi\Checkout\Model\Webhook; use Magento\Checkout\Exception; -use Magento\Framework\Exception\LocalizedException; use Magento\Sales\Api\OrderRepositoryInterface; use Magento\Sales\Model\Order; use Nexi\Checkout\Model\Webhook\Data\WebhookDataLoader; class PaymentReservationCreated { + /** + * PaymentReservationCreated constructor. + * + * @param OrderRepositoryInterface $orderRepository + * @param WebhookDataLoader $webhookDataLoader + */ public function __construct( private OrderRepositoryInterface $orderRepository, private WebhookDataLoader $webhookDataLoader @@ -19,24 +24,27 @@ public function __construct( /** * ProcessWebhook function for 'payment.reservation.created.v2' event. * - * @param $response + * @param $responseData * @return void * @throws Exception - * @throws LocalizedException */ - public function processWebhook($response) + public function processWebhook($responseData) { - $params = json_decode('{"id":"d60fd4bbaad6454a8c2a4377601c969c","timestamp":"2025-02-24T13:58:29.1396+00:00","merchantNumber":100065206,"event":"payment.reservation.created.v2","data":{"paymentMethod":"Visa","paymentType":"CARD","amount":{"amount":5780,"currency":"EUR"},"paymentId":"f369621ef1b149b5b90b65504506eb75"}}', true); - $order = $this->webhookDataLoader->loadOrderByPaymentId($params['data']['paymentId']); + try { + $order = $this->webhookDataLoader->loadOrderByPaymentId($responseData['paymentId']); - $order->getPayment()->setAdditionalInformation('selected_payment_method', $params['data']['paymentMethod']); + $order->getPayment()->setAdditionalInformation('selected_payment_method', $responseData['paymentMethod']); - $this->processOrder($order); - $this->orderRepository->save($order); + $this->processOrder($order); + $this->orderRepository->save($order); + } catch (\Exception $e) { + throw new Exception(__($e->getMessage())); + } } /** * ProcessOrder function. + * * @param $order * @return void * @throws Exception From 7b88f81e8904b1bb063432ec8ccd290a9c0819a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Ka=C5=82u=C5=BCny?= Date: Thu, 27 Mar 2025 09:20:00 +0100 Subject: [PATCH 015/320] revert cb5ad4b --- Model/Webhook/PaymentChargeCreated.php | 30 ++------------ Model/Webhook/PaymentCreated.php | 18 +++------ Model/Webhook/PaymentRefundCompleted.php | 43 +++++++-------------- Model/Webhook/PaymentReservationCreated.php | 26 +++++-------- 4 files changed, 32 insertions(+), 85 deletions(-) diff --git a/Model/Webhook/PaymentChargeCreated.php b/Model/Webhook/PaymentChargeCreated.php index 0b6fcdb4..90f64949 100644 --- a/Model/Webhook/PaymentChargeCreated.php +++ b/Model/Webhook/PaymentChargeCreated.php @@ -13,13 +13,6 @@ class PaymentChargeCreated { - /** - * PaymentChargeCreated constructor. - * - * @param OrderRepositoryInterface $orderRepository - * @param WebhookDataLoader $webhookDataLoader - * @param Builder $transactionBuilder - */ public function __construct( private OrderRepositoryInterface $orderRepository, private WebhookDataLoader $webhookDataLoader, @@ -27,31 +20,16 @@ public function __construct( ) { } - /** - * ProcessWebhook function for 'payment.charge.created.v2' event. - * - * @param $responseData - * @return void - * @throws \Magento\Framework\Exception\LocalizedException - */ - public function processWebhook($responseData) + public function processWebhook() { - $order = $this->webhookDataLoader->loadOrderByPaymentId($responseData['paymentId']); + $params = json_decode('{"id":"312ecc6aaa5241a28a890b2e76ef8c93","timestamp":"2025-02-24T13:58:29.1396+00:00","merchantNumber":100065206,"event":"payment.charge.created.v2","data":{"chargeId":"312ecc6aaa5241a28a890b2e76ef8c93","orderItems":[{"grossTotalAmount":5280,"name":"Orestes Yoga Pant ","netTotalAmount":5280,"quantity":1.0,"reference":"MP10-36-Green","taxRate":0,"taxAmount":0,"unit":"pcs","unitPrice":5280},{"grossTotalAmount":0,"name":"Orestes Yoga Pant -36-Green","netTotalAmount":0,"quantity":1.0,"reference":"MP10-36-Green","taxRate":0,"taxAmount":0,"unit":"pcs","unitPrice":0},{"grossTotalAmount":500,"name":"Flat Rate - Fixed","netTotalAmount":500,"quantity":1.0,"reference":"flatrate_flatrate","taxRate":0,"taxAmount":0,"unit":"pcs","unitPrice":500}],"paymentMethod":"Visa","paymentType":"CARD","amount":{"amount":5780,"currency":"EUR"},"paymentId":"f369621ef1b149b5b90b65504506eb75"}}', true); + $order = $this->webhookDataLoader->loadOrderByPaymentId($params['data']['paymentId']); - $this->processOrder($order, $responseData['paymentId'], $responseData['chargeId']); + $this->processOrder($order, $params['data']['paymentId'], $params['data']['chargeId']); $this->orderRepository->save($order); } - /** - * ProcessOrder function. - * - * @param $order - * @param $paymentId - * @param $chargeTxnId - * @return void - * @throws \Magento\Checkout\Exception - */ private function processOrder($order, $paymentId, $chargeTxnId): void { $transaction = $this->webhookDataLoader->loadTransactionByPaymentId($paymentId); diff --git a/Model/Webhook/PaymentCreated.php b/Model/Webhook/PaymentCreated.php index 4b481cc4..f86014df 100644 --- a/Model/Webhook/PaymentCreated.php +++ b/Model/Webhook/PaymentCreated.php @@ -15,13 +15,6 @@ class PaymentCreated { - /** - * PaymentCreated constructor. - * - * @param Builder $transactionBuilder - * @param OrderRepositoryInterface $orderRepository - * @param WebhookDataLoader $webhookDataLoader - */ public function __construct( private Builder $transactionBuilder, private OrderRepositoryInterface $orderRepository, @@ -30,18 +23,19 @@ public function __construct( } /** - * ProcessWebhook function for 'payment.created' event. + * PaymentCreated webhook service. * - * @param $responseData + * @param $response * @return void * @throws Exception * @throws LocalizedException */ - public function processWebhook($responseData): void + public function processWebhook($response): void { - $order = $this->webhookDataLoader->loadOrderByPaymentId($responseData['paymentId']); + $params = json_decode('{"id":"685dc0ca3c034c8d8ac78e88a577870a","merchantId":100065206,"timestamp":"2025-02-24T13:57:49.2851+00:00","event":"payment.created","data":{"order":{"amount":{"amount":5780,"currency":"EUR"},"reference":"000000020","orderItems":[{"grossTotalAmount":5280,"name":"Orestes Yoga Pant ","netTotalAmount":5280,"quantity":1.0,"reference":"MP10-36-Green","taxRate":0,"taxAmount":0,"unit":"pcs","unitPrice":5280},{"grossTotalAmount":0,"name":"Orestes Yoga Pant -36-Green","netTotalAmount":0,"quantity":1.0,"reference":"MP10-36-Green","taxRate":0,"taxAmount":0,"unit":"pcs","unitPrice":0},{"grossTotalAmount":500,"name":"Flat Rate - Fixed","netTotalAmount":500,"quantity":1.0,"reference":"flatrate_flatrate","taxRate":0,"taxAmount":0,"unit":"pcs","unitPrice":500}]},"paymentId":"f369621ef1b149b5b90b65504506eb75"}}', true); - $this->processOrder($order, $responseData['paymentId']); + $order = $this->webhookDataLoader->loadOrderByPaymentId($params['data']['paymentId']); + $this->processOrder($order, $params['data']['paymentId']); $this->orderRepository->save($order); } diff --git a/Model/Webhook/PaymentRefundCompleted.php b/Model/Webhook/PaymentRefundCompleted.php index 32476319..16acbac9 100644 --- a/Model/Webhook/PaymentRefundCompleted.php +++ b/Model/Webhook/PaymentRefundCompleted.php @@ -4,49 +4,32 @@ namespace Nexi\Checkout\Model\Webhook; -use Magento\Framework\Exception\LocalizedException; + use Magento\Sales\Api\Data\TransactionInterface; use Nexi\Checkout\Model\Transaction\Builder; use Nexi\Checkout\Model\Webhook\Data\WebhookDataLoader; class PaymentRefundCompleted { - /** - * PaymentRefundCompleted contructor. - * - * @param WebhookDataLoader $webhookDataLoader - * @param Builder $transactionBuilder - */ public function __construct( private WebhookDataLoader $webhookDataLoader, private Builder $transactionBuilder ) { } - /** - * ProcessWebhook function for 'payment.refund.completed' event. - * - * @param $responseData - * @return void - * @throws LocalizedException - */ - public function processWebhook($responseData) + public function processWebhook($response) { - try { - $order = $this->webhookDataLoader->loadOrderByPaymentId($responseData['paymentId']); - - $chargeRefundTransaction = $this->transactionBuilder - ->build( - $responseData['refundId'], - $order, - [ - 'payment_id' => $responseData['paymentId'] - ], - TransactionInterface::TYPE_REFUND - )->setParentTxnId($responseData['paymentId']); - } catch (\Exception $e) { - throw new LocalizedException(__($e->getMessage())); - } + $params = json_decode('{"id":"b16aadd52c574dedb8a3242e94ba6261","merchantId":100065206,"timestamp":"2025-03-03T14:54:26.4382+00:00","event":"payment.refund.completed","data":{"refundId":"ae4a07ad84d349acb373f777d29cfa53","reconciliationReference":"RRhncQ0LJITpbvV4LW9FXDSVR","amount":{"amount":4700,"currency":"EUR"},"paymentId":"9d0f058350e84981a9502fd31d8a512f"}}', true); + $order = $this->webhookDataLoader->loadOrderByPaymentId($params['data']['paymentId']); + $chargeRefundTransaction = $this->transactionBuilder + ->build( + $params['data']['refundId'], + $order, + [ + 'payment_id' => $params['data']['paymentId'] + ], + TransactionInterface::TYPE_REFUND + )->setParentTxnId($params['data']['paymentId']); } } diff --git a/Model/Webhook/PaymentReservationCreated.php b/Model/Webhook/PaymentReservationCreated.php index 4f36b85a..db2f4262 100644 --- a/Model/Webhook/PaymentReservationCreated.php +++ b/Model/Webhook/PaymentReservationCreated.php @@ -3,18 +3,13 @@ namespace Nexi\Checkout\Model\Webhook; use Magento\Checkout\Exception; +use Magento\Framework\Exception\LocalizedException; use Magento\Sales\Api\OrderRepositoryInterface; use Magento\Sales\Model\Order; use Nexi\Checkout\Model\Webhook\Data\WebhookDataLoader; class PaymentReservationCreated { - /** - * PaymentReservationCreated constructor. - * - * @param OrderRepositoryInterface $orderRepository - * @param WebhookDataLoader $webhookDataLoader - */ public function __construct( private OrderRepositoryInterface $orderRepository, private WebhookDataLoader $webhookDataLoader @@ -24,27 +19,24 @@ public function __construct( /** * ProcessWebhook function for 'payment.reservation.created.v2' event. * - * @param $responseData + * @param $response * @return void * @throws Exception + * @throws LocalizedException */ - public function processWebhook($responseData) + public function processWebhook($response) { - try { - $order = $this->webhookDataLoader->loadOrderByPaymentId($responseData['paymentId']); + $params = json_decode('{"id":"d60fd4bbaad6454a8c2a4377601c969c","timestamp":"2025-02-24T13:58:29.1396+00:00","merchantNumber":100065206,"event":"payment.reservation.created.v2","data":{"paymentMethod":"Visa","paymentType":"CARD","amount":{"amount":5780,"currency":"EUR"},"paymentId":"f369621ef1b149b5b90b65504506eb75"}}', true); + $order = $this->webhookDataLoader->loadOrderByPaymentId($params['data']['paymentId']); - $order->getPayment()->setAdditionalInformation('selected_payment_method', $responseData['paymentMethod']); + $order->getPayment()->setAdditionalInformation('selected_payment_method', $params['data']['paymentMethod']); - $this->processOrder($order); - $this->orderRepository->save($order); - } catch (\Exception $e) { - throw new Exception(__($e->getMessage())); - } + $this->processOrder($order); + $this->orderRepository->save($order); } /** * ProcessOrder function. - * * @param $order * @return void * @throws Exception From 371bb8587c30933e0104cbcaa93ddbbdba664083 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Ka=C5=82u=C5=BCny?= Date: Thu, 27 Mar 2025 09:20:30 +0100 Subject: [PATCH 016/320] revert b49394a --- Gateway/Handler/WebhookHandler.php | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/Gateway/Handler/WebhookHandler.php b/Gateway/Handler/WebhookHandler.php index 882a7d8a..3907d955 100644 --- a/Gateway/Handler/WebhookHandler.php +++ b/Gateway/Handler/WebhookHandler.php @@ -22,12 +22,6 @@ public function __construct( */ public function handle($response) { - try { - if (in_array($response['event'], $this->webhookHandlers)) { - $this->webhookHandlers[$response['event']]->processWebhook($response['data']); - } - } catch (\Exception $e) { - throw new \Exception($e->getMessage()); - } + $this->webhookHandlers[$response]->processWebhook($response); } } From 8618d16a886e2a8bf479da39c8b80a15d67820ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Ka=C5=82u=C5=BCny?= Date: Thu, 27 Mar 2025 09:20:49 +0100 Subject: [PATCH 017/320] revert 6dd65c9 --- Controller/Payment/Webhook.php | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/Controller/Payment/Webhook.php b/Controller/Payment/Webhook.php index e60974ff..8e6bc671 100644 --- a/Controller/Payment/Webhook.php +++ b/Controller/Payment/Webhook.php @@ -39,15 +39,11 @@ public function execute() ->setBody('Unauthorized'); } - try { - $this->webhookHandler->handle(json_decode($this->getRequest()->getContent(), true)); + $this->webhookHandler->handle($this->getRequest()->getParam('event')); + // TODO: Implement webhook logic here + $this->logger->info('Webhook called: ' . json_encode($this->getRequest()->getContent())); - $this->logger->info('Webhook called: ' . json_encode($this->getRequest()->getContent())); - $this->_response->setHttpResponseCode(200); - } catch (\Exception $e) { - $this->logger->critical($e); - throw new Exception(__($e->getMessage())); - } + $this->_response->setHttpResponseCode(200); } From e33a0dbcb7afb4bb032b970223beb91a9d778609 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Ka=C5=82u=C5=BCny?= Date: Thu, 27 Mar 2025 17:01:08 +0100 Subject: [PATCH 018/320] SQNETS-56: fix code-validation --- Plugin/Order/Data/PaymentMethodCustomerOrderInfo.php | 1 + 1 file changed, 1 insertion(+) diff --git a/Plugin/Order/Data/PaymentMethodCustomerOrderInfo.php b/Plugin/Order/Data/PaymentMethodCustomerOrderInfo.php index d0245537..d7a8fb94 100644 --- a/Plugin/Order/Data/PaymentMethodCustomerOrderInfo.php +++ b/Plugin/Order/Data/PaymentMethodCustomerOrderInfo.php @@ -10,6 +10,7 @@ class PaymentMethodCustomerOrderInfo /** * Around plugin for getPaymentInfoHtml method in Info class. * + * @param Info $subject * @return string */ public function aroundGetPaymentInfoHtml(Info $subject) From 9ee675580cadc8edd73706898a283d491d31de6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Ka=C5=82u=C5=BCny?= Date: Thu, 27 Mar 2025 17:07:14 +0100 Subject: [PATCH 019/320] SQNETS-55: fix code-validation --- Controller/Payment/Webhook.php | 26 +++++++++++++++----- Model/Webhook/Data/WebhookDataLoader.php | 1 - view/adminhtml/templates/info/checkout.phtml | 6 ++--- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/Controller/Payment/Webhook.php b/Controller/Payment/Webhook.php index 8e6bc671..c9d097aa 100644 --- a/Controller/Payment/Webhook.php +++ b/Controller/Payment/Webhook.php @@ -16,7 +16,15 @@ class Webhook extends Action implements CsrfAwareActionInterface, HttpPostActionInterface { - + /** + * Webhook constructor. + * + * @param Context $context + * @param LoggerInterface $logger + * @param Encryptor $encryptor + * @param Config $config + * @param WebhookHandler $webhookHandler + */ public function __construct( Context $context, private readonly LoggerInterface $logger, @@ -28,6 +36,8 @@ public function __construct( } /** + * Execute webhooks method. + * * @return void * @throws Exception */ @@ -46,14 +56,19 @@ public function execute() $this->_response->setHttpResponseCode(200); } - + /** + * Create CSRF validation exception. + * + * @param RequestInterface $request + * @return InvalidRequestException|null + */ public function createCsrfValidationException(RequestInterface $request): ?InvalidRequestException { return null; } /** - * No form key validation needed + * No form key validation needed. * * @param RequestInterface $request * @@ -65,10 +80,9 @@ public function validateForCsrf(RequestInterface $request): ?bool } /** - * @param RequestInterface $request + * Returns is request authorized. * - * @return void - * @throws Exception + * @return bool */ public function isAuthorized(): bool { diff --git a/Model/Webhook/Data/WebhookDataLoader.php b/Model/Webhook/Data/WebhookDataLoader.php index 30b5ac97..c6648432 100644 --- a/Model/Webhook/Data/WebhookDataLoader.php +++ b/Model/Webhook/Data/WebhookDataLoader.php @@ -2,7 +2,6 @@ namespace Nexi\Checkout\Model\Webhook\Data; - use Magento\Checkout\Exception; use Magento\Framework\Api\SearchCriteriaBuilder; use Magento\Framework\Exception\LocalizedException; diff --git a/view/adminhtml/templates/info/checkout.phtml b/view/adminhtml/templates/info/checkout.phtml index ab54d30e..9a71f17f 100644 --- a/view/adminhtml/templates/info/checkout.phtml +++ b/view/adminhtml/templates/info/checkout.phtml @@ -2,7 +2,7 @@ /** @var $block \Nexi\Checkout\Block\Info\Nexi */ ?> - -

getPaymentMethodTitle() ? $block->getPaymentMethodTitle() : '' ?>

+ +

escapeHtml($block->getPaymentMethodTitle() ? $block->getPaymentMethodTitle() : '') ?>

getChildHtml()?> -

Payment method: getPaymentSelectedMethod() ? $block->getPaymentSelectedMethod() : '()' ?>

+

Payment method: escapeHtml($block->getPaymentSelectedMethod() ? $block->getPaymentSelectedMethod() : '()') ?>

From 78e4b51f4260d0544718026903a7219a0e558069 Mon Sep 17 00:00:00 2001 From: Konrad Konieczny Date: Mon, 31 Mar 2025 11:26:41 +0200 Subject: [PATCH 020/320] Refactor test cases and add new tests for transaction handling --- .gitignore | 1 + .../System/Config/TestConnectionTest.php | 216 ++++++++++-------- Test/Unit/TransactionBuilderTest.php | 89 ++++++++ .../web/js/view/payment/initialize-payment.js | 101 ++++---- 4 files changed, 263 insertions(+), 144 deletions(-) create mode 100644 Test/Unit/TransactionBuilderTest.php diff --git a/.gitignore b/.gitignore index 58786aac..6b96e4d3 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ node_modules/ /playwright-report/ /blob-report/ /playwright/.cache/ +auth.json diff --git a/Test/Unit/Controller/Adminhtml/System/Config/TestConnectionTest.php b/Test/Unit/Controller/Adminhtml/System/Config/TestConnectionTest.php index 42ed8f48..4d78427b 100644 --- a/Test/Unit/Controller/Adminhtml/System/Config/TestConnectionTest.php +++ b/Test/Unit/Controller/Adminhtml/System/Config/TestConnectionTest.php @@ -1,104 +1,126 @@ createMock(Context::class); - $this->requestMock = $this->createMock(RequestInterface::class); - $this->jsonFactoryMock = $this->createMock(JsonFactory::class); - $this->jsonMock = $this->createMock(Json::class); - $this->paymentApiFactoryMock = $this->createMock(PaymentApiFactory::class); - $this->configMock = $this->createMock(Config::class); - $stripTagsMock = $this->createMock(StripTags::class); - - $contextMock->method('getRequest')->willReturn($this->requestMock); - $this->jsonFactoryMock->method('create')->willReturn($this->jsonMock); - - $this->controller = $objectManager->getObject( - TestConnection::class, - [ - 'context' => $contextMock, - 'resultJsonFactory' => $this->jsonFactoryMock, - 'tagFilter' => $stripTagsMock, - 'paymentApiFactory' => $this->paymentApiFactoryMock, - 'config' => $this->configMock - ] - ); - } - - public function testExecuteSuccess() - { - $this->requestMock->method('getParams') - ->willReturn( + private $controller; + private $jsonFactoryMock; + private $jsonMock; + private $requestMock; + private $paymentApiFactoryMock; + private $configMock; + + protected function setUp(): void + { + $objectManager = new ObjectManager($this); + + $contextMock = $this->createMock(Context::class); + $this->requestMock = $this->createMock(RequestInterface::class); + $this->jsonFactoryMock = $this->createMock(JsonFactory::class); + $this->jsonMock = $this->createMock(Json::class); + $this->paymentApiFactoryMock = $this->createMock(PaymentApiFactory::class); + $this->configMock = $this->createMock(Config::class); + $stripTagsMock = $this->createMock(StripTags::class); + + $contextMock->method('getRequest')->willReturn($this->requestMock); + $this->jsonFactoryMock->method('create')->willReturn($this->jsonMock); + + $this->controller = $objectManager->getObject( + TestConnection::class, [ - 'api_key' => 'valid_api_key', - 'environment' => Environment::LIVE + 'context' => $contextMock, + 'resultJsonFactory' => $this->jsonFactoryMock, + 'tagFilter' => $stripTagsMock, + 'paymentApiFactory' => $this->paymentApiFactoryMock, + 'config' => $this->configMock ] ); - - $apiMock = $this->createMock(PaymentApi::class); - $this->paymentApiFactoryMock->method('create') - ->willReturn($apiMock); - $apiMock->method('retrievePayment') - ->willThrowException(new \Exception('should be in guid format')); - - $this->jsonMock->expects($this->once()) - ->method('setData') - ->with(['success' => true, 'errorMessage' => '']) - ->willReturnSelf(); - - $this->controller->execute(); - } - - public function testExecuteFailure() - { - $this->requestMock->method('getParams') - ->willReturn( - [ - 'api_key' => 'invalid_api_key', - 'environment' => Environment::LIVE - ] - ); - - $apiMock = $this->createMock(PaymentApi::class); - $this->paymentApiFactoryMock->method('create')->willReturn($apiMock); - $apiMock->method('retrievePayment')->willThrowException(new \Exception('Invalid API key')); - - $this->jsonMock->expects($this->once()) - ->method('setData') - ->with([ - 'success' => false, - 'errorMessage' => 'Please check your API key and environment.' - ]) - ->willReturnSelf(); - - $this->controller->execute(); + } + + public function testExecuteSuccess() + { + $this->requestMock->method('getParams')->willReturn([ + 'api_key' => 'valid_api_key', + 'environment' => Environment::LIVE + ]); + + $apiMock = $this->createMock(PaymentApi::class); + $this->paymentApiFactoryMock->method('create')->willReturn($apiMock); + $apiMock->method('retrievePayment')->willThrowException(new \Exception('should be in guid format')); + + $this->jsonMock->expects($this->once()) + ->method('setData') + ->with(['success' => true, 'errorMessage' => '']) + ->willReturnSelf(); + + $this->controller->execute(); + } + + public function testExecuteFailure() + { + $this->requestMock->method('getParams')->willReturn([ + 'api_key' => 'invalid_api_key', + 'environment' => Environment::LIVE + ]); + + $apiMock = $this->createMock(PaymentApi::class); + $this->paymentApiFactoryMock->method('create')->willReturn($apiMock); + $apiMock->method('retrievePayment')->willThrowException(new \Exception('Invalid API key')); + + $this->jsonMock->expects($this->once()) + ->method('setData') + ->with(['success' => false, 'errorMessage' => 'Please check your API key and environment.']) + ->willReturnSelf(); + + $this->controller->execute(); + } + + // Test for missing parameters + public function testExecuteMissingParams() + { + $this->requestMock->method('getParams')->willReturn([]); + + $this->jsonMock->expects($this->once()) + ->method('setData') + ->with(['success' => false, 'errorMessage' => 'API key and environment are required.']) + ->willReturnSelf(); + + $this->controller->execute(); + } + + // write a new test case for invalid api key + public function testExecuteInvalidApiKey() + { + $this->requestMock->method('getParams')->willReturn([ + 'api_key' => 'invalid_api_key', + 'environment' => Environment::LIVE + ]); + + $apiMock = $this->createMock(PaymentApi::class); + $this->paymentApiFactoryMock->method('create')->willReturn($apiMock); + $apiMock->method('retrievePayment')->willThrowException(new \Exception('Invalid API key')); + + $this->jsonMock->expects($this->once()) + ->method('setData') + ->with(['success' => false, 'errorMessage' => 'Invalid API key']) + ->willReturnSelf(); + + $this->controller->execute(); + } } -} diff --git a/Test/Unit/TransactionBuilderTest.php b/Test/Unit/TransactionBuilderTest.php new file mode 100644 index 00000000..356460d2 --- /dev/null +++ b/Test/Unit/TransactionBuilderTest.php @@ -0,0 +1,89 @@ +transactionBuilderMock = $this->createMock(BuilderInterface::class); + $this->configMock = $this->createMock(Config::class); + $this->builder = new TransactionBuilder($this->transactionBuilderMock, $this->configMock); + } + + public function testBuildTransactionSuccessfully() + { + $transactionId = '12345'; + $order = $this->createMock(Order::class); + $transactionData = ['key' => 'value']; + $action = 'capture'; + + $order->method('getPayment')->willReturn($this->createMock(Order\Payment::class)); + $this->transactionBuilderMock->method('setOrder')->willReturnSelf(); + $this->transactionBuilderMock->method('setPayment')->willReturnSelf(); + $this->transactionBuilderMock->method('setTransactionId')->willReturnSelf(); + $this->transactionBuilderMock->method('setAdditionalInformation')->willReturnSelf(); + $this->transactionBuilderMock->method('setFailSafe')->willReturnSelf(); + $this->transactionBuilderMock->method('setMessage')->willReturnSelf(); + $this->transactionBuilderMock->method('build')->willReturn($this->createMock(TransactionInterface::class)); + + $result = $this->builder->build($transactionId, $order, $transactionData, $action); + + $this->assertInstanceOf(TransactionInterface::class, $result); + } + + public function testBuildTransactionWithNullAction() + { + $transactionId = '12345'; + $order = $this->createMock(Order::class); + $transactionData = ['key' => 'value']; + $action = null; + + $order->method('getPayment')->willReturn($this->createMock(Order\Payment::class)); + $this->transactionBuilderMock->method('setOrder')->willReturnSelf(); + $this->transactionBuilderMock->method('setPayment')->willReturnSelf(); + $this->transactionBuilderMock->method('setTransactionId')->willReturnSelf(); + $this->transactionBuilderMock->method('setAdditionalInformation')->willReturnSelf(); + $this->transactionBuilderMock->method('setFailSafe')->willReturnSelf(); + $this->transactionBuilderMock->method('setMessage')->willReturnSelf(); + $this->transactionBuilderMock->method('build')->willReturn($this->createMock(TransactionInterface::class)); + + $this->configMock->method('getPaymentAction')->willReturn('authorize'); + + $result = $this->builder->build($transactionId, $order, $transactionData, $action); + + $this->assertInstanceOf(TransactionInterface::class, $result); + } + + public function testBuildTransactionWithNullTransactionData() + { + $transactionId = '12345'; + $order = $this->createMock(Order::class); + $transactionData = null; + $action = 'capture'; + + $order->method('getPayment')->willReturn($this->createMock(Order\Payment::class)); + $this->transactionBuilderMock->method('setOrder')->willReturnSelf(); + $this->transactionBuilderMock->method('setPayment')->willReturnSelf(); + $this->transactionBuilderMock->method('setTransactionId')->willReturnSelf(); + $this->transactionBuilderMock->method('setAdditionalInformation')->willReturnSelf(); + $this->transactionBuilderMock->method('setFailSafe')->willReturnSelf(); + $this->transactionBuilderMock->method('setMessage')->willReturnSelf(); + $this->transactionBuilderMock->method('build')->willReturn($this->createMock(TransactionInterface::class)); + + $result = $this->builder->build($transactionId, $order, $transactionData, $action); + + $this->assertInstanceOf(TransactionInterface::class, $result); + } +} diff --git a/view/frontend/web/js/view/payment/initialize-payment.js b/view/frontend/web/js/view/payment/initialize-payment.js index 14ace4e1..daccb33e 100644 --- a/view/frontend/web/js/view/payment/initialize-payment.js +++ b/view/frontend/web/js/view/payment/initialize-payment.js @@ -1,52 +1,59 @@ -define( - [ - 'mage/storage', - 'Magento_Checkout/js/model/url-builder', - 'Magento_Checkout/js/model/quote', - 'Magento_Checkout/js/model/full-screen-loader', - 'Magento_Checkout/js/model/error-processor', - 'Magento_Customer/js/model/customer' - ], - function (storage, urlBuilder, quote, fullScreenLoader, errorProcessor, customer) { - 'use strict'; +define([ + "mage/storage", + "Magento_Checkout/js/model/url-builder", + "Magento_Checkout/js/model/quote", + "Magento_Checkout/js/model/full-screen-loader", + "Magento_Checkout/js/model/error-processor", + "Magento_Customer/js/model/customer", +], function ( + storage, + urlBuilder, + quote, + fullScreenLoader, + errorProcessor, + customer +) { + "use strict"; - return function () { - const payload = { - cartId: quote.getQuoteId(), - paymentMethod: { - method: this.getCode() - }, - integrationType: this.config.integrationType - }; + return function () { + const payload = { + cartId: quote.getQuoteId(), + paymentMethod: { + method: this.getCode(), + }, + integrationType: this.config.integrationType, + }; - const serviceUrl = customer.isLoggedIn() - ? urlBuilder.createUrl('/nexi/carts/mine/payment-initialize', {}) - : urlBuilder.createUrl('/nexi/guest-carts/:quoteId/payment-initialize', { - quoteId: quote.getQuoteId() - }); + const serviceUrl = customer.isLoggedIn() + ? urlBuilder.createUrl("/nexi/carts/mine/payment-initialize", {}) + : urlBuilder.createUrl("/nexi/guest-carts/:quoteId/payment-initialize", { + quoteId: quote.getQuoteId(), + }); - fullScreenLoader.startLoader(); + fullScreenLoader.startLoader(); - return new Promise((resolve, reject) => { - storage.post( - serviceUrl, - JSON.stringify(payload) - ).done(function (response) { - resolve(JSON.parse(response)); - }).fail(function (response) { - errorProcessor.process(response, this.messageContainer); - let redirectURL = response.getResponseHeader('errorRedirectAction'); + return new Promise((resolve, reject) => { + storage + .post(serviceUrl, JSON.stringify(payload)) + .done(function (response) { + resolve(JSON.parse(response)); + }) + .fail( + function (response) { + errorProcessor.process(response, this.messageContainer); + let redirectURL = response.getResponseHeader("errorRedirectAction"); - if (redirectURL) { - setTimeout(function () { - errorProcessor.redirectTo(redirectURL); - }, 3000); - } - reject(response); - }).always(function () { - fullScreenLoader.stopLoader(); - }); - }); - }; - } -); + if (redirectURL) { + setTimeout(function () { + errorProcessor.redirectTo(redirectURL); + }, 3000); + } + reject(response); + }.bind(this) + ) + .always(function () { + fullScreenLoader.stopLoader(); + }); + }); + }; +}); From 2fec3d4355910d4daf2fff53f930fadde0a42b08 Mon Sep 17 00:00:00 2001 From: Konrad Konieczny Date: Mon, 31 Mar 2025 12:56:33 +0200 Subject: [PATCH 021/320] remove debug js file, reformat --- .../System/Config/TestConnection.php | 15 +- Controller/Hpp/ReturnAction.php | 2 + Gateway/Http/Client.php | 2 + .../Request/CreatePaymentRequestBuilder.php | 4 +- view/frontend/web/js/dibs-checkout.js | 448 ------------------ 5 files changed, 15 insertions(+), 456 deletions(-) delete mode 100644 view/frontend/web/js/dibs-checkout.js diff --git a/Block/Adminhtml/System/Config/TestConnection.php b/Block/Adminhtml/System/Config/TestConnection.php index 2db1b952..d565bfd9 100644 --- a/Block/Adminhtml/System/Config/TestConnection.php +++ b/Block/Adminhtml/System/Config/TestConnection.php @@ -10,6 +10,12 @@ class TestConnection extends Field { + /** + * @param Context $context + * @param Structure $configStructure + * @param array $data + * @param SecureHtmlRenderer|null $secureRenderer + */ public function __construct( Context $context, private readonly Structure $configStructure, @@ -35,10 +41,7 @@ public function render(AbstractElement $element) } /** - * Set template to itself - * - * @return $this - * @since 100.1.0 + * @inheritDoc */ protected function _prepareLayout() { @@ -48,9 +51,7 @@ protected function _prepareLayout() } /** - * @param AbstractElement $element - * - * @return string + * @inheritDoc */ protected function _getElementHtml(AbstractElement $element) { diff --git a/Controller/Hpp/ReturnAction.php b/Controller/Hpp/ReturnAction.php index 2886483a..cdb76cfd 100644 --- a/Controller/Hpp/ReturnAction.php +++ b/Controller/Hpp/ReturnAction.php @@ -171,6 +171,8 @@ public function getSuccessRedirect(): Redirect } /** + * Redirect to cart + * * @return Redirect */ public function getCartRedirect(): Redirect diff --git a/Gateway/Http/Client.php b/Gateway/Http/Client.php index 0a541bdd..8549cef1 100644 --- a/Gateway/Http/Client.php +++ b/Gateway/Http/Client.php @@ -112,6 +112,8 @@ public function logResponse(?JsonDeserializeInterface $response): void } /** + * Log request + * * @param string $nexiMethod * @param TransferInterface $transferObject * diff --git a/Gateway/Request/CreatePaymentRequestBuilder.php b/Gateway/Request/CreatePaymentRequestBuilder.php index ae8dc293..34e0b4cb 100644 --- a/Gateway/Request/CreatePaymentRequestBuilder.php +++ b/Gateway/Request/CreatePaymentRequestBuilder.php @@ -39,6 +39,8 @@ public function __construct( } /** + * Build request + * * @param array $buildSubject * * @return array @@ -77,7 +79,7 @@ public function buildOrder($order): Payment\Order } /** - * @param Order $paymentSubject + * @param Order|Quote $paymentSubject * * @return Order\Item|array */ diff --git a/view/frontend/web/js/dibs-checkout.js b/view/frontend/web/js/dibs-checkout.js deleted file mode 100644 index f1c47829..00000000 --- a/view/frontend/web/js/dibs-checkout.js +++ /dev/null @@ -1,448 +0,0 @@ -!function () { - "use strict"; - let e = function (e) { - return e.CategoryBasedCheckout = "CategoryBasedCheckout", e.NexiRebranding = "NexiRebranding", e.ApplePay = "ApplePay", e.GooglePay = "GooglePay", e.Klarna = "Klarna", e.DoubleClick = "DoubleClick", e.PaymentCompletedLogging = "PaymentCompletedLogging", e.ClearSavedPayments = "ClearSavedPayments", e.RemoveRememberMe = "RemoveRememberMe", e.B2CWillNotBeRecognizedWithSSN = "B2CWillNotBeRecognizedWithSSN", e.SelectCardOnApplePayCancellation = "SelectCardOnApplePayCancellation", e.MobilePayCorrectPaymentType = "MobilePayCorrectPaymentType", e.KlarnaB2B = "KlarnaB2B", e.ApplePayUseMerchantCountry = "ApplePayUseMerchantCountry", e.ApplePayOnCancelDontAbort = "ApplePayOnCancelDontAbort", e.GooglePayUseAuthorizationFlow = "GooglePayUseAuthorizationFlow", e.GooglePayDynamicPriceUpdate = "GooglePayDynamicPriceUpdate", e.DisableConsumerLogin = "DisableConsumerLogin", e.EnableVippsForSweden = "EnableVippsForSweden", e - }({}); - var t; - const n = ("string" == typeof (s = "false") ? "true" === s.toLowerCase() : s || !1) || !1; - var s; - null == (t = document) || null == (t = t.currentScript) || t.src; - const i = "https://test.checkout.dibspayment.eu", o = parseInt("13"); - let a, r; - !function (t) { - class s { - constructor(e, t) { - this.eventType = e, this.data = t - } - - toJson() { - return JSON.stringify(this) - } - } - - t.Checkout = class { - constructor(e) { - this.iFrameDibsContainerId = "dibs-checkout-content", this.iFrameDefaultContainerId = "nets-checkout-content", this.iFrameContentStyleClass = "dibs-checkout-wrapper", this.iFrameId = "nets-checkout-iframe", this.applePayJSId = "applepayjs", this.endPointDomain = void 0, this.iFrameSrc = void 0, this.styleSheetSrc = void 0, this.paymentFailed = !1, this.isApplePayEnabled = !1, this.applePaySession = void 0, this.applePayPaymentRequest = void 0, this.allowedShippingCountries = void 0, this.featureToggles = void 0, this.onPaymentCompletedEvent = void 0, this.onPaymentCancelledEvent = void 0, this.onPayInitializedEvent = void 0, this.onAddressChangedEvent = void 0, this.onApplepayContactUpdatedEvent = void 0, this.checkoutInitialized = !1, this.isThemeSetExplicitly = !1, this.options = void 0, this.setupMessageListeners = e => { - if (!this.checkMsgSafe(e)) return; - try { - JSON.parse(e.data) - } catch (e) { - return void this.consoleLog(e) - } - const t = JSON.parse(e.data); - switch (this.consoleDebug(`Event received: ${t.eventType}, ${JSON.stringify(t.data)}`), t.eventType) { - case"featureTogglesChanged": - this.featureToggles = t.data; - break; - case"checkoutInitialized": - this.checkoutInitialized || (this.checkoutInitialized = !0, this.consoleLog("checkoutInitialized"), this.publishUnsentMessagesToCheckout(), this.postThemeToCheckout(), this.sendIframeSizing()); - break; - case"goto3DS": - this.goto3DS(t.data); - break; - case"payInitialized": - this.onPayInitializedEvent ? this.onPayInitializedEvent(t.data) : (this.consoleLog("PaymentInitialized not handled by merchant"), this.sendPaymentOrderFinalizedEvent(!0)); - break; - case"paymentSuccess": - this.onPaymentCompletedEvent(t.data); - break; - case"paymentCancelled": - this.onPaymentCancelledEvent(t.data); - break; - case"resize": - this.resizeIFrame(t.data); - break; - case"addressChanged": - this.onAddressChangedEvent ? this.onAddressChangedEvent(t.data) : this.postMessage(new s("addressChangedNotHandled")); - break; - case"removePaymentFailedQueryParameter": - this.removePaymentFailedQueryParameter(); - break; - case"inceptionIframeInitialized": - this.inceptionIframeInitialized(); - break; - case"getIsApplePaySupportedOnCurrentDevice": - this.getIsApplePaySupportedOnCurrentDevice(); - break; - case"applePayClicked": - this.applePayClicked(t.data); - break; - case"applePaySessionValidated": - this.onReceivedMerchantSession(t.data); - break; - case"applePayPaymentComplete": - this.onApplePayPaymentComplete(t.data); - break; - case"setAllowedShippingCountries": - this.onSetAllowedShippingCountries(t.data); - break; - default: - const n = t.eventType; - this.consoleLog(`unknown event ${n} ${JSON.stringify(e.data)}`) - } - }, this.setupResizeListeners = () => { - const e = new s("resize"); - this.postMessage(e) - }, this.unsentMessages = [], this.options = e, this.init() - } - - on(e, t) { - if (!t) throw new Error(`No function was supplied in the second argument. Please supply the function you want to be called on the ${e} event`); - if ("pay-initialized" === e) this.onPayInitializedEvent = t; else if ("payment-completed" === e) this.onPaymentCompletedEvent = t; else if ("payment-cancelled" === e) this.onPaymentCancelledEvent = t; else if ("address-changed" === e) this.onAddressChangedEvent = t; else if ("applepay-contact-updated" === e) this.onApplepayContactUpdatedEvent = t; else { - const t = e; - this.consoleLog(`${t} is not a valid public event name.`) - } - } - - send(e, t) { - if ("payment-order-finalized" === e) { - const e = t || !1; - this.sendPaymentOrderFinalizedEvent(e) - } else "payment-cancel-initiated" === e && this.postMessage(new s("cancelPayment")) - } - - freezeCheckout() { - this.postMessage(new s("freezeCheckout")) - } - - thawCheckout() { - this.postMessage(new s("thawCheckout")) - } - - setTheme(e) { - this.isThemeSetExplicitly = !0, this.postMessage(new s("setTheme", e)) - } - - setLanguage(e) { - this.postMessage(new s("setLanguage", e)) - } - - completeApplePayShippingContactUpdate(e) { - if (this.isApplePayEnabled && this.applePaySession && this.applePayPaymentRequest) try { - const t = this.applePayPaymentRequest.total; - if (!e) { - this.consoleLog("Does not support this operation. Undefined amount specified."); - const e = new ApplePayError("unknown", void 0, " Undefined amount specified."); - return void this.applePaySession.completeShippingContactSelection({newTotal: t, errors: [e]}) - } - if ("string" != typeof e && "number" != typeof e) { - this.consoleLog("Does not support this operation. Wrong argument type provided."); - const e = new ApplePayError("unknown", void 0, "Wrong argument type provided."); - return void this.applePaySession.completeShippingContactSelection({newTotal: t, errors: [e]}) - } - "number" == typeof e && (e = String(e)), this.consoleLog(`Apple pay order amount update with ${e}`); - const n = new s("updateApplePayOrderAmount", e); - this.postMessage(n); - const {label: i, type: o} = this.applePayPaymentRequest.total, a = Number(e) / 100; - this.applePaySession.completeShippingContactSelection({ - newTotal: { - amount: String(a), - label: i, - type: o - } - }) - } catch (e) { - this.consoleError(e, "Error in completeShippingMethodSelection for ApplePay") - } else this.consoleLog("Does not support this operation. ApplePay is disabled.") - } - - cleanup() { - this.removeListeners() - } - - init() { - var e, t, n, s, o, a; - const r = null == (e = this.options) ? void 0 : e.checkoutKey, - p = null == (t = this.options) ? void 0 : t.paymentId, l = this; - if (this.options.containerId || (this.options.containerId = document.getElementById(this.iFrameDefaultContainerId) ? this.iFrameDefaultContainerId : this.iFrameDibsContainerId), !this.isThemeSet() && i && r && p) { - const e = new XMLHttpRequest; - e.addEventListener("load", (function () { - if (200 === this.status && !l.isThemeSetExplicitly) { - const e = JSON.parse(this.responseText); - l.options.theme = e, l.postThemeToCheckout() - } - })), e.open("GET", `${i}/api/v1/theming/checkout`), e.setRequestHeader("CheckoutKey", r), e.setRequestHeader("PaymentId", p), e.send() - } - this.paymentFailed = "true" === this.getQueryStringParameter("paymentFailed", window.location.href), this.endPointDomain = "https://test.checkout.dibspayment.eu", this.iFrameSrc = `${this.endPointDomain}/v1/?checkoutKey=${null == (n = this.options) ? void 0 : n.checkoutKey}&paymentId=${null == (s = this.options) ? void 0 : s.paymentId}`, null != (o = this.options) && o.partnerMerchantNumber && (this.iFrameSrc += `&partnerMerchantNumber=${this.options.partnerMerchantNumber}`), null != (a = this.options) && a.language && (this.iFrameSrc += `&language=${this.options.language}`), this.paymentFailed && (this.iFrameSrc += `&paymentFailed=${this.paymentFailed}`), this.styleSheetSrc = `${this.endPointDomain}/v1/assets/css/checkout.css`, this.setListeners(); - const h = document.getElementsByTagName("head")[0]; - this.addStyleSheet(h), this.addMainIFrame() - } - - isWindowOnTopLevel() { - try { - return window.top.location.href, !0 - } catch (e) { - return !1 - } - } - - isThemeSet() { - var e; - return !(null == (e = this.options) || !e.theme) && Object.keys(this.options.theme).length > 0 - } - - inceptionIframeInitialized() { - if (this.isWindowOnTopLevel()) { - var e; - const t = this.getIFrameHeight(), n = null == (e = window.top) ? void 0 : e.innerHeight; - if (n && t > n) { - this.resizeIFrame(n); - const e = new s("scrollIntoView", n); - this.postMessage(e) - } - } - } - - getErrorMsg(e, t) { - let n = t; - return "string" == typeof e ? n = `${t} ${e}` : e instanceof Error && (n = `${t} ${e.message}`), n - } - - loadApplePayJs(e) { - const t = document.getElementById(this.applePayJSId); - if (!t) { - const t = document.createElement("script"); - t.src = "https://applepay.cdn-apple.com/jsapi/v1/apple-pay-sdk.js", t.id = this.applePayJSId, t.defer = !0, t.onload = () => { - this.consoleLog("Loaded applepay js script"), e() - }, t.onerror = () => { - this.consoleLog("Apple Pay SDK cannot be loaded", !0) - }, document.body.appendChild(t) - } - t && e && e() - } - - getIsApplePaySupportedOnCurrentDevice() { - this.loadApplePayJs((() => { - try { - const e = window.ApplePaySession; - if (e) { - const t = e.supportsVersion(o), n = e && e.canMakePayments(); - t || this.consoleLog("Does not support applepay version : " + o, !0), n || this.consoleLog("Cannot make Apple payments", !0), this.isApplePayEnabled = e && n && t; - const i = new s("setIsApplePaySupportedOnCurrentDevice", this.isApplePayEnabled); - this.consoleLog("Apple pay enabled : " + this.isApplePayEnabled), this.postMessage(i) - } else { - this.consoleLog("Empty applepay session on the window", !0); - const e = new s("setIsApplePaySupportedOnCurrentDevice", !1); - this.postMessage(e) - } - } catch (e) { - this.consoleError(e, "Something went wrong. Apple pay disabled."); - const t = new s("setIsApplePaySupportedOnCurrentDevice", !1); - this.postMessage(t) - } - })) - } - - isFeatureToggleEnabled(e) { - var t, n; - return null != (t = null == (n = this.featureToggles) || null == (n = n.find((t => t.name === e))) ? void 0 : n.isEnabled) && t - } - - applePayClicked(e) { - try { - this.applePayPaymentRequest = e, this.applePaySession = new window.ApplePaySession(o, e), this.applePaySession.onvalidatemerchant = this.getOnValidateMerchant(), this.applePaySession.onpaymentauthorized = this.getOnPaymentAuthorized(), this.applePaySession.oncancel = this.getOnCancel(), this.applePaySession.onshippingcontactselected = this.getOnShippingContactSelected(), this.applePaySession.begin() - } catch (e) { - this.consoleError(e, "Apple pay clicked. Something went wrong.") - } - } - - abortApplePay() { - try { - this.applePaySession && this.applePaySession.abort() - } catch (e) { - this.consoleError(e, "Apple pay abort. Something went wrong.") - } - } - - getOnCancel() { - return t => { - const n = new s("onApplePayWasCanceled"); - this.postMessage(n), this.isFeatureToggleEnabled(e.ApplePayOnCancelDontAbort) ? this.consoleLog("Apple pay cancelled.") : this.abortApplePay() - } - } - - getOnShippingContactSelected() { - return e => { - var t, n; - const s = this.applePayPaymentRequest.total, - i = null == e || null == (t = e.shippingContact) ? void 0 : t.countryCode; - if (!i) { - const e = new ApplePayError("addressUnserviceable", "country"); - return e.message = "Country is missing in shipping address", void this.applePaySession.completeShippingContactSelection({ - newTotal: s, - errors: [e] - }) - } - if ((null == (n = this.allowedShippingCountries) ? void 0 : n.length) > 0 && !this.allowedShippingCountries.includes(i)) { - const e = new ApplePayError("addressUnserviceable", "country"); - return e.message = "Country specified in shipping address is not supported", void this.applePaySession.completeShippingContactSelection({ - newTotal: s, - errors: [e] - }) - } - if (this.onApplepayContactUpdatedEvent) return void this.onApplepayContactUpdatedEvent({ - postalCode: e.shippingContact.postalCode, - countryCode: e.shippingContact.countryCode - }); - const o = new ApplePayError("unknown", void 0); - o.message = "Applepay contact update handler missing.", this.applePaySession.completeShippingContactSelection({ - newTotal: s, - errors: [o] - }) - } - } - - getOnPaymentAuthorized() { - return e => { - var t, n, i, o; - if ((e => null == e || 0 === e.length || "{}" === JSON.stringify(e))(null == e || null == (t = e.payment) ? void 0 : t.token)) return this.consoleLog(`Apple Pay ${"object" == typeof (null == e ? void 0 : e.payment) && 0 === Object.keys(null == e ? void 0 : e.payment).length ? "payment" : "token"} is missing`, !0), void this.applePaySession.completePayment({ - status: ApplePaySession.STATUS_FAILURE, - errors: [new ApplePayError("unknown", void 0, "Payment data is empty")] - }); - if (null != (n = e.payment.shippingContact) && n.countryCode && (null == (i = this.allowedShippingCountries) ? void 0 : i.length) > 0 && !this.allowedShippingCountries.includes(null == (o = e.payment.shippingContact) ? void 0 : o.countryCode)) return this.consoleLog("Country specified in shipping address is not supported", !0), void this.applePaySession.completePayment({ - status: ApplePaySession.STATUS_FAILURE, - errors: [new ApplePayError("addressUnserviceable", "country", "Country specified in shipping address is not supported")] - }); - const a = new s("authorizeApplePay", e.payment); - this.postMessage(a) - } - } - - getOnValidateMerchant() { - return e => { - const t = new s("validateApplePaySession"); - this.postMessage(t) - } - } - - onReceivedMerchantSession(e) { - try { - this.applePaySession.completeMerchantValidation(e) - } catch (e) { - this.consoleError(e, "Something went wrong while validating merchant session"), this.abortApplePay() - } - } - - onApplePayPaymentComplete(e) { - try { - const t = {status: Boolean(e).valueOf() ? ApplePaySession.STATUS_SUCCESS : ApplePaySession.STATUS_FAILURE}; - this.applePaySession.completePayment(t) - } catch (e) { - this.consoleError(e, "Something went wrong while completing AppleyPay payment."), this.abortApplePay() - } - } - - onSetAllowedShippingCountries(e) { - this.allowedShippingCountries = e - } - - addStyleSheet(e) { - const t = document.createElement("link"); - t.rel = "stylesheet", t.type = "text/css", t.href = this.styleSheetSrc, e.appendChild(t), this.consoleLog("Added stylesheet script " + t.href) - } - - addMainIFrame() { - const e = document.createElement("iframe"); - e.id = this.iFrameId, e.src = this.iFrameSrc, e.referrerPolicy = "strict-origin-when-cross-origin", e.allow = "payment *"; - const t = document.getElementById(this.options.containerId); - null !== t && (t.setAttribute("class", this.iFrameContentStyleClass), t.appendChild(e), this.consoleLog("Added main IFrame script to " + this.options.containerId)), e.onload = () => { - this.consoleLog("iframe ready") - }, e.allowTransparency = "true", e.frameBorder = "0", e.scrolling = "no" - } - - postThemeToCheckout() { - this.options.theme && this.setTheme(this.options.theme) - } - - goto3DS(e) { - const t = e, n = document.createElement("div"); - n.style.display = "none", n.innerHTML = t.form, document.body.appendChild(n); - const s = document.getElementById(t.formId); - null !== s && s.submit() - } - - resizeIFrame(e) { - const t = `${e}px`; - this.getIframe().height = t - } - - sendIframeSizing() { - const e = new s("initialIframeSize", this.getIFrameSize()); - this.postMessage(e) - } - - getIFrameSize() { - const e = this.getIframe(), {offsetWidth: t, offsetHeight: n} = e; - return {width: t, height: n} - } - - getIFrameHeight() { - const e = this.getIframe().height; - return parseInt(e.split("px")[0]) || 0 - } - - sendPaymentOrderFinalizedEvent(e) { - const t = new s("paymentOrderFinalized", e); - this.postMessage(t) - } - - removeListeners() { - window.removeEventListener("message", this.setupMessageListeners), window.removeEventListener("resize", this.setupResizeListeners) - } - - setListeners() { - window.addEventListener("message", this.setupMessageListeners, !1), window.addEventListener("resize", this.setupResizeListeners) - } - - removePaymentFailedQueryParameter() { - const e = new URLSearchParams(window.location.search), t = "paymentFailed"; - if (e.has(t)) { - e.delete(t); - const n = e.toString(), s = `${location.origin}${location.pathname}?${n}`; - window.history.replaceState(void 0, document.title, s) - } - } - - checkMsgSafe(e) { - const t = e.origin; - return void 0 === t ? (this.consoleDebug(`Checkout: unknown origin ${JSON.stringify(t)} (${JSON.stringify(e)}, ${JSON.stringify(e.data)})`), !1) : !(e.data && "react-devtools-bridge" === e.data.source || t !== this.endPointDomain && (this.consoleDebug(`Checkout: unknown origin ${JSON.stringify(t)} (${JSON.stringify(e)}, ${JSON.stringify(e.data)})`), 1)) - } - - getQueryStringParameter(e, t) { - if (t = t || "", 0 === (e = e || "").length || 0 === t.length) return ""; - const n = new RegExp("[?&]" + e + "=([^&#]*)", "i").exec(t); - return n ? n[1] : "" - } - - consoleDebug(e) { - n && console.debug(e) - } - - consoleLog(e, t) { - t ? this.postMessage(new s("logErrorMessage", e)) : n && console.log(e) - } - - consoleError(e, t) { - const {name: i, stack: o} = e, a = this.getErrorMsg(e, t); - this.postMessage(new s("logErrorMessage", {name: i, message: a, stack: o})), n && console.error(a) - } - - getIframe() { - return document.getElementById(this.iFrameId) - } - - postMessage(e) { - const t = this.getIframe(); - null != t && t.contentWindow && this.checkoutInitialized ? t.contentWindow.postMessage(null == e ? void 0 : e.toJson(), this.endPointDomain) : e && this.unsentMessages.push(e) - } - - publishUnsentMessagesToCheckout() { - const e = this.getIframe(); - for (; this.unsentMessages.length;) { - var t, n; - null == e || null == (t = e.contentWindow) || t.postMessage(null == (n = this.unsentMessages.shift()) ? void 0 : n.toJson(), this.endPointDomain) - } - } - }, window.Nets = a - }(a || (a = {})), r || (r = {}), a.Checkout, window.Dibs = a -}(); From 3b74da643f07ca3acb42e7613050a1aeed042be6 Mon Sep 17 00:00:00 2001 From: Konrad Konieczny Date: Mon, 31 Mar 2025 15:24:56 +0200 Subject: [PATCH 022/320] Add API key validation and improve error handling in TestConnection --- .../System/Config/TestConnection.php | 8 + .../System/Config/TestConnectionTest.php | 217 ++++++++---------- Test/Unit/TransactionBuilderTest.php | 4 +- 3 files changed, 108 insertions(+), 121 deletions(-) diff --git a/Controller/Adminhtml/System/Config/TestConnection.php b/Controller/Adminhtml/System/Config/TestConnection.php index fbbb0aa6..bd851ac7 100644 --- a/Controller/Adminhtml/System/Config/TestConnection.php +++ b/Controller/Adminhtml/System/Config/TestConnection.php @@ -53,6 +53,14 @@ public function execute() 'errorMessage' => '', ]; $options = $this->getRequest()->getParams(); + + if (!isset($options['api_key']) ) { + $result['errorMessage'] = __('Please fill the api key.'); + /** @var Json $resultJson */ + $resultJson = $this->resultJsonFactory->create(); + return $resultJson->setData($result); + } + if ($options['api_key'] === '******') { $options['api_key'] = $this->config->getApiKey(); } diff --git a/Test/Unit/Controller/Adminhtml/System/Config/TestConnectionTest.php b/Test/Unit/Controller/Adminhtml/System/Config/TestConnectionTest.php index 4d78427b..4918b32c 100644 --- a/Test/Unit/Controller/Adminhtml/System/Config/TestConnectionTest.php +++ b/Test/Unit/Controller/Adminhtml/System/Config/TestConnectionTest.php @@ -1,126 +1,105 @@ createMock(Context::class); + $this->requestMock = $this->createMock(RequestInterface::class); + $this->jsonFactoryMock = $this->createMock(JsonFactory::class); + $this->jsonMock = $this->createMock(Json::class); + $this->paymentApiFactoryMock = $this->createMock(PaymentApiFactory::class); + $this->configMock = $this->createMock(Config::class); + $stripTagsMock = $this->createMock(StripTags::class); + + $contextMock->method('getRequest')->willReturn($this->requestMock); + $this->jsonFactoryMock->method('create')->willReturn($this->jsonMock); + + $this->controller = $objectManager->getObject( + TestConnection::class, + [ + 'context' => $contextMock, + 'resultJsonFactory' => $this->jsonFactoryMock, + 'tagFilter' => $stripTagsMock, + 'paymentApiFactory' => $this->paymentApiFactoryMock, + 'config' => $this->configMock + ] + ); + } - declare(strict_types=1); + public function testExecuteSuccess() + { + $this->requestMock->method('getParams')->willReturn([ + 'api_key' => 'valid_api_key', + 'environment' => Environment::LIVE + ]); - namespace Nexi\Checkout\Test\Unit\Controller\Adminhtml\System\Config; + $apiMock = $this->createMock(PaymentApi::class); + $this->paymentApiFactoryMock->method('create')->willReturn($apiMock); + $apiMock->method('retrievePayment')->willThrowException(new \Exception('should be in guid format')); - use Magento\Backend\App\Action\Context; - use Magento\Framework\App\RequestInterface; - use Magento\Framework\Controller\Result\Json; - use Magento\Framework\Controller\Result\JsonFactory; - use Magento\Framework\Filter\StripTags; - use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; - use Nexi\Checkout\Controller\Adminhtml\System\Config\TestConnection; - use Nexi\Checkout\Gateway\Config\Config; - use Nexi\Checkout\Model\Config\Source\Environment; - use NexiCheckout\Api\PaymentApi; - use NexiCheckout\Factory\PaymentApiFactory; - use PHPUnit\Framework\TestCase; + $this->jsonMock->expects($this->once()) + ->method('setData') + ->with(['success' => true, 'errorMessage' => '']) + ->willReturnSelf(); - class TestConnectionTest extends TestCase + $this->controller->execute(); + } + + public function testExecuteFailure() { - private $controller; - private $jsonFactoryMock; - private $jsonMock; - private $requestMock; - private $paymentApiFactoryMock; - private $configMock; - - protected function setUp(): void - { - $objectManager = new ObjectManager($this); - - $contextMock = $this->createMock(Context::class); - $this->requestMock = $this->createMock(RequestInterface::class); - $this->jsonFactoryMock = $this->createMock(JsonFactory::class); - $this->jsonMock = $this->createMock(Json::class); - $this->paymentApiFactoryMock = $this->createMock(PaymentApiFactory::class); - $this->configMock = $this->createMock(Config::class); - $stripTagsMock = $this->createMock(StripTags::class); - - $contextMock->method('getRequest')->willReturn($this->requestMock); - $this->jsonFactoryMock->method('create')->willReturn($this->jsonMock); - - $this->controller = $objectManager->getObject( - TestConnection::class, - [ - 'context' => $contextMock, - 'resultJsonFactory' => $this->jsonFactoryMock, - 'tagFilter' => $stripTagsMock, - 'paymentApiFactory' => $this->paymentApiFactoryMock, - 'config' => $this->configMock - ] - ); - } - - public function testExecuteSuccess() - { - $this->requestMock->method('getParams')->willReturn([ - 'api_key' => 'valid_api_key', - 'environment' => Environment::LIVE - ]); - - $apiMock = $this->createMock(PaymentApi::class); - $this->paymentApiFactoryMock->method('create')->willReturn($apiMock); - $apiMock->method('retrievePayment')->willThrowException(new \Exception('should be in guid format')); - - $this->jsonMock->expects($this->once()) - ->method('setData') - ->with(['success' => true, 'errorMessage' => '']) - ->willReturnSelf(); - - $this->controller->execute(); - } - - public function testExecuteFailure() - { - $this->requestMock->method('getParams')->willReturn([ - 'api_key' => 'invalid_api_key', - 'environment' => Environment::LIVE - ]); - - $apiMock = $this->createMock(PaymentApi::class); - $this->paymentApiFactoryMock->method('create')->willReturn($apiMock); - $apiMock->method('retrievePayment')->willThrowException(new \Exception('Invalid API key')); - - $this->jsonMock->expects($this->once()) - ->method('setData') - ->with(['success' => false, 'errorMessage' => 'Please check your API key and environment.']) - ->willReturnSelf(); - - $this->controller->execute(); - } - - // Test for missing parameters - public function testExecuteMissingParams() - { - $this->requestMock->method('getParams')->willReturn([]); - - $this->jsonMock->expects($this->once()) - ->method('setData') - ->with(['success' => false, 'errorMessage' => 'API key and environment are required.']) - ->willReturnSelf(); - - $this->controller->execute(); - } - - // write a new test case for invalid api key - public function testExecuteInvalidApiKey() - { - $this->requestMock->method('getParams')->willReturn([ - 'api_key' => 'invalid_api_key', - 'environment' => Environment::LIVE - ]); - - $apiMock = $this->createMock(PaymentApi::class); - $this->paymentApiFactoryMock->method('create')->willReturn($apiMock); - $apiMock->method('retrievePayment')->willThrowException(new \Exception('Invalid API key')); - - $this->jsonMock->expects($this->once()) - ->method('setData') - ->with(['success' => false, 'errorMessage' => 'Invalid API key']) - ->willReturnSelf(); - - $this->controller->execute(); - } + $this->requestMock->method('getParams')->willReturn([ + 'api_key' => 'invalid_api_key', + 'environment' => Environment::LIVE + ]); + + $apiMock = $this->createMock(PaymentApi::class); + $this->paymentApiFactoryMock->method('create')->willReturn($apiMock); + $apiMock->method('retrievePayment')->willThrowException(new \Exception('Invalid API key')); + + $this->jsonMock->expects($this->once()) + ->method('setData') + ->with(['success' => false, 'errorMessage' => 'Please check your API key and environment.']) + ->willReturnSelf(); + + $this->controller->execute(); + } + + // Test for missing parameters + public function testExecuteMissingParams() + { + $this->requestMock->method('getParams')->willReturn([]); + + $this->jsonMock->expects($this->once()) + ->method('setData') + ->with(['success' => false, 'errorMessage' => __('Please fill the api key.')]) + ->willReturnSelf(); + + $this->controller->execute(); } +} diff --git a/Test/Unit/TransactionBuilderTest.php b/Test/Unit/TransactionBuilderTest.php index 356460d2..6606d63d 100644 --- a/Test/Unit/TransactionBuilderTest.php +++ b/Test/Unit/TransactionBuilderTest.php @@ -6,7 +6,7 @@ use Magento\Sales\Model\Order; use Magento\Sales\Model\Order\Payment\Transaction\BuilderInterface; use Nexi\Checkout\Gateway\Config\Config; -use Nexi\Checkout\Model\Transaction\TransactionBuilder; +use Nexi\Checkout\Model\Transaction\Builder; use PHPUnit\Framework\TestCase; class TransactionBuilderTest extends TestCase @@ -19,7 +19,7 @@ protected function setUp(): void { $this->transactionBuilderMock = $this->createMock(BuilderInterface::class); $this->configMock = $this->createMock(Config::class); - $this->builder = new TransactionBuilder($this->transactionBuilderMock, $this->configMock); + $this->builder = new Builder($this->transactionBuilderMock, $this->configMock); } public function testBuildTransactionSuccessfully() From e2e03f949c74240e1d555f8bd958a37bfcf97117 Mon Sep 17 00:00:00 2001 From: Konrad Konieczny Date: Mon, 31 Mar 2025 19:12:51 +0200 Subject: [PATCH 023/320] Fix formatting in TestConnection.php for API key validation --- Controller/Adminhtml/System/Config/TestConnection.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Controller/Adminhtml/System/Config/TestConnection.php b/Controller/Adminhtml/System/Config/TestConnection.php index bd851ac7..269a1c79 100644 --- a/Controller/Adminhtml/System/Config/TestConnection.php +++ b/Controller/Adminhtml/System/Config/TestConnection.php @@ -54,7 +54,7 @@ public function execute() ]; $options = $this->getRequest()->getParams(); - if (!isset($options['api_key']) ) { + if (!isset($options['api_key'])) { $result['errorMessage'] = __('Please fill the api key.'); /** @var Json $resultJson */ $resultJson = $this->resultJsonFactory->create(); From 4d443c8014873087071787cf7097c2ac17082dbd Mon Sep 17 00:00:00 2001 From: Konrad Konieczny Date: Tue, 15 Apr 2025 15:02:44 +0200 Subject: [PATCH 024/320] Update buildOrder method to accept both Quote and Order types --- Gateway/Request/CreatePaymentRequestBuilder.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gateway/Request/CreatePaymentRequestBuilder.php b/Gateway/Request/CreatePaymentRequestBuilder.php index a4f0e743..44c0a52f 100644 --- a/Gateway/Request/CreatePaymentRequestBuilder.php +++ b/Gateway/Request/CreatePaymentRequestBuilder.php @@ -69,11 +69,11 @@ public function build(array $buildSubject): array /** * Build the Sdk order object * - * @param Order $order + * @param Quote|Order $order * * @return Payment\Order */ - public function buildOrder(Order $order): Payment\Order + public function buildOrder(Quote|Order $order): Payment\Order { return new Payment\Order( items : $this->buildItems($order), From b7b4ddc59e2087581650802376327477d1a8d002 Mon Sep 17 00:00:00 2001 From: Konrad Konieczny Date: Tue, 15 Apr 2025 15:31:40 +0200 Subject: [PATCH 025/320] Refactor PaymentInitialize to improve error handling and logging --- Model/PaymentInitialize.php | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/Model/PaymentInitialize.php b/Model/PaymentInitialize.php index c389949c..707e9203 100644 --- a/Model/PaymentInitialize.php +++ b/Model/PaymentInitialize.php @@ -2,15 +2,16 @@ namespace Nexi\Checkout\Model; -use Composer\Config; +use Magento\Framework\Exception\InputException; use Magento\Framework\Exception\LocalizedException; use Magento\Payment\Gateway\Data\PaymentDataObjectFactoryInterface; use Magento\Quote\Api\CartRepositoryInterface; use Magento\Quote\Model\QuoteIdMaskFactory; use Nexi\Checkout\Gateway\Command\Initialize; -use Nexi\Checkout\Gateway\Request\CreatePaymentRequestBuilder; use Nexi\Checkout\Api\PaymentInitializeInterface; +use Nexi\Checkout\Gateway\Config\Config; use NexiCheckout\Model\Request\Payment\IntegrationTypeEnum; +use Psr\Log\LoggerInterface; class PaymentInitialize implements PaymentInitializeInterface { @@ -18,13 +19,17 @@ class PaymentInitialize implements PaymentInitializeInterface * @param CartRepositoryInterface $quoteRepository * @param Initialize $initializeCommand * @param PaymentDataObjectFactoryInterface $paymentDataObjectFactory + * @param QuoteIdMaskFactory $quoteIdMaskFactory + * @param Config $config + * @param LoggerInterface $logger */ public function __construct( private readonly CartRepositoryInterface $quoteRepository, - private readonly Initialize $initializeCommand, - private readonly PaymentDataObjectFactoryInterface $paymentDataObjectFactory, - private readonly QuoteIdMaskFactory $quoteIdMaskFactory, - private readonly \Nexi\Checkout\Gateway\Config\Config $config, + private readonly Initialize $initializeCommand, + private readonly PaymentDataObjectFactoryInterface $paymentDataObjectFactory, + private readonly QuoteIdMaskFactory $quoteIdMaskFactory, + private readonly Config $config, + private readonly LoggerInterface $logger ) { } @@ -62,10 +67,17 @@ public function initialize(string $cartId, string $integrationType, $paymentMeth 'checkoutKey' => $this->config->getCheckoutKey() ] ), - default => throw new LocalizedException(__('Invalid integration type')) + default => throw new InputException(__('Invalid integration type')) }; } catch (\Exception $e) { - throw new LocalizedException(__('Could not initialize payment: %1', $e->getMessage())); + $this->logger->error( + 'Error initializing payment:', + [ + 'exception' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ] + ); + throw new LocalizedException(__('Could not initialize payment.'), $e); } } } From 36970d7edbe5e57d5f1385e0ee10055b252e9b2d Mon Sep 17 00:00:00 2001 From: Konrad Konieczny Date: Tue, 15 Apr 2025 15:56:22 +0200 Subject: [PATCH 026/320] Refactor checkout rendering methods, rerender on totals change --- .../js/view/payment/method-renderer/nexi-method.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/view/frontend/web/js/view/payment/method-renderer/nexi-method.js b/view/frontend/web/js/view/payment/method-renderer/nexi-method.js index 6754ae68..5a334095 100644 --- a/view/frontend/web/js/view/payment/method-renderer/nexi-method.js +++ b/view/frontend/web/js/view/payment/method-renderer/nexi-method.js @@ -69,7 +69,7 @@ define( } if (this.isActive()) { - renderEmbeddedCheckout.call(this); + this.renderCheckout(); } }, isActive: function () { @@ -90,17 +90,17 @@ define( } } }, - selectPaymentMethod: function () { - this._super(); + renderCheckout() { renderEmbeddedCheckout.call(this); // Subscribe to changes in quote totals quote.totals.subscribe(function (quote) { // Reload Nexi checkout on quote change - console.log('Quote totals changed...', quote); - if (this.dibsCheckout()) { - this.dibsCheckout().thawCheckout(); - } + console.log('Quote totals changed. Reloading the Checkout.', quote); + renderEmbeddedCheckout.call(this); }, this); + }, selectPaymentMethod: function () { + this._super(); + this.renderCheckout(); return true; } From 7db33ac85ae35a1db1895017fe8cceae4fa85a93 Mon Sep 17 00:00:00 2001 From: Konrad Konieczny Date: Tue, 22 Apr 2025 12:59:43 +0200 Subject: [PATCH 027/320] Refactor CreatePaymentRequestBuilder to use NexiRequestOrder and Notification classes --- Gateway/Request/CreatePaymentRequestBuilder.php | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Gateway/Request/CreatePaymentRequestBuilder.php b/Gateway/Request/CreatePaymentRequestBuilder.php index 44c0a52f..2b6f7701 100644 --- a/Gateway/Request/CreatePaymentRequestBuilder.php +++ b/Gateway/Request/CreatePaymentRequestBuilder.php @@ -20,6 +20,8 @@ use NexiCheckout\Model\Request\Payment\HostedCheckout; use NexiCheckout\Model\Request\Payment\IntegrationTypeEnum; use NexiCheckout\Model\Request\Payment\PrivatePerson; +use NexiCheckout\Model\Request\Shared\Notification; +use NexiCheckout\Model\Request\Shared\Order as NexiRequestOrder; class CreatePaymentRequestBuilder implements BuilderInterface { @@ -71,11 +73,11 @@ public function build(array $buildSubject): array * * @param Quote|Order $order * - * @return Payment\Order + * @return NexiRequestOrder */ - public function buildOrder(Quote|Order $order): Payment\Order + public function buildOrder(Quote|Order $order): NexiRequestOrder { - return new Payment\Order( + return new NexiRequestOrder( items : $this->buildItems($order), currency : $order->getBaseCurrencyCode(), amount : (int)($order->getGrandTotal() * 100), @@ -143,7 +145,7 @@ private function buildPayment(Order|Quote $order): Payment return new Payment( order : $this->buildOrder($order), checkout : $this->buildCheckout($order), - notification: new Payment\Notification($this->buildWebhooks()), + notification: new Notification($this->buildWebhooks()), ); } @@ -157,7 +159,7 @@ public function buildWebhooks(): array $webhooks = []; foreach ($this->webhookHandler->getWebhookProcessors() as $eventName => $processor) { $baseUrl = $this->url->getBaseUrl(); - $webhooks[] = new Payment\Webhook( + $webhooks[] = new Notification\Webhook( eventName : $eventName, url : $baseUrl . self::NEXI_PAYMENT_WEBHOOK_PATH, authorization: $this->encryptor->hash($this->config->getWebhookSecret()) From ad856b49ec41667971abb806e315f69b042ef783 Mon Sep 17 00:00:00 2001 From: Konrad Konieczny Date: Tue, 22 Apr 2025 13:00:23 +0200 Subject: [PATCH 028/320] Add PaymentValidateInterface and implementation for payment validation --- Api/PaymentValidateInterface.php | 17 + Gateway/Command/Initialize.php | 4 - Gateway/Handler/Retrieve.php | 39 ++ Gateway/Request/RetrieveRequestBuilder.php | 41 ++ Model/PaymentInitialize.php | 26 +- Model/PaymentValidate.php | 101 +++ Plugin/UpdatePayment.php | 63 -- etc/di.xml | 27 +- etc/webapi.xml | 12 + view/frontend/web/js/dobs.js | 641 ++++++++++++++++++ view/frontend/web/js/sdk/loader.js | 2 +- .../payment/method-renderer/nexi-method.js | 77 ++- .../web/js/view/payment/render-embedded.js | 90 +-- view/frontend/web/js/view/payment/validate.js | 55 ++ 14 files changed, 1058 insertions(+), 137 deletions(-) create mode 100644 Api/PaymentValidateInterface.php create mode 100644 Gateway/Handler/Retrieve.php create mode 100644 Gateway/Request/RetrieveRequestBuilder.php create mode 100644 Model/PaymentValidate.php delete mode 100644 Plugin/UpdatePayment.php create mode 100644 view/frontend/web/js/dobs.js create mode 100644 view/frontend/web/js/view/payment/validate.js diff --git a/Api/PaymentValidateInterface.php b/Api/PaymentValidateInterface.php new file mode 100644 index 00000000..64bb2d3c --- /dev/null +++ b/Api/PaymentValidateInterface.php @@ -0,0 +1,17 @@ +isPaymentAlreadyCreated($payment)) { - return null; - } - try { $commandPool = $this->commandManagerPool->get(Config::CODE); $commandPool->executeByCode( diff --git a/Gateway/Handler/Retrieve.php b/Gateway/Handler/Retrieve.php new file mode 100644 index 00000000..ecc9c7af --- /dev/null +++ b/Gateway/Handler/Retrieve.php @@ -0,0 +1,39 @@ +subjectReader->readPayment($handlingSubject); + $payment = $paymentDO->getPayment(); + + /** @var ChargeResult[] $response */ + $retrieveResult = reset($response); + + if (!$retrieveResult instanceof RetrievePaymentResult) { + return; + } + + $payment->setData('retrieved_payment', $retrieveResult); + } +} diff --git a/Gateway/Request/RetrieveRequestBuilder.php b/Gateway/Request/RetrieveRequestBuilder.php new file mode 100644 index 00000000..808b603d --- /dev/null +++ b/Gateway/Request/RetrieveRequestBuilder.php @@ -0,0 +1,41 @@ +getPayment(); + $creditmemo = $payment->getCreditmemo(); + + return [ + 'nexi_method' => 'retrievePayment', + 'body' => [ + 'paymentId' => $payment->getAdditionalInformation('payment_id') + ] + ]; + } +} diff --git a/Model/PaymentInitialize.php b/Model/PaymentInitialize.php index 707e9203..5706111d 100644 --- a/Model/PaymentInitialize.php +++ b/Model/PaymentInitialize.php @@ -2,6 +2,7 @@ namespace Nexi\Checkout\Model; +use Magento\Checkout\Model\Session; use Magento\Framework\Exception\InputException; use Magento\Framework\Exception\LocalizedException; use Magento\Payment\Gateway\Data\PaymentDataObjectFactoryInterface; @@ -29,7 +30,8 @@ public function __construct( private readonly PaymentDataObjectFactoryInterface $paymentDataObjectFactory, private readonly QuoteIdMaskFactory $quoteIdMaskFactory, private readonly Config $config, - private readonly LoggerInterface $logger + private readonly LoggerInterface $logger, + private readonly Session $checkoutSession ) { } @@ -39,21 +41,25 @@ public function __construct( public function initialize(string $cartId, string $integrationType, $paymentMethod): string { try { - $quoteIdMask = $this->quoteIdMaskFactory->create()->load($cartId, 'masked_id'); - $quote = $this->quoteRepository->get($quoteIdMask->getQuoteId()); + if (!is_numeric($cartId)) { + $quoteIdMask = $this->quoteIdMaskFactory->create()->load($cartId, 'masked_id'); + $cartId = $quoteIdMask->getQuoteId(); + } + $quote = $this->quoteRepository->get($cartId); + + if (!$quote->getIsActive()) { + $this->checkoutSession->restoreQuote(); + } $paymentMethod = $quote->getPayment(); if (!$paymentMethod) { throw new LocalizedException(__('No payment method found for the quote')); } - if ($paymentMethod->getAdditionalInformation('payment_id')) { - } else { - $paymentData = $this->paymentDataObjectFactory->create($paymentMethod); - $cratePayment = $this->initializeCommand->cratePayment($paymentData); - $quote->setData('no_payment_update_flag', true); - $this->quoteRepository->save($quote); - } + $paymentData = $this->paymentDataObjectFactory->create($paymentMethod); + $cratePayment = $this->initializeCommand->cratePayment($paymentData); + $quote->setData('no_payment_update_flag', true); + $this->quoteRepository->save($quote); return match ($integrationType) { IntegrationTypeEnum::HostedPaymentPage->name => json_encode( diff --git a/Model/PaymentValidate.php b/Model/PaymentValidate.php new file mode 100644 index 00000000..9d1aae39 --- /dev/null +++ b/Model/PaymentValidate.php @@ -0,0 +1,101 @@ +quoteIdMaskFactory->create()->load($cartId, 'masked_id'); + $cartId = $quoteIdMask->getQuoteId(); + } + $quote = $this->quoteRepository->get($cartId); + $paymentMethod = $quote->getPayment(); + if (!$paymentMethod) { + throw new LocalizedException(__('No payment method found for the quote')); + } + + $paymentData = $this->paymentDataObjectFactory->create($paymentMethod); + + $paymentId = $paymentMethod->getAdditionalInformation('payment_id'); + + $paymentDeteaild = $this->commandManagerPool->get(Config::CODE)->executeByCode( + 'retrieve', + $paymentMethod, + [ + 'quote' => $quote + ] + ); + + $this->compareAmounts($paymentMethod->getData('retrieved_payment'), $quote); + + return $this->json->serialize([ + 'payment_id' => $paymentId, + 'success' => true + ]); + + + } catch (\Exception $e) { + $this->logger->error( + 'Error initializing payment:', + [ + 'exception' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ] + ); + throw new LocalizedException(__('Could not finalize payment.'), $e); + } + } + + /** + * Compare items and amounts between retrieved payment and quote + * + * @param array $retrievedPayment + * @param \Magento\Quote\Model\Quote $quote + * @return void + * @throws LocalizedException + */ + private function compareAmounts(RetrievePaymentResult $retrievedPayment, \Magento\Quote\Model\Quote $quote): void + { + $quoteTotal = $quote->getGrandTotal() * 100; + $retrievedTotal = $retrievedPayment->getPayment()->getOrderDetails()->getAmount(); + + if ((float)$quoteTotal !== (float)$retrievedTotal) { + throw new LocalizedException(__('The payment amount does not match the quote total.')); + } + } +} diff --git a/Plugin/UpdatePayment.php b/Plugin/UpdatePayment.php deleted file mode 100644 index 6d99994a..00000000 --- a/Plugin/UpdatePayment.php +++ /dev/null @@ -1,63 +0,0 @@ -config->isEmbedded() || $object->getData('no_payment_update_flag')) { - - return $result; - } - - //send information to the payment gateway - $quote = $object; - $payment = $quote->getPayment(); - $paymentMethod = $payment->getMethod(); - if ($paymentMethod !== Config::CODE) { - return $result; - } - - $paymentId = $payment->getAdditionalInformation('payment_id'); - if (!$paymentId) { - return $result; - } - - try { - $commandPool = $this->commandManagerPool->get(Config::CODE); - $result = $commandPool->executeByCode( - commandCode: 'update_order', - arguments : ['payment' => $payment,] - ); - } catch (\Exception $e) { - $this->logger->error($e->getMessage()); - throw new LocalizedException(__('An error occurred during the payment process. Please try again later.')); - } - - return $result; - } -} diff --git a/etc/di.xml b/etc/di.xml index 10e50ea0..cd79a871 100644 --- a/etc/di.xml +++ b/etc/di.xml @@ -65,6 +65,7 @@ NexiCommandCreatePayment Nexi\Checkout\Gateway\Command\Initialize + NexiCommandRetrieve NexiCommandCapture NexiCommandRefund NexiCommandUpdateOrder @@ -78,6 +79,18 @@ Nexi\Checkout\Gateway\Http\TransferFactory Nexi\Checkout\Gateway\Http\Client Nexi\Checkout\Gateway\Handler\CreatePayment + NexiVirtualLogger +
+
+ + + + Nexi\Checkout\Gateway\Request\RetrieveRequestBuilder + Nexi\Checkout\Gateway\Http\TransferFactory + Nexi\Checkout\Gateway\Http\Client + Nexi\Checkout\Gateway\Handler\Retrieve + + NexiVirtualLogger @@ -86,6 +99,7 @@ Nexi\Checkout\Gateway\Request\UpdateOrderRequestBuilder Nexi\Checkout\Gateway\Http\TransferFactory Nexi\Checkout\Gateway\Http\Client + NexiVirtualLogger
@@ -95,6 +109,7 @@ Nexi\Checkout\Gateway\Http\TransferFactory Nexi\Checkout\Gateway\Http\Client Nexi\Checkout\Gateway\Handler\Capture + NexiVirtualLogger @@ -104,6 +119,7 @@ Nexi\Checkout\Gateway\Http\TransferFactory Nexi\Checkout\Gateway\Http\Client Nexi\Checkout\Gateway\Handler\RefundCharge + NexiVirtualLogger @@ -191,11 +207,7 @@ - - - + @@ -218,4 +230,9 @@ NexiVirtualLogger + + + Magento\Checkout\Model\Session\Proxy + + diff --git a/etc/webapi.xml b/etc/webapi.xml index b4b26690..8effcb93 100644 --- a/etc/webapi.xml +++ b/etc/webapi.xml @@ -13,4 +13,16 @@ + + + + + + + + + + + + diff --git a/view/frontend/web/js/dobs.js b/view/frontend/web/js/dobs.js new file mode 100644 index 00000000..447f537d --- /dev/null +++ b/view/frontend/web/js/dobs.js @@ -0,0 +1,641 @@ +!function () { + "use strict"; + let e = function (e) { + return e.CategoryBasedCheckout = "CategoryBasedCheckout", + e.NexiRebranding = "NexiRebranding", + e.ApplePay = "ApplePay", + e.GooglePay = "GooglePay", + e.Klarna = "Klarna", + e.DoubleClick = "DoubleClick", + e.PaymentCompletedLogging = "PaymentCompletedLogging", + e.ClearSavedPayments = "ClearSavedPayments", + e.RemoveRememberMe = "RemoveRememberMe", + e.B2CWillNotBeRecognizedWithSSN = "B2CWillNotBeRecognizedWithSSN", + e.SelectCardOnApplePayCancellation = "SelectCardOnApplePayCancellation", + e.MobilePayCorrectPaymentType = "MobilePayCorrectPaymentType", + e.KlarnaB2B = "KlarnaB2B", + e.ApplePayUseMerchantCountry = "ApplePayUseMerchantCountry", + e.ApplePayOnCancelDontAbort = "ApplePayOnCancelDontAbort", + e.GooglePayUseAuthorizationFlow = "GooglePayUseAuthorizationFlow", + e.GooglePayDynamicPriceUpdate = "GooglePayDynamicPriceUpdate", + e.DisableConsumerLogin = "DisableConsumerLogin", + e.EnableVippsForSweden = "EnableVippsForSweden", + e.EasyPiecesCardInputSeparationEnabled = "EasyPiecesCardInputSeparationEnabled", + e.SurchargeUI = "SurchargeUI", + e.NewPaymentNotificationTexts = "NewPaymentNotificationTexts", + e.NewApplePayButton = "NewApplePayButton", + e.TopWindowFullRedirect = "TopWindowFullRedirect", + e.RivertyCreditWarning = "RivertyCreditWarning", + e + }({}); + var t; + Object.values(e); + const n = ("string" == typeof (s = "false") ? "true" === s.toLowerCase() : s || !1) || !1; + var s; + null == (t = document) || null == (t = t.currentScript) || t.src; + const i = "https://test.checkout.dibspayment.eu" + , o = parseInt("13"); + let a, r; + !function (t) { + class s { + constructor(e, t) { + this.eventType = e, + this.data = t + } + + toJson() { + return JSON.stringify(this) + } + } + + t.Checkout = class { + constructor(e) { + this.iFrameDibsContainerId = "dibs-checkout-content", + this.iFrameDefaultContainerId = "nets-checkout-content", + this.iFrameContentStyleClass = "dibs-checkout-wrapper", + this.iFrameId = "nets-checkout-iframe", + this.applePayJSId = "applepayjs", + this.endPointDomain = void 0, + this.iFrameSrc = void 0, + this.styleSheetSrc = void 0, + this.paymentFailed = !1, + this.isApplePayEnabled = !1, + this.applePaySession = void 0, + this.applePayPaymentRequest = void 0, + this.allowedShippingCountries = void 0, + this.featureToggles = void 0, + this.onPaymentCompletedEvent = void 0, + this.onPaymentCancelledEvent = void 0, + this.onPayInitializedEvent = void 0, + this.onAddressChangedEvent = void 0, + this.onApplepayContactUpdatedEvent = void 0, + this.checkoutInitialized = !1, + this.isThemeSetExplicitly = !1, + this.options = void 0, + this.setupMessageListeners = e => { + if (!this.checkMsgSafe(e)) + return; + try { + JSON.parse(e.data) + } catch (e) { + return void this.consoleLog(e) + } + const t = JSON.parse(e.data); + switch (this.consoleDebug(`Event received: ${t.eventType}, ${JSON.stringify(t.data)}`), + t.eventType) { + case "featureTogglesChanged": + console.log("SDK - Feature toggles changed"); + this.featureToggles = t.data; + break; + case "checkoutInitialized": + console.log("SDK - Checkout initialized"); + this.checkoutInitialized || (this.checkoutInitialized = !0, + this.consoleLog("checkoutInitialized"), + this.publishUnsentMessagesToCheckout(), + this.postThemeToCheckout(), + this.sendIframeSizing()); + break; + case "goto3DS": + console.log("SDK - goto3DS"); + this.goto3DS(t.data); + break; + case "payInitialized": + console.log("SDK - payInitialized"); + this.onPayInitializedEvent ? this.onPayInitializedEvent(t.data) : (this.consoleLog("PaymentInitialized not handled by merchant"), + this.sendPaymentOrderFinalizedEvent(!0)); + break; + case "paymentSuccess": + console.log("SDK - paymentSuccess"); + this.onPaymentCompletedEvent(t.data); + break; + case "paymentCancelled": + console.log("SDK - paymentCancelled"); + this.onPaymentCancelledEvent(t.data); + break; + case "resize": + console.log("SDK - resize"); + this.resizeIFrame(t.data); + break; + case "addressChanged": + console.log("SDK - addressChanged"); + this.onAddressChangedEvent ? this.onAddressChangedEvent(t.data) : this.postMessage(new s("addressChangedNotHandled")); + break; + case "removePaymentFailedQueryParameter": + console.log("SDK - removePaymentFailedQueryParameter"); + this.removePaymentFailedQueryParameter(); + break; + case "inceptionIframeInitialized": + console.log("SDK - inceptionIframeInitialized"); + this.inceptionIframeInitialized(); + break; + case "getIsApplePaySupportedOnCurrentDevice": + console.log("SDK - getIsApplePaySupportedOnCurrentDevice"); + this.getIsApplePaySupportedOnCurrentDevice(); + break; + case "applePayClicked": + console.log("SDK - applePayClicked"); + this.applePayClicked(t.data); + break; + case "applePaySessionValidated": + this.onReceivedMerchantSession(t.data); + break; + case "applePayPaymentComplete": + this.onApplePayPaymentComplete(t.data); + break; + case "setAllowedShippingCountries": + console.log("SDK - setAllowedShippingCountries"); + this.onSetAllowedShippingCountries(t.data); + break; + default: + + const n = t.eventType; + + this.consoleLog(`unknown event ${n} ${JSON.stringify(e.data)}`) + } + } + , + this.setupResizeListeners = () => { + const e = new s("resize"); + this.postMessage(e) + } + , + this.unsentMessages = [], + this.options = e, + this.init() + } + + on(e, t) { + if (!t) + throw new Error(`No function was supplied in the second argument. Please supply the function you want to be called on the ${e} event`); + if ("pay-initialized" === e) + this.onPayInitializedEvent = t; + else if ("payment-completed" === e) + this.onPaymentCompletedEvent = t; + else if ("payment-cancelled" === e) + this.onPaymentCancelledEvent = t; + else if ("address-changed" === e) + this.onAddressChangedEvent = t; + else if ("applepay-contact-updated" === e) + this.onApplepayContactUpdatedEvent = t; + else { + const t = e; + this.consoleLog(`${t} is not a valid public event name.`) + } + } + + send(e, t) { + if ("payment-order-finalized" === e) { + const e = t || !1; + this.sendPaymentOrderFinalizedEvent(e) + } else + "payment-cancel-initiated" === e && this.postMessage(new s("cancelPayment")) + } + + freezeCheckout() { + this.postMessage(new s("freezeCheckout")) + } + + thawCheckout() { + this.postMessage(new s("thawCheckout")) + } + + setTheme(e) { + this.isThemeSetExplicitly = !0, + this.postMessage(new s("setTheme", e)) + } + + setLanguage(e) { + this.postMessage(new s("setLanguage", e)) + } + + completeApplePayShippingContactUpdate(e) { + if (this.isApplePayEnabled && this.applePaySession && this.applePayPaymentRequest) + try { + const t = this.applePayPaymentRequest.total; + if (!e) { + this.consoleLog("Does not support this operation. Undefined amount specified."); + const e = new ApplePayError("unknown", void 0, " Undefined amount specified."); + return void this.applePaySession.completeShippingContactSelection({ + newTotal: t, + errors: [e] + }) + } + if ("string" != typeof e && "number" != typeof e) { + this.consoleLog("Does not support this operation. Wrong argument type provided."); + const e = new ApplePayError("unknown", void 0, "Wrong argument type provided."); + return void this.applePaySession.completeShippingContactSelection({ + newTotal: t, + errors: [e] + }) + } + "number" == typeof e && (e = String(e)), + this.consoleLog(`Apple pay order amount update with ${e}`); + const n = new s("updateApplePayOrderAmount", e); + this.postMessage(n); + const {label: i, type: o} = this.applePayPaymentRequest.total + , a = Number(e) / 100; + this.applePaySession.completeShippingContactSelection({ + newTotal: { + amount: String(a), + label: i, + type: o + } + }) + } catch (e) { + this.consoleError(e, "Error in completeShippingMethodSelection for ApplePay") + } + else + this.consoleLog("Does not support this operation. ApplePay is disabled.") + } + + cleanup() { + this.removeListeners() + } + + init() { + var e, t, n, s, o, a; + const r = null == (e = this.options) ? void 0 : e.checkoutKey + , p = null == (t = this.options) ? void 0 : t.paymentId + , l = this; + if (this.options.containerId || (this.options.containerId = document.getElementById(this.iFrameDefaultContainerId) ? this.iFrameDefaultContainerId : this.iFrameDibsContainerId), + !this.isThemeSet() && i && r && p) { + const e = new XMLHttpRequest; + e.addEventListener("load", (function () { + if (200 === this.status && !l.isThemeSetExplicitly) { + const e = JSON.parse(this.responseText); + l.options.theme = e, + l.postThemeToCheckout() + } + } + )), + e.open("GET", `${i}/api/v1/theming/checkout`), + e.setRequestHeader("CheckoutKey", r), + e.setRequestHeader("PaymentId", p), + e.send() + } + this.paymentFailed = "true" === this.getQueryStringParameter("paymentFailed", window.location.href), + this.endPointDomain = "https://test.checkout.dibspayment.eu", + this.iFrameSrc = `${this.endPointDomain}/v1/?checkoutKey=${null == (n = this.options) ? void 0 : n.checkoutKey}&paymentId=${null == (s = this.options) ? void 0 : s.paymentId}`, + null != (o = this.options) && o.partnerMerchantNumber && (this.iFrameSrc += `&partnerMerchantNumber=${this.options.partnerMerchantNumber}`), + null != (a = this.options) && a.language && (this.iFrameSrc += `&language=${this.options.language}`), + this.paymentFailed && (this.iFrameSrc += `&paymentFailed=${this.paymentFailed}`), + this.styleSheetSrc = `${this.endPointDomain}/v1/assets/css/checkout.css`, + this.setListeners(); + const h = document.getElementsByTagName("head")[0]; + this.addStyleSheet(h), + this.addMainIFrame() + } + + isWindowOnTopLevel() { + try { + return window.top.location.href, + !0 + } catch (e) { + return !1 + } + } + + isThemeSet() { + var e; + return !(null == (e = this.options) || !e.theme) && Object.keys(this.options.theme).length > 0 + } + + inceptionIframeInitialized() { + if (this.isWindowOnTopLevel()) { + var e; + const t = this.getIFrameHeight() + , n = null == (e = window.top) ? void 0 : e.innerHeight; + if (n && t > n) { + this.resizeIFrame(n); + const e = new s("scrollIntoView", n); + this.postMessage(e) + } + } + } + + getErrorMsg(e, t) { + let n = t; + return "string" == typeof e ? n = `${t} ${e}` : e instanceof Error && (n = `${t} ${e.message}`), + n + } + + loadApplePayJs(t) { + const n = document.getElementById(this.applePayJSId); + if (!n) { + const n = document.createElement("script"); + this.isFeatureToggleEnabled(e.NewApplePayButton) ? n.src = "https://applepay.cdn-apple.com/jsapi/1.latest/apple-pay-sdk.js" : n.src = "https://applepay.cdn-apple.com/jsapi/v1/apple-pay-sdk.js", + n.id = this.applePayJSId, + n.defer = !0, + n.onload = () => { + this.consoleLog("Loaded applepay js script"), + t() + } + , + n.onerror = () => { + this.consoleLog("Apple Pay SDK cannot be loaded", !0) + } + , + document.body.appendChild(n) + } + n && t && t() + } + + getIsApplePaySupportedOnCurrentDevice() { + this.loadApplePayJs((() => { + try { + const e = window.ApplePaySession; + if (e) { + const t = e.supportsVersion(o) + , n = e && e.canMakePayments(); + t || this.consoleLog("Does not support applepay version : " + o, !0), + n || this.consoleLog("Cannot make Apple payments", !0), + this.isApplePayEnabled = e && n && t; + const i = new s("setIsApplePaySupportedOnCurrentDevice", this.isApplePayEnabled); + this.consoleLog("Apple pay enabled : " + this.isApplePayEnabled), + this.postMessage(i) + } else { + this.consoleLog("Empty applepay session on the window", !0); + const e = new s("setIsApplePaySupportedOnCurrentDevice", !1); + this.postMessage(e) + } + } catch (e) { + this.consoleError(e, "Something went wrong. Apple pay disabled."); + const t = new s("setIsApplePaySupportedOnCurrentDevice", !1); + this.postMessage(t) + } + } + )) + } + + isFeatureToggleEnabled(e) { + var t, n; + return null != (t = null == (n = this.featureToggles) || null == (n = n.find((t => t.name === e))) ? void 0 : n.isEnabled) && t + } + + applePayClicked(e) { + try { + this.applePayPaymentRequest = e, + this.applePaySession = new window.ApplePaySession(o, e), + this.applePaySession.onvalidatemerchant = this.getOnValidateMerchant(), + this.applePaySession.onpaymentauthorized = this.getOnPaymentAuthorized(), + this.applePaySession.oncancel = this.getOnCancel(), + this.applePaySession.onshippingcontactselected = this.getOnShippingContactSelected(), + this.applePaySession.begin() + } catch (e) { + this.consoleError(e, "Apple pay clicked. Something went wrong.") + } + } + + abortApplePay() { + try { + this.applePaySession && this.applePaySession.abort() + } catch (e) { + this.consoleError(e, "Apple pay abort. Something went wrong.") + } + } + + getOnCancel() { + return t => { + const n = new s("onApplePayWasCanceled"); + this.postMessage(n), + this.isFeatureToggleEnabled(e.ApplePayOnCancelDontAbort) ? this.consoleLog("Apple pay cancelled.") : this.abortApplePay() + } + } + + getOnShippingContactSelected() { + return e => { + var t, n; + const s = this.applePayPaymentRequest.total + , i = null == e || null == (t = e.shippingContact) ? void 0 : t.countryCode; + if (!i) { + const e = new ApplePayError("addressUnserviceable", "country"); + return e.message = "Country is missing in shipping address", + void this.applePaySession.completeShippingContactSelection({ + newTotal: s, + errors: [e] + }) + } + if ((null == (n = this.allowedShippingCountries) ? void 0 : n.length) > 0 && !this.allowedShippingCountries.includes(i)) { + const e = new ApplePayError("addressUnserviceable", "country"); + return e.message = "Country specified in shipping address is not supported", + void this.applePaySession.completeShippingContactSelection({ + newTotal: s, + errors: [e] + }) + } + if (this.onApplepayContactUpdatedEvent) + return void this.onApplepayContactUpdatedEvent({ + postalCode: e.shippingContact.postalCode, + countryCode: e.shippingContact.countryCode + }); + const o = new ApplePayError("unknown", void 0); + o.message = "Applepay contact update handler missing.", + this.applePaySession.completeShippingContactSelection({ + newTotal: s, + errors: [o] + }) + } + } + + getOnPaymentAuthorized() { + return e => { + var t, n, i, o; + if ((e => null == e || 0 === e.length || "{}" === JSON.stringify(e))(null == e || null == (t = e.payment) ? void 0 : t.token)) + return this.consoleLog(`Apple Pay ${"object" == typeof (null == e ? void 0 : e.payment) && 0 === Object.keys(null == e ? void 0 : e.payment).length ? "payment" : "token"} is missing`, !0), + void this.applePaySession.completePayment({ + status: ApplePaySession.STATUS_FAILURE, + errors: [new ApplePayError("unknown", void 0, "Payment data is empty")] + }); + if (null != (n = e.payment.shippingContact) && n.countryCode && (null == (i = this.allowedShippingCountries) ? void 0 : i.length) > 0 && !this.allowedShippingCountries.includes(null == (o = e.payment.shippingContact) ? void 0 : o.countryCode)) + return this.consoleLog("Country specified in shipping address is not supported", !0), + void this.applePaySession.completePayment({ + status: ApplePaySession.STATUS_FAILURE, + errors: [new ApplePayError("addressUnserviceable", "country", "Country specified in shipping address is not supported")] + }); + const a = new s("authorizeApplePay", e.payment); + this.postMessage(a) + } + } + + getOnValidateMerchant() { + return e => { + const t = new s("validateApplePaySession"); + this.postMessage(t) + } + } + + onReceivedMerchantSession(e) { + try { + this.applePaySession.completeMerchantValidation(e) + } catch (e) { + this.consoleError(e, "Something went wrong while validating merchant session"), + this.abortApplePay() + } + } + + onApplePayPaymentComplete(e) { + try { + const t = { + status: Boolean(e).valueOf() ? ApplePaySession.STATUS_SUCCESS : ApplePaySession.STATUS_FAILURE + }; + this.applePaySession.completePayment(t) + } catch (e) { + this.consoleError(e, "Something went wrong while completing AppleyPay payment."), + this.abortApplePay() + } + } + + onSetAllowedShippingCountries(e) { + this.allowedShippingCountries = e + } + + addStyleSheet(e) { + const t = document.createElement("link"); + t.rel = "stylesheet", + t.type = "text/css", + t.href = this.styleSheetSrc, + e.appendChild(t), + this.consoleLog("Added stylesheet script " + t.href) + } + + addMainIFrame() { + const e = document.createElement("iframe"); + e.id = this.iFrameId, + e.src = this.iFrameSrc, + e.referrerPolicy = "strict-origin-when-cross-origin", + e.allow = "payment *"; + const t = document.getElementById(this.options.containerId); + null !== t && (t.setAttribute("class", this.iFrameContentStyleClass), + t.appendChild(e), + this.consoleLog("Added main IFrame script to " + this.options.containerId)), + e.onload = () => { + this.consoleLog("iframe ready") + } + , + e.allowTransparency = "true", + e.frameBorder = "0", + e.scrolling = "no" + } + + postThemeToCheckout() { + this.options.theme && this.setTheme(this.options.theme) + } + + goto3DS(e) { + const t = e + , n = document.createElement("div"); + n.style.display = "none", + n.innerHTML = t.form, + document.body.appendChild(n); + const s = document.getElementById(t.formId); + null !== s && s.submit() + } + + resizeIFrame(e) { + const t = `${e}px`; + this.getIframe().height = t + } + + sendIframeSizing() { + const e = new s("initialIframeSize", this.getIFrameSize()); + this.postMessage(e) + } + + getIFrameSize() { + const e = this.getIframe() + , {offsetWidth: t, offsetHeight: n} = e; + return { + width: t, + height: n + } + } + + getIFrameHeight() { + const e = this.getIframe().height; + return parseInt(e.split("px")[0]) || 0 + } + + sendPaymentOrderFinalizedEvent(e) { + const t = new s("paymentOrderFinalized", e); + this.postMessage(t) + } + + removeListeners() { + window.removeEventListener("message", this.setupMessageListeners), + window.removeEventListener("resize", this.setupResizeListeners) + } + + setListeners() { + window.addEventListener("message", this.setupMessageListeners, !1), + window.addEventListener("resize", this.setupResizeListeners) + } + + removePaymentFailedQueryParameter() { + const e = new URLSearchParams(window.location.search) + , t = "paymentFailed"; + if (e.has(t)) { + e.delete(t); + const n = e.toString() + , s = `${location.origin}${location.pathname}?${n}`; + window.history.replaceState(void 0, document.title, s) + } + } + + checkMsgSafe(e) { + const t = e.origin; + return void 0 === t ? (this.consoleDebug(`Checkout: unknown origin ${JSON.stringify(t)} (${JSON.stringify(e)}, ${JSON.stringify(e.data)})`), + !1) : !(e.data && "react-devtools-bridge" === e.data.source || t !== this.endPointDomain && (this.consoleDebug(`Checkout: unknown origin ${JSON.stringify(t)} (${JSON.stringify(e)}, ${JSON.stringify(e.data)})`), + 1)) + } + + getQueryStringParameter(e, t) { + if (t = t || "", + 0 === (e = e || "").length || 0 === t.length) + return ""; + const n = new RegExp("[?&]" + e + "=([^&#]*)", "i").exec(t); + return n ? n[1] : "" + } + + consoleDebug(e) { + n && console.debug(e) + } + + consoleLog(e, t) { + t ? this.postMessage(new s("logErrorMessage", e)) : n && console.log(e) + } + + consoleError(e, t) { + const {name: i, stack: o} = e + , a = this.getErrorMsg(e, t); + this.postMessage(new s("logErrorMessage", { + name: i, + message: a, + stack: o + })), + n && console.error(a) + } + + getIframe() { + return document.getElementById(this.iFrameId) + } + + postMessage(e) { + const t = this.getIframe(); + null != t && t.contentWindow && this.checkoutInitialized ? t.contentWindow.postMessage(null == e ? void 0 : e.toJson(), this.endPointDomain) : e && this.unsentMessages.push(e) + } + + publishUnsentMessagesToCheckout() { + const e = this.getIframe(); + for (; this.unsentMessages.length;) { + var t, n; + null == e || null == (t = e.contentWindow) || t.postMessage(null == (n = this.unsentMessages.shift()) ? void 0 : n.toJson(), this.endPointDomain) + } + } + } + , + window.Nets = a + }(a || (a = {})), + r || (r = {}), + a.Checkout, + window.Dibs = a +}(); diff --git a/view/frontend/web/js/sdk/loader.js b/view/frontend/web/js/sdk/loader.js index 9c5aa56a..61302781 100644 --- a/view/frontend/web/js/sdk/loader.js +++ b/view/frontend/web/js/sdk/loader.js @@ -13,7 +13,7 @@ define([ } const sdkUrl = isTestMode - ? 'https://test.checkout.dibspayment.eu/v1/checkout.js?v=1' + ? 'https://app.nexi2.test/static/frontend/Magento/luma/en_US/Nexi_Checkout/js/dobs.js' //'https://test.checkout.dibspayment.eu/v1/checkout.js?v=1' : 'https://checkout.dibspayment.eu/v1/checkout.js?v=1'; fullScreenLoader.startLoader(); diff --git a/view/frontend/web/js/view/payment/method-renderer/nexi-method.js b/view/frontend/web/js/view/payment/method-renderer/nexi-method.js index 5a334095..ce3a3559 100644 --- a/view/frontend/web/js/view/payment/method-renderer/nexi-method.js +++ b/view/frontend/web/js/view/payment/method-renderer/nexi-method.js @@ -22,7 +22,8 @@ define( 'Magento_Ui/js/modal/modal', 'Nexi_Checkout/js/sdk/loader', 'Nexi_Checkout/js/view/payment/initialize-payment', - 'Nexi_Checkout/js/view/payment/render-embedded' + 'Nexi_Checkout/js/view/payment/render-embedded', + 'Nexi_Checkout/js/view/payment/validate' ], function ( ko, @@ -47,7 +48,8 @@ define( modal, sdkLoader, initializeCartPayment, - renderEmbeddedCheckout + renderEmbeddedCheckout, + validatePayment ) { 'use strict'; @@ -58,6 +60,8 @@ define( }, isEmbedded: ko.observable(false), dibsCheckout: ko.observable(false), + isRendering: ko.observable(false), + eventsSubscribed: ko.observable(false), isHosted: function () { return !this.isEmbedded(); @@ -76,6 +80,7 @@ define( return this.getCode() === this.isChecked(); }, placeOrder: function (data, event) { + console.log("DEBUG: Place order called"); let placeOrder = placeOrderAction(this.getData(), false, this.messageContainer); return $.when(placeOrder).done(function (response) { @@ -90,19 +95,71 @@ define( } } }, - renderCheckout() { - renderEmbeddedCheckout.call(this); - // Subscribe to changes in quote totals - quote.totals.subscribe(function (quote) { - // Reload Nexi checkout on quote change - console.log('Quote totals changed. Reloading the Checkout.', quote); - renderEmbeddedCheckout.call(this); + async renderCheckout() { + await renderEmbeddedCheckout.call(this); + this.subscribeToEvents(); + quote.totals.subscribe(async function (quote) { + await renderEmbeddedCheckout.call(this); + this.subscribeToEvents(); + }, this); - }, selectPaymentMethod: function () { + }, + selectPaymentMethod: function () { this._super(); this.renderCheckout(); return true; + }, + subscribeToEvents: function () { + if (this.dibsCheckout() && this.eventsSubscribed() === false) { + console.log("DEBUG: Subscribing to events"); + this.dibsCheckout().on( + "payment-completed", + async function () { + window.location.href = url.build("checkout/onepage/success"); + }.bind(this) + ); + + this.dibsCheckout().on( + "pay-initialized", + async function (paymentId) { + try { + const validationResult = await validatePayment.call(this); + if (!validationResult.success) { + console.warn("DEBUG: Validation failed, reloading the checkout. Nexi paymentId: ", paymentId); + + await renderEmbeddedCheckout.call(this); + this.subscribeToEvents(); + } else { + console.log("DEBUG: Validation ok, placing the order. Nexi paymentId: ", paymentId); + await this.placeOrder(); + fullScreenLoader.startLoader(); + document.getElementById("nexi-checkout-container").style.position = "relative"; + document.getElementById("nexi-checkout-container").style.zIndex = "9999"; + this.dibsCheckout().send("payment-order-finalized", true); + // add some mask to block the screen, only allow to deal with the iframe + + } + }catch (error) { + console.error("DEBUG: Error in payment initialization:", error); + await renderEmbeddedCheckout.call(this); + this.subscribeToEvents(); + } + }.bind(this) + ); + + // TODO: check how to trigger remove mask if payment cancelled in the iframe, + // as this seems to not work + this.dibsCheckout().on( + "payment-cancelled", + async function (paymentId) { + fullScreenLoader.stopLoader(); + console.log("DEBUG: Payment cancelled with ID:", paymentId); + }.bind(this) + ); + + this.eventsSubscribed(true); + } } }); } diff --git a/view/frontend/web/js/view/payment/render-embedded.js b/view/frontend/web/js/view/payment/render-embedded.js index 5df5df07..260120f9 100644 --- a/view/frontend/web/js/view/payment/render-embedded.js +++ b/view/frontend/web/js/view/payment/render-embedded.js @@ -1,50 +1,52 @@ -define( - [ - 'Nexi_Checkout/js/sdk/loader', - 'Nexi_Checkout/js/view/payment/initialize-payment', - 'mage/url' - ], - function (sdkLoader, initializeCartPayment, url) { - 'use strict'; +define([ + "Nexi_Checkout/js/sdk/loader", + "Nexi_Checkout/js/view/payment/initialize-payment", + "Nexi_Checkout/js/view/payment/validate", + "mage/url", + 'Magento_Checkout/js/model/quote', +], function (sdkLoader, initializePayment, validatePayment, url, quote) { + "use strict"; - return async function () { - try { - await sdkLoader.loadSdk(this.config.environment === 'test'); + // Define the rendering function + return async function () { + if (this.isRendering()) { + console.log("Rendering already in progress. Skipping this call."); + return; + } - //clear checkout container - document.getElementById("nexi-checkout-container").innerHTML = ""; + // get selected payment method from the quote + let selectedPaymentMethod = quote.paymentMethod(); - const response = await initializeCartPayment.call(this); - if (response.paymentId) { - let checkoutOptions = { - checkoutKey: response.checkoutKey, - paymentId: response.paymentId, - containerId: "nexi-checkout-container", - language: "en-GB", - theme: { - buttonRadius: "5px" - } - }; - this.dibsCheckout(new Dibs.Checkout(checkoutOptions)); + if (!selectedPaymentMethod || selectedPaymentMethod.method !== "nexi") { + console.log("Selected payment method is not Nexi. Skipping rendering."); + return; + } - // add this as a global variable for debugging - window.dibsCheckout = this.dibsCheckout; - console.log('DEBUG: Dibs Checkout initialized as global `window.dibsCheckout` :', this.dibsCheckout()); + this.isRendering(true); + try { + await sdkLoader.loadSdk(this.config.environment === "test"); - this.dibsCheckout().on('payment-completed', async function () { - window.location.href = url.build('checkout/onepage/success'); - }.bind(this)); + // Clear the container before rendering + document.getElementById("nexi-checkout-container").innerHTML = ""; - this.dibsCheckout().on('pay-initialized', async function (paymentId) { - //TODO: validate with backend - await this.placeOrder(); - console.log('DEBUG: Payment initialized with ID:', paymentId); - this.dibsCheckout().send('payment-order-finalized', true); - }.bind(this)); - } - } catch (error) { - console.error('Error loading Nexi SDK or initializing payment:', error); - } - }; - } -); + const response = await initializePayment.call(this) + if (response.paymentId) { + let checkoutOptions = { + checkoutKey: response.checkoutKey, + paymentId: response.paymentId, + containerId: "nexi-checkout-container", + language: "en-GB", + theme: { + buttonRadius: "5px", + }, + }; + this.dibsCheckout(new Dibs.Checkout(checkoutOptions)); + console.log("Nexi Checkout SDK loaded successfully. paymentId: ", response.paymentId); + } + } catch (error) { + console.error("Error loading Nexi SDK or initializing payment:", error); + } finally { + this.isRendering(false); + } + } +}); diff --git a/view/frontend/web/js/view/payment/validate.js b/view/frontend/web/js/view/payment/validate.js new file mode 100644 index 00000000..aca48586 --- /dev/null +++ b/view/frontend/web/js/view/payment/validate.js @@ -0,0 +1,55 @@ +define([ + "mage/storage", + "Magento_Checkout/js/model/url-builder", + "Magento_Checkout/js/model/quote", + "Magento_Checkout/js/model/full-screen-loader", + "Magento_Checkout/js/model/error-processor", + "Magento_Customer/js/model/customer", +], function ( + storage, + urlBuilder, + quote, + fullScreenLoader, + errorProcessor, + customer +) { + "use strict"; + + return function () { + const payload = { + cartId: quote.getQuoteId() + }; + + const serviceUrl = customer.isLoggedIn() + ? urlBuilder.createUrl("/nexi/carts/mine/payment-validate", {}) + : urlBuilder.createUrl("/nexi/guest-carts/:quoteId/payment-validate", { + quoteId: quote.getQuoteId(), + }); + + fullScreenLoader.startLoader(); + + return new Promise((resolve, reject) => { + storage + .post(serviceUrl, JSON.stringify(payload)) + .done(function (response) { + resolve(JSON.parse(response)); + }) + .fail( + function (response) { + errorProcessor.process(response, this.messageContainer); + let redirectURL = response.getResponseHeader("errorRedirectAction"); + + if (redirectURL) { + setTimeout(function () { + errorProcessor.redirectTo(redirectURL); + }, 3000); + } + reject(response); + }.bind(this) + ) + .always(function () { + fullScreenLoader.stopLoader(); + }); + }); + }; +}); From c4424e7403abe84cdb651e5de9abd86ce2799fba Mon Sep 17 00:00:00 2001 From: Konrad Konieczny Date: Tue, 22 Apr 2025 12:59:43 +0200 Subject: [PATCH 029/320] Implement webhook processing enhancements and add transaction ID handling --- Gateway/Command/Initialize.php | 5 ++ Model/Webhook/PaymentCreated.php | 63 +++++++++++++++----- Plugin/QouteToOrder/ProcessTransactionId.php | 28 +++++++++ etc/di.xml | 5 ++ 4 files changed, 85 insertions(+), 16 deletions(-) create mode 100644 Plugin/QouteToOrder/ProcessTransactionId.php diff --git a/Gateway/Command/Initialize.php b/Gateway/Command/Initialize.php index c85b3f5c..183d4f0c 100644 --- a/Gateway/Command/Initialize.php +++ b/Gateway/Command/Initialize.php @@ -47,6 +47,11 @@ public function execute(array $commandSubject) $paymentData = $this->subjectReader->readPayment($commandSubject); $stateObject = $this->subjectReader->readStateObject($commandSubject); + // For embedded integration, we don't need to create payment here, it was already created for the quote. + if ($paymentData->getPayment()->getAdditionalInformation('payment_id')) { + return; + } + /** @var InfoInterface $payment */ $payment = $paymentData->getPayment(); $payment->setIsTransactionPending(true); diff --git a/Model/Webhook/PaymentCreated.php b/Model/Webhook/PaymentCreated.php index 7af662b0..1bd1f87a 100644 --- a/Model/Webhook/PaymentCreated.php +++ b/Model/Webhook/PaymentCreated.php @@ -11,6 +11,7 @@ use Magento\Sales\Api\Data\TransactionInterface; use Magento\Sales\Api\OrderRepositoryInterface; use Magento\Sales\Model\Order; +use Magento\Sales\Model\ResourceModel\Order\Payment\CollectionFactory as PaymentCollectionFactory; use Nexi\Checkout\Model\Transaction\Builder; use Nexi\Checkout\Model\Webhook\Data\WebhookDataLoader; @@ -23,50 +24,80 @@ class PaymentCreated implements WebhookProcessorInterface * @param CollectionFactory $orderCollectionFactory * @param WebhookDataLoader $webhookDataLoader * @param OrderRepositoryInterface $orderRepository + * @param PaymentCollectionFactory $paymentCollectionFactory */ public function __construct( - private readonly Builder $transactionBuilder, - private readonly CollectionFactory $orderCollectionFactory, - private readonly WebhookDataLoader $webhookDataLoader, - private readonly OrderRepositoryInterface $orderRepository + private readonly Builder $transactionBuilder, + private readonly CollectionFactory $orderCollectionFactory, + private readonly WebhookDataLoader $webhookDataLoader, + private readonly OrderRepositoryInterface $orderRepository, + private readonly PaymentCollectionFactory $paymentCollectionFactory ) { } /** * PaymentCreated webhook service. * - * @param $webhookData + * @param array $webhookData * * @return void * @throws Exception - * @throws LocalizedException + * @throws LocalizedException|NotFound */ - public function processWebhook($webhookData): void + public function processWebhook(array $webhookData): void { - $transaction = $this->webhookDataLoader->getTransactionByPaymentId($webhookData['data']['paymentId']); + $paymentId = $webhookData['data']['paymentId']; + $transaction = $this->webhookDataLoader->getTransactionByPaymentId($paymentId); + $order = null; if ($transaction) { return; } - $order = $this->orderCollectionFactory->create()->addFieldToFilter( - 'increment_id', - $webhookData['data']['order']['reference'] - )->getFirstItem(); + $orderReference = $webhookData['data']['order']['reference'] ?? null; - $this->createPaymentTransaction($order, $webhookData['data']['paymentId']); + if ($orderReference === null) { + $order = $this->getOrderByPaymentId($paymentId); + $orderReference = $order->getIncrementId(); + } + + if (!$order) { + $order = $this->orderCollectionFactory->create()->addFieldToFilter( + 'increment_id', + $orderReference + )->getFirstItem(); + } + + $this->createPaymentTransaction($order, $paymentId); $this->orderRepository->save($order); } + /** + * Get order by payment id. + * + * @param string $paymentId + * + * @return Order + * @throws NotFound + */ + private function getOrderByPaymentId(string $paymentId) + { + $payment = $this->paymentCollectionFactory->create() + ->addFieldToFilter('last_trans_id', $paymentId) + ->getFirstItem(); + $orderId = $payment->getParentId(); + + return $this->orderCollectionFactory->create()->addFieldToFilter('entity_id', $orderId)->getFirstItem(); + } + /** * ProcessOrder function. * - * @param $order - * @param $paymentId + * @param Order $order + * @param int $paymentId * * @return void - * @throws Exception */ private function createPaymentTransaction($order, $paymentId): void { diff --git a/Plugin/QouteToOrder/ProcessTransactionId.php b/Plugin/QouteToOrder/ProcessTransactionId.php new file mode 100644 index 00000000..e516d3ee --- /dev/null +++ b/Plugin/QouteToOrder/ProcessTransactionId.php @@ -0,0 +1,28 @@ +getAdditionalInformation('payment_id')) { + $result->setLastTransId($result->getAdditionalInformation('payment_id')); + } + + return $result; + } +} diff --git a/etc/di.xml b/etc/di.xml index cd79a871..c7c27d8a 100644 --- a/etc/di.xml +++ b/etc/di.xml @@ -235,4 +235,9 @@ Magento\Checkout\Model\Session\Proxy + + + From d6ed5613cb43d53dd81bb11c502295875cdbf2ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Ka=C5=82u=C5=BCny?= Date: Wed, 30 Apr 2025 11:41:56 +0200 Subject: [PATCH 030/320] SQNETS-45: create configuration for subscriptions --- etc/adminhtml/system.xml | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/etc/adminhtml/system.xml b/etc/adminhtml/system.xml index 1848dae0..e0470e01 100644 --- a/etc/adminhtml/system.xml +++ b/etc/adminhtml/system.xml @@ -74,6 +74,46 @@ Nexi\Checkout\Model\Config\Source\Environment + + + + + Activating subscriptions feature + Magento\Config\Model\Config\Source\Yesno + + + + + 1 + + + + + + 1 + + + + + Controls the amount of days between notifying customer about upcoming payment and billing the payment + validate-digit validate-range range-0-30 + + 1 + + + + + Magento\Config\Model\Config\Source\Yesno + + 1 + + + From e80477b95175638b5c1ddb275403e022c592efaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Ka=C5=82u=C5=BCny?= Date: Wed, 30 Apr 2025 12:28:37 +0200 Subject: [PATCH 031/320] SQNETS-45: update config path --- etc/adminhtml/system.xml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/etc/adminhtml/system.xml b/etc/adminhtml/system.xml index e0470e01..9c87c2cc 100644 --- a/etc/adminhtml/system.xml +++ b/etc/adminhtml/system.xml @@ -78,6 +78,7 @@ + nexi/subscriptions/active_subscriptions Activating subscriptions feature Magento\Config\Model\Config\Source\Yesno @@ -86,14 +87,14 @@ showInDefault="1" showInWebsite="0" showInStore="0" canRestore="1"> - 1 + 1 - 1 + 1 Controls the amount of days between notifying customer about upcoming payment and billing the payment validate-digit validate-range range-0-30 - 1 + 1 Force billing date to be on weekday (monday-friday) Magento\Config\Model\Config\Source\Yesno - 1 + 1 From 5b992d3197749bc1315a3024b2b0bbd248b06c02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Ka=C5=82u=C5=BCny?= Date: Wed, 30 Apr 2025 12:28:47 +0200 Subject: [PATCH 032/320] SQNETS-45: add config default value --- etc/config.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/etc/config.xml b/etc/config.xml index 04c74dca..d53c92d1 100644 --- a/etc/config.xml +++ b/etc/config.xml @@ -41,6 +41,9 @@ HostedPaymentPage 1 + + 0 + From 2cb458a28baded47e74d3ab646cb58fd1d0f0053 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Ka=C5=82u=C5=BCny?= Date: Wed, 30 Apr 2025 12:34:00 +0200 Subject: [PATCH 033/320] SQNETS-45: create menu for subscriptions and profiles --- etc/adminhtml/menu.xml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 etc/adminhtml/menu.xml diff --git a/etc/adminhtml/menu.xml b/etc/adminhtml/menu.xml new file mode 100644 index 00000000..110d24a7 --- /dev/null +++ b/etc/adminhtml/menu.xml @@ -0,0 +1,35 @@ + + + + + + + + From 93e2b94d83a3eb57bab2537fed0817e1d08ed4a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Ka=C5=82u=C5=BCny?= Date: Wed, 30 Apr 2025 12:46:19 +0200 Subject: [PATCH 034/320] SQNETS-45: update configuration --- etc/adminhtml/system.xml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/etc/adminhtml/system.xml b/etc/adminhtml/system.xml index 9c87c2cc..db0df48c 100644 --- a/etc/adminhtml/system.xml +++ b/etc/adminhtml/system.xml @@ -87,14 +87,14 @@ showInDefault="1" showInWebsite="0" showInStore="0" canRestore="1"> - 1 + 1 - 1 + 1 Controls the amount of days between notifying customer about upcoming payment and billing the payment validate-digit validate-range range-0-30 - 1 + 1 Force billing date to be on weekday (monday-friday) Magento\Config\Model\Config\Source\Yesno - 1 + 1 From 0993e8430a8f965f0f8ec24be9cd94a9b38c08da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Ka=C5=82u=C5=BCny?= Date: Wed, 7 May 2025 17:49:12 +0200 Subject: [PATCH 035/320] SQNETS-45: commit controllers and interfaces --- Api/Data/SubscriptionProfileInterface.php | 71 +++++++++++ ...bscriptionProfileSearchResultInterface.php | 21 ++++ ...SubscriptionProfileRepositoryInterface.php | 45 +++++++ Controller/Adminhtml/Profile/Delete.php | 44 +++++++ Controller/Adminhtml/Profile/Edit.php | 38 ++++++ Controller/Adminhtml/Profile/Index.php | 34 ++++++ Controller/Adminhtml/Profile/MassDelete.php | 48 ++++++++ Controller/Adminhtml/Profile/Save.php | 112 ++++++++++++++++++ Model/ResourceModel/Subscriptions/Profile.php | 37 ++++++ .../Subscriptions/Profile/Collection.php | 79 ++++++++++++ 10 files changed, 529 insertions(+) create mode 100644 Api/Data/SubscriptionProfileInterface.php create mode 100644 Api/Data/SubscriptionProfileSearchResultInterface.php create mode 100644 Api/SubscriptionProfileRepositoryInterface.php create mode 100644 Controller/Adminhtml/Profile/Delete.php create mode 100644 Controller/Adminhtml/Profile/Edit.php create mode 100644 Controller/Adminhtml/Profile/Index.php create mode 100644 Controller/Adminhtml/Profile/MassDelete.php create mode 100644 Controller/Adminhtml/Profile/Save.php create mode 100644 Model/ResourceModel/Subscriptions/Profile.php create mode 100644 Model/ResourceModel/Subscriptions/Profile/Collection.php diff --git a/Api/Data/SubscriptionProfileInterface.php b/Api/Data/SubscriptionProfileInterface.php new file mode 100644 index 00000000..c8ebad0a --- /dev/null +++ b/Api/Data/SubscriptionProfileInterface.php @@ -0,0 +1,71 @@ +context->getRequest()->getParam('id'); + $resultRedirect = $this->context->getResultFactory()->create(ResultFactory::TYPE_REDIRECT); + + try { + $profile = $this->profileRepo->get($id); + $this->profileRepo->delete($profile); + $resultRedirect->setPath('subscriptions/profile'); + $this->context->getMessageManager()->addSuccessMessage('Subscription profile deleted'); + } catch (\Throwable $e) { + $this->context->getMessageManager()->addErrorMessage($e->getMessage()); + $resultRedirect->setPath('subscriptions/profile/edit', ['id' => $id]); + } + + return $resultRedirect; + } +} diff --git a/Controller/Adminhtml/Profile/Edit.php b/Controller/Adminhtml/Profile/Edit.php new file mode 100644 index 00000000..e4f28f46 --- /dev/null +++ b/Controller/Adminhtml/Profile/Edit.php @@ -0,0 +1,38 @@ +initialize(); + $title = $this->context->getRequest()->getParam('id') + ? __('Edit Subscription profile') + : __('Add new profile'); + $page->getConfig()->getTitle()->prepend($title); + + return $page; + } + + private function initialize() + { + $resultPage = $this->context->getResultFactory()->create( + \Magento\Framework\Controller\ResultFactory::TYPE_PAGE + ); + $resultPage->setActiveMenu('Magento_Sales::sales_order'); + + return $resultPage; + } +} diff --git a/Controller/Adminhtml/Profile/Index.php b/Controller/Adminhtml/Profile/Index.php new file mode 100644 index 00000000..66f80094 --- /dev/null +++ b/Controller/Adminhtml/Profile/Index.php @@ -0,0 +1,34 @@ +initialize(); + $page->getConfig()->getTitle()->prepend(__('Subscription Profiles')); + + return $page; + } + + private function initialize() + { + $resultPage = $this->context->getResultFactory() + ->create(\Magento\Framework\Controller\ResultFactory::TYPE_PAGE); + $resultPage->setActiveMenu('Magento_Sales::sales_order'); + + return $resultPage; + } +} diff --git a/Controller/Adminhtml/Profile/MassDelete.php b/Controller/Adminhtml/Profile/MassDelete.php new file mode 100644 index 00000000..9d6ef75a --- /dev/null +++ b/Controller/Adminhtml/Profile/MassDelete.php @@ -0,0 +1,48 @@ +context->getResultFactory()->create(ResultFactory::TYPE_REDIRECT); + +// $collection = $this->filter->getCollection($this->factory->create()); +// $collectionSize = $collection->getSize(); +// +// foreach ($collection as $item) { +// $this->profileResource->delete($item); +// } +// +// $this->context->getMessageManager()->addSuccessMessage( +// __('A total of %1 record(s) have been deleted.', $collectionSize) +// ); + + return $resultRedirect->setPath('*/*/'); + } +} diff --git a/Controller/Adminhtml/Profile/Save.php b/Controller/Adminhtml/Profile/Save.php new file mode 100644 index 00000000..7c73a618 --- /dev/null +++ b/Controller/Adminhtml/Profile/Save.php @@ -0,0 +1,112 @@ +context->getRequest()->getParam('profile_id'); + if ($id) { + $profile = $this->profileRepo->get($id); + } else { + $profile = $this->factory->create(); + } + + $data = $this->getRequestData(); + $resultRedirect = $this->context->getResultFactory()->create(ResultFactory::TYPE_REDIRECT); + + if ($this->validateProfile($data) === false) { + $this->context->getMessageManager()->addErrorMessage( + "Schedule can't be saved due to the profile's payment period exceeding your store's quote lifetime. + Please make sure the quote lifetime is longer than your profile's payment schedule in days. + See Stores->Configuration->Sales->Checkout->Shopping Cart->Quote Lifetime (days)" + ); + $resultRedirect->setPath('subscriptions/profile/edit', ['id' => $id]); + return $resultRedirect; + } + + $profile->setData($data); + try { + $this->profileRepo->save($profile); + $resultRedirect->setPath('subscriptions/profile'); + } catch (CouldNotSaveException $e) { + $this->context->getMessageManager()->addErrorMessage($e->getMessage()); + $resultRedirect->setPath('subscriptions/profile/edit', ['id' => $id]); + } + + return $resultRedirect; + } + + private function getRequestData() + { + $data = $this->context->getRequest()->getParams(); + + if (isset($data['interval_period']) && isset($data['interval_unit'])) { + $schedule = [ + 'interval' => $data['interval_period'], + 'unit' => $data['interval_unit'], + ]; + + $data['schedule'] = $this->serializer->serialize($schedule); + } + + if (!$data['profile_id']) { + unset($data['profile_id']); + } + + return $data; + } + + private function validateProfile($data) + { + $quoteLimit = $this->scopeConfig->getValue( + self::DELETE_QUOTE_AFTER, + ScopeInterface::SCOPE_STORE + ); + switch ($data['interval_unit']) { + case 'D': + $days = 1; + break; + case 'W': + $days = 7; + break; + case 'M': + $days = 30.436875; + break; + case 'Y': + $days = 365.2425; + break; + } + if (isset($days) &&$data['interval_period'] * $days > $quoteLimit) { + return false;} + + return true; + } +} diff --git a/Model/ResourceModel/Subscriptions/Profile.php b/Model/ResourceModel/Subscriptions/Profile.php new file mode 100644 index 00000000..fd3387d6 --- /dev/null +++ b/Model/ResourceModel/Subscriptions/Profile.php @@ -0,0 +1,37 @@ +_init('subscriptions_profiles', 'profile_id'); + } + + protected function _beforeDelete(\Magento\Framework\Model\AbstractModel $object) + { + if (!$this->canDelete($object)) { + throw new CouldNotDeleteException(__('Profiles that are in use by subscriptions cannot be deleted')); + } + + return parent::_beforeDelete($object); + } + + /** + * @param \Magento\Framework\Model\AbstractModel $object + * @return bool + */ + private function canDelete(\Magento\Framework\Model\AbstractModel $object): bool + { + $connection = $this->getConnection(); + $select = $connection->select() + ->from('nexi_subscriptions', ['entity_id', 'subscription_profile_id']) + ->where('subscription_profile_id = ?', $object->getData('profile_id')); + + return empty($connection->fetchPairs($select)); + } +} diff --git a/Model/ResourceModel/Subscriptions/Profile/Collection.php b/Model/ResourceModel/Subscriptions/Profile/Collection.php new file mode 100644 index 00000000..465527d1 --- /dev/null +++ b/Model/ResourceModel/Subscriptions/Profile/Collection.php @@ -0,0 +1,79 @@ +_init( + Profile::class, + ProfileResource::class + ); + } + + /** + * Set items list. + * + * @param \Magento\Framework\DataObject[] $items + * @return \Nexi\Checkout\Model\ResourceModel\Subscriptions\Profile\Collection + * @throws \Exception + */ + public function setItems(array $items = null) + { + if (!$items) { + return $this; + } + foreach ($items as $item) { + $this->addItem($item); + } + return $this; + } + + public function getSearchCriteria() + { + return $this->searchCriteria; + } + + /** + * @param \Magento\Framework\Api\SearchCriteriaInterface $searchCriteria + * @return $this|Collection + */ + public function setSearchCriteria(\Magento\Framework\Api\SearchCriteriaInterface $searchCriteria) + { + $this->searchCriteria = $searchCriteria; + + return $this; + } + + /** + * Get total count. + * + * @return int + */ + public function getTotalCount() + { + return $this->getSize(); + } + + /** + * Set total count. + * + * @param int $totalCount + * @return \Nexi\Checkout\Model\ResourceModel\Subscriptions\Profile\Collection + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function setTotalCount($totalCount) + { + // total count is the collections size, do not modify it. + return $this; + } +} From ce55509abcf9fb4f52e615a89d527d257790222d Mon Sep 17 00:00:00 2001 From: Konrad Konieczny Date: Fri, 23 May 2025 09:05:47 +0200 Subject: [PATCH 036/320] cast order id to int --- Model/Webhook/PaymentChargeCreated.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Model/Webhook/PaymentChargeCreated.php b/Model/Webhook/PaymentChargeCreated.php index e608d9a2..67399dcc 100644 --- a/Model/Webhook/PaymentChargeCreated.php +++ b/Model/Webhook/PaymentChargeCreated.php @@ -59,7 +59,7 @@ public function processWebhook(array $webhookData): void private function processOrder(Order $order, array $webhookData): void { $reservationTxn = $this->webhookDataLoader->getTransactionByOrderId( - $order->getId(), + (int)$order->getId(), TransactionInterface::TYPE_AUTH ); From 538abf617f0beed9408f36e78daeadaef497c51a Mon Sep 17 00:00:00 2001 From: Konrad Konieczny Date: Fri, 23 May 2025 11:04:07 +0200 Subject: [PATCH 037/320] issue invoice only for shipping costs --- Model/Webhook/PaymentChargeCreated.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Model/Webhook/PaymentChargeCreated.php b/Model/Webhook/PaymentChargeCreated.php index 67399dcc..0fee0ac2 100644 --- a/Model/Webhook/PaymentChargeCreated.php +++ b/Model/Webhook/PaymentChargeCreated.php @@ -141,9 +141,6 @@ public function fullInvoice(Order $order, string $chargeTxnId): void /** * Create partial invoice. Add shipping amount if charged * - * TODO: investigate how to invoice only shipping cost in magento? probably not possible separately - without any - * TODO: order item invoiced now its only in order history comments (if charge only for shipping) - * * @param Order $order * @param string $chargeTxnId * @param array $webhookItems @@ -156,6 +153,12 @@ private function partialInvoice(Order $order, string $chargeTxnId, array $webhoo if ($order->canInvoice()) { $qtys = []; $shippingItem = null; + + // Initialize all items with 0 qty + foreach ($order->getAllItems() as $item) { + $qtys[$item->getId()] = 0; + } + foreach ($webhookItems as $webhookItem) { if ($webhookItem['reference'] === SalesDocumentItemsBuilder::SHIPPING_COST_REFERENCE) { From b9f52fc12aae32bde875d9963caf5fed891f4c1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Ka=C5=82u=C5=BCny?= Date: Fri, 23 May 2025 12:10:19 +0200 Subject: [PATCH 038/320] SQNETS-55: update webhooks --- Controller/Payment/Webhook.php | 2 +- Model/Webhook/Data/WebhookDataLoader.php | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/Controller/Payment/Webhook.php b/Controller/Payment/Webhook.php index f9afb296..321004db 100644 --- a/Controller/Payment/Webhook.php +++ b/Controller/Payment/Webhook.php @@ -120,4 +120,4 @@ public function isAuthorized(): bool return hash_equals($hash, $authString); } -} \ No newline at end of file +} diff --git a/Model/Webhook/Data/WebhookDataLoader.php b/Model/Webhook/Data/WebhookDataLoader.php index 15ae208d..9d9c9d9a 100644 --- a/Model/Webhook/Data/WebhookDataLoader.php +++ b/Model/Webhook/Data/WebhookDataLoader.php @@ -2,7 +2,6 @@ declare(strict_types=1); -use Magento\Checkout\Exception; namespace Nexi\Checkout\Model\Webhook\Data; use Magento\Framework\Api\SearchCriteriaBuilder; From 4a9cf4af015288012b32d0d0cd7005cd9f0b2b2c Mon Sep 17 00:00:00 2001 From: Webben Oy Ab Date: Sun, 25 May 2025 20:35:06 +0300 Subject: [PATCH 039/320] Added most of the files --- .idea/.gitignore | 8 + .idea/modules.xml | 8 + .idea/nexi-checkout.iml | 11 + .idea/php.xml | 22 ++ .idea/phpunit.xml | 10 + .idea/vcs.xml | 6 + Api/CardManagementInterface.php | 27 ++ Api/Data/RecurringProfileInterface.php | 71 +++++ .../RecurringProfileSearchResultInterface.php | 21 ++ Api/Data/SubscriptionInterface.php | 171 +++++++++++ Api/Data/SubscriptionLinkInterface.php | 55 ++++ .../SubscriptionLinkSearchResultInterface.php | 21 ++ .../SubscriptionSearchResultInterface.php | 20 ++ Api/RecurringProfileRepositoryInterface.php | 46 +++ Api/SubscriptionLinkRepositoryInterface.php | 70 +++++ Api/SubscriptionManagementInterface.php | 37 +++ Api/SubscriptionRepositoryInterface.php | 52 ++++ Console/Command/Bill.php | 55 ++++ Console/Command/Notify.php | 53 ++++ Controller/Order/Payments.php | 34 +++ Controller/Payment/Stop.php | 95 ++++++ Controller/Recurring/Delete.php | 35 +++ Controller/Recurring/Index.php | 31 ++ Controller/Recurring/MassDelete.php | 39 +++ Controller/Recurring/Save.php | 45 +++ Controller/Recurring/StopSchedule.php | 120 ++++++++ Controller/Recurring/View.php | 33 +++ Controller/Redirect/Token.php | 242 ++++++++++++++++ Cron/RecurringPaymentBill.php | 34 +++ Cron/RecurringPaymentNotify.php | 33 +++ Exceptions/CheckoutException.php | 12 + Exceptions/TransactionSuccessException.php | 12 + Gateway/Request/TokenRequestDataBuilder.php | 14 + Gateway/Validator/HmacValidator.php | 62 ++++ Gateway/Validator/ResponseValidator.php | 127 ++++++++ Logger/NexiLogger.php | 128 ++++++++ Logger/Request.php | 23 ++ Logger/Response.php | 23 ++ Model/Adapter/Adapter.php | 39 +++ Model/Api/ShowSubscriptionsDataProvider.php | 82 ++++++ Model/ApplePay/ApplePayDataProvider.php | 136 +++++++++ Model/Attribute/SelectData.php | 47 +++ Model/Card/VaultConfig.php | 41 +++ Model/CardManagement.php | 109 +++++++ Model/FinnishReferenceNumber.php | 156 ++++++++++ Model/OptionSource/IntervalUnits.php | 28 ++ Model/OptionSource/ProfileOptions.php | 42 +++ Model/OptionSource/SelectedToken.php | 122 ++++++++ Model/OptionSource/SubscriptionStatus.php | 34 +++ Model/Receipt/CancelOrderService.php | 102 +++++++ Model/Receipt/LoadService.php | 71 +++++ Model/Receipt/PaymentTransaction.php | 92 ++++++ Model/Receipt/ProcessPayment.php | 134 +++++++++ Model/Receipt/ProcessService.php | 274 ++++++++++++++++++ Model/ReceiptDataProvider.php | 128 ++++++++ Model/Recurring/Bill.php | 54 ++++ Model/Recurring/Notify.php | 103 +++++++ Model/Recurring/TotalConfigProvider.php | 117 ++++++++ Model/ResourceModel/Subscription.php | 163 +++++++++++ .../ResourceModel/Subscription/Collection.php | 109 +++++++ Model/ResourceModel/Subscription/Profile.php | 37 +++ .../Subscription/Profile/Collection.php | 79 +++++ .../Subscription/SubscriptionLink.php | 18 ++ .../SubscriptionLink/Collection.php | 85 ++++++ Model/Subscription.php | 107 +++++++ Model/Subscription/ActiveOrderProvider.php | 64 ++++ Model/Subscription/Email.php | 189 ++++++++++++ Model/Subscription/NextDateCalculator.php | 179 ++++++++++++ Model/Subscription/OrderBiller.php | 195 +++++++++++++ Model/Subscription/OrderCloner.php | 174 +++++++++++ Model/Subscription/PaymentCount.php | 52 ++++ Model/Subscription/Profile.php | 65 +++++ Model/Subscription/ProfileRepository.php | 112 +++++++ Model/Subscription/QuoteToOrder.php | 71 +++++ Model/Subscription/SubscriptionCreate.php | 124 ++++++++ Model/Subscription/SubscriptionLink.php | 68 +++++ .../SubscriptionLinkRepository.php | 223 ++++++++++++++ Model/SubscriptionManagement.php | 207 +++++++++++++ Model/SubscriptionRepository.php | 87 ++++++ Model/Token/Payment.php | 233 +++++++++++++++ Model/Token/RequestData.php | 42 +++ Model/Ui/ConfigProvider.php | 125 ++++++-- .../Ui/DataProvider/PaymentProvidersData.php | 244 ++++++++++++++++ .../Product/Form/Modifier/Attributes.php | 69 +++++ Model/Validation/CustomerData.php | 36 +++ Model/Validation/PreventAdminActions.php | 36 +++ Observer/RecurringPaymentFromQuoteToOrder.php | 35 +++ Observer/ScheduledCartValidation.php | 59 ++++ Plugin/Api/OrderRepository.php | 66 +++++ Plugin/PreventDifferentScheduledCart.php | 47 +++ Plugin/RecurringToOrderGrid.php | 46 +++ .../AddRecurringPaymentScheduleAttribute.php | 96 ++++++ Setup/Patch/Data/InstallProfilesPatch.php | 96 ++++++ .../Listing/Column/RecurringAction.php | 82 ++++++ Ui/DataProvider/RecurringPayment.php | 91 ++++++ Ui/DataProvider/RecurringPaymentForm.php | 139 +++++++++ Ui/DataProvider/RecurringProfile.php | 58 ++++ Ui/DataProvider/RecurringProfileForm.php | 102 +++++++ etc/acl.xml | 14 + etc/adminhtml/di.xml | 18 ++ etc/adminhtml/menu.xml | 35 +++ etc/adminhtml/routes.xml | 3 + etc/adminhtml/system.xml | 42 +++ etc/catalog_attributes.xml | 8 + etc/config.xml | 10 + etc/crontab.xml | 12 + etc/db_schema.xml | 73 +++++ etc/db_schema_whitelist.json | 51 ++++ etc/di.xml | 41 +++ etc/events.xml | 8 + etc/frontend/di.xml | 8 +- etc/webapi.xml | 26 ++ i18n/en_US.csv | 4 + i18n/fi_FI.csv | 15 + .../recurring_payments_profile_edit.xml | 10 + .../recurring_payments_profile_index.xml | 8 + .../recurring_payments_recurring_index.xml | 8 + .../recurring_payments_recurring_view.xml | 13 + .../recurring_payments_listing.xml | 187 ++++++++++++ .../ui_component/recurring_payments_view.xml | 181 ++++++++++++ .../ui_component/recurring_profile_edit.xml | 128 ++++++++ .../recurring_profile_listing.xml | 151 ++++++++++ .../ui_component/sales_order_grid.xml | 49 ++++ view/adminhtml/web/css/recurring.css | 8 + .../web/template/form/element/link.html | 1 + view/frontend/email/recurring_new.html | 95 ++++++ .../email/restore_order_notification.html | 27 ++ view/frontend/layout/checkout_index_index.xml | 29 ++ view/frontend/layout/customer_account.xml | 16 + view/frontend/templates/order/payments.phtml | 140 +++++++++ .../checkout/summary/recurring_total.html | 15 + 131 files changed, 9341 insertions(+), 20 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/modules.xml create mode 100644 .idea/nexi-checkout.iml create mode 100644 .idea/php.xml create mode 100644 .idea/phpunit.xml create mode 100644 .idea/vcs.xml create mode 100644 Api/CardManagementInterface.php create mode 100644 Api/Data/RecurringProfileInterface.php create mode 100644 Api/Data/RecurringProfileSearchResultInterface.php create mode 100644 Api/Data/SubscriptionInterface.php create mode 100644 Api/Data/SubscriptionLinkInterface.php create mode 100644 Api/Data/SubscriptionLinkSearchResultInterface.php create mode 100644 Api/Data/SubscriptionSearchResultInterface.php create mode 100644 Api/RecurringProfileRepositoryInterface.php create mode 100644 Api/SubscriptionLinkRepositoryInterface.php create mode 100644 Api/SubscriptionManagementInterface.php create mode 100644 Api/SubscriptionRepositoryInterface.php create mode 100644 Console/Command/Bill.php create mode 100644 Console/Command/Notify.php create mode 100644 Controller/Order/Payments.php create mode 100644 Controller/Payment/Stop.php create mode 100644 Controller/Recurring/Delete.php create mode 100644 Controller/Recurring/Index.php create mode 100644 Controller/Recurring/MassDelete.php create mode 100644 Controller/Recurring/Save.php create mode 100644 Controller/Recurring/StopSchedule.php create mode 100644 Controller/Recurring/View.php create mode 100755 Controller/Redirect/Token.php create mode 100644 Cron/RecurringPaymentBill.php create mode 100644 Cron/RecurringPaymentNotify.php create mode 100755 Exceptions/CheckoutException.php create mode 100755 Exceptions/TransactionSuccessException.php create mode 100755 Gateway/Request/TokenRequestDataBuilder.php create mode 100755 Gateway/Validator/HmacValidator.php create mode 100755 Gateway/Validator/ResponseValidator.php create mode 100644 Logger/NexiLogger.php create mode 100644 Logger/Request.php create mode 100755 Logger/Response.php create mode 100644 Model/Adapter/Adapter.php create mode 100644 Model/Api/ShowSubscriptionsDataProvider.php create mode 100644 Model/ApplePay/ApplePayDataProvider.php create mode 100644 Model/Attribute/SelectData.php create mode 100644 Model/Card/VaultConfig.php create mode 100644 Model/CardManagement.php create mode 100644 Model/FinnishReferenceNumber.php create mode 100644 Model/OptionSource/IntervalUnits.php create mode 100644 Model/OptionSource/ProfileOptions.php create mode 100644 Model/OptionSource/SelectedToken.php create mode 100644 Model/OptionSource/SubscriptionStatus.php create mode 100644 Model/Receipt/CancelOrderService.php create mode 100644 Model/Receipt/LoadService.php create mode 100644 Model/Receipt/PaymentTransaction.php create mode 100644 Model/Receipt/ProcessPayment.php create mode 100644 Model/Receipt/ProcessService.php create mode 100755 Model/ReceiptDataProvider.php create mode 100644 Model/Recurring/Bill.php create mode 100644 Model/Recurring/Notify.php create mode 100644 Model/Recurring/TotalConfigProvider.php create mode 100644 Model/ResourceModel/Subscription.php create mode 100644 Model/ResourceModel/Subscription/Collection.php create mode 100644 Model/ResourceModel/Subscription/Profile.php create mode 100644 Model/ResourceModel/Subscription/Profile/Collection.php create mode 100644 Model/ResourceModel/Subscription/SubscriptionLink.php create mode 100644 Model/ResourceModel/Subscription/SubscriptionLink/Collection.php create mode 100644 Model/Subscription.php create mode 100644 Model/Subscription/ActiveOrderProvider.php create mode 100644 Model/Subscription/Email.php create mode 100644 Model/Subscription/NextDateCalculator.php create mode 100644 Model/Subscription/OrderBiller.php create mode 100644 Model/Subscription/OrderCloner.php create mode 100644 Model/Subscription/PaymentCount.php create mode 100644 Model/Subscription/Profile.php create mode 100644 Model/Subscription/ProfileRepository.php create mode 100644 Model/Subscription/QuoteToOrder.php create mode 100644 Model/Subscription/SubscriptionCreate.php create mode 100644 Model/Subscription/SubscriptionLink.php create mode 100644 Model/Subscription/SubscriptionLinkRepository.php create mode 100644 Model/SubscriptionManagement.php create mode 100644 Model/SubscriptionRepository.php create mode 100644 Model/Token/Payment.php create mode 100644 Model/Token/RequestData.php mode change 100644 => 100755 Model/Ui/ConfigProvider.php create mode 100755 Model/Ui/DataProvider/PaymentProvidersData.php create mode 100644 Model/Ui/DataProvider/Product/Form/Modifier/Attributes.php create mode 100644 Model/Validation/CustomerData.php create mode 100644 Model/Validation/PreventAdminActions.php create mode 100644 Observer/RecurringPaymentFromQuoteToOrder.php create mode 100644 Observer/ScheduledCartValidation.php create mode 100644 Plugin/Api/OrderRepository.php create mode 100644 Plugin/PreventDifferentScheduledCart.php create mode 100644 Plugin/RecurringToOrderGrid.php create mode 100644 Setup/Patch/Data/AddRecurringPaymentScheduleAttribute.php create mode 100644 Setup/Patch/Data/InstallProfilesPatch.php create mode 100644 Ui/Component/Listing/Column/RecurringAction.php create mode 100644 Ui/DataProvider/RecurringPayment.php create mode 100644 Ui/DataProvider/RecurringPaymentForm.php create mode 100644 Ui/DataProvider/RecurringProfile.php create mode 100644 Ui/DataProvider/RecurringProfileForm.php create mode 100644 etc/acl.xml create mode 100644 etc/adminhtml/di.xml create mode 100644 etc/adminhtml/menu.xml create mode 100644 etc/catalog_attributes.xml create mode 100644 etc/crontab.xml create mode 100644 etc/db_schema.xml create mode 100644 etc/db_schema_whitelist.json create mode 100644 etc/events.xml create mode 100755 i18n/en_US.csv create mode 100644 i18n/fi_FI.csv create mode 100755 view/adminhtml/layout/recurring_payments_profile_edit.xml create mode 100644 view/adminhtml/layout/recurring_payments_profile_index.xml create mode 100644 view/adminhtml/layout/recurring_payments_recurring_index.xml create mode 100644 view/adminhtml/layout/recurring_payments_recurring_view.xml create mode 100644 view/adminhtml/ui_component/recurring_payments_listing.xml create mode 100644 view/adminhtml/ui_component/recurring_payments_view.xml create mode 100644 view/adminhtml/ui_component/recurring_profile_edit.xml create mode 100644 view/adminhtml/ui_component/recurring_profile_listing.xml create mode 100644 view/adminhtml/ui_component/sales_order_grid.xml create mode 100644 view/adminhtml/web/css/recurring.css create mode 100644 view/adminhtml/web/template/form/element/link.html create mode 100644 view/frontend/email/recurring_new.html create mode 100644 view/frontend/email/restore_order_notification.html create mode 100644 view/frontend/layout/customer_account.xml create mode 100644 view/frontend/templates/order/payments.phtml create mode 100644 view/frontend/web/template/checkout/summary/recurring_total.html diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 00000000..13566b81 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 00000000..c7bf828c --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/nexi-checkout.iml b/.idea/nexi-checkout.iml new file mode 100644 index 00000000..17f11803 --- /dev/null +++ b/.idea/nexi-checkout.iml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/php.xml b/.idea/php.xml new file mode 100644 index 00000000..23db7153 --- /dev/null +++ b/.idea/php.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/phpunit.xml b/.idea/phpunit.xml new file mode 100644 index 00000000..4f8104cf --- /dev/null +++ b/.idea/phpunit.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 00000000..35eb1ddf --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Api/CardManagementInterface.php b/Api/CardManagementInterface.php new file mode 100644 index 00000000..2a7b2c77 --- /dev/null +++ b/Api/CardManagementInterface.php @@ -0,0 +1,27 @@ +setName('nexi:recurring:bill'); + $this->setDescription('Invoice customers of recurring orders'); + } + + /** + * Execute + * + * @param InputInterface $input + * @param OutputInterface $output + * @return int + * @throws LocalizedException + */ + public function execute(InputInterface $input, OutputInterface $output) + { + $this->state->setAreaCode(Area::AREA_CRONTAB); + $this->bill->process(); + + return Cli::RETURN_SUCCESS; + } +} diff --git a/Console/Command/Notify.php b/Console/Command/Notify.php new file mode 100644 index 00000000..a90c2bd6 --- /dev/null +++ b/Console/Command/Notify.php @@ -0,0 +1,53 @@ +setName('nexi:recurring:notify'); + $this->setDescription('Send recurring payment notification emails.'); + } + + /** + * Execute + * + * @param InputInterface $input + * @param OutputInterface $output + * @return int + * @throws \Magento\Framework\Exception\LocalizedException + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $this->state->setAreaCode(\Magento\Framework\App\Area::AREA_CRONTAB); + $this->notify->process(); + + return Cli::RETURN_SUCCESS; + } +} diff --git a/Controller/Order/Payments.php b/Controller/Order/Payments.php new file mode 100644 index 00000000..9cbc49c9 --- /dev/null +++ b/Controller/Order/Payments.php @@ -0,0 +1,34 @@ +resultPageFactory->create(); + $resultPage->getConfig()->getTitle()->set(__('My Subscriptions')); + + return $resultPage; + } +} diff --git a/Controller/Payment/Stop.php b/Controller/Payment/Stop.php new file mode 100644 index 00000000..51b8c995 --- /dev/null +++ b/Controller/Payment/Stop.php @@ -0,0 +1,95 @@ +context->getRequest()->getParam('payment_id'); + $resultRedirect = $this->context->getResultFactory()->create(ResultFactory::TYPE_REDIRECT); + + if ($this->preventAdminActions->isAdminAsCustomer()) { + $this->messageManager->addErrorMessage(__('Admin user is not authorized for this operation')); + $resultRedirect->setPath('paytrail/order/payments'); + + return $resultRedirect; + } + + try { + $subscription = $this->subscriptionRepositoryInterface->get((int)$subscriptionId); + $orderIds = $this->subscriptionLinkRepositoryInterface->getOrderIdsBySubscriptionId( + (int)$subscriptionId + ); + + foreach ($orderIds as $orderId) { + $order = $this->orderRepositoryInterface->get($orderId); + if (!$this->customerSession->getId() || $this->customerSession->getId() != $order->getCustomerId()) { + throw new LocalizedException(__('Customer is not authorized for this operation')); + } + $subscription->setStatus(self::STATUS_CLOSED); + if ($order->getStatus() === Order::STATE_PENDING_PAYMENT + || $order->getStatus() === self::ORDER_PENDING_STATUS) { + $this->orderManagementInterface->cancel($order->getId()); + } + } + + $this->subscriptionRepositoryInterface->save($subscription); + $resultRedirect->setPath('paytrail/order/payments'); + $this->messageManager->addSuccessMessage('Subscription stopped successfully'); + } catch (\Exception $e) { + $this->logger->error($e->getMessage()); + $this->messageManager->addErrorMessage(__('Unable to stop payment')); + $resultRedirect->setPath('paytrail/order/payments'); + } + + return $resultRedirect; + } +} diff --git a/Controller/Recurring/Delete.php b/Controller/Recurring/Delete.php new file mode 100644 index 00000000..03710bd9 --- /dev/null +++ b/Controller/Recurring/Delete.php @@ -0,0 +1,35 @@ +context->getRequest()->getParam('id'); + $resultRedirect = $this->context->getResultFactory()->create(ResultFactory::TYPE_REDIRECT); + + try { + $payment = $this->subscriptionRepository->get($id); + $this->subscriptionRepository->delete($payment); + $resultRedirect->setPath('recurring_payments/recurring'); + $this->context->getMessageManager()->addSuccessMessage('Recurring payment deleted'); + } catch (\Throwable $e) { + $this->context->getMessageManager()->addErrorMessage($e->getMessage()); + $resultRedirect->setPath('recurring_payments/recurring/edit', ['id' => $id]); + } + + return $resultRedirect; + } +} diff --git a/Controller/Recurring/Index.php b/Controller/Recurring/Index.php new file mode 100644 index 00000000..017c9f92 --- /dev/null +++ b/Controller/Recurring/Index.php @@ -0,0 +1,31 @@ +initialize(); + $page->getConfig()->getTitle()->prepend(__('Subscriptions')); + + return $page; + } + + private function initialize() + { + /** @var \Magento\Backend\Model\View\Result\Page $resultPage */ + $resultPage = $this->context->getResultFactory()->create(\Magento\Framework\Controller\ResultFactory::TYPE_PAGE); + $resultPage->setActiveMenu('Magento_Sales::sales_order'); + + return $resultPage; + } +} diff --git a/Controller/Recurring/MassDelete.php b/Controller/Recurring/MassDelete.php new file mode 100644 index 00000000..f35e0213 --- /dev/null +++ b/Controller/Recurring/MassDelete.php @@ -0,0 +1,39 @@ +context->getResultFactory()->create(ResultFactory::TYPE_REDIRECT); + + $collection = $this->filter->getCollection($this->factory->create()); + $collectionSize = $collection->getSize(); + + foreach ($collection as $item) { + $this->subscription->delete($item); + } + + $this->context->getMessageManager()->addSuccessMessage( + __('A total of %1 record(s) have been deleted.', $collectionSize) + ); + + return $resultRedirect->setPath('*/*/'); + } +} diff --git a/Controller/Recurring/Save.php b/Controller/Recurring/Save.php new file mode 100644 index 00000000..5026761e --- /dev/null +++ b/Controller/Recurring/Save.php @@ -0,0 +1,45 @@ +context->getRequest()->getParam('entity_id'); + + if ($id) { + $payment = $this->paymentRepo->get($id); + } else { + $payment = $this->factory->create(); + } + + $data = $this->getRequest()->getParams(); + $payment->setData($data); + $resultRedirect = $this->context->getResultFactory()->create(ResultFactory::TYPE_REDIRECT); + try { + $this->paymentRepo->save($payment); + $resultRedirect->setPath('recurring_payments/recurring'); + } catch (CouldNotSaveException $e) { + $this->context->getMessageManager()->addErrorMessage($e->getMessage()); + $resultRedirect->setPath('recurring_payments/recurring/edit', ['id' => $id]); + } + + return $resultRedirect; + } +} diff --git a/Controller/Recurring/StopSchedule.php b/Controller/Recurring/StopSchedule.php new file mode 100644 index 00000000..ab8c43ef --- /dev/null +++ b/Controller/Recurring/StopSchedule.php @@ -0,0 +1,120 @@ +context->getResultFactory()->create(ResultFactory::TYPE_REDIRECT); + $resultRedirect->setPath($this->_redirect->getRefererUrl()); + $id = $this->context->getRequest()->getParam('id'); + + $subscription = $this->getRecurringPayment($id); + if (!$subscription) { + return $resultRedirect; + } + $this->cancelOrder($subscription); + $this->updateRecurringStatus($subscription); + + return $resultRedirect; + } + + /** + * @param $id + * + * @return false|SubscriptionInterface + */ + private function getRecurringPayment($subscriptionId) + { + try { + return $this->subscriptionRepository->get($subscriptionId); + } catch (\Magento\Framework\Exception\NoSuchEntityException $e) { + $this->context->getMessageManager()->addErrorMessage( + \__( + 'Unable to load subscription with ID: %id', + ['id' => $subscriptionId] + ) + ); + } + + return false; + } + + /** + * @param SubscriptionInterface $subscription + */ + private function cancelOrder(SubscriptionInterface $subscription): void + { + try { + // Only cancel unpaid orders. + $ordersId = $this->subscriptionLinkRepoInterface->getOrderIdsBySubscriptionId($subscription->getId()); + if ($subscription->getStatus() !== SubscriptionInterface::STATUS_CLOSED) { + foreach ($ordersId as $orderId) { + $this->orderManagement->cancel($orderId); + } + } else { + $this->context->getMessageManager()->addWarningMessage( + \__( + 'Order ID %id has a status other than %status, automatic order cancel disabled. If the order is unpaid please cancel it manually', + [ + 'id' => array_shift($ordersId), + 'status' => self::ORDER_PENDING_STATUS + ] + ) + ); + } + } catch (LocalizedException $exception) { + $this->context->getMessageManager()->addErrorMessage( + \__( + 'Error occurred while cancelling the order: %error', + ['error' => $exception->getMessage()] + ) + ); + } + } + + private function updateRecurringStatus(SubscriptionInterface $subscription) + { + $subscription->setStatus(SubscriptionInterface::STATUS_CLOSED); + + try { + $this->subscriptionRepository->save($subscription); + $this->context->getMessageManager()->addSuccessMessage( + \__( + 'Recurring payments stopped for payment id: %id', + [ + 'id' => $subscription->getId() + ] + ) + ); + } catch (CouldNotSaveException $exception) { + $this->context->getMessageManager()->addErrorMessage( + \__( + 'Error occurred while updating recurring payment: %error', + ['error' => $exception->getMessage()] + ) + ); + } + } +} diff --git a/Controller/Recurring/View.php b/Controller/Recurring/View.php new file mode 100644 index 00000000..7f0dbb19 --- /dev/null +++ b/Controller/Recurring/View.php @@ -0,0 +1,33 @@ +initialize(); + $page->getConfig()->getTitle()->prepend(__('View Recurring Payment')); + + return $page; + } + + private function initialize() + { + $resultPage = $this->context->getResultFactory()->create( + \Magento\Framework\Controller\ResultFactory::TYPE_PAGE + ); + $resultPage->setActiveMenu('Magento_Sales::sales_order'); + + return $resultPage; + } +} diff --git a/Controller/Redirect/Token.php b/Controller/Redirect/Token.php new file mode 100755 index 00000000..69ca6588 --- /dev/null +++ b/Controller/Redirect/Token.php @@ -0,0 +1,242 @@ +request->getParam('selected_token'); + $selectedTokenId = preg_replace('/[^0-9a-f]{2,}$/', '', $selectedTokenRaw); + + if (empty($selectedTokenId)) { + $this->errorMsg = __('No payment token selected'); + throw new LocalizedException(__('No payment token selected')); + } + + $order = $this->orderFactory->create(); + $order = $order->loadByIncrementId( + $this->checkoutSession->getLastRealOrderId() + ); + + $resultJson = $this->jsonFactory->create(); + if ($order->getStatus() === Order::STATE_PROCESSING) { + $this->errorMsg = __('Payment already processed'); + return $resultJson->setData( + [ + 'success' => false, + 'message' => $this->errorMsg + ] + ); + } + + $customer = $this->customerSession->getCustomer(); + try { + $responseData = $this->getTokenResponseData($order, $selectedTokenId, $customer); + if ($this->totalConfigProvider->isRecurringPaymentEnabled()) { + if ($this->subscriptionCreate->getSubscriptionSchedule($order) && $responseData->getTransactionId()) { + $orderSchedule = $this->subscriptionCreate->getSubscriptionSchedule($order); + $this->subscriptionCreate->createSubscription( + $orderSchedule, + $selectedTokenId, + $customer->getId(), + $order->getId() + ); + } + } + } catch (CheckoutException $exception) { + $this->errorMsg = __('Error processing token payment'); + if ($order) { + $this->orderManagementInterface->cancel($order->getId()); + $order->addCommentToStatusHistory( + __('Order canceled. Failed to process token payment.') + ); + $this->orderRepository->save($order); + } + + $this->checkoutSession->restoreQuote(); + + return $resultJson->setData( + [ + 'success' => false, + 'message' => $this->errorMsg + ] + ); + } + + $redirectUrl = $responseData->getThreeDSecureUrl(); + $resultJson = $this->jsonFactory->create(); + + if ($redirectUrl) { + return $resultJson->setData( + [ + 'success' => true, + 'data' => 'redirect', + 'redirect' => $redirectUrl + ] + ); + } + + /* fetch payment response using transaction id */ + $response = $this->getPaymentData($responseData->getTransactionId()); + + $receiptData = [ + 'checkout-account' => $this->gatewayConfig->getMerchantId(), + 'checkout-algorithm' => 'sha256', + 'checkout-amount' => $response['data']->getAmount(), + 'checkout-stamp' => $response['data']->getStamp(), + 'checkout-reference' => $response['data']->getReference(), + 'checkout-transaction-id' => $response['data']->getTransactionId(), + 'checkout-status' => $response['data']->getStatus(), + 'checkout-provider' => $response['data']->getProvider(), + 'signature' => HmacValidator::SKIP_HMAC_VALIDATION + ]; + + $this->receiptDataProvider->execute($receiptData); + + return $resultJson->setData( + [ + 'success' => true, + 'data' => 'redirect', + 'reference' => $response['data']->getReference(), + 'redirect' => $redirectUrl + ] + ); + } + + /** + * GetTokenResponseData function + * + * @param Order $order + * @param string $tokenId + * @param Customer $customer + * @return mixed + * @throws CheckoutException + * @throws \Magento\Framework\Exception\NotFoundException + * @throws \Magento\Payment\Gateway\Command\CommandException + */ + private function getTokenResponseData($order, $tokenId, $customer) + { + $commandExecutor = $this->commandManagerPool->get('paytrail'); + $response = $commandExecutor->executeByCode( + 'token_payment', + null, + [ + 'order' => $order, + 'token_id' => $tokenId, + 'customer' => $customer + ] + ); + + $errorMsg = $response['error']; + + if (isset($errorMsg)) { + $this->errorMsg = ($errorMsg); + $this->processService->processError($errorMsg); + } + + return $response["data"]; + } + + /** + * GetPaymentData function + * + * @param string $transactionId + * @return mixed + * @throws CheckoutException + * @throws \Magento\Framework\Exception\NotFoundException + * @throws \Magento\Payment\Gateway\Command\CommandException + */ + private function getPaymentData($transactionId) + { + $commandExecutor = $this->commandManagerPool->get('paytrail'); + $response = $commandExecutor->executeByCode( + 'get_payment_data', + null, + [ + 'transaction_id' => $transactionId + ] + ); + + $errorMsg = $response['error']; + + if (isset($errorMsg)) { + $this->errorMsg = ($errorMsg); + $this->processService->processError($errorMsg); + } + + return $response; + } +} diff --git a/Cron/RecurringPaymentBill.php b/Cron/RecurringPaymentBill.php new file mode 100644 index 00000000..077e9aa5 --- /dev/null +++ b/Cron/RecurringPaymentBill.php @@ -0,0 +1,34 @@ +totalConfigProvider->isRecurringPaymentEnabled()) { + $this->bill->process(); + } + } +} diff --git a/Cron/RecurringPaymentNotify.php b/Cron/RecurringPaymentNotify.php new file mode 100644 index 00000000..6f692a4b --- /dev/null +++ b/Cron/RecurringPaymentNotify.php @@ -0,0 +1,33 @@ +totalConfigProvider->isRecurringPaymentEnabled()) { + $this->notify->process(); + } + } +} diff --git a/Exceptions/CheckoutException.php b/Exceptions/CheckoutException.php new file mode 100755 index 00000000..bf738137 --- /dev/null +++ b/Exceptions/CheckoutException.php @@ -0,0 +1,12 @@ +log->debugLog( + 'request', + \sprintf( + 'Validating Hmac for transaction: %s', + $params["checkout-transaction-id"] + ) + ); + $nexiClient = $this->nexiAdapter->initNexiMerchantClient(); + + $nexiClient->validateHmac($params, '', $signature); + } catch (\Exception $e) { + $this->log->error(sprintf( + 'Nexi PaymentService error: Hmac validation failed for transaction %s', + $params["checkout-transaction-id"] + )); + + return false; + } + $this->log->debugLog( + 'response', + sprintf( + 'Hmac validation successful for transaction: %s', + $params["checkout-transaction-id"] + ) + ); + + return true; + } +} diff --git a/Gateway/Validator/ResponseValidator.php b/Gateway/Validator/ResponseValidator.php new file mode 100755 index 00000000..7f721622 --- /dev/null +++ b/Gateway/Validator/ResponseValidator.php @@ -0,0 +1,127 @@ +createResult($isValid, $fails); + } + + if ($this->isRequestMerchantIdEmpty($this->gatewayConfig->getMerchantId())) { + $fails[] = "Request MerchantId is empty"; + } + + if ($this->isResponseMerchantIdEmpty($validationSubject["checkout-account"])) { + $fails[] = "Response MerchantId is empty"; + } + + if ($this->isMerchantIdValid($validationSubject["checkout-account"]) == false) { + $fails[] = "Response and Request merchant ids does not match"; + } + + if ($this->validateResponse($validationSubject) == false) { + $fails[] = "Invalid response data from Nexi"; + } + + if ($this->validateAlgorithm($validationSubject["checkout-algorithm"]) == false) { + $fails[] = "Invalid response data from Nexi"; + } + + if (count($fails) > 0) { + $isValid = false; + } + return $this->createResult($isValid, $fails); + } + + /** + * Is merchant ID is valid. + * + * @param string $responseMerchantId + * @return bool + */ + public function isMerchantIdValid($responseMerchantId) + { + $requestMerchantId = $this->gatewayConfig->getMerchantId(); + if ($requestMerchantId == $responseMerchantId) { + return true; + } + + return false; + } + + /** + * Is request Merchant ID empty. + * + * @param string $requestMerchantId + * @return bool + */ + public function isRequestMerchantIdEmpty($requestMerchantId) + { + return empty($requestMerchantId); + } + + /** + * Is sponse merchant ID empty. + * + * @param string $responseMerchantId + * @return bool + */ + public function isResponseMerchantIdEmpty($responseMerchantId) + { + return empty($responseMerchantId); + } + + /** + * Validate algorithm. + * + * @param string $algorithm + * @return bool + */ + public function validateAlgorithm($algorithm) + { + return in_array($algorithm, $this->gatewayConfig->getValidAlgorithms(), true); + } + + /** + * Validate response. + * + * @param array $params + * @return bool + */ + public function validateResponse($params) + { + return $this->hmacValidator->validateHmac($params, $params["signature"]); + } +} diff --git a/Logger/NexiLogger.php b/Logger/NexiLogger.php new file mode 100644 index 00000000..80e45745 --- /dev/null +++ b/Logger/NexiLogger.php @@ -0,0 +1,128 @@ +getMessage(); + } + + if (is_array($message) || is_object($message)) { + $message = $this->serializer->serialize($message); + } + + $this->log( + $level, + $message + ); + } + + /** + * Debug log. + * + * @param string $type + * @param string $message + */ + public function debugLog($type, $message) + { + if (!$this->isDebugActive($type)) { + return; + } + + $level = $this->resolveLogLevel($type); + $this->logData($level, $message); + } + + /** + * Resolve log level. + * + * @param string $logType + * @return string + */ + public function resolveLogLevel(string $logType) : string + { + $level = \Monolog\Logger::DEBUG; + + if ($logType == 'request') { + $level = \Monolog\Logger::INFO; + } elseif ($logType == 'response') { + $level = \Monolog\Logger::NOTICE; + } + + return $level; + } + + /** + * Is debug active. + * + * @param string $type + * @return int + */ + private function isDebugActive($type) + { + if (!isset($this->debugActive[$type])) { + $this->debugActive[$type] = $type == 'request' + ? $this->gatewayConfig->getRequestLog() + : $this->gatewayConfig->getResponseLog(); + } + + return $this->debugActive[$type]; + } + + /** + * Log data to file. + * + * @param string $logType + * @param string $level + * @param mixed $data + * @return void + */ + public function logCheckoutData($logType, $level, $data): void + { + if ($level !== 'error' && + (($logType === 'request' && $this->gatewayConfig->getRequestLog() == false) + || ($logType === 'response' && $this->gatewayConfig->getResponseLog() == false)) + ) { + return; + } + + $level = $level == 'error' ? $level : $this->resolveLogLevel($logType); + $this->logData($level, $data); + } +} diff --git a/Logger/Request.php b/Logger/Request.php new file mode 100644 index 00000000..ec502065 --- /dev/null +++ b/Logger/Request.php @@ -0,0 +1,23 @@ +moduleList->getOne(self::MODULE_CODE)['setup_version']; + } +} diff --git a/Model/Api/ShowSubscriptionsDataProvider.php b/Model/Api/ShowSubscriptionsDataProvider.php new file mode 100644 index 00000000..008f77e0 --- /dev/null +++ b/Model/Api/ShowSubscriptionsDataProvider.php @@ -0,0 +1,82 @@ +paymentTokenRepository = $paymentTokenRepository; + $this->subscriptionLinkRepository = $subscriptionLinkRepository; + $this->orderRepository = $orderRepository; + $this->jsonSerializer = $jsonSerializer; + } + + /** + * @param SearchCriteriaInterface $searchCriteria + * @return array|mixed + */ + public function getMaskedCCById(SearchCriteriaInterface $searchCriteria) + { + $paymentTokenCollection = $this->paymentTokenRepository->getList($searchCriteria)->getItems(); + + $paymentToken = []; + foreach ($paymentTokenCollection as $paymentTokenItem) { + $paymentToken[$paymentTokenItem->getEntityId()] = + $this->jsonSerializer->unserialize($paymentTokenItem->getTokenDetails())['maskedCC']; + } + + return $paymentToken; + } + + /** + * @param $subscriptionId + * @return array + */ + public function getOrderDataFromSubscriptionId($subscriptionId) + { + $orderIds = array_last($this->subscriptionLinkRepository->getOrderIdsBySubscriptionId($subscriptionId)); + $order = $this->orderRepository->get($orderIds); + + return [ + 'increment_id' => $order->getIncrementId(), + 'grand_total' => $order->getGrandTotal() + ]; + } +} diff --git a/Model/ApplePay/ApplePayDataProvider.php b/Model/ApplePay/ApplePayDataProvider.php new file mode 100644 index 00000000..f7ce2626 --- /dev/null +++ b/Model/ApplePay/ApplePayDataProvider.php @@ -0,0 +1,136 @@ +isSafariBrowser() && $this->gatewayConfig->isApplePayEnabled()) { + return true; + } + + return false; + } + + /** + * Adds Apple Pay method data into payment methods groups. + * + * @param array $groupMethods + * @return array + */ + public function addApplePayPaymentMethod(array $groupMethods): array + { + foreach ($groupMethods as $key => $method) { + if ($method['id'] === 'mobile') { + $groupMethods[$key]['providers'][] = $this->getApplePayProviderData(); + } + } + + return $groupMethods; + } + + /** + * Get params for processing order and payment. + * + * @param array $params + * @param Order $order + * @return array + * @throws \Nexi\Checkout\Exceptions\CheckoutException + */ + public function getApplePayFailParams($params, $order): array + { + $paramsToProcess = [ + 'checkout-transaction-id' => '', + 'checkout-account' => '', + 'checkout-method' => '', + 'checkout-algorithm' => '', + 'checkout-timestamp' => '', + 'checkout-nonce' => '', + 'checkout-reference' => $this->referenceNumber->getReference($order), + 'checkout-provider' => Config::APPLE_PAY_PAYMENT_CODE, + 'checkout-status' => Config::NEXI_API_PAYMENT_STATUS_FAIL, + 'checkout-stamp' => $this->paymentDataProvider->getStamp($order), + 'signature' => '', + 'skip_validation' => 1 + ]; + + foreach ($params as $param) { + if (array_key_exists($param['name'], $paramsToProcess)) { + $paramsToProcess[$param['name']] = $param['value']; + } + } + + return $paramsToProcess; + } + + /** + * Get Apple Pay provider data for payment render. + * + * @return Provider + */ + private function getApplePayProviderData(): Provider + { + $applePayProvider = new Provider(); + + $applePayProvider + ->setId('applepay') + ->setGroup('mobile') + ->setUrl(null) + ->setIcon($this->assetRepository->getUrl('Nexi_PaymentService::images/apple-pay-logo.png')) + ->setName('Apple Pay') + ->setParameters(null) + ->setSvg($this->assetRepository->getUrl('Nexi_PaymentService::images/apple-pay-logo.svg')); + + return $applePayProvider; + } + + /** + * Checks if user browser is Safari. + * + * @return bool + */ + private function isSafariBrowser(): bool + { + $user_agent = $this->httpHeader->getHttpUserAgent(); + + if (stripos($user_agent, 'Chrome') !== false && stripos($user_agent, 'Safari') !== false) { + return false; + } elseif (stripos($user_agent, 'Safari') !== false) { + return true; + } else { + return false; + } + } +} diff --git a/Model/Attribute/SelectData.php b/Model/Attribute/SelectData.php new file mode 100644 index 00000000..e8039b60 --- /dev/null +++ b/Model/Attribute/SelectData.php @@ -0,0 +1,47 @@ +collectionFactory = $collectionFactory; + } + + /** + * Get all options + * @return array + */ + public function getAllOptions() + { + $profilesCollection = $this->collectionFactory->create(); + $collectionData = $profilesCollection->getData(); + + if (!$this->_options && $collectionData) { + foreach ($collectionData as $data) { + if (isset($data['schedule']) && isset($data['profile_id'])) { + $this->_options[] = ['label' => $data['name'], 'value' => $data['profile_id']]; + } + } + $this->_options[] = ['label' => __('No recurring payment'), 'value' => self::NO_RECURRING_PAYMENT_VALUE]; + } + + return $this->_options; + } +} diff --git a/Model/Card/VaultConfig.php b/Model/Card/VaultConfig.php new file mode 100644 index 00000000..bceef40e --- /dev/null +++ b/Model/Card/VaultConfig.php @@ -0,0 +1,41 @@ +scopeConfig->getValue(self::VAULT_FOR_NEXI_PATH); + } + + /** + * Returns is stored cards are displayed on checkout page. + * + * @return bool + */ + public function isShowStoredCards(): bool + { + return (bool)$this->scopeConfig->getValue(self::NEXI_SHOW_STORED_CARDS); + } +} diff --git a/Model/CardManagement.php b/Model/CardManagement.php new file mode 100644 index 00000000..ec43651c --- /dev/null +++ b/Model/CardManagement.php @@ -0,0 +1,109 @@ +commandManagerPool->get('nexi'); + $response = $commandExecutor->executeByCode('add_card'); + + if (isset($response['error'])) { + // Use Nexi exception here + // throw new ValidationException($response['error']); + } + + return $response['data']->getHeader('Location')[0]; + } + + /** + * @inheritdoc + */ + public function delete(string $cardId): bool + { + $paymentToken = $this->paymentTokenRepository->getById((int)$cardId); + if (!$paymentToken || (int)$paymentToken->getCustomerId() !== $this->userContext->getUserId()) { + throw new LocalizedException(__('Card not found')); + } + + $subscriptionWithCard = $this->getSubscriptionForPaymentToken($paymentToken); + if ($subscriptionWithCard->getTotalCount()) { + throw new LocalizedException(__('The card has active subscriptions')); + } + + $this->paymentTokenRepository->delete($paymentToken); + + return true; + } + + /** + * Get subscription for payment token. + * + * @param PaymentTokenInterface $paymentToken + * @return SubscriptionSearchResultInterface + */ + private function getSubscriptionForPaymentToken( + PaymentTokenInterface $paymentToken + ): SubscriptionSearchResultInterface { + $selectedTokenFilter = $this->filterBuilder + ->setField('selected_token') + ->setValue($paymentToken->getEntityId()) + ->setConditionType('eq') + ->create(); + + $statusFilter = $this->filterBuilder + ->setField('status') + ->setValue(SubscriptionInterface::STATUS_ACTIVE) + ->setConditionType('eq') + ->create(); + + $statusFilterGroup = $this->filterGroupBuilder->addFilter($statusFilter)->create(); + $selectedTokenFilterGroup = $this->filterGroupBuilder->addFilter($selectedTokenFilter)->create(); + + $searchCriteria = $this->searchCriteriaBuilder->setFilterGroups([$statusFilterGroup, $selectedTokenFilterGroup]) + ->create(); + + return $this->subscriptionRepository->getList($searchCriteria); + } +} diff --git a/Model/FinnishReferenceNumber.php b/Model/FinnishReferenceNumber.php new file mode 100644 index 00000000..2fb8ebd7 --- /dev/null +++ b/Model/FinnishReferenceNumber.php @@ -0,0 +1,156 @@ +gatewayConfig = $gatewayConfig; + $this->orderFactory = $orderFactory; + $this->orderRepository = $orderRepository; + $this->criteriaBuilderFactory = $criteriaBuilderFactory; + } + + /** + * @param mixed $reference + * + * @return Order + * @throws \Exception + */ + public function getOrderByReference(mixed $reference): Order + { + if (!$this->gatewayConfig->getGenerateReferenceForOrder()) { + return $this->orderFactory->create()->loadByIncrementId($reference); + } + + $criteriaBuilder = $this->criteriaBuilderFactory->create(); + $searchCriteria = $criteriaBuilder->addFilter('finnish_reference_number', $reference) + ->create(); + + /** @var Order[] $orders */ + $orders = $this->orderRepository->getList($searchCriteria) + ->getItems(); + + if (count($orders) > 1) { + throw new CheckoutException(__('Multiple orders found with same reference number')); + } + + return reset($orders); + } + + /** + * Get order increment id from checkout reference number + * + * @param string $reference + * + * @return string|null + * @throws \Exception + */ + public function getIdFromOrderReferenceNumber(string $reference): ?string + { + return $this->getOrderByReference($reference)->getIncrementId(); + } + + /** + * Calculate Finnish reference number from order increment id + * according to Finnish reference number algorithm + * if increment id is not numeric - letters will be converted to numbers -> (ord($letter) % 10) + * + * @param \Magento\Sales\Model\Order $order + * + * @return string + * @throws \Nexi\Checkout\Exceptions\CheckoutException + */ + public function calculateOrderReferenceNumber(Order $order): string + { + $numericIncrementId = preg_replace('/\D/', '', $order->getIncrementId()); + + $prefixedId = $order->getStoreId() . $numericIncrementId; + if ($prefixedId[0] === '0') { + $prefixedId = '1' . $prefixedId; + } + + $sum = 0; + $length = strlen($prefixedId); + + for ($i = 0; $i < $length; ++$i) { + $substr = substr($prefixedId, -1 - $i, 1); + $numSubstring = is_numeric($substr) ? (int)$substr : (ord($substr) % 10); + + $sum += $numSubstring * [7, 3, 1][$i % 3]; + } + $num = (10 - $sum % 10) % 10; + $referenceNum = $prefixedId . $num; + + if ($referenceNum > 9999999999999999999) { + throw new CheckoutException('Order reference number is too long'); + } + + $asString = trim(chunk_split($referenceNum, 5, ' ')); + + $order->setFinnishReferenceNumber($asString); + $this->orderRepository->save($order); + + return $asString; + } + + /** + * @param Order $order + * + * @return string reference number + * @throws \Nexi\Checkout\Exceptions\CheckoutException + */ + public function getReference(Order $order): string + { + if ($order->getFinnishReferenceNumber()) { + return $order->getFinnishReferenceNumber(); + } + + if (!$this->gatewayConfig->getGenerateReferenceForOrder() && $order->getIncrementId()) { + return $order->getIncrementId(); + } + + return $order->getExtensionAttributes()->getFinnishReferenceNumber() + ?: $this->calculateOrderReferenceNumber($order); + } +} diff --git a/Model/OptionSource/IntervalUnits.php b/Model/OptionSource/IntervalUnits.php new file mode 100644 index 00000000..221af041 --- /dev/null +++ b/Model/OptionSource/IntervalUnits.php @@ -0,0 +1,28 @@ + 'D', + 'label' => __('Days') + ], + [ + 'value' => 'W', + 'label' => __('Weeks') + ], + [ + 'value' => 'M', + 'label' => __('Months') + ], + [ + 'value' => 'Y', + 'label' => __('Years') + ], + ]; + } +} diff --git a/Model/OptionSource/ProfileOptions.php b/Model/OptionSource/ProfileOptions.php new file mode 100644 index 00000000..57174c7a --- /dev/null +++ b/Model/OptionSource/ProfileOptions.php @@ -0,0 +1,42 @@ +collectionFactory = $collectionFactory; + } + + public function toOptionArray() + { + /** @var Collection $profiles */ + $profiles = $this->collectionFactory->create(); + $profiles->addFieldToSelect(['profile_id', 'name']); + + return $this->formatProfilesToOptions($profiles); + } + + private function formatProfilesToOptions(Collection $profiles) + { + $options = []; + /** @var \Nexi\Checkout\Api\Data\RecurringProfileInterface $profile */ + foreach ($profiles as $profile) { + $options[] = [ + 'value' => $profile->getId(), + 'label' => $profile->getName() + ]; + } + + return $options; + } +} diff --git a/Model/OptionSource/SelectedToken.php b/Model/OptionSource/SelectedToken.php new file mode 100644 index 00000000..89d6215b --- /dev/null +++ b/Model/OptionSource/SelectedToken.php @@ -0,0 +1,122 @@ +orderRepository = $orderRepository; + $this->request = $request; + $this->paymentTokenManagement = $paymentTokenManagement; + $this->serializer = $serializer; + $this->subscriptionLinkRepoInterface = $subscriptionLinkRepoInterface; + } + + /** + * @return array + * @throws \Magento\Framework\Exception\InputException + * @throws \Magento\Framework\Exception\NoSuchEntityException + */ + public function toOptionArray() + { + $returnArray = []; + $orderId = $this->getOrderIdFromUrl(); + $customerId = $this->getCustomerIdFromOrderId($orderId); + foreach ($this->getVaultCardToken($customerId) as $paymentToken) { + if ($paymentToken->getIsActive() && $paymentToken->getIsVisible()) { + $returnArray[] = [ + 'value' => $paymentToken->getId(), + 'label' => '**** **** **** ' . $this->serializer->unserialize( + $paymentToken->getTokenDetails() + )[self::MASKED_CC_VALUE] + ]; + } + } + + return $returnArray; + } + + /** + * @return int + * @throws \Magento\Framework\Exception\NoSuchEntityException + */ + private function getOrderIdFromUrl() + { + $subscriptionId = (int)$this->request->getParams()['id']; + $orderIds = $this->subscriptionLinkRepoInterface->getOrderIdsBySubscriptionId($subscriptionId); + + return reset($orderIds); + } + + /** + * @param $orderId + * + * @return int|null + * @throws \Magento\Framework\Exception\InputException + * @throws \Magento\Framework\Exception\NoSuchEntityException + */ + private function getCustomerIdFromOrderId($orderId) + { + $order = $this->orderRepository->get($orderId); + + return $order->getCustomerId(); + } + + /** + * @param $customerId + * + * @return array + */ + private function getVaultCardToken($customerId) + { + return $this->paymentTokenManagement->getListByCustomerId($customerId); + } +} diff --git a/Model/OptionSource/SubscriptionStatus.php b/Model/OptionSource/SubscriptionStatus.php new file mode 100644 index 00000000..99b6a025 --- /dev/null +++ b/Model/OptionSource/SubscriptionStatus.php @@ -0,0 +1,34 @@ + SubscriptionInterface::STATUS_PENDING_PAYMENT, + 'label' => __('Pending Payment') + ], + [ + 'value' => SubscriptionInterface::STATUS_ACTIVE, + 'label' => __('Paid') + ], + [ + 'value' => SubscriptionInterface::STATUS_CLOSED, + 'label' => __('Closed') + ], + [ + 'value' => SubscriptionInterface::STATUS_FAILED, + 'label' => __('Failed') + ], + [ + 'value' => SubscriptionInterface::STATUS_RESCHEDULED, + 'label' => __('Rescheduled') + ], + ]; + } +} diff --git a/Model/Receipt/CancelOrderService.php b/Model/Receipt/CancelOrderService.php new file mode 100644 index 00000000..f3cdac12 --- /dev/null +++ b/Model/Receipt/CancelOrderService.php @@ -0,0 +1,102 @@ +gatewayConfig->getNotificationEmail(), FILTER_VALIDATE_EMAIL)) { + $transport = $this->transportBuilder + ->setTemplateIdentifier('restore_order_notification') + ->setTemplateOptions([ + 'area' => \Magento\Framework\App\Area::AREA_FRONTEND, + 'store' => \Magento\Store\Model\Store::DEFAULT_STORE_ID + ]) + ->setTemplateVars([ + 'order' => [ + 'increment' => $currentOrder->getIncrementId(), + 'url' => $this->backendUrl->getUrl( + 'sales/order/view', + ['order_id' => $currentOrder->getId()] + ) + ] + ]) + ->setFrom([ + 'name' => $this->scopeConfig->getValue('general/store_information/name') . ' - Magento', + 'email' => $this->scopeConfig->getValue('trans_email/ident_general/email'), + ])->addTo([ + $this->gatewayConfig->getNotificationEmail() + ])->getTransport(); + $transport->sendMessage(); + } + } catch (\Exception $e) { + $this->logger->logData(\Monolog\Logger::ERROR, $e->getMessage()); + } + } + + /** + * CancelOrderById function + * + * @param string $orderId + * @return void + * @throws CheckoutException + */ + public function cancelOrderById($orderId): void + { + if ($this->gatewayConfig->getCancelOrderOnFailedPayment()) { + try { + $this->orderManagementInterface->cancel($orderId); + } catch (\Exception $e) { + $this->logger->critical(sprintf( + 'Nexi exception during order cancel: %s,\n error trace: %s', + $e->getMessage(), + $e->getTraceAsString() + )); + + // Mask and throw end-user friendly exception + throw new CheckoutException(__( + 'Error while cancelling order. + Please contact customer support with order id: %id to release discount coupons.', + [ 'id'=> $orderId ] + )); + } + } + } +} diff --git a/Model/Receipt/LoadService.php b/Model/Receipt/LoadService.php new file mode 100644 index 00000000..313c5aaa --- /dev/null +++ b/Model/Receipt/LoadService.php @@ -0,0 +1,71 @@ +transactionRepository->getByTransactionId( + $transactionId, + $currentOrder->getPayment()->getId(), + $orderId + ); + } catch (InputException $e) { + $this->nexiLogger->logData(\Monolog\Logger::ERROR, $e->getMessage()); + throw new CheckoutException(__($e->getMessage())); + } + + return $transaction; + } + + /** + * LoadOrder function + * + * @param string $orderIncrementalId + * + * @return Order + * @throws CheckoutException + */ + public function loadOrder($orderIncrementalId) + { + $order = $this->orderFactory->create()->loadByIncrementId($orderIncrementalId); + if (!$order->getId()) { + $this->nexiLogger->logData(\Monolog\Logger::ERROR, 'Order not found'); + throw new CheckoutException(__('Order not found')); + } + return $order; + } +} diff --git a/Model/Receipt/PaymentTransaction.php b/Model/Receipt/PaymentTransaction.php new file mode 100644 index 00000000..a30b8545 --- /dev/null +++ b/Model/Receipt/PaymentTransaction.php @@ -0,0 +1,92 @@ +getPayment(); + + /** @var \Magento\Sales\Api\Data\TransactionInterface $transaction */ + $transaction = $this->transactionBuilder + ->setPayment($payment)->setOrder($order) + ->setTransactionId($transactionId) + ->setAdditionalInformation([Transaction::RAW_DETAILS => (array)$details]) + ->setFailSafe(true) + ->build(Transaction::TYPE_CAPTURE); + $transaction->setIsClosed(0); + return $transaction; + } + + /** + * VerifyPaymentData function + * + * @param array $params + * @param Order $currentOrder + * @return mixed|string|void + * @throws \Nexi\Checkout\Exceptions\CheckoutException + */ + public function verifyPaymentData($params, $currentOrder) + { + $status = $params['checkout-status']; + + // skip HMAC validator if signature is 'skip_hmac' for token payment + if ($params['signature'] === HmacValidator::SKIP_HMAC_VALIDATION) { + $verifiedPayment = true; + } else { + $verifiedPayment = $this->hmacValidator->validateHmac($params, $params['signature']); + } + + if ($verifiedPayment && ($status === 'ok' || $status == 'pending' || $status == 'delayed')) { + return $status; + } else { + $currentOrder->addCommentToStatusHistory(__('Failed to complete the payment.')); + $this->orderRepositoryInterface->save($currentOrder); + $this->cancelOrderService->cancelOrderById($currentOrder->getId()); + + $this->nexiLogger->logData( + \Monolog\Logger::ERROR, + 'Failed to complete the payment. Please try again or contact the customer service.' + ); + throw new CheckoutException( + __('Failed to complete the payment. Please try again or contact the customer service.') + ); + } + } +} diff --git a/Model/Receipt/ProcessPayment.php b/Model/Receipt/ProcessPayment.php new file mode 100644 index 00000000..efeac39e --- /dev/null +++ b/Model/Receipt/ProcessPayment.php @@ -0,0 +1,134 @@ +responseValidator->validate($params); + + if (!$validationResponse->isValid()) { // if response params are not valid, redirect back to the cart + + /** @var string $failMessage */ + foreach ($validationResponse->getFailsDescription() as $failMessage) { + $errors[] = $failMessage; + } + + $session->restoreQuote(); // should it be restored? + + return $errors; + } + + /** @var string $reference */ + $reference = $params['checkout-reference']; + + /** @var string $orderNo */ + $orderNo = $this->gatewayConfig->getGenerateReferenceForOrder() + ? $this->finnishReferenceNumber->getIdFromOrderReferenceNumber($reference) + : $reference; + + /** @var array $ret */ + $ret = $this->processPayment($params, $session, $orderNo); + + return array_merge($ret, $errors); + } + + /** + * ProcessPayment function + * + * @param array $params + * @param Session $session + * @param string $orderNo + * @return array + * @throws \Magento\Framework\Exception\LocalizedException + */ + private function processPayment($params, $session, $orderNo) + { + /** @var array $errors */ + $errors = []; + + /** @var bool $isValid */ + $isValid = true; + + /** @var null|string $failMessage */ + $failMessage = null; + + if (empty($orderNo)) { + $session->restoreQuote(); + + return $errors; + } + + try { + /* + there are 2 calls called from Nexi Payment Service. + One call is when a customer is redirected back to the magento store. + There is also the second, parallel, call from Nexi Payment Service + to make sure the payment is confirmed (if for any reason customer was not redirected back to the store). + Sometimes, the calls are called with too small time difference between them that Magento cannot handle them. + The second call must be ignored or slowed down. + */ + $this->receiptDataProvider->execute($params); + } catch (CheckoutException $exception) { + $isValid = false; + $failMessage = $exception->getMessage(); + array_push($errors, $failMessage); + } catch (TransactionSuccessException $successException) { + $isValid = true; + } + + if ($isValid == false) { + $session->restoreQuote(); + } else { + /** @var \Magento\Quote\Model\Quote $quote */ + $quote = $session->getQuote(); + $quote->setIsActive(false); + $this->cartRepository->save($quote); + } + + return $errors; + } +} diff --git a/Model/Receipt/ProcessService.php b/Model/Receipt/ProcessService.php new file mode 100644 index 00000000..46b69fe9 --- /dev/null +++ b/Model/Receipt/ProcessService.php @@ -0,0 +1,274 @@ +gatewayConfig->getDefaultOrderStatus(); + + if ($paymentVerified === 'ok') { + $currentOrder->setState(Order::STATE_PROCESSING)->setStatus($orderState); + $currentOrder->addCommentToStatusHistory(__('Payment has been completed')); + } else { + $currentOrder->setState(InstallNexi::ORDER_STATE_CUSTOM_CODE); + $currentOrder->setStatus(InstallNexi::ORDER_STATUS_CUSTOM_CODE); + $currentOrder->addCommentToStatusHistory(__('Pending payment from Nexi Payment Service')); + } + + $this->orderRepositoryInterface->save($currentOrder); + + try { + $this->orderSender->send($currentOrder); + } catch (\Exception $e) { + $this->logger->error(\sprintf( + 'Nexi: Order email sending failed: %s', + $e->getMessage() + )); + } + + return $this; + } + + /** + * ProcessInvoice function + * + * @param Order $currentOrder + * @return void + * @throws \Nexi\Checkout\Exceptions\CheckoutException + */ + public function processInvoice($currentOrder) + { + if ($currentOrder->canInvoice()) { + try { + $invoice = $this->invoiceService->prepareInvoice($currentOrder); + //TODO: catch \InvalidArgumentException which extends \Exception + $invoice->setRequestedCaptureCase(Invoice::CAPTURE_ONLINE); + $invoice->setTransactionId($this->currentOrderPayment->getLastTransId()); + $invoice->register(); + $transactionSave = $this->transactionFactory->create(); + $transactionSave->addObject( + $invoice + )->addObject( + $currentOrder + )->save(); + } catch (\Exception $exception) { + $this->processError($exception->getMessage()); + } + } + } + + /** + * ProcessPayment function. + * + * @param Order $currentOrder + * @param string $transactionId + * @param array $details + * @return void + */ + public function processPayment($currentOrder, $transactionId, $details) + { + $transaction = $this->paymentTransaction->addPaymentTransaction($currentOrder, $transactionId, $details); + + $this->currentOrderPayment->setOrder($currentOrder); + $this->currentOrderPayment->addTransactionCommentsToOrder($transaction, ''); + $this->currentOrderPayment->setLastTransId($transactionId); + } + + /** + * ProcessExistingTransaction function + * + * @param Transaction $transaction + * @return void + * @throws \Nexi\Checkout\Exceptions\TransactionSuccessException + */ + private function processExistingTransaction($transaction) + { + $details = $transaction->getAdditionalInformation(Transaction::RAW_DETAILS); + if (is_array($details)) { + $this->processSuccess(); + } + } + + /** + * ProcessTransaction function. + * + * @param string $paymentStatus + * @param string $transactionId + * @param Order $currentOrder + * @param string|int $orderId + * @param array $paymentDetails + * @return void + * @throws CheckoutException + * @throws TransactionSuccessException + * @throws LocalizedException + * @throws \Magento\Framework\Exception\MailException + */ + public function processTransaction($paymentStatus, $transactionId, $currentOrder, $orderId, $paymentDetails) + { + $oldTransaction = $this->loadService->loadTransaction($transactionId, $currentOrder, $orderId); + $this->validateOldTransaction($oldTransaction, $transactionId); + $oldStatus = false; + $paymentDetails['api_status'] = $paymentStatus; + + if ($oldTransaction) { + // Backwards compatibility: If transaction exists without api_status, assume OK status since + // only 'ok' status could create transactions in old version. + $oldStatus = isset($oldTransaction->getAdditionalInformation(Transaction::RAW_DETAILS)['api_status']) + ? $oldTransaction->getAdditionalInformation(Transaction::RAW_DETAILS)['api_status'] + : 'ok'; + + $transaction = $this->updateOldTransaction($oldTransaction, $paymentDetails); + } else { + $transaction = $this->paymentTransaction->addPaymentTransaction( + $currentOrder, + $transactionId, + $paymentDetails + ); + } + + // Only append transaction comments to orders if the payment status changes + if ($oldStatus !== $paymentStatus) { + $this->currentOrderPayment->setOrder($currentOrder); + $this->currentOrderPayment->addTransactionCommentsToOrder( + $transaction, + __('Nexi Api - New payment status: "%status"', ['status' => $paymentStatus]) + ); + $this->currentOrderPayment->setLastTransId($transactionId); + } + + if ($currentOrder->getStatus() == 'canceled') { + $this->cancelOrderService->notifyCanceledOrder($currentOrder); + } + } + + /** + * Validates ongoing transaction against the information in previous transaction + * + * Validate transaction id is the same as old id and that previous transaction did not finish the payment + * Note that for backwards compatibility if transaction is missing api_status field, assume completed payment. + * + * @param \Magento\Sales\Model\Order\Payment\Transaction|bool $transaction + * @param string $transactionId + * + * @throws \Nexi\Checkout\Exceptions\TransactionSuccessException thrown if previous transaction got "ok" + * @throws CheckoutException thrown if multiple transaction ids are present. + */ + private function validateOldTransaction($transaction, $transactionId) + { + if ($transaction) { + if ($transaction->getTxnId() !== $transactionId) { + $this->processError('Payment failed, multiple transactions detected'); + } + + $details = $transaction->getAdditionalInformation(Transaction::RAW_DETAILS); + if (isset($details['api_status']) && in_array($details['api_status'], self::CONTINUABLE_STATUSES)) { + return; + } + + // transaction was already completed with 'Ok' status. + $this->processSuccess(); + } + } + + /** + * Update old transaction. + * + * @param bool|Transaction $oldTransaction + * @param array $paymentDetails + * @return Transaction + * @throws LocalizedException + */ + private function updateOldTransaction(bool|Transaction $oldTransaction, array $paymentDetails): Transaction + { + $transaction = $oldTransaction->setAdditionalInformation(Transaction::RAW_DETAILS, $paymentDetails); + $this->transactionRepository->save($transaction); + + return $transaction; + } + + /** + * Process error + * + * @param string $errorMessage + * + * @throws CheckoutException + */ + public function processError($errorMessage) + { + $this->nexiLogger->logData(\Monolog\Logger::ERROR, $errorMessage); + throw new CheckoutException(__($errorMessage)); + } + + /** + * Process success + * + * @throws TransactionSuccessException + */ + public function processSuccess(): void + { + throw new TransactionSuccessException(__('Success')); + } +} diff --git a/Model/ReceiptDataProvider.php b/Model/ReceiptDataProvider.php new file mode 100755 index 00000000..1bad7ced --- /dev/null +++ b/Model/ReceiptDataProvider.php @@ -0,0 +1,128 @@ +gatewayConfig->getGenerateReferenceForOrder()) { + $this->orderIncrementalId = $this->referenceNumber->getIdFromOrderReferenceNumber( + $params["checkout-reference"] + ); + } else { + $this->orderIncrementalId + = $params["checkout-reference"]; + } + $this->transactionId = $params["checkout-transaction-id"]; + $this->paramsStamp = $params['checkout-stamp']; + $this->paramsMethod = $params['checkout-provider']; + + $this->session->unsCheckoutRedirectUrl(); + + $this->currentOrder = $this->loadService->loadOrder($this->orderIncrementalId); + $this->orderId = $this->currentOrder->getId(); + + /** @var string|void $paymentVerified */ + $paymentVerified = $this->paymentTransaction->verifyPaymentData($params, $this->currentOrder); + $this->processService->processTransaction( + $paymentVerified, + $this->transactionId, + $this->currentOrder, + $this->orderId, + $this->getDetails($paymentVerified) + ); + if ($paymentVerified === 'ok') { + $this->processService + ->processPayment($this->currentOrder, $this->transactionId, $this->getDetails($paymentVerified)); + $this->processService->processInvoice($this->currentOrder); + } + $this->processService->processOrder($paymentVerified, $this->currentOrder); + } + + /** + * Get details. + * + * @param string $paymentStatus + * GetDetails function + * + * @return array + */ + protected function getDetails($paymentStatus): array + { + return [ + 'orderNo' => $this->orderIncrementalId, + 'stamp' => $this->paramsStamp, + 'method' => $this->paramsMethod, + 'api_status' => $paymentStatus + ]; + } +} diff --git a/Model/Recurring/Bill.php b/Model/Recurring/Bill.php new file mode 100644 index 00000000..57e109a0 --- /dev/null +++ b/Model/Recurring/Bill.php @@ -0,0 +1,54 @@ +orderBiller = $orderBiller; + $this->activeOrders = $activeOrderProvider; + } + + /** + * @throws \Magento\Framework\Exception\LocalizedException + */ + public function process() + { + $validOrders = $this->getValidOrderIds(); + + if (empty($validOrders)) { + return; + } + $this->orderBiller->billOrdersById($validOrders); + } + + /** + * @return array + * @throws \Magento\Framework\Exception\LocalizedException + */ + private function getValidOrderIds() + { + return $this->activeOrders->getPayableOrderIds(); + } +} diff --git a/Model/Recurring/Notify.php b/Model/Recurring/Notify.php new file mode 100644 index 00000000..9aefdc43 --- /dev/null +++ b/Model/Recurring/Notify.php @@ -0,0 +1,103 @@ +orderCloner = $orderCloner; + $this->subscriptionResource = $subscriptionResource; + $this->email = $email; + $this->subscriptionLinkRepository = $subscriptionLinkRepository; + $this->logger = $logger; + } + + /** + * Clones recurring payments that are due in the next payment period and notifies customer's whos orders were + * cloned. + * + * @return void + */ + public function process() + { + $validIds = $this->getValidOrderIds(); + if (empty($validIds)) { + return; + } + + $clonedOrders = $this->orderCloner->cloneOrders($validIds); + $i = self::ARRAY_INDEX_ZERO; + $validIds = array_values($validIds); + + if (count($validIds) === count($clonedOrders)) { + foreach ($clonedOrders as $clonedOrder) { + $this->subscriptionLinkRepository->linkOrderToSubscription( + $clonedOrder->getId(), + $this->subscriptionLinkRepository->getSubscriptionIdFromOrderId($validIds[$i]) + ); + $i++; + } + } + + $this->email->sendNotifications($clonedOrders); + } + + private function getValidOrderIds() + { + try { + return $this->subscriptionResource->getClonableOrderIds(); + } catch (LocalizedException $e) { + $this->logger->error(\__( + 'Recurring Payment unable to fetch clonable order ids: %error', + ['error' => $e->getMessage()] + )); + + return []; + } + } +} diff --git a/Model/Recurring/TotalConfigProvider.php b/Model/Recurring/TotalConfigProvider.php new file mode 100644 index 00000000..69b99f40 --- /dev/null +++ b/Model/Recurring/TotalConfigProvider.php @@ -0,0 +1,117 @@ +checkoutSession = $checkoutSession; + $this->scopeConfig = $scopeConfig; + } + + /** + * Is recurring payment feature enable. + * + * @return bool + */ + public function isRecurringPaymentEnabled(): bool + { + return (bool)$this->scopeConfig->getValue( + self::IS_RECURRING_PAYMENT_ENABLED, + ScopeInterface::SCOPE_STORE + ); + } + + /** + * Get recurring payment values to config. + * + * @return array + * @throws LocalizedException + * @throws NoSuchEntityException + */ + public function getConfig(): array + { + return [ + 'isRecurringScheduled' => $this->isRecurringScheduled(), + 'recurringSubtotal' => $this->getRecurringSubtotal() + ]; + } + + /** + * Is cart has recurring payment schedule products. + * + * @return bool + * @throws LocalizedException + * @throws NoSuchEntityException + */ + private function isRecurringScheduled(): bool + { + $quoteItems = $this->checkoutSession->getQuote()->getAllItems(); + if ($quoteItems) { + foreach ($quoteItems as $item) { + if ($item->getProduct()->getCustomAttribute('recurring_payment_schedule') != self::NO_SCHEDULE_VALUE) { + return true; + } + } + } + + return false; + } + + /** + * Get recurring-payment cart subtotal value. + * + * @return float + * @throws LocalizedException + * @throws NoSuchEntityException + */ + private function getRecurringSubtotal(): float + { + if ($this->isRecurringPaymentEnabled()) { + $recurringSubtotal = 0.00; + if ($this->isRecurringScheduled()) { + $quoteItems = $this->checkoutSession->getQuote()->getAllItems(); + foreach ($quoteItems as $item) { + if ($item->getProduct() + ->getCustomAttribute('recurring_payment_schedule') != self::NO_SCHEDULE_VALUE) { + $recurringSubtotal = $recurringSubtotal + ($item->getPrice() * $item->getQty()); + } + } + } + + return $recurringSubtotal; + } + + return 0.00; + } +} diff --git a/Model/ResourceModel/Subscription.php b/Model/ResourceModel/Subscription.php new file mode 100644 index 00000000..232cfb2c --- /dev/null +++ b/Model/ResourceModel/Subscription.php @@ -0,0 +1,163 @@ +_init(self::NEXI_SUBSCRIPTIONS_TABLENAME, 'entity_id'); + } + + /** + * Fetches an array of [ nexi_subscriptions::entity_id => Sales_order::entity_id ] + * Consider limiting query results to certain count to allow for better performance when hundreds or thousands of + * recurring payments exist. + * + * @return array + * @throws LocalizedException + */ + public function getClonableOrderIds() + { + $connection = $this->getConnection(); + $newestOrderIds = $this->getNewestOrderIds(true); + + return $this->filterUnPaidIds($connection, $newestOrderIds); + } + + /** + * BeforeSave function + * + * @param \Magento\Framework\Model\AbstractModel $object + * @return $this|Subscription + * @throws CouldNotSaveException + */ + protected function _beforeSave(\Magento\Framework\Model\AbstractModel $object) + { + if (!$this->canSave($object)) { + throw new CouldNotSaveException(__('Invalid recurring payment profile')); + } + + return $this; + } + + /** + * CanSave function + * + * @param \Magento\Framework\Model\AbstractModel $object + * @return bool + * @throws CouldNotSaveException + */ + private function canSave(\Magento\Framework\Model\AbstractModel $object) + { + if (!$object->getData('recurring_profile_id')) { + throw new CouldNotSaveException(__('Cannot save recurring payments without profiles')); + } + + $connection = $this->getConnection(); + + $select = $connection->select() + ->from('recurring_payment_profiles') + ->where('profile_id = ?', $object->getData('recurring_profile_id')); + + return !empty($this->getConnection()->fetchRow($select)); + } + + /** + * Updates subscription status to failed with a direct query. + * + * @param int $subscriptionId + * @return void + */ + public function forceFailedStatus($subscriptionId) + { + $connection = $this->getConnection(); + + $connection->update( + self::NEXI_SUBSCRIPTIONS_TABLENAME, + ['status' => SubscriptionInterface::STATUS_FAILED], + $connection->quoteInto('entity_id = ?', $subscriptionId) + ); + } + + /** + * GetNewestOrderIds function + * + * @param bool $addDateFilter + * @return array + */ + public function getNewestOrderIds($addDateFilter = false) + { + $select = $this->getConnection()->select(); + $select->from( + ['sublink' => 'nexi_subscription_link'], + [ + 'subscription_id' => 'subscription_id', + 'order_id' => 'MAX(order_id)' + ] + ); + $select->join( + ['sub' => self::NEXI_SUBSCRIPTIONS_TABLENAME], + 'sub.entity_id = sublink.subscription_id', + [] + ); + $select->where( + 'sub.status IN (?)', + SubscriptionInterface::CLONEABLE_STATUSES + ); + + if ($addDateFilter) { + $date = new \DateTime(); + $date->modify('+7 day'); // consider making this configurable. + $select->where( + 'sub.next_order_date < ?', + $date->format('Y-m-d H:i:s') + ); + } + + $select->group('sublink.subscription_id'); + + return $this->getConnection()->fetchPairs($select); + } + + /** + * FilterUnPaidIds function + * + * @param AdapterInterface|false $connection + * @param array $newestOrderIds + * @return mixed + */ + private function filterUnPaidIds($connection, array $newestOrderIds) + { + $select = $connection->select(); + $select->from( + ['sublink' => 'nexi_subscription_link'], + ['subscription_id', 'order_id'] + ); + $select->where('order_id IN (?)', $newestOrderIds); + $select->join( + ['so' => 'sales_order'], + 'so.entity_id = sublink.order_id', + [] + ); + $select->where( + 'so.grand_total != 0 + AND so.grand_total = so.total_paid' + ); + + return $connection->fetchPairs($select); + } +} diff --git a/Model/ResourceModel/Subscription/Collection.php b/Model/ResourceModel/Subscription/Collection.php new file mode 100644 index 00000000..a3c5d16f --- /dev/null +++ b/Model/ResourceModel/Subscription/Collection.php @@ -0,0 +1,109 @@ +_init( + \Nexi\Checkout\Model\Subscription::class, + \Nexi\Checkout\Model\ResourceModel\Subscription::class + ); + } + + /** + * Set items list. + * + * @param \Magento\Framework\Api\ExtensibleDataInterface[] $items + * @return $this + * @throws \Exception + */ + public function setItems(array $items = null) + { + if (!$items) { + return $this; + } + foreach ($items as $item) { + $this->addItem($item); + } + return $this; + } + + /** + * Get search criteria. + * + * @return \Magento\Framework\Api\SearchCriteriaInterface|null + */ + public function getSearchCriteria() + { + return $this->searchCriteria; + } + + /** + * Set search criteria. + * + * @param \Magento\Framework\Api\SearchCriteriaInterface $searchCriteria + * @return $this + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function setSearchCriteria(\Magento\Framework\Api\SearchCriteriaInterface $searchCriteria) + { + $this->searchCriteria = $searchCriteria; + + return $this; + } + + /** + * Get total count. + * + * @return int + */ + public function getTotalCount() + { + return $this->getSize(); + } + + /** + * Set total count. + * + * @param int $totalCount + * @return $this + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function setTotalCount($totalCount) + { + // total count is the collections size, do not modify it. + return $this; + } + + public function getBillingCollectionByOrderIds($orderIds) + { + $this->join( + ['links' => SubscriptionLink::LINK_TABLE_NAME], + $this->getConnection()->quoteInto( + 'links.subscription_id = main_table.entity_id AND links.order_id IN (?)', + $orderIds + ), + ['order_id'] + ); + + $this->join( + ['token' => 'vault_payment_token'], + 'token.entity_id = main_table.selected_token', + [ + 'token' => 'public_hash', + 'token_active' => 'is_active', + 'token_visible' => 'is_visible' + ] + ); + + return $this; + } +} diff --git a/Model/ResourceModel/Subscription/Profile.php b/Model/ResourceModel/Subscription/Profile.php new file mode 100644 index 00000000..c2c026c6 --- /dev/null +++ b/Model/ResourceModel/Subscription/Profile.php @@ -0,0 +1,37 @@ +_init('recurring_payment_profiles', 'profile_id'); + } + + protected function _beforeDelete(\Magento\Framework\Model\AbstractModel $object) + { + if (!$this->canDelete($object)) { + throw new CouldNotDeleteException(__('Profiles that are in use by recurring payments cannot be deleted')); + } + + return parent::_beforeDelete($object); + } + + /** + * @param \Magento\Framework\Model\AbstractModel $object + * @return bool + */ + private function canDelete(\Magento\Framework\Model\AbstractModel $object): bool + { + $connection = $this->getConnection(); + $select = $connection->select() + ->from('nexi_subscriptions', ['entity_id', 'recurring_profile_id']) + ->where('recurring_profile_id = ?', $object->getData('profile_id')); + + return empty($connection->fetchPairs($select)); + } +} diff --git a/Model/ResourceModel/Subscription/Profile/Collection.php b/Model/ResourceModel/Subscription/Profile/Collection.php new file mode 100644 index 00000000..8f1f17be --- /dev/null +++ b/Model/ResourceModel/Subscription/Profile/Collection.php @@ -0,0 +1,79 @@ +_init( + Profile::class, + ProfileResource::class + ); + } + + /** + * Set items list. + * + * @param \Magento\Framework\DataObject[] $items + * @return \Nexi\Checkout\Model\ResourceModel\Subscription\Profile\Collection + * @throws \Exception + */ + public function setItems(array $items = null) + { + if (!$items) { + return $this; + } + foreach ($items as $item) { + $this->addItem($item); + } + return $this; + } + + public function getSearchCriteria() + { + return $this->searchCriteria; + } + + /** + * @param \Magento\Framework\Api\SearchCriteriaInterface $searchCriteria + * @return $this|Collection + */ + public function setSearchCriteria(\Magento\Framework\Api\SearchCriteriaInterface $searchCriteria) + { + $this->searchCriteria = $searchCriteria; + + return $this; + } + + /** + * Get total count. + * + * @return int + */ + public function getTotalCount() + { + return $this->getSize(); + } + + /** + * Set total count. + * + * @param int $totalCount + * @return \Nexi\Checkout\Model\ResourceModel\Subscription\Profile\Collection + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function setTotalCount($totalCount) + { + // total count is the collections size, do not modify it. + return $this; + } +} diff --git a/Model/ResourceModel/Subscription/SubscriptionLink.php b/Model/ResourceModel/Subscription/SubscriptionLink.php new file mode 100644 index 00000000..64084803 --- /dev/null +++ b/Model/ResourceModel/Subscription/SubscriptionLink.php @@ -0,0 +1,18 @@ +_init(self::LINK_TABLE_NAME, SubscriptionLinkInterface::FIELD_LINK_ID); + } +} diff --git a/Model/ResourceModel/Subscription/SubscriptionLink/Collection.php b/Model/ResourceModel/Subscription/SubscriptionLink/Collection.php new file mode 100644 index 00000000..47d8b0a2 --- /dev/null +++ b/Model/ResourceModel/Subscription/SubscriptionLink/Collection.php @@ -0,0 +1,85 @@ +_init( + SubscriptionLink::class, + SubscriptionLinkResource::class + ); + } + + /** + * Set items list. + * + * @param \Magento\Framework\DataObject[] $items + * @return \Nexi\Checkout\Model\ResourceModel\Subscription\SubscriptionLink\Collection + * @throws \Exception + */ + public function setItems(array $items = null) + { + if (!$items) { + return $this; + } + foreach ($items as $item) { + $this->addItem($item); + } + return $this; + } + + /** + * @return \Magento\Framework\Api\SearchCriteriaInterface + */ + public function getSearchCriteria() + { + return $this->searchCriteria; + } + + /** + * @param \Magento\Framework\Api\SearchCriteriaInterface $searchCriteria + * @return $this|Collection + */ + public function setSearchCriteria(\Magento\Framework\Api\SearchCriteriaInterface $searchCriteria) + { + $this->searchCriteria = $searchCriteria; + + return $this; + } + + /** + * Get total count. + * + * @return int + */ + public function getTotalCount() + { + return $this->getSize(); + } + + /** + * Set total count. + * + * @param int $totalCount + * @return \Nexi\Checkout\Model\ResourceModel\Subscription\SubscriptionLink\Collection + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function setTotalCount($totalCount) + { + // total count is the collections size, do not modify it. + return $this; + } +} diff --git a/Model/Subscription.php b/Model/Subscription.php new file mode 100644 index 00000000..90907531 --- /dev/null +++ b/Model/Subscription.php @@ -0,0 +1,107 @@ +_init(\Nexi\Checkout\Model\ResourceModel\Subscription::class); + } + + public function getId() + { + return $this->getData(SubscriptionInterface::FIELD_ENTITY_ID); + } + + public function getCustomerId() + { + return $this->getData(SubscriptionInterface::FIELD_CUSTOMER_ID); + } + + public function getStatus(): string + { + return $this->getData(SubscriptionInterface::FIELD_STATUS); + } + + public function getNextOrderDate(): string + { + return $this->getData(SubscriptionInterface::FIELD_NEXT_ORDER_DATE); + } + + public function getRecurringProfileId(): int + { + return $this->getData(SubscriptionInterface::FIELD_RECURRING_PROFILE_ID); + } + + public function getUpdatedAt(): string + { + return $this->getData(SubscriptionInterface::FIELD_UPDATED_AT); + } + + public function getRepeatCountLeft(): int + { + return $this->getData(SubscriptionInterface::FIELD_REPEAT_COUNT_LEFT); + } + + public function getRetryCount(): int + { + return $this->getData(SubscriptionInterface::FIELD_RETRY_COUNT); + } + + public function getSelectedToken(): int + { + return $this->getData(SubscriptionInterface::FIELD_SELECTED_TOKEN); + } + + public function setId($entityId): self + { + return $this->setData(SubscriptionInterface::FIELD_ENTITY_ID, $entityId); + } + + public function setCustomerId($customerId): self + { + return $this->setData(SubscriptionInterface::FIELD_CUSTOMER_ID, $customerId); + } + + public function setStatus($status): SubscriptionInterface + { + return $this->setData(SubscriptionInterface::FIELD_STATUS, $status); + } + + public function setNextOrderDate(string $date): SubscriptionInterface + { + return $this->setData(SubscriptionInterface::FIELD_NEXT_ORDER_DATE, $date); + } + + public function setRecurringProfileId(int $profileId): SubscriptionInterface + { + return $this->setData(SubscriptionInterface::FIELD_RECURRING_PROFILE_ID, $profileId); + } + + public function setUpdatedAt(string $updatedAt): SubscriptionInterface + { + return $this->setData(SubscriptionInterface::FIELD_UPDATED_AT, $updatedAt); + } + + public function setRepeatCountLeft(int $count): SubscriptionInterface + { + return $this->setData(SubscriptionInterface::FIELD_REPEAT_COUNT_LEFT, $count); + } + + public function setRetryCount(int $count): SubscriptionInterface + { + return $this->setData(SubscriptionInterface::FIELD_RETRY_COUNT, $count); + } + + public function setSelectedToken(int $tokenId): SubscriptionInterface + { + return $this->setData(SubscriptionInterface::FIELD_SELECTED_TOKEN, $tokenId); + } +} diff --git a/Model/Subscription/ActiveOrderProvider.php b/Model/Subscription/ActiveOrderProvider.php new file mode 100644 index 00000000..d9bd965a --- /dev/null +++ b/Model/Subscription/ActiveOrderProvider.php @@ -0,0 +1,64 @@ +linkFactory = $collectionFactory; + $this->orderConfig = $orderConfig; + } + + /** + * @return int[] + */ + public function getPayableOrderIds() + { + return $this->getCollection()->getColumnValues('order_id'); + } + + /** + * @return \Nexi\Checkout\Model\ResourceModel\Subscription\SubscriptionLink\Collection + */ + private function getCollection(): \Nexi\Checkout\Model\ResourceModel\Subscription\SubscriptionLink\Collection + { + /** @var \Nexi\Checkout\Model\ResourceModel\Subscription\SubscriptionLink\Collection $subscriptionLinks */ + $subscriptionLinks = $this->linkFactory->create(); + $subscriptionLinks->join( + ['sub' =>\Nexi\Checkout\Model\ResourceModel\Subscription::NEXI_SUBSCRIPTIONS_TABLENAME], + 'main_table.subscription_id = sub.entity_id', + ); + $subscriptionLinks->join( + 'sales_order', + 'main_table.order_id = sales_order.entity_id' + ); + $select = $subscriptionLinks->getSelect(); + $select->where( + 'sub.status IN (?)', + \Nexi\Checkout\Api\Data\SubscriptionInterface::CLONEABLE_STATUSES + ); + $select->where( + 'sales_order.status IN (?)', + $this->orderConfig->getStateDefaultStatus( + \Magento\Sales\Model\Order::STATE_PENDING_PAYMENT + ) + ); + + $currentDate = new \DateTime(); + $select->where( + 'sub.next_order_date <= ?', + $currentDate->format('Y-m-d H:i:s') + ); + + return $subscriptionLinks; + } +} diff --git a/Model/Subscription/Email.php b/Model/Subscription/Email.php new file mode 100644 index 00000000..052e4447 --- /dev/null +++ b/Model/Subscription/Email.php @@ -0,0 +1,189 @@ +transportBuilder = $transportBuilder; + $this->emulation = $emulation; + $this->paymentHelper = $paymentHelper; + $this->addressRenderer = $addressRenderer; + $this->scopeConfig = $scopeConfig; + $this->logger = $logger; + } + + /** + * @param Order[] $clonedOrders + */ + public function sendNotifications(array $clonedOrders) + { + foreach ($clonedOrders as $order) { + $this->notify($order); + } + } + + /** + * @param Order $order + */ + private function notify($order) + { + try { + $transport = $this->transportBuilder->setTemplateIdentifier($this->getEmailTemplateId($order)) + ->setTemplateOptions($this->getTemplateOptions($order)) + ->setTemplateVars($this->prepareTemplateVars($order)) + ->setFromByScope( + 'sales', + $order->getStoreId() + )->addTo($order->getCustomerEmail()) + ->getTransport(); + + $this->emulation->startEnvironmentEmulation($order->getStoreId()); + $transport->sendMessage(); + $this->emulation->stopEnvironmentEmulation(); + } catch (\Exception $e) { + $this->logger->info($e->getMessage()); + } + } + + /** + * + * @param Order $order + * @return string + */ + private function getEmailTemplateId($order) + { + return $this->scopeConfig->getValue( + self::XML_PATH_EMAIL_TEMPLATE, + \Magento\Store\Model\ScopeInterface::SCOPE_STORE, + $order->getStoreId() + ); + } + + /** + * @param Order $order + * @return string[] + */ + private function prepareTemplateVars($order): array + { + return [ + 'order' => $order, + 'order_id' => $order->getId(), + 'billing' => $order->getBillingAddress(), + 'payment_html' => $this->getPaymentHtml($order), + 'store' => $order->getStore(), + 'formattedShippingAddress' => $this->getFormattedShippingAddress($order), + 'formattedBillingAddress' => $this->getFormattedBillingAddress($order), + 'created_at_formatted' => $order->getCreatedAtFormatted(2), + 'warning_period' => $this->getWarningPeriod($order), + 'order_data' => [ + 'customer_name' => $order->getCustomerName(), + 'is_not_virtual' => $order->getIsNotVirtual(), + 'email_customer_note' => $order->getEmailCustomerNote(), + 'frontend_status_label' => $order->getFrontendStatusLabel() + ] + ]; + } + + /** + * Get payment info block as html + * + * @param Order $order + * @return string + */ + private function getPaymentHtml(Order $order) + { + return $order->getPayment()->getMethod(); + } + + /** + * Render shipping address into html. + * + * @param Order $order + * @return string|null + */ + private function getFormattedShippingAddress($order) + { + return $order->getIsVirtual() + ? null + : $this->addressRenderer->format($order->getShippingAddress(), 'html'); + } + + /** + * Render billing address into html. + * + * @param Order $order + * @return string|null + */ + private function getFormattedBillingAddress($order) + { + return $this->addressRenderer->format($order->getBillingAddress(), 'html'); + } + + /** + * @param Order $order + * @return array + */ + private function getTemplateOptions(Order $order): array + { + return [ + 'area' => \Magento\Framework\App\Area::AREA_FRONTEND, + 'store' => $order->getStoreId(), + ]; + } + + private function getWarningPeriod($order) + { + return $this->scopeConfig->getValue( + self::XML_PATH_EMAIL_WARNING_PERIOD, + \Magento\Store\Model\ScopeInterface::SCOPE_STORE, + $order->getStoreId() + ); + } +} diff --git a/Model/Subscription/NextDateCalculator.php b/Model/Subscription/NextDateCalculator.php new file mode 100644 index 00000000..fe619548 --- /dev/null +++ b/Model/Subscription/NextDateCalculator.php @@ -0,0 +1,179 @@ +profileRepo = $profileRepository; + $this->serializer = $serializer; + $this->scopeConfig = $scopeConfig; + } + + /** + * Calculate next date for a profile. + * + * @param int $profileId + * @param string $startDate + * + * @return string + * @throws NoSuchEntityException + * @throws Exception + */ + public function getNextDate($profileId, $startDate = 'now') + { + $profile = $this->getProfileById($profileId); + + return $this->calculateNextDate($profile->getSchedule(), $startDate); + } + + /** + * Calculate next date based on the schedule. + * + * @param string $schedule + * @param string $startDate + * + * @return string + * @throws Exception + */ + private function calculateNextDate($schedule, $startDate) + { + $schedule = $this->serializer->unserialize($schedule); + $carbonDate = $startDate === 'now' ? Carbon::now() : Carbon::createFromFormat('Y-m-d H:i:s', $startDate); + + switch ($schedule['unit']) { + case 'D': + $nextDate = $carbonDate->addDays($schedule['interval']); + break; + case 'W': + $nextDate = $carbonDate->addWeeks($schedule['interval']); + break; + case 'M': + $nextDate = $this->addMonthsNoOverflow($carbonDate, $schedule['interval']); + break; + case 'Y': + $nextDate = $carbonDate->addYearsNoOverflow($schedule['interval']); + break; + default: + throw new LocalizedException(__('Schedule type not supported')); + } + + if ($this->isForceWeekdays()) { + $nextDate = $this->getNextWeekday($nextDate); + } + + return $nextDate->format('Y-m-d'); + } + + /** + * Get profile by id + * + * @param int $profileId + * + * @return RecurringProfileInterface + * @throws NoSuchEntityException + */ + private function getProfileById($profileId): RecurringProfileInterface + { + if (!isset($this->profiles[$profileId])) { + $this->profiles[$profileId] = $this->profileRepo->get($profileId); + } + + return $this->profiles[$profileId]; + } + + /** + * Get force weekdays config + * + * @return bool + */ + private function isForceWeekdays() + { + if (!isset($this->forceWeekdays)) { + $this->forceWeekdays = $this->scopeConfig->isSetFlag('sales/recurring_payment/force_weekdays'); + } + return $this->forceWeekdays; + } + + /** + * Get next weekday + * + * @param Carbon $nextDate + * + * @return Carbon + */ + private function getNextWeekday($nextDate) + { + $newCarbonDate = new Carbon($nextDate); + if (!$newCarbonDate->isWeekday()) { + $newCarbonDate = $newCarbonDate->nextWeekday(); + if ($nextDate->format('m') != $newCarbonDate->format('m')) { + $newCarbonDate = $newCarbonDate->previousWeekday(); + } + } + return $newCarbonDate; + } + + /** + * Add months no overflow + * + * @param Carbon $carbonDate + * @param int $interval + * + * @return Carbon + */ + private function addMonthsNoOverflow($carbonDate, $interval) + { + $isLastOfMonth = $carbonDate->isLastOfMonth(); + $nextDate = $carbonDate->addMonthsNoOverflow($interval); + + // adjust date to match the last day of month if the previous date was also last date of month. + if ($isLastOfMonth) { + $nextDate->endOfMonth(); + } + + return $nextDate; + } +} diff --git a/Model/Subscription/OrderBiller.php b/Model/Subscription/OrderBiller.php new file mode 100644 index 00000000..26cb3d31 --- /dev/null +++ b/Model/Subscription/OrderBiller.php @@ -0,0 +1,195 @@ +collectionFactory->create(); + $subscriptionsToCharge->getBillingCollectionByOrderIds($orderIds); + + /** @var Subscription $subscription */ + foreach ($subscriptionsToCharge as $subscription) { + if (!$this->validateToken($subscription)) { + continue; + } + + $paymentSuccess = $this->createMitPayment($subscription); + if (!$paymentSuccess) { + $this->paymentCount->reduceFailureRetryCount($subscription); + continue; + } + $this->sendOrderConfirmationEmail($subscription->getId()); + $this->updateNextOrderDate($subscription); + } + } + + /** + * Send order confirmation email. + * + * @param string $subscriptionId + * + * @return void + * @throws \Magento\Framework\Exception\InputException + * @throws \Magento\Framework\Exception\NoSuchEntityException + */ + private function sendOrderConfirmationEmail($subscriptionId) + { + $orderIds = $this->subscriptionLinkRepository->getOrderIdsBySubscriptionId($subscriptionId); + $this->orderSender->send($this->orderRepository->get(array_last($orderIds))); + } + + /** + * Validate token. + * + * @param Subscription $subscription + * + * @return bool + */ + private function validateToken($subscription) + { + $valid = true; + if (!$subscription->getData('token_active') || !$subscription->getData('token_visible')) { + $this->logger->warning( + \__( + 'Unable to charge subscription id: %id token is invalid', + ['id' => $subscription->getId()] + ) + ); + $this->paymentCount->reduceFailureRetryCount($subscription); + + $valid = false; + } + + return $valid; + } + + /** + * Create MIT payment request. + * + * @param Subscription $subscription Must include order id of the subscription and public hash of the vault token. + * + * @return bool + * For subscription param @see Collection::getBillingCollectionByOrderIds + */ + private function createMitPayment($subscription): bool + { + $paymentSuccess = false; + try { + $paymentSuccess = $this->mitPayment->makeMitPayment( + $subscription->getData('order_id'), + $subscription->getData('token') + ); + } catch (LocalizedException $e) { + $this->logger->error( + \__( + 'Recurring Payment: Unable to create a charge to customer token error: %error', + ['error' => $e->getMessage()] + ) + ); + } + return $paymentSuccess; + } + + /** + * Update next order date. + * + * @param Subscription $subscription + * + * @return void + * @throws \Magento\Framework\Exception\NoSuchEntityException + */ + private function updateNextOrderDate(Subscription $subscription) + { + $subscription->setNextOrderDate( + $this->nextDateCalculator->getNextDate( + $subscription->getRecurringProfileId(), + $subscription->getNextOrderDate() + ) + ); + + $this->saveSubscription($subscription); + } + + /** + * Save subscription. + * + * @param Subscription $subscription + * + * @return void + */ + private function saveSubscription(Subscription $subscription): void + { + try { + $this->subscriptionRepository->save($subscription); + } catch (CouldNotSaveException $e) { + $this->logger->critical( + \__( + 'Recurring payment: + Cancelling subscription %id, unable to update subscription\'s next order date: %error', + [ + 'id' => $subscription->getId(), + 'error' => $e->getMessage() + ] + ) + ); + + // Prevent subscription from being rebilled over and over again + // if for some reason the subscription is unable to be saved. + $this->subscriptionResource->forceFailedStatus($subscription->getId()); + } + } +} diff --git a/Model/Subscription/OrderCloner.php b/Model/Subscription/OrderCloner.php new file mode 100644 index 00000000..f0d9f9d4 --- /dev/null +++ b/Model/Subscription/OrderCloner.php @@ -0,0 +1,174 @@ +orderCollection = $orderCollection; + $this->unavailableProducts = $unavailableProducts; + $this->quoteSession = $quoteSession; + $this->joinProcessor = $joinProcessor; + $this->quoteManagement = $quoteManagement; + $this->logger = $logger; + $this->cartRepositoryInterface = $cartRepositoryInterface; + } + + /** + * Clones orders by existing order ids, if performance becomes an issue. Consider limiting results from + * @param int[] $orderIds + * @return \Magento\Sales\Model\Order[] + * @see \Nexi\Checkout\Model\ResourceModel\Subscription::getClonableOrderIds + * + */ + public function cloneOrders($orderIds): array + { + if (empty($orderIds)) { + return []; + } + + $orderCollection = $this->orderCollection->create(); + $orderCollection->addFieldToFilter('entity_id', $orderIds); + $this->joinProcessor->process($orderCollection); + $newOrders = []; + + /** @var \Magento\Sales\Model\Order $order */ + foreach ($orderCollection as $order) { + try { + $clonedOrder = $this->clone($order); + $newOrders[$clonedOrder->getId()] = $clonedOrder; + } catch (Exception $exception) { + $this->logger->error(__( + 'Recurring payment order cloning error: %error', + ['error' => $exception->getMessage()] + )); + continue; + } + } + + return $newOrders; + } + + /** + * @param \Magento\Sales\Api\Data\OrderInterface|\Magento\Sales\Model\Order $oldOrder + * @throws LocalizedException + */ + private function clone( + \Magento\Sales\Api\Data\OrderInterface $oldOrder + ) { + $this->validateOrder($oldOrder); + + $this->quoteSession->clearStorage(); + $this->quoteSession->setData('use_old_shipping_method', true); + $oldOrder->setData('reordered', true); + + $quote = $this->getQuote($oldOrder); + + $this->removeNonScheduledProducts($quote); + + return $this->quoteManagement->submit($quote); + } + + /** + * @param $quote + * @return void + */ + private function removeNonScheduledProducts($quote): void + { + foreach ($quote->getAllVisibleItems() as $quoteItem) { + if (!$quoteItem->getProduct()->getRecurringPaymentSchedule()) { + $quote->deleteItem($quoteItem); + $quote->setTotalsCollectedFlag(false); + } + } + + $quote->save(); + $quote->collectTotals(); + } + + /** + * @param \Magento\Sales\Model\Order $order + */ + private function validateOrder($order) + { + if ($order->canReorder() + && count($this->unavailableProducts->getForOrder($order)) == 0 + ) { + return true; + } + + throw new LocalizedException(__( + 'Order id: %id cannot be reordered', + ['id' => $order->getId()] + )); + } + + /** + * @param \Magento\Sales\Model\Order $oldOrder + * @return \Magento\Quote\Model\Quote + * @throws LocalizedException + */ + private function getQuote(\Magento\Sales\Api\Data\OrderInterface $oldOrder): \Magento\Quote\Model\Quote + { + $quote = $this->cartRepositoryInterface->get($oldOrder->getQuoteId()); + $quote->setData('recurring_payment_flag', true); + + return $quote; + } +} diff --git a/Model/Subscription/PaymentCount.php b/Model/Subscription/PaymentCount.php new file mode 100644 index 00000000..219bb174 --- /dev/null +++ b/Model/Subscription/PaymentCount.php @@ -0,0 +1,52 @@ +subscriptionRepository = $subscriptionRepository; + $this->logger = $logger; + } + + public function reduceFailureRetryCount($subscription) + { + $subscription->setRetryCount($subscription->getRetryCount()-1); + if ($subscription->getRetryCount() <= 0) { + $subscription->setStatus(SubscriptionInterface::STATUS_FAILED); + } + + $this->save($subscription); + } + + /** + * @param $subscription + * @return void + */ + private function save($subscription): void + { + try { + $this->subscriptionRepository->save($subscription); + } catch (CouldNotSaveException $e) { + $this->logger->error(\__( + 'Unable to reduce subscription retry count or mark subscription id: %id as failed', + ['id' => $subscription->getId()] + )); + } + } +} diff --git a/Model/Subscription/Profile.php b/Model/Subscription/Profile.php new file mode 100644 index 00000000..a9e6e626 --- /dev/null +++ b/Model/Subscription/Profile.php @@ -0,0 +1,65 @@ +_init(\Nexi\Checkout\Model\ResourceModel\Subscription\Profile::class); + } + + /** + * @return int + */ + public function getId() + { + return $this->getData('profile_id'); + } + + /** + * @return string + */ + public function getName() + { + return $this->getData('name'); + } + + /** + * @return string + */ + public function getDescription() + { + return $this->getData('description'); + } + + /** + * @return string + */ + public function getSchedule() + { + return $this->getData('schedule'); + } + + public function setId($profileId): self + { + return $this->setData('profile_id', $profileId); + } + + public function setName($name): self + { + return $this->setData('name', $name); + } + + public function setDescription($description): self + { + return $this->setData('description', $description); + } + + public function setSchedule($schedule): self + { + return $this->setData('schedule', $schedule); + } +} diff --git a/Model/Subscription/ProfileRepository.php b/Model/Subscription/ProfileRepository.php new file mode 100644 index 00000000..7549c402 --- /dev/null +++ b/Model/Subscription/ProfileRepository.php @@ -0,0 +1,112 @@ +profileResource = $profileResource; + $this->profileFactory = $profileFactory; + $this->profileResultFactory = $profileResultFactory; + $this->collectionProcessor = $collectionProcessor; + } + + /** + * @inheritDoc + */ + public function get($profileId) + { + /** @var Subscription $subscription */ + $subscription = $this->profileFactory->create(); + $this->profileResource->load($subscription, $profileId); + + if (!$subscription->getId()) { + throw new NoSuchEntityException(\__( + 'No subscription profile found with id %id', + [ + 'id' => $profileId + ] + )); + } + + return $subscription; + } + + /** + * @inheritDoc + */ + public function save(Data\RecurringProfileInterface $profile) + { + try { + $this->profileResource->save($profile); + } catch (\Throwable $e) { + throw new CouldNotSaveException(\__( + 'Could not save Recurring Profile: %error', + ['error' => $e->getMessage()] + )); + } + + return $profile; + } + + /** + * @inheritDoc + */ + public function delete(Data\RecurringProfileInterface $profile) + { + try { + $this->profileResource->delete($profile); + } catch (\Exception $e) { + throw new CouldNotDeleteException(__( + 'Unable to delete recurring profile: %error', + ['error' => $e->getMessage()] + )); + } + } + + /** + * @inheritDoc + */ + public function getList( + \Magento\Framework\Api\SearchCriteriaInterface $searchCriteria + ) : Data\RecurringProfileSearchResultInterface { + /** @var Data\RecurringProfileSearchResultInterface $searchResult */ + $searchResult = $this->profileResultFactory->create(); + $this->collectionProcessor->process($searchCriteria, $searchResult); + $searchResult->setSearchCriteria($searchCriteria); + + return $searchResult; + } +} diff --git a/Model/Subscription/QuoteToOrder.php b/Model/Subscription/QuoteToOrder.php new file mode 100644 index 00000000..db9e3540 --- /dev/null +++ b/Model/Subscription/QuoteToOrder.php @@ -0,0 +1,71 @@ +subscriptionFactory = $subscriptionFactory; + $this->orderExtensionFactory = $extensionFactory; + $this->dateCalculator = $dateCalculator; + } + + /** + * @param \Magento\Sales\Model\Order $order + * @param \Magento\Quote\Model\Quote $quote + */ + public function addRecurringPaymentToOrder($order, $quote) + { + $oldPayment = $quote->getData('old_order_recurring_payment'); + if (!$oldPayment) { + return; + } + + $extensionAttributes = $order->getExtensionAttributes(); + if (!$extensionAttributes) { + $extensionAttributes = $this->orderExtensionFactory->create(); + } + $extensionAttributes->setRecurringPayment($this->createNewRecurringPayment($oldPayment)); + $order->setExtensionAttributes($extensionAttributes); + } + + /** + * @param \Nexi\Checkout\Api\Data\SubscriptionInterface $oldPayment + * @return SubscriptionInterface + */ + private function createNewRecurringPayment( + $oldPayment + ): SubscriptionInterface { + /** @var \Nexi\Checkout\Api\Data\SubscriptionInterface $subscription */ + $subscription = $this->subscriptionFactory->create(); + $subscription->setStatus(\Nexi\Checkout\Api\Data\SubscriptionInterface::STATUS_PENDING_PAYMENT); + $subscription->setCustomerId($oldPayment->getCustomerId()); + $subscription->setNextOrderDate($this->dateCalculator->getNextDate($oldPayment->getRecurringProfileId())); + $subscription->setRecurringProfileId($oldPayment->getRecurringProfileId()); + $subscription->setRepeatCountLeft($oldPayment->getRepeatCountLeft() - 1); + $subscription->setRetryCount(5); + + return $subscription; + } +} diff --git a/Model/Subscription/SubscriptionCreate.php b/Model/Subscription/SubscriptionCreate.php new file mode 100644 index 00000000..10f7c603 --- /dev/null +++ b/Model/Subscription/SubscriptionCreate.php @@ -0,0 +1,124 @@ +subscriptionRepository = $subscriptionRepository; + $this->subscriptionInterfaceFactory = $subscriptionInterfaceFactory; + $this->productRepositoryInterface = $productRepositoryInterface; + $this->dateCalculator = $dateCalculator; + $this->paymentToken = $paymentToken; + $this->subscriptionLinkRepository = $subscriptionLinkRepository; + } + + /** + * @param $orderSchedule + * @param $selectedToken + * @param $customerId + * @return void + * @throws CouldNotSaveException + */ + public function createSubscription($orderSchedule, $selectedToken, $customerId, $orderId) + { + try { + $subscription = $this->subscriptionInterfaceFactory->create(); + $subscription->setStatus(SubscriptionInterface::STATUS_ACTIVE); + $subscription->setCustomerId($customerId); + $subscription->setNextOrderDate($this->dateCalculator->getNextDate(reset($orderSchedule))); + $subscription->setRecurringProfileId((int)reset($orderSchedule)); + $subscription->setRepeatCountLeft(self::REPEAT_COUNT_STATIC_VALUE); + $subscription->setRetryCount(self::REPEAT_COUNT_STATIC_VALUE); + $subscription->setSelectedToken( + (int)$this->paymentToken->getByPublicHash($selectedToken,$customerId)[SubscriptionInterface::FIELD_ENTITY_ID]); + + $this->subscriptionRepository->save($subscription); + + $this->subscriptionLinkRepository->linkOrderToSubscription($orderId, $subscription->getId()); + } catch (\Exception $exception) { + throw new CouldNotSaveException(__($exception->getMessage())); + } + } + + /** + * @param $order + * @return array + */ + public function getSubscriptionSchedule($order): array + { + $orderSchedule = []; + try { + foreach ($order->getItems() as $item) { + $product = $this->productRepositoryInterface->getById($item->getProductId()); + if (is_object($product->getCustomAttribute(self::SCHEDULED_ATTRIBUTE_CODE))){ + if ($product->getCustomAttribute(self::SCHEDULED_ATTRIBUTE_CODE)->getValue() >= 0) { + $orderSchedule[] = $product->getCustomAttribute(self::SCHEDULED_ATTRIBUTE_CODE)->getValue(); + } + } + } + } catch (NoSuchEntityException $e) { + return []; + } + + return $orderSchedule; + } +} diff --git a/Model/Subscription/SubscriptionLink.php b/Model/Subscription/SubscriptionLink.php new file mode 100644 index 00000000..3d3f20db --- /dev/null +++ b/Model/Subscription/SubscriptionLink.php @@ -0,0 +1,68 @@ +_init(\Nexi\Checkout\Model\ResourceModel\Subscription\SubscriptionLink::class); + } + + /** + * @return int + */ + public function getId() + { + return $this->getData(self::FIELD_LINK_ID); + } + + /** + * @return string + */ + public function getOrderId() + { + return $this->getData(self::FIELD_ORDER_ID); + } + + /** + * @return string + */ + public function getSubscriptionId() + { + return $this->getData(self::FIELD_SUBSCRIPTION_ID); + } + + /** + * @param $linkId + * @return $this + */ + public function setId($linkId): self + { + return $this->setData(self::FIELD_LINK_ID, $linkId); + } + + /** + * @param $orderId + * @return $this + */ + public function setOrderId($orderId): self + { + return $this->setData(self::FIELD_ORDER_ID, $orderId); + } + + /** + * @param $subscriptionId + * @return $this + */ + public function setSubscriptionId($subscriptionId): self + { + return $this->setData(self::FIELD_SUBSCRIPTION_ID, $subscriptionId); + } +} diff --git a/Model/Subscription/SubscriptionLinkRepository.php b/Model/Subscription/SubscriptionLinkRepository.php new file mode 100644 index 00000000..4889f92b --- /dev/null +++ b/Model/Subscription/SubscriptionLinkRepository.php @@ -0,0 +1,223 @@ +subscriptionLinkResource = $subscriptionLinkResource; + $this->subscriptionLinkFactory = $subscriptionLinkFactory; + $this->subscriptionLinkResultFactory = $subscriptionLinkResultFactory; + $this->collectionProcessor = $collectionProcessor; + $this->messageManager = $messageManager; + $this->subscriptionResource = $subscriptionResource; + $this->subscriptionFactory = $subscriptionFactory; + $this->subscriptionLinkCollectionFactory = $subscriptionLinkCollectionFactory; + } + + /** + * @inheritDoc + */ + public function get($linkId) + { + /** @var Subscription $subscription */ + $subscription = $this->subscriptionLinkFactory->create(); + $this->subscriptionLinkResource->load($subscription, $linkId); + + if (!$subscription->getId()) { + throw new NoSuchEntityException(\__( + 'No subscription link found with id %id', + [ + 'id' => $linkId + ] + )); + } + + return $subscription; + } + + /** + * @inheritDoc + */ + public function save(Data\SubscriptionLinkInterface $subscriptionLink) + { + try { + $this->subscriptionLinkResource->save($subscriptionLink); + } catch (\Throwable $e) { + throw new CouldNotSaveException(\__( + 'Could not save Recurring Profile: %error', + ['error' => $e->getMessage()] + )); + } + + return $subscriptionLink; + } + + /** + * @inheritDoc + */ + public function delete(Data\SubscriptionLinkInterface $subscriptionLink) + { + try { + $this->subscriptionLinkResource->delete($subscriptionLink); + } catch (\Exception $e) { + throw new CouldNotDeleteException(__( + 'Unable to delete subscription link: %error', + ['error' => $e->getMessage()] + )); + } + } + + /** + * @inheritDoc + */ + public function getList( + \Magento\Framework\Api\SearchCriteriaInterface $searchCriteria + ) : Data\SubscriptionLinkSearchResultInterface { + /** @var Data\RecurringProfileSearchResultInterface $searchResult */ + $searchResult = $this->subscriptionLinkResultFactory->create(); + $this->collectionProcessor->process($searchCriteria, $searchResult); + $searchResult->setSearchCriteria($searchCriteria); + + return $searchResult; + } + + /** + * @param $orderId + * @return SubscriptionInterface + */ + public function getSubscriptionFromOrderId($orderId): SubscriptionInterface + { + $subscriptionId = $this->getSubscriptionIdFromOrderId($orderId); + $subscription = $this->subscriptionFactory->create(); + $this->subscriptionResource->load($subscription, $subscriptionId, 'entity_id'); + + return $subscription; + } + + /** + * @param $orderId + * @param $subscriptionId + * @return mixed|void + */ + public function linkOrderToSubscription($orderId, $subscriptionId) + { + try { + $subscriptionLink = $this->subscriptionLinkFactory->create(); + $subscriptionLink->setOrderId($orderId); + $subscriptionLink->setSubscriptionId($subscriptionId); + $this->save($subscriptionLink); + } + catch (CouldNotSaveException $e) { + $this->messageManager->addErrorMessage($e->getMessage()); + } + } + + /** + * @param $orderId + * @return string + */ + public function getSubscriptionIdFromOrderId($orderId) + { + $subscription = $this->subscriptionLinkFactory->create(); + $this->subscriptionLinkResource->load($subscription, $orderId, 'order_id'); + + return $subscription->getSubscriptionId(); + } + + /** + * @param $subscriptionId + * @return array + */ + public function getOrderIdsBySubscriptionId($subscriptionId): array + { + $collection = $this->subscriptionLinkCollectionFactory->create(); + $collection->addFieldToFilter( + 'subscription_id',['eq' => $subscriptionId] + ); + + $subscriptionLinks = []; + foreach ($collection->getItems() as $item) { + $subscriptionLinks[] = $item->getOrderId(); + } + + return $subscriptionLinks; + } + + +} diff --git a/Model/SubscriptionManagement.php b/Model/SubscriptionManagement.php new file mode 100644 index 00000000..78cb0364 --- /dev/null +++ b/Model/SubscriptionManagement.php @@ -0,0 +1,207 @@ +userContext->getUserId(); + if (!$customerId) { + throw new LocalizedException(__('Customer is not authorized for this operation')); + } + + try { + $subscription = $this->subscriptionRepository->get((int)$subscriptionId); + if ($subscription->getStatus() === self::STATUS_CLOSED) { + return __('Subscription is closed')->render(); + } + + $customerId = $this->userContext->getUserId(); + $orderIds = $this->subscriptionLinkRepository->getOrderIdsBySubscriptionId((int)$subscriptionId); + $searchCriteria = $this->searchCriteriaBuilder + ->addFilter('entity_id', $orderIds, 'in') + ->create(); + $orders = $this->orderRepository->getList($searchCriteria); + + foreach ($orders->getItems() as $order) { + if ($customerId != $order->getCustomerId()) { + throw new LocalizedException(__('Customer is not authorized for this operation')); + } + $subscription->setStatus(self::STATUS_CLOSED); + if ($order->getStatus() === Order::STATE_PENDING_PAYMENT + || $order->getStatus() === self::ORDER_PENDING_STATUS) { + $this->orderManagementInterface->cancel($order->getId()); + } + } + + $this->subscriptionRepository->save($subscription); + } catch (Exception $e) { + $this->logger->error($e->getMessage()); + throw new LocalizedException(__("Subscription couldn't be canceled")); + } + + return __('Subscription has been canceled correctly')->render(); + } + + /** + * @param SearchCriteriaInterface $searchCriteria + * + * @return array + * @throws LocalizedException + */ + public function showSubscriptions(SearchCriteriaInterface $searchCriteria): array + { + $subscriptions = []; + try { + if ($this->userContext->getUserId()) { + $this->filterByCustomer($searchCriteria); + $subscriptionCollection = $this->subscriptionRepository->getList($searchCriteria)->getItems(); + $paymentToken = $this->showSubscriptionsDataProvider->getMaskedCCById($searchCriteria); + + foreach ($subscriptionCollection as $subscription) { + $orderData = $this->showSubscriptionsDataProvider + ->getOrderDataFromSubscriptionId($subscription->getId()); + $subscriptions[] = [ + 'entity_id' => $subscription->getId(), + 'customer_id' => $subscription->getCustomerId(), + 'status' => $subscription->getStatus(), + 'next_order_date' => $subscription->getNextOrderDate(), + 'recurring_profile_id' => $subscription->getRecurringProfileId(), + 'updated_at' => $subscription->getUpdatedAt(), + 'repeat_count_left' => $subscription->getRepeatCountLeft(), + 'retry_count' => $subscription->getRetryCount(), + 'selected_token' => $subscription->getSelectedToken(), + 'masked_cc' => $paymentToken[$subscription->getSelectedToken()], + 'grand_total' => $orderData['grand_total'], + 'last_order_increment_id' => $orderData['increment_id'] + ]; + } + + return $subscriptions; + } + } catch (\Throwable $e) { + $this->logger->error($e->getMessage()); + throw new LocalizedException(__("Subscription orders can't be shown")); + } + + throw new LocalizedException(__("Customer is not logged in")); + } + + /** + * @param SearchCriteriaInterface $searchCriteria + * + * @return void + */ + private function filterByCustomer(SearchCriteriaInterface $searchCriteria): void + { + $customerFilter = $this->filterBuilder + ->setField('customer_id') + ->setValue($this->userContext->getUserId()) + ->setConditionType('eq') + ->create(); + $customerFilterGroup = $this->groupBuilder->addFilter($customerFilter)->create(); + $groups = $searchCriteria->getFilterGroups(); + $groups[] = $customerFilterGroup; + $searchCriteria->setFilterGroups($groups); + } + + /** + * Change assigned card for subscription + * + * @param string $subscriptionId + * @param string $cardId + * + * @return bool + * + * @throws LocalizedException + */ + public function changeSubscription(string $subscriptionId, string $cardId): bool + { + $paymentToken = $this->paymentTokenRepository->getById((int)$cardId); + $subscription = $this->subscriptionRepository->get((int)$subscriptionId); + + $customerId = (int)$this->userContext->getUserId(); + + $this->customerData->validateTokensCustomer($paymentToken, $customerId); + $this->customerData->validateSubscriptionsCustomer($subscription, $customerId); + + $subscription->setSelectedToken($paymentToken->getEntityId()); + + return $this->save($subscription); + } + + /** + * @param SubscriptionInterface $subscription + * + * @return bool + */ + private function save(SubscriptionInterface $subscription): bool + { + try { + $this->subscriptionRepository->save($subscription); + } catch (CouldNotSaveException $e) { + return false; + } + + return true; + } +} diff --git a/Model/SubscriptionRepository.php b/Model/SubscriptionRepository.php new file mode 100644 index 00000000..952337a9 --- /dev/null +++ b/Model/SubscriptionRepository.php @@ -0,0 +1,87 @@ +subscriptionResource = $subscriptionResource; + $this->subscriptionFactory = $subscriptionFactory; + $this->searchResultFactory = $searchResultFactory; + $this->collectionProcessor = $collectionProcessor; + } + + public function get(int $entityId): SubscriptionInterface + { + /** @var Subscription $subscription */ + $subscription = $this->subscriptionFactory->create(); + $this->subscriptionResource->load($subscription, $entityId); + + if (!$subscription->getId()) { + throw new NoSuchEntityException(\__( + 'No recurring payment found with id %id', + [ + 'id' => $entityId + ] + )); + } + + return $subscription; + } + + public function save(SubscriptionInterface $subscription): SubscriptionInterface + { + try { + $this->subscriptionResource->save($subscription); + } catch (\Throwable $e) { + throw new CouldNotSaveException(\__( + 'Could not save Recurring Payment: %error', + ['error' => $e->getMessage()] + )); + } + + return $subscription; + } + + public function getList( + \Magento\Framework\Api\SearchCriteriaInterface $searchCriteria + ): SubscriptionSearchResultInterface { + /** @var SubscriptionSearchResultInterface $searchResult */ + $searchResult = $this->searchResultFactory->create(); + $this->collectionProcessor->process($searchCriteria, $searchResult); + $searchResult->setSearchCriteria($searchCriteria); + + return $searchResult; + } + + public function delete(SubscriptionInterface $subscription) + { + try { + $this->subscriptionResource->delete($subscription); + } catch (\Exception $e) { + throw new CouldNotDeleteException(__( + 'Unable to delete recurring payment: %error', + ['error' => $e->getMessage()] + )); + } + } +} diff --git a/Model/Token/Payment.php b/Model/Token/Payment.php new file mode 100644 index 00000000..a562f4a4 --- /dev/null +++ b/Model/Token/Payment.php @@ -0,0 +1,233 @@ +orderRepository->get($orderId); + $customer = $this->customerRepository->getById((int)$order->getCustomerId()); + $commandExecutor = $this->commandManagerPool->get('paytrail'); + + $mitResponse = $commandExecutor->executeByCode( + 'token_payment_mit', + null, + [ + 'order' => $order, + 'token_id' => $cardToken, + 'customer' => $customer, + ] + ); + + if ($mitResponse['data']?->getTransactionId() === null) { + $this->paytrailLogger->logCheckoutData( + 'response', + 'error', + 'A problem occurred: Payment transaction id missing in request response' + ); + + return false; + } + } catch (\Exception $e) { + $this->paytrailLogger->logCheckoutData( + 'response', + 'error', + 'A problem occurred: ' . $e->getMessage() + ); + return false; + }//end try + + $this->createInvoice($order, $mitResponse['data']); + $this->createTransaction($order, $mitResponse['data']); + $this->updateOrder($order, $mitResponse['data']); + + return true; + } + + /** + * Create invoice. + * + * @param OrderInterface $order + * @param MitPaymentResponse $mitResponse + */ + private function createInvoice($order, $mitResponse) + { + if ($order->canInvoice()) { + try { + $invoice = $this->invoiceService->prepareInvoice($order); + $invoice->register(); + $invoice->setTransactionId($mitResponse->getTransactionId()); + $invoice->save(); + $transactionSave = $this->transaction->addObject( + $invoice + )->addObject( + $invoice->getOrder() + ); + $transactionSave->save(); + } catch (\Exception $e) { + $this->paytrailLogger->logCheckoutData( + 'response', + 'error', + 'A problem with creating invoice after payment ' + . $e->getMessage() + ); + } + } + } + + /** + * Create transaction. + * + * @param OrderInterface $order + * @param MitPaymentResponse $mitResponse + * + * @return int|void + */ + private function createTransaction($order, $mitResponse) + { + try { + $payment = $order->getPayment(); + $payment->setLastTransId($mitResponse->getTransactionId()); + $payment->setTransactionId($mitResponse->getTransactionId()); + $payment->setAdditionalInformation( + [\Magento\Sales\Model\Order\Payment\Transaction::RAW_DETAILS => (array)$mitResponse] + ); + + $trans = $this->transactionBuilder; + $transaction = $trans->setPayment($payment) + ->setOrder($order) + ->setTransactionId($mitResponse->getTransactionId()) + ->setAdditionalInformation( + [\Magento\Sales\Model\Order\Payment\Transaction::RAW_DETAILS => (array)$mitResponse] + ) + ->setFailSafe(true) + //build method creates the transaction and returns the object + ->build(\Magento\Sales\Model\Order\Payment\Transaction::TYPE_CAPTURE); + + $payment->setParentTransactionId(null); + $payment->save(); + $order->save(); + + return $transaction->save()->getTransactionId(); + } catch (\Exception $e) { + $this->paytrailLogger->logCheckoutData( + 'response', + 'error', + 'A problem occurred: while creating transaction' + . $e->getMessage() + ); + } + } + + /** + * Get MIT payment request. + * + * @return MitPaymentRequest + */ + private function getMitPaymentRequest(): MitPaymentRequest + { + return new MitPaymentRequest(); + } + + /** + * Update order. + * + * @param OrderInterface $order + * @param MitPaymentResponse $mitResponse + * + * @return void + * @throws \Magento\Framework\Exception\CouldNotSaveException + */ + private function updateOrder( + OrderInterface $order, + MitPaymentResponse $mitResponse + ): void { + + $commentsArray = [ + 'pending_payment' => __('Transaction ID: ') . $mitResponse->getTransactionId(), + 'processing' => __('Payment has been completed') + ]; + + foreach ($commentsArray as $status => $comment) { + $historyComment = $this->orderStatusHistoryFactory->create(); + $historyComment + ->setStatus($status) + ->setComment($comment); + $this->orderManagement->addComment($order->getEntityId(), $historyComment); + $this->orderStatusHistoryRepository->save($historyComment); + } + + $order->setState(\Magento\Sales\Model\Order::STATE_PROCESSING); + $this->orderRepository->save($order); + } +} diff --git a/Model/Token/RequestData.php b/Model/Token/RequestData.php new file mode 100644 index 00000000..5bfb921a --- /dev/null +++ b/Model/Token/RequestData.php @@ -0,0 +1,42 @@ +paymentTokenManagement->getByPublicHash($tokenHash, $customerId); + } +} diff --git a/Model/Ui/ConfigProvider.php b/Model/Ui/ConfigProvider.php old mode 100644 new mode 100755 index efae8bff..2f05a880 --- a/Model/Ui/ConfigProvider.php +++ b/Model/Ui/ConfigProvider.php @@ -3,45 +3,132 @@ namespace Nexi\Checkout\Model\Ui; use Magento\Checkout\Model\ConfigProviderInterface; +use Magento\Checkout\Model\Session; use Magento\Framework\Exception\LocalizedException; -use Nexi\Checkout\Gateway\Config\Config; +use Magento\Framework\Exception\NoSuchEntityException; use Magento\Payment\Helper\Data as PaymentHelper; +use Magento\Store\Model\StoreManagerInterface; +use Nexi\Checkout\Gateway\Config\Config; +use Nexi\Checkout\Model\Card\VaultConfig; +use Nexi\Checkout\Model\ApplePay\ApplePayDataProvider; +use Nexi\Checkout\Model\Ui\DataProvider\PaymentProvidersData; class ConfigProvider implements ConfigProviderInterface { /** - * ConfigProvider constructor. + * @var string[] + */ + private $methodCodes = [ + Config::CODE, + Config::CC_VAULT_CODE + ]; + + /** + * @var \Magento\Payment\Model\MethodInterface[] + */ + private $methods; + + /** + * ConfigProvider constructor * - * @param Config $config * @param PaymentHelper $paymentHelper + * @param Session $checkoutSession + * @param Config $gatewayConfig + * @param StoreManagerInterface $storeManager + * @param PaymentProvidersData $paymentProvidersData + * @param VaultConfig $vaultConfig + * @param ApplePayDataProvider $applePayDataProvider + * @throws LocalizedException */ public function __construct( - private readonly Config $config, - private readonly PaymentHelper $paymentHelper, + PaymentHelper $paymentHelper, + private Session $checkoutSession, + private Config $gatewayConfig, + private StoreManagerInterface $storeManager, + private PaymentProvidersData $paymentProvidersData, + private VaultConfig $vaultConfig, + private ApplePayDataProvider $applePayDataProvider ) { + foreach ($this->methodCodes as $code) { + $this->methods[$code] = $paymentHelper->getMethodInstance($code); + } } /** - * Returns Nexi configuration values. + * GetConfig function * - * @return array|\array[][] - * @throws LocalizedException + * @return array + * @throws NoSuchEntityException */ public function getConfig() { - if (!$this->config->isActive()) { - return []; + $storeId = $this->storeManager->getStore()->getId(); + $config = []; + $status = $this->gatewayConfig->isActive($storeId); + + if (!$status) { + return $config; } + try { + $groupData = $this->paymentProvidersData->getAllPaymentMethods(); + $scheduledMethod = []; + + if (array_key_exists('creditcard', $this->paymentProvidersData + ->handlePaymentProviderGroupData($groupData['groups']))) { + $scheduledMethod[] = $this->paymentProvidersData + ->handlePaymentProviderGroupData($groupData['groups'])['creditcard']; + } + + if ($this->applePayDataProvider->canApplePay()) { + $groupData['groups'] = $this->applePayDataProvider->addApplePayPaymentMethod($groupData['groups']); + } - return [ - 'payment' => [ - Config::CODE => [ - 'isActive' => $this->config->isActive(), - 'environment' => $this->config->getEnvironment(), - 'label' => $this->paymentHelper->getMethodInstance(Config::CODE)->getTitle(), - 'integrationType' => $this->config->getIntegrationType(), + $config = [ + 'payment' => [ + Config::CODE => [ + 'instructions' => $this->gatewayConfig->getInstructions(), + 'skip_method_selection' => $this->gatewayConfig->getSkipBankSelection(), + 'payment_redirect_url' => $this->gatewayConfig->getPaymentRedirectUrl(), + 'payment_template' => $this->gatewayConfig->getPaymentTemplate(), + 'method_groups' => array_values($this->paymentProvidersData + ->handlePaymentProviderGroupData($groupData['groups'])), + 'scheduled_method_group' => array_values($scheduledMethod), + 'payment_terms' => $groupData['terms'], + 'payment_method_styles' => $this->paymentProvidersData->wrapPaymentMethodStyles($storeId), + 'addcard_redirect_url' => $this->gatewayConfig->getAddCardRedirectUrl(), + 'pay_and_addcard_redirect_url' => $this->gatewayConfig->getPayAndAddCardRedirectUrl(), + 'credit_card_providers_ids' => array_shift($scheduledMethod)['providers'] ?? [], + 'token_payment_redirect_url' => $this->gatewayConfig->getTokenPaymentRedirectUrl(), + 'default_success_page_url' => $this->gatewayConfig->getDefaultSuccessPageUrl(), + 'is_vault_for_nexi' => $this->vaultConfig->isVaultForNexiEnabled(), + 'is_show_stored_cards' => $this->vaultConfig->isShowStoredCards(), + 'is_new_ui_enabled' => $this->gatewayConfig->isNewUiEnabled($storeId) + ] ] - ] - ]; + ]; + //Get images for payment groups + foreach ($groupData['groups'] as $group) { + $groupId = $group['id']; + $groupImage = $group['svg']; + $config['payment'][Config::CODE]['image'][$groupId] = ''; + if ($groupImage) { + $config['payment'][Config::CODE]['image'][$groupId] = $groupImage; + } + } + } catch (\Exception $e) { + $config['payment'][Config::CODE]['success'] = 0; + + return $config; + } + if ($this->checkoutSession->getData('nexi_previous_error')) { + $config['payment'][Config::CODE]['previous_error'] = $this->checkoutSession + ->getData('nexi_previous_error', 1); + } elseif ($this->checkoutSession->getData('nexi_previous_success')) { + $config['payment'][Config::CODE]['previous_success'] = $this->checkoutSession + ->getData('nexi_previous_success', 1); + } + $config['payment'][Config::CODE]['success'] = 1; + + return $config; } } diff --git a/Model/Ui/DataProvider/PaymentProvidersData.php b/Model/Ui/DataProvider/PaymentProvidersData.php new file mode 100755 index 00000000..47ec9eef --- /dev/null +++ b/Model/Ui/DataProvider/PaymentProvidersData.php @@ -0,0 +1,244 @@ +checkoutSession->getQuote()->getGrandTotal() ?: 0; + + $commandExecutor = $this->commandManagerPool->get('nexi'); + $response = $commandExecutor->executeByCode( + 'method_provider', + null, + ['amount' => $orderValue] + ); + + $errorMsg = $response['error']; + + if (isset($errorMsg)) { + $this->log->error( + 'Error occurred during providing payment methods: ' + . $errorMsg + ); + $this->nexiLogger->logData(\Monolog\Logger::ERROR, $errorMsg); + throw new CheckoutException(__($errorMsg)); + } + + return $response["data"]; + } + + /** + * Create payment page styles from the values entered in Nexi configuration. + * + * @param string $storeId + * + * @return string + */ + public function wrapPaymentMethodStyles($storeId) + { + if ($this->gatewayConfig->isNewUiEnabled()) { + $styles = '.nexi-group-collapsible{ background-color: #ffffff; margin-top:1%; margin-bottom:2%;}'; + $styles .= '.nexi-group-collapsible.active{ background-color: #ffffff;}'; + $styles .= '.nexi-group-collapsible span{ color: #323232;}'; + $styles .= '.nexi-group-collapsible li{ color: #323232}'; + $styles .= '.nexi-group-collapsible.active span{ color: #000000;}'; + $styles .= '.nexi-group-collapsible.active li{ color: #000000}'; + $styles .= '.nexi-group-collapsible:hover:not(.active) {background-color: #ffffff}'; + $styles .= '.nexi-payment-methods .nexi-payment-method.active{ border: 2px solid ' + . $this->gatewayConfig->getPaymentMethodHighlightColorNewUi($storeId) . ';border-width:2px;}'; + $styles .= '.nexi-payment-methods .nexi-stored-token.active{ border-color:' + . $this->gatewayConfig->getPaymentMethodHighlightColorNewUi($storeId) . ';border-width:2px;}'; + $styles .= '.nexi-payment-methods .nexi-payment-method:hover, + .nexi-payment-methods .nexi-payment-method:not(.active):hover { border: 2px solid ' + . $this->gatewayConfig->getPaymentMethodHoverHighlightNewUi($storeId) . '}'; + $styles .= '.nexi-stored-token:hover { border: 2px solid ' + . $this->gatewayConfig->getPaymentMethodHoverHighlightNewUi($storeId) . '}'; + $styles .= '.nexi-store-card-button:hover { border: 2px solid ' + . $this->gatewayConfig->getPaymentMethodHoverHighlightNewUi($storeId) . ';}'; + $styles .= '.nexi-store-card-login-button:hover { border: 2px solid ' + . $this->gatewayConfig->getPaymentMethodHoverHighlightNewUi($storeId) . ';}'; + $styles .= $this->gatewayConfig->getAdditionalCss($storeId); + } else { + $styles = '.nexi-group-collapsible{ background-color:' + . $this->gatewayConfig->getPaymentGroupBgColor($storeId) . '; margin-top:1%; margin-bottom:2%;}'; + $styles .= '.nexi-group-collapsible.active{ background-color:' + . $this->gatewayConfig->getPaymentGroupHighlightBgColor($storeId) . ';}'; + $styles .= '.nexi-group-collapsible span{ color:' + . $this->gatewayConfig->getPaymentGroupTextColor($storeId) . ';}'; + $styles .= '.nexi-group-collapsible li{ color:' + . $this->gatewayConfig->getPaymentGroupTextColor($storeId) . '}'; + $styles .= '.nexi-group-collapsible.active span{ color:' + . $this->gatewayConfig->getPaymentGroupHighlightTextColor($storeId) . ';}'; + $styles .= '.nexi-group-collapsible.active li{ color:' + . $this->gatewayConfig->getPaymentGroupHighlightTextColor($storeId) . '}'; + $styles .= '.nexi-group-collapsible:hover:not(.active) {background-color:' + . $this->gatewayConfig->getPaymentGroupHoverColor() . '}'; + $styles .= '.nexi-payment-methods .nexi-payment-method.active{ border: 2px solid ' + . $this->gatewayConfig->getPaymentMethodHighlightColor($storeId) . ';border-width:2px;}'; + $styles .= '.nexi-payment-methods .nexi-stored-token.active{ border-color:' + . $this->gatewayConfig->getPaymentMethodHighlightColor($storeId) . ';border-width:2px;}'; + $styles .= '.nexi-payment-methods .nexi-payment-method:hover, + .nexi-payment-methods .nexi-payment-method:not(.active):hover { border: 2px solid ' + . $this->gatewayConfig->getPaymentMethodHoverHighlight($storeId) . ';}'; + $styles .= '.nexi-stored-token:hover { border: 2px solid ' + . $this->gatewayConfig->getPaymentMethodHoverHighlight($storeId) . '}'; + $styles .= '.nexi-store-card-button:hover { border: 2px solid ' + . $this->gatewayConfig->getPaymentMethodHoverHighlight($storeId) . ';}'; + $styles .= '.nexi-store-card-login-button:hover { border: 2px solid ' + . $this->gatewayConfig->getPaymentMethodHoverHighlight($storeId) . ';}'; + $styles .= $this->gatewayConfig->getAdditionalCss($storeId); + } + + return $styles; + } + + /** + * Create array for payment providers and groups containing unique method id + * + * @param array $responseData + * + * @return array + */ + public function handlePaymentProviderGroupData($responseData) + { + $allMethods = []; + $allGroups = []; + foreach ($responseData as $group) { + $allGroups[$group['id']] = [ + 'id' => $group['id'], + 'name' => $group['name'], + 'icon' => $group['icon'] + ]; + + foreach ($group['providers'] as $provider) { + $allMethods[] = $provider; + } + } + foreach ($allGroups as $key => $group) { + if ($group['id'] == 'creditcard') { + $allGroups[$key]["can_tokenize"] = true; + $allGroups[$key]["tokens"] = $this->gatewayConfig->getCustomerTokens(); + } else { + $allGroups[$key]["can_tokenize"] = false; + $allGroups[$key]["tokens"] = false; + } + + $allGroups[$key]['providers'] = $this->addProviderDataToGroup($allMethods, $group['id']); + } + return $allGroups; + } + + /** + * Add payment method data to group + * + * @param array $responseData + * @param string $groupId + * + * @return array + */ + private function addProviderDataToGroup($responseData, $groupId) + { + $methods = []; + $i = 1; + + foreach ($responseData as $key => $method) { + if ($method->getGroup() == $groupId) { + $id = $groupId === self::CREDITCARD_GROUP_ID ? $method->getId() + . self::ID_INCREMENT_SEPARATOR + . strtolower($method->getName()) : $method->getId(); + $methods[] = [ + 'checkoutId' => $method->getId(), + 'id' => $this->getIncrementalId($id, $i), + 'name' => $method->getName(), + 'group' => $method->getGroup(), + 'icon' => $method->getIcon(), + 'svg' => $method->getSvg() + ]; + } + } + + return $methods; + } + + /** + * Returns incremental id. + * + * @param string $id + * @param int $i + * @return string + */ + public function getIncrementalId($id, int &$i): string + { + return $id . self::ID_INCREMENT_SEPARATOR . ($i++); + } + + /** + * Returns id without increment. + * + * @param string $id + * + * @return string + */ + public function getIdWithoutIncrement(string $id): string + { + return explode(self::ID_INCREMENT_SEPARATOR, $id)[0]; + } + + /** + * Returns card type. + * + * @param string $id + * + * @return ?string + */ + public function getCardType(string $id): ?string + { + $idParts = explode(self::ID_INCREMENT_SEPARATOR, $id); + + if (count($idParts) == 3) { + return $idParts[1]; + } + + return null; + } +} diff --git a/Model/Ui/DataProvider/Product/Form/Modifier/Attributes.php b/Model/Ui/DataProvider/Product/Form/Modifier/Attributes.php new file mode 100644 index 00000000..252eb07e --- /dev/null +++ b/Model/Ui/DataProvider/Product/Form/Modifier/Attributes.php @@ -0,0 +1,69 @@ +arrayManager = $arrayManager; + $this->totalConfigProvider = $totalConfigProvider; + } + + /** + * ModifyData + * + * @param array $data + * + * @return array + */ + public function modifyData(array $data) + { + return $data; + } + + /** + * ModifyMeta. + * + * @param array $meta + * + * @return array + */ + public function modifyMeta(array $meta) + { + if (isset($meta['product-details']['children']['container_recurring_payment_schedule'])) { + $attribute = 'recurring_payment_schedule'; + $path = $this->arrayManager->findPath($attribute, $meta, null, 'children'); + + if (!$this->totalConfigProvider->isRecurringPaymentEnabled()) { + $meta = $this->arrayManager->set( + "{$path}/arguments/data/config/visible", + $meta, + false + ); + } else { + $meta = $this->arrayManager->set( + "{$path}/arguments/data/config/visible", + $meta, + true + ); + } + } + + return $meta; + } +} diff --git a/Model/Validation/CustomerData.php b/Model/Validation/CustomerData.php new file mode 100644 index 00000000..4c9cc4ed --- /dev/null +++ b/Model/Validation/CustomerData.php @@ -0,0 +1,36 @@ +getCustomerId() !== $customerId) { + throw new LocalizedException(__("The payment token doesn't belong to the customer")); + } + } + + /** + * @param SubscriptionInterface $subscription + * @param int $customerId + * @return void + * @throws LocalizedException + */ + public function validateSubscriptionsCustomer(SubscriptionInterface $subscription, int $customerId): void + { + if ((int)$subscription->getCustomerId() !== $customerId) { + throw new LocalizedException(__("The subscription doesn't belong to the customer")); + } + } +} diff --git a/Model/Validation/PreventAdminActions.php b/Model/Validation/PreventAdminActions.php new file mode 100644 index 00000000..9d714f86 --- /dev/null +++ b/Model/Validation/PreventAdminActions.php @@ -0,0 +1,36 @@ +customerSession = $customerSession; + } + + /** + * @return bool + */ + public function isAdminAsCustomer(): bool + { + if ($this->customerSession->getData(self::KEY_LOGIN_AS_CUSTOMER_SESSION)) { + return true; + } + + return false; + } +} diff --git a/Observer/RecurringPaymentFromQuoteToOrder.php b/Observer/RecurringPaymentFromQuoteToOrder.php new file mode 100644 index 00000000..b312e5f9 --- /dev/null +++ b/Observer/RecurringPaymentFromQuoteToOrder.php @@ -0,0 +1,35 @@ +quoteConverter = $quoteConverter; + } + + public function execute(Observer $observer) + { + /** @var \Magento\Quote\Model\Quote $quote */ + $quote = $observer->getEvent()->getQuote(); + if ($quote->getData('recurring_payment_flag')) { + /** @var \Magento\Sales\Model\Order $order */ + $order = $observer->getEvent()->getOrder(); + $order->setCanSendNewEmailFlag(false); + $this->quoteConverter->addRecurringPaymentToOrder($order, $quote); + } + } +} diff --git a/Observer/ScheduledCartValidation.php b/Observer/ScheduledCartValidation.php new file mode 100644 index 00000000..ff589569 --- /dev/null +++ b/Observer/ScheduledCartValidation.php @@ -0,0 +1,59 @@ +getEvent()->getOrder()->getQuoteId(); + $cart = $this->cartRepository->get($cartId); + + if ($cart->getItems() && $this->totalConfigProvider->isRecurringPaymentEnabled()) { + foreach ($cart->getItems() as $cartItem) { + $cartItemSchedule = $cartItem + ->getProduct() + ->getCustomAttribute(PreventDifferentScheduledCart::SCHEDULE_CODE); + + if ($cartItemSchedule && $cartItemSchedule->getValue()) { + if (null !== $cartSchedule && $cartSchedule !== $cartItemSchedule->getValue()) { + throw new LocalizedException(__("Can't place order with different scheduled products in cart")); + } else { + $cartSchedule = $cartItemSchedule->getValue(); + } + } + } + } + } +} diff --git a/Plugin/Api/OrderRepository.php b/Plugin/Api/OrderRepository.php new file mode 100644 index 00000000..586484db --- /dev/null +++ b/Plugin/Api/OrderRepository.php @@ -0,0 +1,66 @@ +subscriptionRepository = $subscriptionRepository; + $this->orderExtensionFactory = $extensionFactory; + $this->subscriptionLinkRepository = $subscriptionLinkRepository; + } + + /** + * @param \Magento\Sales\Api\OrderRepositoryInterface $subject + * @param \Magento\Sales\Api\Data\OrderInterface $order + * @return \Magento\Sales\Api\Data\OrderInterface + * @throws \Magento\Framework\Exception\CouldNotSaveException + */ + public function afterSave( + \Magento\Sales\Api\OrderRepositoryInterface $subject, + $order + ) { + $extensionAttributes = $order->getExtensionAttributes(); + if ($extensionAttributes && $extensionAttributes->getRecurringPayment()) { + $extensionAttributes->getRecurringPayment()->setOrderId($order->getId()); + $this->subscriptionRepository->save($extensionAttributes->getRecurringPayment()); + } + + return $order; + } + + /** + * @param \Magento\Sales\Api\Data\OrderInterface $order + * @return SubscriptionInterface|bool + */ + private function getRecurringPayment(\Magento\Sales\Api\Data\OrderInterface $order) + { + try { + $payment = $this->subscriptionLinkRepository->getSubscriptionFromOrderId($order->getId()); + } catch (NoSuchEntityException $e) { + $payment = false; + } + + return $payment; + } +} diff --git a/Plugin/PreventDifferentScheduledCart.php b/Plugin/PreventDifferentScheduledCart.php new file mode 100644 index 00000000..98a7f68f --- /dev/null +++ b/Plugin/PreventDifferentScheduledCart.php @@ -0,0 +1,47 @@ +getItems() ?: []; + $addItemSchedule = $product->getCustomAttribute(self::SCHEDULE_CODE); + if (!$addItemSchedule) { + return [$product, $request, $processMode]; + } + foreach ($cartItems as $item) { + $cartItemSchedule = $item->getProduct()->getCustomAttribute(self::SCHEDULE_CODE); + if ($cartItemSchedule && $cartItemSchedule->getValue() != $addItemSchedule->getValue()) { + throw new LocalizedException(__("Can't add product with different payment schedule")); + } + } + + return [$product, $request, $processMode]; + } +} diff --git a/Plugin/RecurringToOrderGrid.php b/Plugin/RecurringToOrderGrid.php new file mode 100644 index 00000000..cbaa551b --- /dev/null +++ b/Plugin/RecurringToOrderGrid.php @@ -0,0 +1,46 @@ +isLoaded()) { + $primaryKey = $subject->getResource()->getIdFieldName(); + $tableName = $subject->getResource()->getTable('nexi_subscription_link'); + + $subject->getSelect()->joinLeft( + $tableName, + 'main_table.' . $primaryKey . ' = ' . $tableName . '.order_id', + 'subscription_id' + ); + + $subject->getSelect()->joinLeft( + $subject->getResource()->getTable('nexi_subscriptions'), + $tableName . '.subscription_id = nexi_subscriptions.entity_id', + [ + 'recurring_status' => 'nexi_subscriptions.status', + 'customer_id' => 'nexi_subscriptions.customer_id', + 'subscription_id' => 'nexi_subscriptions.entity_id' + ] + ); + + $subject->getSelect()->joinLeft( + $subject->getResource()->getTable('recurring_payment_profiles'), + 'nexi_subscriptions.recurring_profile_id = recurring_payment_profiles.profile_id', + ['recurring_profile' => 'recurring_payment_profiles.name'] + ); + } + + return null; + } +} diff --git a/Setup/Patch/Data/AddRecurringPaymentScheduleAttribute.php b/Setup/Patch/Data/AddRecurringPaymentScheduleAttribute.php new file mode 100644 index 00000000..f0b0c610 --- /dev/null +++ b/Setup/Patch/Data/AddRecurringPaymentScheduleAttribute.php @@ -0,0 +1,96 @@ +moduleDataSetup = $moduleDataSetup; + $this->eavSetupFactory = $eavSetupFactory; + } + + /** + * @return DataPatchInterface + * @throws LocalizedException + * @throws ValidateException + */ + public function apply() + { + /** @var EavSetup $eavSetup */ + $eavSetup = $this->eavSetupFactory->create(['setup' => $this->moduleDataSetup]); + + $eavSetup->addAttribute(\Magento\Catalog\Model\Product::ENTITY, 'recurring_payment_schedule', [ + 'type' => 'int', + 'backend' => '', + 'frontend' => '', + 'label' => 'Recurring Payment Schedule', + 'input' => 'select', + 'class' => '', + 'source' => 'Nexi\Checkout\Model\Attribute\SelectData', + 'global' => ScopedAttributeInterface::SCOPE_GLOBAL, + 'visible' => true, + 'required' => false, + 'user_defined' => false, + 'default' => SelectData::NO_RECURRING_PAYMENT_VALUE, + 'searchable' => true, + 'filterable' => true, + 'comparable' => false, + 'visible_on_front' => true, + 'used_in_product_listing' => true, + 'unique' => false, + ]); + + return $this; + } + + /** + * {@inheritdoc} + */ + public static function getDependencies() + { + return []; + } + + /** + * {@inheritdoc} + */ + public function getAliases() + { + return []; + } + + /** + * {@inheritdoc} + */ + public static function getVersion() + { + return '2.0.0'; + } +} diff --git a/Setup/Patch/Data/InstallProfilesPatch.php b/Setup/Patch/Data/InstallProfilesPatch.php new file mode 100644 index 00000000..fe6a6d84 --- /dev/null +++ b/Setup/Patch/Data/InstallProfilesPatch.php @@ -0,0 +1,96 @@ + 'Weekly', + 'schedule' => [ + 'interval' => 1, + 'unit' => 'W' + ] + ], + [ + 'name' => 'Monthly', + 'schedule' => [ + 'interval' => 1, + 'unit' => 'M' + ] + ], + ]; + /** + * @var ModuleDataSetupInterface + */ + private $moduleDataSetup; + /** + * @var SerializerInterface + */ + private $serializer; + + /** + * @param ModuleDataSetupInterface $moduleDataSetup + */ + public function __construct( + ModuleDataSetupInterface $moduleDataSetup, + SerializerInterface $serializer + ) { + $this->moduleDataSetup = $moduleDataSetup; + $this->serializer = $serializer; + } + + /** + * @inheritdoc + */ + public function apply() + { + $this->moduleDataSetup->getConnection()->startSetup(); + $this->addDefaultProfiles(); + $this->moduleDataSetup->getConnection()->endSetup(); + + return $this; + } + + /** + * @inheritdoc + */ + public static function getDependencies() + { + return []; + } + + /** + * @inheritdoc + */ + public function getAliases() + { + return []; + } + + private function addDefaultProfiles() + { + $connection = $this->moduleDataSetup->getConnection(); + $this->moduleDataSetup->getConnection()->insertMultiple( + $connection->getTableName('recurring_payment_profiles'), + $this->getProfileData() + ); + } + + private function getProfileData() + { + $data = []; + + foreach (self::DEFAULT_PROFILE_DATA as $profile) { + $profile['schedule'] = $this->serializer->serialize($profile['schedule']); + $data[] = $profile; + } + + return $data; + } +} diff --git a/Ui/Component/Listing/Column/RecurringAction.php b/Ui/Component/Listing/Column/RecurringAction.php new file mode 100644 index 00000000..2f1d1210 --- /dev/null +++ b/Ui/Component/Listing/Column/RecurringAction.php @@ -0,0 +1,82 @@ +urlBuilder = $urlBuilder; + parent::__construct($context, $uiComponentFactory, $components, $data); + } + + /** + * Prepare Theme Data Source + * + * @param array $dataSource + * @return array + */ + public function prepareDataSource(array $dataSource) : array + { + if (isset($dataSource['data']['items'])) { + foreach ($dataSource['data']['items'] as & $item) { + $indexField = $this->getData('config/indexField') ?: 'entity_id'; + if (isset($item[$indexField])) { + $viewUrlPath = $this->getData('config/viewUrlPath') ?: '#'; + $urlEntityParamName = $this->getData('config/urlEntityParamName') ?: 'id'; + $item[$this->getData('name')] = [ + 'view' => [ + 'href' => $this->urlBuilder->getUrl( + $viewUrlPath, + [ + $urlEntityParamName => $item[$indexField] + ] + ), + 'label' => __('View'), + ] + ]; + $item[$this->getData('name')]['view_order'] = [ + 'href' => $this->urlBuilder->getUrl( + 'sales/order/view', + [ + 'order_id' => $item['last_order_id'] + ] + ), + 'label' => __('View last Order'), + ]; + } + } + } + + return $dataSource; + } +} diff --git a/Ui/DataProvider/RecurringPayment.php b/Ui/DataProvider/RecurringPayment.php new file mode 100644 index 00000000..258396e9 --- /dev/null +++ b/Ui/DataProvider/RecurringPayment.php @@ -0,0 +1,91 @@ +collectionFactory = $collectionFactory; + } + + /** + * @return array + */ + public function getData() + { + $collection = $this->getCollection(); + + return $collection->toArray(); + } + + /** + * @param Filter $filter + * @return mixed|void + */ + public function addFilter(Filter $filter) + { + if ($filter->getField() == 'entity_id') { + $filter->setField('main_table.entity_id'); + } + + parent::addFilter($filter); + } + + /** + * @return AbstractCollection|Collection + */ + public function getCollection() + { + if (!$this->collection) { + $this->collection = $this->collectionFactory->create(); + $this->collection->getSelect() + ->join( + ['cu' => 'customer_entity'], + 'main_table.customer_id = cu.entity_id', + ['cu.email'] + )->join( + ['sublink' => 'nexi_subscription_link'], + 'main_table.entity_id = sublink.subscription_id', + ['last_order_id' => 'MAX(sublink.order_id)'] + )->join( + ['profile' => 'recurring_payment_profiles'], + 'main_table.recurring_profile_id = profile.profile_id', + ['profile_name' => 'profile.name'] + )->group('main_table.entity_id'); + } + + return $this->collection; + } +} diff --git a/Ui/DataProvider/RecurringPaymentForm.php b/Ui/DataProvider/RecurringPaymentForm.php new file mode 100644 index 00000000..a6df3dd6 --- /dev/null +++ b/Ui/DataProvider/RecurringPaymentForm.php @@ -0,0 +1,139 @@ +collectionFactory = $collectionFactory; + $this->url = $url; + } + + /** + * @return array + */ + public function getData() + { + if (isset($this->loadedData)) { + return $this->loadedData; + } + + $this->loadedData = []; + foreach ($this->getCollection() as $subscription) { + $this->prepareLinks($subscription); + $this->loadedData[$subscription->getId()] = $subscription->getData(); + } + + return $this->loadedData; + } + + /** + * @param Filter $filter + * @return mixed|void + */ + public function addFilter(Filter $filter) + { + if ($filter->getField() == 'entity_id') { + $filter->setField('main_table.entity_id'); + } + + parent::addFilter($filter); // TODO: Change the autogenerated stub + } + + /** + * @return Collection + */ + public function getCollection() + { + if (!$this->collection) { + $this->collection = $this->collectionFactory->create(); + $this->joinProfilesToCollection(); + } + + return $this->collection; + } + + /** + * @param Subscription $subscription + */ + private function prepareLinks($subscription) + { + $subscription->setData( + 'profile_link', + $this->createLinkData( + 'recurring_payments/profile/edit', + ['id' => $subscription->getRecurringProfileId()], + $subscription->getProfileName() + ) + ); + } + + /** + * @return void + */ + private function joinProfilesToCollection() + { + $this->collection->join( + ['rpp' => 'recurring_payment_profiles'], + 'main_table.recurring_profile_id = rpp.profile_id', + ['profile_name' => 'name'] + ); + } + + /** + * @param string $path + * @param array $params + * @param string $linkText + * @return array + */ + private function createLinkData(string $path, array $params, string $linkText) + { + return [ + 'link_text' => $linkText, + 'link' => $this->url->getUrl( + $path, + $params + ) + ]; + } +} diff --git a/Ui/DataProvider/RecurringProfile.php b/Ui/DataProvider/RecurringProfile.php new file mode 100644 index 00000000..b723737b --- /dev/null +++ b/Ui/DataProvider/RecurringProfile.php @@ -0,0 +1,58 @@ +collectionFactory = $collectionFactory; + } + + /** + * @return array + */ + public function getData() + { + $collection = $this->getCollection(); + + return $collection->toArray(); + } + + /** + * @return AbstractCollection|Collection + */ + public function getCollection() + { + if (!$this->collection) { + $this->collection = $this->collectionFactory->create(); + } + + return $this->collection; + } +} diff --git a/Ui/DataProvider/RecurringProfileForm.php b/Ui/DataProvider/RecurringProfileForm.php new file mode 100644 index 00000000..8e986aaf --- /dev/null +++ b/Ui/DataProvider/RecurringProfileForm.php @@ -0,0 +1,102 @@ +collectionFactory = $collectionFactory; + $this->serializer = $serializer; + } + + /** + * @return array + */ + public function getData() + { + if (isset($this->loadedData)) { + return $this->loadedData; + } + + $this->loadedData = []; + foreach ($this->getCollection() as $recurringProfile) { + $recurringProfile->setData('interval_period', $this->parseSchedule('interval', $recurringProfile)); + $recurringProfile->setData('interval_unit', $this->parseSchedule('unit', $recurringProfile)); + $this->loadedData[$recurringProfile->getId()] = $recurringProfile->getData(); + } + + return $this->loadedData; + } + + /** + * @return AbstractCollection|Collection + */ + public function getCollection() + { + if (!$this->collection) { + $this->collection = $this->collectionFactory->create(); + } + + return $this->collection; + } + + /** + * @param string $value + * @param $recurringProfile + * @return mixed|string|null + */ + private function parseSchedule(string $value, $recurringProfile) + { + if (!$this->schedule) { + try { + $this->schedule = $this->serializer->unserialize($recurringProfile->getSchedule()); + } catch (\InvalidArgumentException $e) { + $this->schedule = []; + } + } + + return $this->schedule[$value] ?? null; + } +} diff --git a/etc/acl.xml b/etc/acl.xml new file mode 100644 index 00000000..8a5e4743 --- /dev/null +++ b/etc/acl.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + diff --git a/etc/adminhtml/di.xml b/etc/adminhtml/di.xml new file mode 100644 index 00000000..4a676316 --- /dev/null +++ b/etc/adminhtml/di.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + Nexi\Checkout\Model\Ui\DataProvider\Product\Form\Modifier\Attributes + 1000 + + + + + diff --git a/etc/adminhtml/menu.xml b/etc/adminhtml/menu.xml new file mode 100644 index 00000000..a24eef9e --- /dev/null +++ b/etc/adminhtml/menu.xml @@ -0,0 +1,35 @@ + + + + + + + + diff --git a/etc/adminhtml/routes.xml b/etc/adminhtml/routes.xml index 7d76a081..9307e842 100644 --- a/etc/adminhtml/routes.xml +++ b/etc/adminhtml/routes.xml @@ -10,5 +10,8 @@ + + + diff --git a/etc/adminhtml/system.xml b/etc/adminhtml/system.xml index 2f040556..62e33c4a 100644 --- a/etc/adminhtml/system.xml +++ b/etc/adminhtml/system.xml @@ -77,5 +77,47 @@ +
+ + + + + Activating Recurring Payment feature + Magento\Config\Model\Config\Source\Yesno + + + + + 1 + + + + + + 1 + + + + + Controls the amount of days between notifying customer about upcoming payment and billing the payment + validate-digit validate-range range-0-30 + + 1 + + + + + Magento\Config\Model\Config\Source\Yesno + + 1 + + + +
diff --git a/etc/catalog_attributes.xml b/etc/catalog_attributes.xml new file mode 100644 index 00000000..9b68724f --- /dev/null +++ b/etc/catalog_attributes.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/etc/config.xml b/etc/config.xml index 04c74dca..b05464ec 100644 --- a/etc/config.xml +++ b/etc/config.xml @@ -43,5 +43,15 @@ 1 + + + 0 + 0 1 * * * + 15 3 * * * + recurring_email_template + 7 + 0 + + diff --git a/etc/crontab.xml b/etc/crontab.xml new file mode 100644 index 00000000..26dbc9b6 --- /dev/null +++ b/etc/crontab.xml @@ -0,0 +1,12 @@ + + + + + + sales/recurring_payment/bill_cron_schedule + + + sales/recurring_payment/notify_cron_schedule + + + diff --git a/etc/db_schema.xml b/etc/db_schema.xml new file mode 100644 index 00000000..b0b70bbb --- /dev/null +++ b/etc/db_schema.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + +
+ + + + + + + + + + + + + + + + +
+
diff --git a/etc/db_schema_whitelist.json b/etc/db_schema_whitelist.json new file mode 100644 index 00000000..e9ef8549 --- /dev/null +++ b/etc/db_schema_whitelist.json @@ -0,0 +1,51 @@ +{ + "nexi_subscriptions": { + "column": { + "entity_id": true, + "status": true, + "next_order_date": true, + "recurring_profile_id": true, + "updated_at": true, + "end_date": true, + "repeat_count_left": true, + "retry_count": true, + "selected_token": true, + "customer_id": true + }, + "index": { + "NEXI_SUBSCRIPTIONS_STATUS": true + }, + "constraint": { + "PRIMARY": true, + "FK_8D8B31C8FA705FDDF495326B9267594D": true, + "NEXI_SUBSCRIPTIONS_SELECTED_TOKEN_VAULT_PAYMENT_TOKEN_ENTITY_ID": true, + "FK_C52FB2CF53BB599FD4BAF601615C36C6": true, + "NEXI_SUBSCRIPTIONS_CUSTOMER_ID_CUSTOMER_ENTITY_ENTITY_ID": true, + "FK_A3A41A8A2917DB96EB7845EE6B4A46B6": true + } + }, + "recurring_payment_profiles": { + "column": { + "profile_id": true, + "name": true, + "description": true, + "schedule": true + }, + "constraint": { + "PRIMARY": true + } + }, + "nexi_subscription_link": { + "column": { + "link_id": true, + "order_id": true, + "subscription_id": true + }, + "constraint": { + "PRIMARY": true, + "NEXI_SUBSCRIPTION_LINK_ORDER_ID_SALES_ORDER_ENTITY_ID": true, + "FK_86F57A98A67516BAAF65D6F8B80F2C0D": true, + "NEXI_SUBSCRIPTION_LINK_ORDER_ID_SUBSCRIPTION_ID": true + } + } +} diff --git a/etc/di.xml b/etc/di.xml index fbd3a0de..2c6f8c27 100644 --- a/etc/di.xml +++ b/etc/di.xml @@ -202,4 +202,45 @@ NexiVirtualLogger + + + + + + Nexi\Checkout\Console\Command\Bill + + Nexi\Checkout\Console\Command\Notify + + + + + + + + + + + + + + + + + + + + Magento\Backend\Model\Session\Quote\Proxy + + diff --git a/etc/events.xml b/etc/events.xml new file mode 100644 index 00000000..303eeaf9 --- /dev/null +++ b/etc/events.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/etc/frontend/di.xml b/etc/frontend/di.xml index c1d044c3..2e5b4f4c 100644 --- a/etc/frontend/di.xml +++ b/etc/frontend/di.xml @@ -10,5 +10,11 @@ - + + + + Nexi\Checkout\Model\Recurring\TotalConfigProvider + + + diff --git a/etc/webapi.xml b/etc/webapi.xml index 3fa88589..b5a55486 100644 --- a/etc/webapi.xml +++ b/etc/webapi.xml @@ -1,5 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/i18n/en_US.csv b/i18n/en_US.csv new file mode 100755 index 00000000..eef3ad19 --- /dev/null +++ b/i18n/en_US.csv @@ -0,0 +1,4 @@ + +"Card for subscription changed successfully","Card for subscription changed successfully" +"The subscription doesn't belong to the customer", "The subscription doesn't belong to the customer" +"The card has active subscriptions","The card has an active subscriptions" diff --git a/i18n/fi_FI.csv b/i18n/fi_FI.csv new file mode 100644 index 00000000..d1036216 --- /dev/null +++ b/i18n/fi_FI.csv @@ -0,0 +1,15 @@ +"You have subscribed for recurring order from %store_name.","Olet tehnyt toistuvan tilauksen verkkokaupassamme." +"If you want to cancel your recurring order log into your account.", "Jos haluat peruutta toistuvan tilauksen kirjaudu käyttäjätilillesi." +"Should the recurring payment fail, we will add %warning_period days to your order, so that you can update your payment details.", "Jos automaattinen uusinta epäonnistuu, tilaukseesi lisätään x päivää lisäaikaa, jotta ehdit päivittää maksutietosi." +"Recurring payment stopped successfully","Tilaus pysäytettiin onnistuneesti" +"Can't add product with different payment schedule","Tuotetta ei voi lisätä eri maksuaikataululla" +"Recurring Payment","Toistuvat maksut" +"Recurring total","Toistuvat maksut yhteensä" +"Recurring payment purchases require using a saved card.","Toistuvat tilaukset vaativat tallennetun luottokortin maksuja varten" +"Subscription couldn't be canceled","Tilausta ei voitu peruuttaa" +"Subscription has been canceled correctly","Tilaus on peruutettu" +"Subscription is closed","Tilaus on suljettu" +"Subscription orders can't be shown","Tilaustilauksia ei voida näyttää" +"The subscription doesn't belong to the customer", "Tilaus ei kuulu asiakkaalle" +"The card has active subscriptions","Kortilla on aktiivisia tilauksia" +"My Subscriptions","Toistuvat tilaukseni" diff --git a/view/adminhtml/layout/recurring_payments_profile_edit.xml b/view/adminhtml/layout/recurring_payments_profile_edit.xml new file mode 100755 index 00000000..2145f3aa --- /dev/null +++ b/view/adminhtml/layout/recurring_payments_profile_edit.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/view/adminhtml/layout/recurring_payments_profile_index.xml b/view/adminhtml/layout/recurring_payments_profile_index.xml new file mode 100644 index 00000000..a074f394 --- /dev/null +++ b/view/adminhtml/layout/recurring_payments_profile_index.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/view/adminhtml/layout/recurring_payments_recurring_index.xml b/view/adminhtml/layout/recurring_payments_recurring_index.xml new file mode 100644 index 00000000..c4f20902 --- /dev/null +++ b/view/adminhtml/layout/recurring_payments_recurring_index.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/view/adminhtml/layout/recurring_payments_recurring_view.xml b/view/adminhtml/layout/recurring_payments_recurring_view.xml new file mode 100644 index 00000000..a2e57bc9 --- /dev/null +++ b/view/adminhtml/layout/recurring_payments_recurring_view.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/view/adminhtml/ui_component/recurring_payments_listing.xml b/view/adminhtml/ui_component/recurring_payments_listing.xml new file mode 100644 index 00000000..fc966817 --- /dev/null +++ b/view/adminhtml/ui_component/recurring_payments_listing.xml @@ -0,0 +1,187 @@ + ++ + + recurring_payments_listing.recurring_payments_listing_data_source + recurring_payments_listing.recurring_payments_listing_data_source + + recurring_payments_columns + + + + Nexi\Checkout\Ui\DataProvider\RecurringPayment + recurring_payments_listing_data_source + entity_id + entity_id + + + + + entity_id + + + + + + + Magento_Ui/js/grid/provider + + + + + + + + + + recurring_payments_listing.recurring_payments_listing.recurring_payments_columns.ids + bottom + Magento_Ui/js/grid/tree-massactions + entity_id + + + + + + delete + Delete + + + Delete items + Are you sure you want to delete selected items? + + + + + + + + + + + + Magento_Ui/js/form/element/ui-select + ui/grid/filters/elements/ui-select + + + + + + + + + + + + + recurring_payments_listing.recurring_payments_listing.recurring_payments_columns.actions + applyAction + + view + ${ $.$data.rowIndex } + + + + + + + + entity_id + + + + + + textRange + + 25 + + + + + text + ui/grid/cells/text + + + + + + dateRange + date + + + + + + text + ui/grid/cells/text + + false + + + + + text + ui/grid/cells/text + + + + + + text + ui/grid/cells/text + + + + + + dateRange + date + + false + + + + + dateRange + date + + false + + + + + text + ui/grid/cells/text + + false + + + + + text + ui/grid/cells/text + + false + + + + + text + ui/grid/cells/text + + + + + + + recurring_payments/recurring/view + id + + + + entity_id + + + + diff --git a/view/adminhtml/ui_component/recurring_payments_view.xml b/view/adminhtml/ui_component/recurring_payments_view.xml new file mode 100644 index 00000000..5b283578 --- /dev/null +++ b/view/adminhtml/ui_component/recurring_payments_view.xml @@ -0,0 +1,181 @@ + +
+ + + recurring_payments_view.recurring_payment_data_source + recurring_payments_view.recurring_payment_data_source + + Sample Form + templates/form/collapsible + + + Nexi\Checkout\Block\Adminhtml\Subscription\Edit\BackButton + Nexi\Checkout\Block\Adminhtml\Subscription\Edit\DeleteButton + Nexi\Checkout\Block\Adminhtml\Subscription\Edit\StopButton + Nexi\Checkout\Block\Adminhtml\Subscription\Edit\SaveButton + + + + data + recurring_payments_view + + + + + + + Magento_Ui/js/form/provider + + + + + + + + id + entity_id + + + + +
+ + + Recurring payment + + + + + + + + false + text + input + + + + + + + + Status + true + text + select + true + + + + + true + + + + + + + + + + + Next order date + true + true + date + date + + + + + + + + profile + false + true + text + input + + + + + + + + ui/form/field + Nexi_Checkout/form/element/link + Profile + text + true + input + + + + + + + + Payments remaining + true + true + true + text + input + + + + + + + + Final order date + true + true + date + date + + + + + + + + Failure retries left + true + false + text + input + + + + + + + + Selected card + true + text + select + false + + + + + true + + + + + + +
+
diff --git a/view/adminhtml/ui_component/recurring_profile_edit.xml b/view/adminhtml/ui_component/recurring_profile_edit.xml new file mode 100644 index 00000000..02e73e5c --- /dev/null +++ b/view/adminhtml/ui_component/recurring_profile_edit.xml @@ -0,0 +1,128 @@ + +
+ + + recurring_profile_edit.recurring_profile_data_source + recurring_profile_edit.recurring_profile_data_source + + Edit profile + templates/form/collapsible + + + Nexi\Checkout\Block\Adminhtml\Subscription\Edit\BackButton + Nexi\Checkout\Block\Adminhtml\Subscription\Edit\DeleteButton + Nexi\Checkout\Block\Adminhtml\Subscription\Edit\ResetButton + Nexi\Checkout\Block\Adminhtml\Subscription\Edit\SaveButton + + + + data + recurring_profile_edit + + + + + + + Magento_Ui/js/form/provider + + + + + + + + id + profile_id + + + + +
+ + + Recurring payment + + + + + + + + false + text + input + + + + + + + + Name + true + text + input + + + + + true + 3 + + + + + + + + Description + true + text + textarea + + + + + + + + Schedule triggered every X + true + text + input + + + + + true + true + + + + + + + + Schedule time measured in + true + text + select + + + + + true + + + + + + +
+
diff --git a/view/adminhtml/ui_component/recurring_profile_listing.xml b/view/adminhtml/ui_component/recurring_profile_listing.xml new file mode 100644 index 00000000..00a02edb --- /dev/null +++ b/view/adminhtml/ui_component/recurring_profile_listing.xml @@ -0,0 +1,151 @@ + ++ + + recurring_profile_listing.recurring_profile_listing_data_source + + recurring_profile_listing.recurring_profile_listing_data_source + + recurring_profile_columns + + + + + + + + + Nexi\Checkout\Ui\DataProvider\RecurringProfile + recurring_profile_listing_data_source + profile_id + profile_id + + + + + profile_id + + + + + + + Magento_Ui/js/grid/provider + + + + + + + + + + + recurring_profile_listing.recurring_profile_listing.recurring_profile_columns.ids + + bottom + Magento_Ui/js/grid/tree-massactions + profile_id + + + + + + delete + Delete + + + Delete items + Are you sure you want to delete + selected items? + + + + + + + + + + + + + Magento_Ui/js/form/element/ui-select + ui/grid/filters/elements/ui-select + + + + + + + + + + + + + + recurring_profile_listing.recurring_profile_listing.recurring_profile_columns.actions + + applyAction + + edit + ${ $.$data.rowIndex } + + + + + + + + profile_id + + + + + + textRange + + 25 + + + + + text + ui/grid/cells/text + + + + + + text + ui/grid/cells/text + + + + + + text + ui/grid/cells/text + + + + + + + recurring_payments/profile/edit + id + + + + profile_id + + + + diff --git a/view/adminhtml/ui_component/sales_order_grid.xml b/view/adminhtml/ui_component/sales_order_grid.xml new file mode 100644 index 00000000..28973de4 --- /dev/null +++ b/view/adminhtml/ui_component/sales_order_grid.xml @@ -0,0 +1,49 @@ + ++ + + + + text + Recurring Payment Status + + + + false + + + + + + text + Recurring Customer Id + + + + false + + + + + + text + Recurring Payment ID + + + + false + + + + + + text + Recurring Payment Profile + + + + false + + + + diff --git a/view/adminhtml/web/css/recurring.css b/view/adminhtml/web/css/recurring.css new file mode 100644 index 00000000..4eb36549 --- /dev/null +++ b/view/adminhtml/web/css/recurring.css @@ -0,0 +1,8 @@ +.recurring-order-link { + display: inline-block; + padding-top: 8px; +} + +.form-inline .admin__fieldset > .admin__field { + margin-bottom: 22px; +} \ No newline at end of file diff --git a/view/adminhtml/web/template/form/element/link.html b/view/adminhtml/web/template/form/element/link.html new file mode 100644 index 00000000..8cd86e78 --- /dev/null +++ b/view/adminhtml/web/template/form/element/link.html @@ -0,0 +1 @@ +View \ No newline at end of file diff --git a/view/frontend/email/recurring_new.html b/view/frontend/email/recurring_new.html new file mode 100644 index 00000000..35c7583b --- /dev/null +++ b/view/frontend/email/recurring_new.html @@ -0,0 +1,95 @@ + + + +{{template config_path="design/email/header_template"}} + + + + + + + + + + + +
+ +{{template config_path="design/email/footer_template"}} diff --git a/view/frontend/email/restore_order_notification.html b/view/frontend/email/restore_order_notification.html new file mode 100644 index 00000000..8bca8d6f --- /dev/null +++ b/view/frontend/email/restore_order_notification.html @@ -0,0 +1,27 @@ + + + +{{template config_path="design/email/header_template"}} + +

{{trans "Hello"}}

+

+ {{trans + 'Restore order %order_increment' + + order_url=$order.url + order_increment=$order.increment + |raw}} +

+

+ {{trans + "The payment for the order %order_increment has been completed while the order was in canceled state. + Please navigate to your Magento orders, select the order in question, and click restore order. Note the stock." + + order_increment=$order.increment + |raw}} +

+ +{{template config_path="design/email/footer_template"}} \ No newline at end of file diff --git a/view/frontend/layout/checkout_index_index.xml b/view/frontend/layout/checkout_index_index.xml index 023f2cf0..a4d2031b 100644 --- a/view/frontend/layout/checkout_index_index.xml +++ b/view/frontend/layout/checkout_index_index.xml @@ -8,6 +8,27 @@ + + + + + + + + uiComponent + 35 + + + Nexi_Checkout/js/view/checkout/summary/recurring_total + + + + + + + + + @@ -25,6 +46,14 @@ + + Nexi_Checkout/js/view/payment/nexi-payment + + + true + + + diff --git a/view/frontend/layout/customer_account.xml b/view/frontend/layout/customer_account.xml new file mode 100644 index 00000000..c6b8d7e5 --- /dev/null +++ b/view/frontend/layout/customer_account.xml @@ -0,0 +1,16 @@ + + + + + + + + nexi/order/payments + My Subscriptions + 229 + + + + + diff --git a/view/frontend/templates/order/payments.phtml b/view/frontend/templates/order/payments.phtml new file mode 100644 index 00000000..439ef05a --- /dev/null +++ b/view/frontend/templates/order/payments.phtml @@ -0,0 +1,140 @@ + +getRecurringPayments() ?> +getClosedSubscriptions() ?> +isSubscriptionsEnabled()): ?> + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
escapeHtml(__('Subscriptions')) ?>
escapeHtml(__('Next order date')) ?>escapeHtml(__('Status')) ?>escapeHtml(__('Recurring payment profile')) ?>escapeHtml(__('Grand total')) ?>escapeHtml(__('Card number')) ?>escapeHtml(__('Action')) ?>
escapeHtml($block->validateDate($recurringPayment->getNextOrderDate())) ?>escapeHtml($block->getRecurringPaymentStatusName($recurringPayment->getStatus())) ?>escapeHtml($recurringPayment->getName()) ?>escapeHtml($block->getCurrentCurrency()) . $block->escapeHtml($recurringPayment->getBaseGrandTotal()) ?>escapeHtml($block->getCardNumber($recurringPayment)) ?> + +
+
+ getPagerHtml()) : ?> +
getPagerHtml() ?>
+ + +
escapeHtml($block->getEmptyRecurringPaymentsMessage()) ?> +
+ + + +

escapeHtml(__('Canceled subscriptions')) ?>

+
+ + + + + + + + + + + + + + + + + + + + + +
escapeHtml(__('Canceled subscriptions')) ?>
escapeHtml(__('Next order date')) ?>escapeHtml(__('Status')) ?>escapeHtml(__('Recurring payment profile')) ?>escapeHtml(__('Grand total')) ?>
escapeHtml($block->validateDate($closedSubscription->getNextOrderDate())) ?>escapeHtml($block->getRecurringPaymentStatusName($closedSubscription->getStatus())) ?>escapeHtml($closedSubscription->getName()) ?>escapeHtml($closedSubscription->getBaseGrandTotal()) ?> + + escapeHtml(__('View Order')) ?> + +
+
+ + +
+
+ + getChildHtml('vault.cards.list') ?> + + + + +
+
+ + + diff --git a/view/frontend/web/template/checkout/summary/recurring_total.html b/view/frontend/web/template/checkout/summary/recurring_total.html new file mode 100644 index 00000000..036dad67 --- /dev/null +++ b/view/frontend/web/template/checkout/summary/recurring_total.html @@ -0,0 +1,15 @@ + + + + + Subtotal + Shipping + Recurring Total + + +

+

+

+ + + From 75f6833b3f22e16082a5bdcd81a036a83b0359f6 Mon Sep 17 00:00:00 2001 From: Webben Oy Ab Date: Sun, 25 May 2025 21:26:03 +0300 Subject: [PATCH 040/320] Removed unwanted classes --- Api/CardManagementInterface.php | 27 -- Block/Order/Payments.php | 303 ++++++++++++++++++ Gateway/Validator/HmacValidator.php | 62 ---- Gateway/Validator/ResponseValidator.php | 127 -------- Model/Adapter/Adapter.php | 39 --- Model/ApplePay/ApplePayDataProvider.php | 136 -------- Model/Card/VaultConfig.php | 41 --- Model/CardManagement.php | 109 ------- Model/FinnishReferenceNumber.php | 156 --------- Model/Receipt/PaymentTransaction.php | 28 +- Model/Receipt/ProcessPayment.php | 119 +------ Model/ReceiptDataProvider.php | 3 - Model/Subscription/OrderBiller.php | 38 +-- Model/Token/Payment.php | 233 -------------- Model/Token/RequestData.php | 42 --- Model/Ui/ConfigProvider.php | 127 ++------ .../Ui/DataProvider/PaymentProvidersData.php | 244 -------------- view/frontend/layout/checkout_index_index.xml | 8 - view/frontend/templates/order/payments.phtml | 2 +- 19 files changed, 329 insertions(+), 1515 deletions(-) delete mode 100644 Api/CardManagementInterface.php create mode 100644 Block/Order/Payments.php delete mode 100755 Gateway/Validator/HmacValidator.php delete mode 100755 Gateway/Validator/ResponseValidator.php delete mode 100644 Model/Adapter/Adapter.php delete mode 100644 Model/ApplePay/ApplePayDataProvider.php delete mode 100644 Model/Card/VaultConfig.php delete mode 100644 Model/CardManagement.php delete mode 100644 Model/FinnishReferenceNumber.php delete mode 100644 Model/Token/Payment.php delete mode 100644 Model/Token/RequestData.php mode change 100755 => 100644 Model/Ui/ConfigProvider.php delete mode 100755 Model/Ui/DataProvider/PaymentProvidersData.php diff --git a/Api/CardManagementInterface.php b/Api/CardManagementInterface.php deleted file mode 100644 index 2a7b2c77..00000000 --- a/Api/CardManagementInterface.php +++ /dev/null @@ -1,27 +0,0 @@ -pageConfig->getTitle()->set(__('My Subscriptions')); + } + + /** + * Is Subscriptions functionality is enabled. + * + * @return bool + */ + public function isSubscriptionsEnabled(): bool + { + return $this->totalConfigProvider->isRecurringPaymentEnabled(); + } + + /** + * Get recurring payments (subscriptions). + * + * @return SubscriptionCollection + */ + public function getRecurringPayments() + { + $collection = $this->subscriptionCollectionFactory->create(); + $collection->addFieldToFilter('main_table.status', ['active', 'pending_payment', 'failed', 'rescheduled']); + + $collection->getSelect()->join( + ['link' => 'nexi_subscription_link'], + 'main_table.entity_id = link.subscription_id' + )->columns('MAX(link.order_id) as max_id') + ->group('link.subscription_id'); + + $collection->getSelect()->join( + ['so' => 'sales_order'], + 'link.order_id = so.entity_id', + ['main_table.entity_id', 'so.base_grand_total'] + ); + $collection->getSelect()->join( + ['rpp' => 'recurring_payment_profiles'], + 'main_table.recurring_profile_id = rpp.profile_id', + 'name' + ); + + $collection->addFieldToFilter('main_table.customer_id', $this->customerSession->getId()); + + return $collection; + } + + /** + * Get closed subscriptions. + * + * @return SubscriptionCollection + */ + public function getClosedSubscriptions() + { + $collection = $this->subscriptionCollectionFactory->create(); + $collection->addFieldToFilter('main_table.status', SubscriptionInterface::STATUS_CLOSED); + + $collection->getSelect()->join( + ['link' => 'nexi_subscription_link'], + 'main_table.entity_id = link.subscription_id' + )->columns('MAX(link.order_id) as max_id') + ->group('link.subscription_id'); + + $collection->getSelect()->join( + ['so' => 'sales_order'], + 'link.order_id = so.entity_id', + ['main_table.entity_id', 'so.base_grand_total'] + ); + $collection->getSelect()->join( + ['rpp' => 'recurring_payment_profiles'], + 'main_table.recurring_profile_id = rpp.profile_id', + 'name' + ); + + $collection->addFieldToFilter('main_table.customer_id', $this->customerSession->getId()); + + return $collection; + } + + /** + * Validate date. + * + * @param string $date + * + * @return string + */ + public function validateDate($date): string + { + $newDate = explode(' ', $date); + return $newDate[0]; + } + + /** + * Get recurring payment status name. + * + * @param string $recurringPaymentStatus + * + * @return Phrase|string + */ + public function getRecurringPaymentStatusName(string $recurringPaymentStatus): Phrase|string + { + switch ($recurringPaymentStatus) { + case 'active': + return __('Active'); + case 'paid': + return __('Paid'); + case 'failed': + return __('Failed'); + case 'pending_payment': + return __('Pending Payment'); + case 'rescheduled': + return __('Rescheduled'); + case 'closed': + return __('Closed'); + } + return ''; + } + + /** + * Get current currency. + * + * @return string + * @throws NoSuchEntityException + * @throws LocalizedException + */ + public function getCurrentCurrency() + { + return $this->storeManager->getStore()->getCurrentCurrency()->getCurrencySymbol(); + } + + /** + * Get view url. + * + * @param SubscriptionInterface $recurringPayment + * + * @return string + */ + public function getViewUrl(SubscriptionInterface $recurringPayment) + { + return $this->getUrl('sales/order/view', ['order_id' => $recurringPayment->getOrderId()]); + } + + /** + * Prepare layout. + * + * @return $this|Payments + * @throws LocalizedException + */ + protected function _prepareLayout() + { + parent::_prepareLayout(); + if ($this->getRecurringPayments()) { + $pager = $this->getLayout()->createBlock( + Pager::class, + 'checkout.order.recurring.payments.pager' + )->setCollection( + $this->getRecurringPayments() + ); + $this->setChild('pager', $pager); + $this->getRecurringPayments()->load(); + } + return $this; + } + + /** + * Get pager html. + * + * @return string + */ + public function getPagerHtml() + { + return $this->getChildHtml('pager'); + } + + /** + * Get stop payment url. + * + * @param SubscriptionInterface $recurringPayment + * + * @return string + */ + public function getStopPaymentUrl(SubscriptionInterface $recurringPayment) + { + return $this->getUrl('nexi/payments/stop', ['payment_id' => $recurringPayment->getId()]); + } + + /** + * Get empty recurring payment message. + * + * @return Phrase + */ + public function getEmptyRecurringPaymentsMessage(): Phrase + { + return __('You have no payments to display.'); + } + + /** + * Get credit card number. + * + * @param SubscriptionInterface $recurringPayment + * + * @return string + */ + public function getCardNumber(SubscriptionInterface $recurringPayment): string + { + $token = $this->paymentTokenRepository->getById($recurringPayment->getSelectedToken()); + if ($token) { + $tokenDetails = $this->serializer->unserialize($token->getTokenDetails()); + return '**** **** **** ' . $tokenDetails['maskedCC']; + } + + return ''; + } + + /** + * Get add_card request redirect url. + * + * @return string|null + */ + public function getAddCardRedirectUrl(): ?string + { + return $this->config->getAddCardRedirectUrl(); + } + + /** + * Get previous error. + * + * @return Phrase|null + */ + public function getPreviousError(): ?Phrase + { + if ($this->checkoutSession->getData('nexi_previous_error')) { + $previousError = $this->checkoutSession + ->getData('nexi_previous_error', 1); + } + + return $previousError ?? null; + } +} diff --git a/Gateway/Validator/HmacValidator.php b/Gateway/Validator/HmacValidator.php deleted file mode 100755 index c312ed59..00000000 --- a/Gateway/Validator/HmacValidator.php +++ /dev/null @@ -1,62 +0,0 @@ -log->debugLog( - 'request', - \sprintf( - 'Validating Hmac for transaction: %s', - $params["checkout-transaction-id"] - ) - ); - $nexiClient = $this->nexiAdapter->initNexiMerchantClient(); - - $nexiClient->validateHmac($params, '', $signature); - } catch (\Exception $e) { - $this->log->error(sprintf( - 'Nexi PaymentService error: Hmac validation failed for transaction %s', - $params["checkout-transaction-id"] - )); - - return false; - } - $this->log->debugLog( - 'response', - sprintf( - 'Hmac validation successful for transaction: %s', - $params["checkout-transaction-id"] - ) - ); - - return true; - } -} diff --git a/Gateway/Validator/ResponseValidator.php b/Gateway/Validator/ResponseValidator.php deleted file mode 100755 index 7f721622..00000000 --- a/Gateway/Validator/ResponseValidator.php +++ /dev/null @@ -1,127 +0,0 @@ -createResult($isValid, $fails); - } - - if ($this->isRequestMerchantIdEmpty($this->gatewayConfig->getMerchantId())) { - $fails[] = "Request MerchantId is empty"; - } - - if ($this->isResponseMerchantIdEmpty($validationSubject["checkout-account"])) { - $fails[] = "Response MerchantId is empty"; - } - - if ($this->isMerchantIdValid($validationSubject["checkout-account"]) == false) { - $fails[] = "Response and Request merchant ids does not match"; - } - - if ($this->validateResponse($validationSubject) == false) { - $fails[] = "Invalid response data from Nexi"; - } - - if ($this->validateAlgorithm($validationSubject["checkout-algorithm"]) == false) { - $fails[] = "Invalid response data from Nexi"; - } - - if (count($fails) > 0) { - $isValid = false; - } - return $this->createResult($isValid, $fails); - } - - /** - * Is merchant ID is valid. - * - * @param string $responseMerchantId - * @return bool - */ - public function isMerchantIdValid($responseMerchantId) - { - $requestMerchantId = $this->gatewayConfig->getMerchantId(); - if ($requestMerchantId == $responseMerchantId) { - return true; - } - - return false; - } - - /** - * Is request Merchant ID empty. - * - * @param string $requestMerchantId - * @return bool - */ - public function isRequestMerchantIdEmpty($requestMerchantId) - { - return empty($requestMerchantId); - } - - /** - * Is sponse merchant ID empty. - * - * @param string $responseMerchantId - * @return bool - */ - public function isResponseMerchantIdEmpty($responseMerchantId) - { - return empty($responseMerchantId); - } - - /** - * Validate algorithm. - * - * @param string $algorithm - * @return bool - */ - public function validateAlgorithm($algorithm) - { - return in_array($algorithm, $this->gatewayConfig->getValidAlgorithms(), true); - } - - /** - * Validate response. - * - * @param array $params - * @return bool - */ - public function validateResponse($params) - { - return $this->hmacValidator->validateHmac($params, $params["signature"]); - } -} diff --git a/Model/Adapter/Adapter.php b/Model/Adapter/Adapter.php deleted file mode 100644 index f4bf8d02..00000000 --- a/Model/Adapter/Adapter.php +++ /dev/null @@ -1,39 +0,0 @@ -moduleList->getOne(self::MODULE_CODE)['setup_version']; - } -} diff --git a/Model/ApplePay/ApplePayDataProvider.php b/Model/ApplePay/ApplePayDataProvider.php deleted file mode 100644 index f7ce2626..00000000 --- a/Model/ApplePay/ApplePayDataProvider.php +++ /dev/null @@ -1,136 +0,0 @@ -isSafariBrowser() && $this->gatewayConfig->isApplePayEnabled()) { - return true; - } - - return false; - } - - /** - * Adds Apple Pay method data into payment methods groups. - * - * @param array $groupMethods - * @return array - */ - public function addApplePayPaymentMethod(array $groupMethods): array - { - foreach ($groupMethods as $key => $method) { - if ($method['id'] === 'mobile') { - $groupMethods[$key]['providers'][] = $this->getApplePayProviderData(); - } - } - - return $groupMethods; - } - - /** - * Get params for processing order and payment. - * - * @param array $params - * @param Order $order - * @return array - * @throws \Nexi\Checkout\Exceptions\CheckoutException - */ - public function getApplePayFailParams($params, $order): array - { - $paramsToProcess = [ - 'checkout-transaction-id' => '', - 'checkout-account' => '', - 'checkout-method' => '', - 'checkout-algorithm' => '', - 'checkout-timestamp' => '', - 'checkout-nonce' => '', - 'checkout-reference' => $this->referenceNumber->getReference($order), - 'checkout-provider' => Config::APPLE_PAY_PAYMENT_CODE, - 'checkout-status' => Config::NEXI_API_PAYMENT_STATUS_FAIL, - 'checkout-stamp' => $this->paymentDataProvider->getStamp($order), - 'signature' => '', - 'skip_validation' => 1 - ]; - - foreach ($params as $param) { - if (array_key_exists($param['name'], $paramsToProcess)) { - $paramsToProcess[$param['name']] = $param['value']; - } - } - - return $paramsToProcess; - } - - /** - * Get Apple Pay provider data for payment render. - * - * @return Provider - */ - private function getApplePayProviderData(): Provider - { - $applePayProvider = new Provider(); - - $applePayProvider - ->setId('applepay') - ->setGroup('mobile') - ->setUrl(null) - ->setIcon($this->assetRepository->getUrl('Nexi_PaymentService::images/apple-pay-logo.png')) - ->setName('Apple Pay') - ->setParameters(null) - ->setSvg($this->assetRepository->getUrl('Nexi_PaymentService::images/apple-pay-logo.svg')); - - return $applePayProvider; - } - - /** - * Checks if user browser is Safari. - * - * @return bool - */ - private function isSafariBrowser(): bool - { - $user_agent = $this->httpHeader->getHttpUserAgent(); - - if (stripos($user_agent, 'Chrome') !== false && stripos($user_agent, 'Safari') !== false) { - return false; - } elseif (stripos($user_agent, 'Safari') !== false) { - return true; - } else { - return false; - } - } -} diff --git a/Model/Card/VaultConfig.php b/Model/Card/VaultConfig.php deleted file mode 100644 index bceef40e..00000000 --- a/Model/Card/VaultConfig.php +++ /dev/null @@ -1,41 +0,0 @@ -scopeConfig->getValue(self::VAULT_FOR_NEXI_PATH); - } - - /** - * Returns is stored cards are displayed on checkout page. - * - * @return bool - */ - public function isShowStoredCards(): bool - { - return (bool)$this->scopeConfig->getValue(self::NEXI_SHOW_STORED_CARDS); - } -} diff --git a/Model/CardManagement.php b/Model/CardManagement.php deleted file mode 100644 index ec43651c..00000000 --- a/Model/CardManagement.php +++ /dev/null @@ -1,109 +0,0 @@ -commandManagerPool->get('nexi'); - $response = $commandExecutor->executeByCode('add_card'); - - if (isset($response['error'])) { - // Use Nexi exception here - // throw new ValidationException($response['error']); - } - - return $response['data']->getHeader('Location')[0]; - } - - /** - * @inheritdoc - */ - public function delete(string $cardId): bool - { - $paymentToken = $this->paymentTokenRepository->getById((int)$cardId); - if (!$paymentToken || (int)$paymentToken->getCustomerId() !== $this->userContext->getUserId()) { - throw new LocalizedException(__('Card not found')); - } - - $subscriptionWithCard = $this->getSubscriptionForPaymentToken($paymentToken); - if ($subscriptionWithCard->getTotalCount()) { - throw new LocalizedException(__('The card has active subscriptions')); - } - - $this->paymentTokenRepository->delete($paymentToken); - - return true; - } - - /** - * Get subscription for payment token. - * - * @param PaymentTokenInterface $paymentToken - * @return SubscriptionSearchResultInterface - */ - private function getSubscriptionForPaymentToken( - PaymentTokenInterface $paymentToken - ): SubscriptionSearchResultInterface { - $selectedTokenFilter = $this->filterBuilder - ->setField('selected_token') - ->setValue($paymentToken->getEntityId()) - ->setConditionType('eq') - ->create(); - - $statusFilter = $this->filterBuilder - ->setField('status') - ->setValue(SubscriptionInterface::STATUS_ACTIVE) - ->setConditionType('eq') - ->create(); - - $statusFilterGroup = $this->filterGroupBuilder->addFilter($statusFilter)->create(); - $selectedTokenFilterGroup = $this->filterGroupBuilder->addFilter($selectedTokenFilter)->create(); - - $searchCriteria = $this->searchCriteriaBuilder->setFilterGroups([$statusFilterGroup, $selectedTokenFilterGroup]) - ->create(); - - return $this->subscriptionRepository->getList($searchCriteria); - } -} diff --git a/Model/FinnishReferenceNumber.php b/Model/FinnishReferenceNumber.php deleted file mode 100644 index 2fb8ebd7..00000000 --- a/Model/FinnishReferenceNumber.php +++ /dev/null @@ -1,156 +0,0 @@ -gatewayConfig = $gatewayConfig; - $this->orderFactory = $orderFactory; - $this->orderRepository = $orderRepository; - $this->criteriaBuilderFactory = $criteriaBuilderFactory; - } - - /** - * @param mixed $reference - * - * @return Order - * @throws \Exception - */ - public function getOrderByReference(mixed $reference): Order - { - if (!$this->gatewayConfig->getGenerateReferenceForOrder()) { - return $this->orderFactory->create()->loadByIncrementId($reference); - } - - $criteriaBuilder = $this->criteriaBuilderFactory->create(); - $searchCriteria = $criteriaBuilder->addFilter('finnish_reference_number', $reference) - ->create(); - - /** @var Order[] $orders */ - $orders = $this->orderRepository->getList($searchCriteria) - ->getItems(); - - if (count($orders) > 1) { - throw new CheckoutException(__('Multiple orders found with same reference number')); - } - - return reset($orders); - } - - /** - * Get order increment id from checkout reference number - * - * @param string $reference - * - * @return string|null - * @throws \Exception - */ - public function getIdFromOrderReferenceNumber(string $reference): ?string - { - return $this->getOrderByReference($reference)->getIncrementId(); - } - - /** - * Calculate Finnish reference number from order increment id - * according to Finnish reference number algorithm - * if increment id is not numeric - letters will be converted to numbers -> (ord($letter) % 10) - * - * @param \Magento\Sales\Model\Order $order - * - * @return string - * @throws \Nexi\Checkout\Exceptions\CheckoutException - */ - public function calculateOrderReferenceNumber(Order $order): string - { - $numericIncrementId = preg_replace('/\D/', '', $order->getIncrementId()); - - $prefixedId = $order->getStoreId() . $numericIncrementId; - if ($prefixedId[0] === '0') { - $prefixedId = '1' . $prefixedId; - } - - $sum = 0; - $length = strlen($prefixedId); - - for ($i = 0; $i < $length; ++$i) { - $substr = substr($prefixedId, -1 - $i, 1); - $numSubstring = is_numeric($substr) ? (int)$substr : (ord($substr) % 10); - - $sum += $numSubstring * [7, 3, 1][$i % 3]; - } - $num = (10 - $sum % 10) % 10; - $referenceNum = $prefixedId . $num; - - if ($referenceNum > 9999999999999999999) { - throw new CheckoutException('Order reference number is too long'); - } - - $asString = trim(chunk_split($referenceNum, 5, ' ')); - - $order->setFinnishReferenceNumber($asString); - $this->orderRepository->save($order); - - return $asString; - } - - /** - * @param Order $order - * - * @return string reference number - * @throws \Nexi\Checkout\Exceptions\CheckoutException - */ - public function getReference(Order $order): string - { - if ($order->getFinnishReferenceNumber()) { - return $order->getFinnishReferenceNumber(); - } - - if (!$this->gatewayConfig->getGenerateReferenceForOrder() && $order->getIncrementId()) { - return $order->getIncrementId(); - } - - return $order->getExtensionAttributes()->getFinnishReferenceNumber() - ?: $this->calculateOrderReferenceNumber($order); - } -} diff --git a/Model/Receipt/PaymentTransaction.php b/Model/Receipt/PaymentTransaction.php index a30b8545..63b5ad00 100644 --- a/Model/Receipt/PaymentTransaction.php +++ b/Model/Receipt/PaymentTransaction.php @@ -7,7 +7,6 @@ use Magento\Sales\Model\Order\Payment\Transaction; use Magento\Sales\Model\Order\Payment\Transaction\BuilderInterface as TransactionBuilderInterface; use Nexi\Checkout\Exceptions\CheckoutException; -use Nexi\Checkout\Gateway\Validator\HmacValidator; use Nexi\Checkout\Logger\NexiLogger; class PaymentTransaction @@ -16,14 +15,12 @@ class PaymentTransaction * PaymentTransaction constructor. * * @param TransactionBuilderInterface $transactionBuilder - * @param HmacValidator $hmacValidator * @param CancelOrderService $cancelOrderService * @param OrderRepositoryInterface $orderRepositoryInterface * @param NexiLogger $nexiLogger */ public function __construct( private TransactionBuilderInterface $transactionBuilder, - private HmacValidator $hmacValidator, private CancelOrderService $cancelOrderService, private OrderRepositoryInterface $orderRepositoryInterface, private NexiLogger $nexiLogger @@ -64,29 +61,6 @@ public function addPaymentTransaction(Order $order, $transactionId, array $detai */ public function verifyPaymentData($params, $currentOrder) { - $status = $params['checkout-status']; - - // skip HMAC validator if signature is 'skip_hmac' for token payment - if ($params['signature'] === HmacValidator::SKIP_HMAC_VALIDATION) { - $verifiedPayment = true; - } else { - $verifiedPayment = $this->hmacValidator->validateHmac($params, $params['signature']); - } - - if ($verifiedPayment && ($status === 'ok' || $status == 'pending' || $status == 'delayed')) { - return $status; - } else { - $currentOrder->addCommentToStatusHistory(__('Failed to complete the payment.')); - $this->orderRepositoryInterface->save($currentOrder); - $this->cancelOrderService->cancelOrderById($currentOrder->getId()); - - $this->nexiLogger->logData( - \Monolog\Logger::ERROR, - 'Failed to complete the payment. Please try again or contact the customer service.' - ); - throw new CheckoutException( - __('Failed to complete the payment. Please try again or contact the customer service.') - ); - } + // TODO: Create nexi specific } } diff --git a/Model/Receipt/ProcessPayment.php b/Model/Receipt/ProcessPayment.php index efeac39e..ced88382 100644 --- a/Model/Receipt/ProcessPayment.php +++ b/Model/Receipt/ProcessPayment.php @@ -2,133 +2,18 @@ namespace Nexi\Checkout\Model\Receipt; -use Magento\Checkout\Model\Session; -use Magento\Quote\Api\CartRepositoryInterface; -use Nexi\Checkout\Gateway\Config\Config; -use Nexi\Checkout\Gateway\Validator\HmacValidator; -use Nexi\Checkout\Gateway\Validator\ResponseValidator; -use Nexi\Checkout\Exceptions\CheckoutException; -use Nexi\Checkout\Model\FinnishReferenceNumber; -use Nexi\Checkout\Model\ReceiptDataProvider; -use Nexi\Checkout\Exceptions\TransactionSuccessException; class ProcessPayment { private const PAYMENT_PROCESSING_CACHE_PREFIX = "nexi-processing-payment-"; - /** - * ProcessPayment constructor. - * - * @param ResponseValidator $responseValidator - * @param ReceiptDataProvider $receiptDataProvider - * @param CartRepositoryInterface $cartRepository - * @param Config $gatewayConfig - * @param FinnishReferenceNumber $finnishReferenceNumber - */ - public function __construct( - private ResponseValidator $responseValidator, - private ReceiptDataProvider $receiptDataProvider, - private CartRepositoryInterface $cartRepository, - private Config $gatewayConfig, - private FinnishReferenceNumber $finnishReferenceNumber - ) { - } - - /** - * Process function - * - * @param array $params - * @param Session $session - * @return array - * @throws \Magento\Framework\Exception\LocalizedException - */ public function process($params, $session) { - /** @var array $errors */ - $errors = []; - - /** @var \Magento\Payment\Gateway\Validator\Result $validationResponse */ - $validationResponse = $this->responseValidator->validate($params); - - if (!$validationResponse->isValid()) { // if response params are not valid, redirect back to the cart - - /** @var string $failMessage */ - foreach ($validationResponse->getFailsDescription() as $failMessage) { - $errors[] = $failMessage; - } - - $session->restoreQuote(); // should it be restored? - - return $errors; - } - - /** @var string $reference */ - $reference = $params['checkout-reference']; - - /** @var string $orderNo */ - $orderNo = $this->gatewayConfig->getGenerateReferenceForOrder() - ? $this->finnishReferenceNumber->getIdFromOrderReferenceNumber($reference) - : $reference; - - /** @var array $ret */ - $ret = $this->processPayment($params, $session, $orderNo); - - return array_merge($ret, $errors); + // TODO: Create nexi specific } - /** - * ProcessPayment function - * - * @param array $params - * @param Session $session - * @param string $orderNo - * @return array - * @throws \Magento\Framework\Exception\LocalizedException - */ private function processPayment($params, $session, $orderNo) { - /** @var array $errors */ - $errors = []; - - /** @var bool $isValid */ - $isValid = true; - - /** @var null|string $failMessage */ - $failMessage = null; - - if (empty($orderNo)) { - $session->restoreQuote(); - - return $errors; - } - - try { - /* - there are 2 calls called from Nexi Payment Service. - One call is when a customer is redirected back to the magento store. - There is also the second, parallel, call from Nexi Payment Service - to make sure the payment is confirmed (if for any reason customer was not redirected back to the store). - Sometimes, the calls are called with too small time difference between them that Magento cannot handle them. - The second call must be ignored or slowed down. - */ - $this->receiptDataProvider->execute($params); - } catch (CheckoutException $exception) { - $isValid = false; - $failMessage = $exception->getMessage(); - array_push($errors, $failMessage); - } catch (TransactionSuccessException $successException) { - $isValid = true; - } - - if ($isValid == false) { - $session->restoreQuote(); - } else { - /** @var \Magento\Quote\Model\Quote $quote */ - $quote = $session->getQuote(); - $quote->setIsActive(false); - $this->cartRepository->save($quote); - } - - return $errors; + // TODO: Create Nexi specfic } } diff --git a/Model/ReceiptDataProvider.php b/Model/ReceiptDataProvider.php index 1bad7ced..310b23c0 100755 --- a/Model/ReceiptDataProvider.php +++ b/Model/ReceiptDataProvider.php @@ -7,7 +7,6 @@ use Magento\Sales\Model\Order; use Nexi\Checkout\Exceptions\CheckoutException; use Nexi\Checkout\Gateway\Config\Config; -use Nexi\Checkout\Model\FinnishReferenceNumber; use Nexi\Checkout\Model\Receipt\LoadService; use Nexi\Checkout\Model\Receipt\PaymentTransaction; use Nexi\Checkout\Model\Receipt\ProcessService; @@ -52,7 +51,6 @@ class ReceiptDataProvider * @param ProcessService $processService * @param LoadService $loadService * @param PaymentTransaction $paymentTransaction - * @param FinnishReferenceNumber $referenceNumber */ public function __construct( private Session $session, @@ -60,7 +58,6 @@ public function __construct( private ProcessService $processService, private LoadService $loadService, private PaymentTransaction $paymentTransaction, - private FinnishReferenceNumber $referenceNumber ) { } diff --git a/Model/Subscription/OrderBiller.php b/Model/Subscription/OrderBiller.php index 26cb3d31..8e0db3ac 100644 --- a/Model/Subscription/OrderBiller.php +++ b/Model/Subscription/OrderBiller.php @@ -12,7 +12,6 @@ use Nexi\Checkout\Model\ResourceModel\Subscription as SubscriptionResource; use Nexi\Checkout\Model\ResourceModel\Subscription\CollectionFactory; use Nexi\Checkout\Model\Subscription; -use Nexi\Checkout\Model\Token\Payment; use Psr\Log\LoggerInterface; use \Nexi\Checkout\Model\ResourceModel\Subscription\Collection; @@ -22,7 +21,6 @@ class OrderBiller * OrderBiller constructor. * * @param PaymentCount $paymentCount - * @param Payment $mitPayment * @param CollectionFactory $collectionFactory * @param NextDateCalculator $nextDateCalculator * @param SubscriptionRepositoryInterface $subscriptionRepository @@ -34,7 +32,6 @@ class OrderBiller */ public function __construct( private PaymentCount $paymentCount, - private Payment $mitPayment, private CollectionFactory $collectionFactory, private NextDateCalculator $nextDateCalculator, private SubscriptionRepositoryInterface $subscriptionRepository, @@ -57,24 +54,7 @@ public function __construct( */ public function billOrdersById($orderIds) { - /** @var Collection $subscriptionsToCharge */ - $subscriptionsToCharge = $this->collectionFactory->create(); - $subscriptionsToCharge->getBillingCollectionByOrderIds($orderIds); - - /** @var Subscription $subscription */ - foreach ($subscriptionsToCharge as $subscription) { - if (!$this->validateToken($subscription)) { - continue; - } - - $paymentSuccess = $this->createMitPayment($subscription); - if (!$paymentSuccess) { - $this->paymentCount->reduceFailureRetryCount($subscription); - continue; - } - $this->sendOrderConfirmationEmail($subscription->getId()); - $this->updateNextOrderDate($subscription); - } + // TODO: Create with nexi config } /** @@ -127,21 +107,7 @@ private function validateToken($subscription) */ private function createMitPayment($subscription): bool { - $paymentSuccess = false; - try { - $paymentSuccess = $this->mitPayment->makeMitPayment( - $subscription->getData('order_id'), - $subscription->getData('token') - ); - } catch (LocalizedException $e) { - $this->logger->error( - \__( - 'Recurring Payment: Unable to create a charge to customer token error: %error', - ['error' => $e->getMessage()] - ) - ); - } - return $paymentSuccess; + // TODO: Create with nexi config } /** diff --git a/Model/Token/Payment.php b/Model/Token/Payment.php deleted file mode 100644 index a562f4a4..00000000 --- a/Model/Token/Payment.php +++ /dev/null @@ -1,233 +0,0 @@ -orderRepository->get($orderId); - $customer = $this->customerRepository->getById((int)$order->getCustomerId()); - $commandExecutor = $this->commandManagerPool->get('paytrail'); - - $mitResponse = $commandExecutor->executeByCode( - 'token_payment_mit', - null, - [ - 'order' => $order, - 'token_id' => $cardToken, - 'customer' => $customer, - ] - ); - - if ($mitResponse['data']?->getTransactionId() === null) { - $this->paytrailLogger->logCheckoutData( - 'response', - 'error', - 'A problem occurred: Payment transaction id missing in request response' - ); - - return false; - } - } catch (\Exception $e) { - $this->paytrailLogger->logCheckoutData( - 'response', - 'error', - 'A problem occurred: ' . $e->getMessage() - ); - return false; - }//end try - - $this->createInvoice($order, $mitResponse['data']); - $this->createTransaction($order, $mitResponse['data']); - $this->updateOrder($order, $mitResponse['data']); - - return true; - } - - /** - * Create invoice. - * - * @param OrderInterface $order - * @param MitPaymentResponse $mitResponse - */ - private function createInvoice($order, $mitResponse) - { - if ($order->canInvoice()) { - try { - $invoice = $this->invoiceService->prepareInvoice($order); - $invoice->register(); - $invoice->setTransactionId($mitResponse->getTransactionId()); - $invoice->save(); - $transactionSave = $this->transaction->addObject( - $invoice - )->addObject( - $invoice->getOrder() - ); - $transactionSave->save(); - } catch (\Exception $e) { - $this->paytrailLogger->logCheckoutData( - 'response', - 'error', - 'A problem with creating invoice after payment ' - . $e->getMessage() - ); - } - } - } - - /** - * Create transaction. - * - * @param OrderInterface $order - * @param MitPaymentResponse $mitResponse - * - * @return int|void - */ - private function createTransaction($order, $mitResponse) - { - try { - $payment = $order->getPayment(); - $payment->setLastTransId($mitResponse->getTransactionId()); - $payment->setTransactionId($mitResponse->getTransactionId()); - $payment->setAdditionalInformation( - [\Magento\Sales\Model\Order\Payment\Transaction::RAW_DETAILS => (array)$mitResponse] - ); - - $trans = $this->transactionBuilder; - $transaction = $trans->setPayment($payment) - ->setOrder($order) - ->setTransactionId($mitResponse->getTransactionId()) - ->setAdditionalInformation( - [\Magento\Sales\Model\Order\Payment\Transaction::RAW_DETAILS => (array)$mitResponse] - ) - ->setFailSafe(true) - //build method creates the transaction and returns the object - ->build(\Magento\Sales\Model\Order\Payment\Transaction::TYPE_CAPTURE); - - $payment->setParentTransactionId(null); - $payment->save(); - $order->save(); - - return $transaction->save()->getTransactionId(); - } catch (\Exception $e) { - $this->paytrailLogger->logCheckoutData( - 'response', - 'error', - 'A problem occurred: while creating transaction' - . $e->getMessage() - ); - } - } - - /** - * Get MIT payment request. - * - * @return MitPaymentRequest - */ - private function getMitPaymentRequest(): MitPaymentRequest - { - return new MitPaymentRequest(); - } - - /** - * Update order. - * - * @param OrderInterface $order - * @param MitPaymentResponse $mitResponse - * - * @return void - * @throws \Magento\Framework\Exception\CouldNotSaveException - */ - private function updateOrder( - OrderInterface $order, - MitPaymentResponse $mitResponse - ): void { - - $commentsArray = [ - 'pending_payment' => __('Transaction ID: ') . $mitResponse->getTransactionId(), - 'processing' => __('Payment has been completed') - ]; - - foreach ($commentsArray as $status => $comment) { - $historyComment = $this->orderStatusHistoryFactory->create(); - $historyComment - ->setStatus($status) - ->setComment($comment); - $this->orderManagement->addComment($order->getEntityId(), $historyComment); - $this->orderStatusHistoryRepository->save($historyComment); - } - - $order->setState(\Magento\Sales\Model\Order::STATE_PROCESSING); - $this->orderRepository->save($order); - } -} diff --git a/Model/Token/RequestData.php b/Model/Token/RequestData.php deleted file mode 100644 index 5bfb921a..00000000 --- a/Model/Token/RequestData.php +++ /dev/null @@ -1,42 +0,0 @@ -paymentTokenManagement->getByPublicHash($tokenHash, $customerId); - } -} diff --git a/Model/Ui/ConfigProvider.php b/Model/Ui/ConfigProvider.php old mode 100755 new mode 100644 index 2f05a880..5e25f360 --- a/Model/Ui/ConfigProvider.php +++ b/Model/Ui/ConfigProvider.php @@ -1,134 +1,47 @@ methodCodes as $code) { - $this->methods[$code] = $paymentHelper->getMethodInstance($code); - } } /** - * GetConfig function + * Returns Nexi configuration values. * - * @return array - * @throws NoSuchEntityException + * @return array|\array[][] + * @throws LocalizedException */ public function getConfig() { - $storeId = $this->storeManager->getStore()->getId(); - $config = []; - $status = $this->gatewayConfig->isActive($storeId); - - if (!$status) { - return $config; + if (!$this->config->isActive()) { + return []; } - try { - $groupData = $this->paymentProvidersData->getAllPaymentMethods(); - $scheduledMethod = []; - - if (array_key_exists('creditcard', $this->paymentProvidersData - ->handlePaymentProviderGroupData($groupData['groups']))) { - $scheduledMethod[] = $this->paymentProvidersData - ->handlePaymentProviderGroupData($groupData['groups'])['creditcard']; - } - if ($this->applePayDataProvider->canApplePay()) { - $groupData['groups'] = $this->applePayDataProvider->addApplePayPaymentMethod($groupData['groups']); - } - - $config = [ - 'payment' => [ - Config::CODE => [ - 'instructions' => $this->gatewayConfig->getInstructions(), - 'skip_method_selection' => $this->gatewayConfig->getSkipBankSelection(), - 'payment_redirect_url' => $this->gatewayConfig->getPaymentRedirectUrl(), - 'payment_template' => $this->gatewayConfig->getPaymentTemplate(), - 'method_groups' => array_values($this->paymentProvidersData - ->handlePaymentProviderGroupData($groupData['groups'])), - 'scheduled_method_group' => array_values($scheduledMethod), - 'payment_terms' => $groupData['terms'], - 'payment_method_styles' => $this->paymentProvidersData->wrapPaymentMethodStyles($storeId), - 'addcard_redirect_url' => $this->gatewayConfig->getAddCardRedirectUrl(), - 'pay_and_addcard_redirect_url' => $this->gatewayConfig->getPayAndAddCardRedirectUrl(), - 'credit_card_providers_ids' => array_shift($scheduledMethod)['providers'] ?? [], - 'token_payment_redirect_url' => $this->gatewayConfig->getTokenPaymentRedirectUrl(), - 'default_success_page_url' => $this->gatewayConfig->getDefaultSuccessPageUrl(), - 'is_vault_for_nexi' => $this->vaultConfig->isVaultForNexiEnabled(), - 'is_show_stored_cards' => $this->vaultConfig->isShowStoredCards(), - 'is_new_ui_enabled' => $this->gatewayConfig->isNewUiEnabled($storeId) - ] + return [ + 'payment' => [ + Config::CODE => [ + 'isActive' => $this->config->isActive(), + 'environment' => $this->config->getEnvironment(), + 'label' => $this->paymentHelper->getMethodInstance(Config::CODE)->getTitle(), + 'integrationType' => $this->config->getIntegrationType(), ] - ]; - //Get images for payment groups - foreach ($groupData['groups'] as $group) { - $groupId = $group['id']; - $groupImage = $group['svg']; - $config['payment'][Config::CODE]['image'][$groupId] = ''; - if ($groupImage) { - $config['payment'][Config::CODE]['image'][$groupId] = $groupImage; - } - } - } catch (\Exception $e) { - $config['payment'][Config::CODE]['success'] = 0; - - return $config; - } - if ($this->checkoutSession->getData('nexi_previous_error')) { - $config['payment'][Config::CODE]['previous_error'] = $this->checkoutSession - ->getData('nexi_previous_error', 1); - } elseif ($this->checkoutSession->getData('nexi_previous_success')) { - $config['payment'][Config::CODE]['previous_success'] = $this->checkoutSession - ->getData('nexi_previous_success', 1); - } - $config['payment'][Config::CODE]['success'] = 1; - - return $config; + ] + ]; } } diff --git a/Model/Ui/DataProvider/PaymentProvidersData.php b/Model/Ui/DataProvider/PaymentProvidersData.php deleted file mode 100755 index 47ec9eef..00000000 --- a/Model/Ui/DataProvider/PaymentProvidersData.php +++ /dev/null @@ -1,244 +0,0 @@ -checkoutSession->getQuote()->getGrandTotal() ?: 0; - - $commandExecutor = $this->commandManagerPool->get('nexi'); - $response = $commandExecutor->executeByCode( - 'method_provider', - null, - ['amount' => $orderValue] - ); - - $errorMsg = $response['error']; - - if (isset($errorMsg)) { - $this->log->error( - 'Error occurred during providing payment methods: ' - . $errorMsg - ); - $this->nexiLogger->logData(\Monolog\Logger::ERROR, $errorMsg); - throw new CheckoutException(__($errorMsg)); - } - - return $response["data"]; - } - - /** - * Create payment page styles from the values entered in Nexi configuration. - * - * @param string $storeId - * - * @return string - */ - public function wrapPaymentMethodStyles($storeId) - { - if ($this->gatewayConfig->isNewUiEnabled()) { - $styles = '.nexi-group-collapsible{ background-color: #ffffff; margin-top:1%; margin-bottom:2%;}'; - $styles .= '.nexi-group-collapsible.active{ background-color: #ffffff;}'; - $styles .= '.nexi-group-collapsible span{ color: #323232;}'; - $styles .= '.nexi-group-collapsible li{ color: #323232}'; - $styles .= '.nexi-group-collapsible.active span{ color: #000000;}'; - $styles .= '.nexi-group-collapsible.active li{ color: #000000}'; - $styles .= '.nexi-group-collapsible:hover:not(.active) {background-color: #ffffff}'; - $styles .= '.nexi-payment-methods .nexi-payment-method.active{ border: 2px solid ' - . $this->gatewayConfig->getPaymentMethodHighlightColorNewUi($storeId) . ';border-width:2px;}'; - $styles .= '.nexi-payment-methods .nexi-stored-token.active{ border-color:' - . $this->gatewayConfig->getPaymentMethodHighlightColorNewUi($storeId) . ';border-width:2px;}'; - $styles .= '.nexi-payment-methods .nexi-payment-method:hover, - .nexi-payment-methods .nexi-payment-method:not(.active):hover { border: 2px solid ' - . $this->gatewayConfig->getPaymentMethodHoverHighlightNewUi($storeId) . '}'; - $styles .= '.nexi-stored-token:hover { border: 2px solid ' - . $this->gatewayConfig->getPaymentMethodHoverHighlightNewUi($storeId) . '}'; - $styles .= '.nexi-store-card-button:hover { border: 2px solid ' - . $this->gatewayConfig->getPaymentMethodHoverHighlightNewUi($storeId) . ';}'; - $styles .= '.nexi-store-card-login-button:hover { border: 2px solid ' - . $this->gatewayConfig->getPaymentMethodHoverHighlightNewUi($storeId) . ';}'; - $styles .= $this->gatewayConfig->getAdditionalCss($storeId); - } else { - $styles = '.nexi-group-collapsible{ background-color:' - . $this->gatewayConfig->getPaymentGroupBgColor($storeId) . '; margin-top:1%; margin-bottom:2%;}'; - $styles .= '.nexi-group-collapsible.active{ background-color:' - . $this->gatewayConfig->getPaymentGroupHighlightBgColor($storeId) . ';}'; - $styles .= '.nexi-group-collapsible span{ color:' - . $this->gatewayConfig->getPaymentGroupTextColor($storeId) . ';}'; - $styles .= '.nexi-group-collapsible li{ color:' - . $this->gatewayConfig->getPaymentGroupTextColor($storeId) . '}'; - $styles .= '.nexi-group-collapsible.active span{ color:' - . $this->gatewayConfig->getPaymentGroupHighlightTextColor($storeId) . ';}'; - $styles .= '.nexi-group-collapsible.active li{ color:' - . $this->gatewayConfig->getPaymentGroupHighlightTextColor($storeId) . '}'; - $styles .= '.nexi-group-collapsible:hover:not(.active) {background-color:' - . $this->gatewayConfig->getPaymentGroupHoverColor() . '}'; - $styles .= '.nexi-payment-methods .nexi-payment-method.active{ border: 2px solid ' - . $this->gatewayConfig->getPaymentMethodHighlightColor($storeId) . ';border-width:2px;}'; - $styles .= '.nexi-payment-methods .nexi-stored-token.active{ border-color:' - . $this->gatewayConfig->getPaymentMethodHighlightColor($storeId) . ';border-width:2px;}'; - $styles .= '.nexi-payment-methods .nexi-payment-method:hover, - .nexi-payment-methods .nexi-payment-method:not(.active):hover { border: 2px solid ' - . $this->gatewayConfig->getPaymentMethodHoverHighlight($storeId) . ';}'; - $styles .= '.nexi-stored-token:hover { border: 2px solid ' - . $this->gatewayConfig->getPaymentMethodHoverHighlight($storeId) . '}'; - $styles .= '.nexi-store-card-button:hover { border: 2px solid ' - . $this->gatewayConfig->getPaymentMethodHoverHighlight($storeId) . ';}'; - $styles .= '.nexi-store-card-login-button:hover { border: 2px solid ' - . $this->gatewayConfig->getPaymentMethodHoverHighlight($storeId) . ';}'; - $styles .= $this->gatewayConfig->getAdditionalCss($storeId); - } - - return $styles; - } - - /** - * Create array for payment providers and groups containing unique method id - * - * @param array $responseData - * - * @return array - */ - public function handlePaymentProviderGroupData($responseData) - { - $allMethods = []; - $allGroups = []; - foreach ($responseData as $group) { - $allGroups[$group['id']] = [ - 'id' => $group['id'], - 'name' => $group['name'], - 'icon' => $group['icon'] - ]; - - foreach ($group['providers'] as $provider) { - $allMethods[] = $provider; - } - } - foreach ($allGroups as $key => $group) { - if ($group['id'] == 'creditcard') { - $allGroups[$key]["can_tokenize"] = true; - $allGroups[$key]["tokens"] = $this->gatewayConfig->getCustomerTokens(); - } else { - $allGroups[$key]["can_tokenize"] = false; - $allGroups[$key]["tokens"] = false; - } - - $allGroups[$key]['providers'] = $this->addProviderDataToGroup($allMethods, $group['id']); - } - return $allGroups; - } - - /** - * Add payment method data to group - * - * @param array $responseData - * @param string $groupId - * - * @return array - */ - private function addProviderDataToGroup($responseData, $groupId) - { - $methods = []; - $i = 1; - - foreach ($responseData as $key => $method) { - if ($method->getGroup() == $groupId) { - $id = $groupId === self::CREDITCARD_GROUP_ID ? $method->getId() - . self::ID_INCREMENT_SEPARATOR - . strtolower($method->getName()) : $method->getId(); - $methods[] = [ - 'checkoutId' => $method->getId(), - 'id' => $this->getIncrementalId($id, $i), - 'name' => $method->getName(), - 'group' => $method->getGroup(), - 'icon' => $method->getIcon(), - 'svg' => $method->getSvg() - ]; - } - } - - return $methods; - } - - /** - * Returns incremental id. - * - * @param string $id - * @param int $i - * @return string - */ - public function getIncrementalId($id, int &$i): string - { - return $id . self::ID_INCREMENT_SEPARATOR . ($i++); - } - - /** - * Returns id without increment. - * - * @param string $id - * - * @return string - */ - public function getIdWithoutIncrement(string $id): string - { - return explode(self::ID_INCREMENT_SEPARATOR, $id)[0]; - } - - /** - * Returns card type. - * - * @param string $id - * - * @return ?string - */ - public function getCardType(string $id): ?string - { - $idParts = explode(self::ID_INCREMENT_SEPARATOR, $id); - - if (count($idParts) == 3) { - return $idParts[1]; - } - - return null; - } -} diff --git a/view/frontend/layout/checkout_index_index.xml b/view/frontend/layout/checkout_index_index.xml index a4d2031b..55460222 100644 --- a/view/frontend/layout/checkout_index_index.xml +++ b/view/frontend/layout/checkout_index_index.xml @@ -46,14 +46,6 @@ - - Nexi_Checkout/js/view/payment/nexi-payment - - - true - - - diff --git a/view/frontend/templates/order/payments.phtml b/view/frontend/templates/order/payments.phtml index 439ef05a..d92dfd47 100644 --- a/view/frontend/templates/order/payments.phtml +++ b/view/frontend/templates/order/payments.phtml @@ -1,6 +1,6 @@ getRecurringPayments() ?> getClosedSubscriptions() ?> From 5d36b33692d65da2b0dc269c54a7487628697cf2 Mon Sep 17 00:00:00 2001 From: Konrad Konieczny Date: Mon, 26 May 2025 08:28:11 +0200 Subject: [PATCH 041/320] add shipping tax rate getter --- .../Request/CreatePaymentRequestBuilder.php | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/Gateway/Request/CreatePaymentRequestBuilder.php b/Gateway/Request/CreatePaymentRequestBuilder.php index 5224a43f..2023016b 100644 --- a/Gateway/Request/CreatePaymentRequestBuilder.php +++ b/Gateway/Request/CreatePaymentRequestBuilder.php @@ -13,6 +13,7 @@ use Magento\Payment\Gateway\Request\BuilderInterface; use Magento\Sales\Model\Order; use Magento\Sales\Model\Order\Item as OrderItem; +use Magento\Tax\Model\Sales\Total\Quote\CommonTaxCollector; use Nexi\Checkout\Gateway\Config\Config; use Nexi\Checkout\Gateway\Request\NexiCheckout\SalesDocumentItemsBuilder; use Nexi\Checkout\Gateway\StringSanitizer; @@ -127,7 +128,7 @@ private function buildItems(Order $order): OrderItem|array netTotalAmount : $this->amountConverter->convertToNexiAmount($order->getShippingAmount()), reference : SalesDocumentItemsBuilder::SHIPPING_COST_REFERENCE, taxRate : $this->amountConverter->convertToNexiAmount( - $order->getTaxAmount() / $order->getGrandTotal() + $this->getShippingTaxRate($order) ), taxAmount : $this->amountConverter->convertToNexiAmount($order->getShippingTaxAmount()), ); @@ -275,4 +276,28 @@ public function getNumber(Order $order): PhoneNumber number: (string)$number->getNationalNumber(), ); } + + /** + * Get shipping tax rate from the order + * + * @param Order $order + * + * @return int|void + */ + private function getShippingTaxRate(Order $order) + { + if (!$order->getExtensionAttributes()?->getItemAppliedTaxes()) { + return 0; + } + $shippingTax = 0; + foreach ($order->getExtensionAttributes()->getItemAppliedTaxes() as $tax) { + if ($tax->getType() == CommonTaxCollector::ITEM_TYPE_SHIPPING) { + $appliedTaxes = $tax->getAppliedTaxes(); + + $shippingTax = reset($appliedTaxes)->getPercent(); + } + } + + return $shippingTax; + } } From 44dac0b53d8a5ff337629a664a8a0ba0bb7a9e57 Mon Sep 17 00:00:00 2001 From: Konrad Konieczny Date: Mon, 26 May 2025 08:45:20 +0200 Subject: [PATCH 042/320] refactor --- Gateway/Request/CreatePaymentRequestBuilder.php | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/Gateway/Request/CreatePaymentRequestBuilder.php b/Gateway/Request/CreatePaymentRequestBuilder.php index 2023016b..9955baa7 100644 --- a/Gateway/Request/CreatePaymentRequestBuilder.php +++ b/Gateway/Request/CreatePaymentRequestBuilder.php @@ -282,22 +282,17 @@ public function getNumber(Order $order): PhoneNumber * * @param Order $order * - * @return int|void + * @return float */ private function getShippingTaxRate(Order $order) { - if (!$order->getExtensionAttributes()?->getItemAppliedTaxes()) { - return 0; - } - $shippingTax = 0; - foreach ($order->getExtensionAttributes()->getItemAppliedTaxes() as $tax) { + foreach ($order->getExtensionAttributes()?->getItemAppliedTaxes() as $tax) { if ($tax->getType() == CommonTaxCollector::ITEM_TYPE_SHIPPING) { $appliedTaxes = $tax->getAppliedTaxes(); - - $shippingTax = reset($appliedTaxes)->getPercent(); + return reset($appliedTaxes)->getPercent(); } } - return $shippingTax; + return 0.0; } } From 6270c78633815d1a1e7e978cdb945bd3a8779ced Mon Sep 17 00:00:00 2001 From: Konrad Konieczny Date: Mon, 26 May 2025 11:24:31 +0200 Subject: [PATCH 043/320] add order history comment saver to improve performance --- Model/Order/Comment.php | 40 ++++++++++++++++++++++++++ Model/Webhook/PaymentCancelCreated.php | 25 +++++++++++++++- Model/Webhook/PaymentCancelFailed.php | 25 +++++++++++++++- Model/Webhook/PaymentChargeCreated.php | 9 +++++- Model/Webhook/PaymentChargeFailed.php | 26 ++++++++++++++++- Model/Webhook/PaymentCreated.php | 20 +++++++------ Model/Webhook/PaymentRefundFailed.php | 26 ++++++++++++++++- 7 files changed, 157 insertions(+), 14 deletions(-) create mode 100644 Model/Order/Comment.php diff --git a/Model/Order/Comment.php b/Model/Order/Comment.php new file mode 100644 index 00000000..05058999 --- /dev/null +++ b/Model/Order/Comment.php @@ -0,0 +1,40 @@ +historyFactory->create(); + $history->setComment($comment) + ->setIsCustomerNotified(false) + ->setStatus($order->getStatus()) // Default status, can be changed as needed + ->setParentId($order->getId()); + + $this->historyRepository->save($history); + } +} diff --git a/Model/Webhook/PaymentCancelCreated.php b/Model/Webhook/PaymentCancelCreated.php index 9586784c..35f83289 100644 --- a/Model/Webhook/PaymentCancelCreated.php +++ b/Model/Webhook/PaymentCancelCreated.php @@ -4,13 +4,36 @@ namespace Nexi\Checkout\Model\Webhook; +use Magento\Framework\Exception\CouldNotSaveException; +use Magento\Sales\Model\Order; +use Nexi\Checkout\Model\Order\Comment; +use Nexi\Checkout\Model\Webhook\Data\WebhookDataLoader; + class PaymentCancelCreated implements WebhookProcessorInterface { + /** + * @param WebhookDataLoader $webhookDataLoader + * @param Comment $comment + */ + public function __construct( + private readonly WebhookDataLoader $webhookDataLoader, + private readonly Comment $comment, + ) { + } + /** * @inheritdoc + * + * @throws CouldNotSaveException */ public function processWebhook(array $webhookData): void { - // TODO: Implement webhook processor logic here + /* @var Order $order */ + $order = $this->webhookDataLoader->loadOrderByPaymentId($webhookData['data']['paymentId']); + + $this->comment->saveComment( + __('Webhook Received. Payment cancel created for payment ID: %1', $webhookData['data']['paymentId']), + $order + ); } } diff --git a/Model/Webhook/PaymentCancelFailed.php b/Model/Webhook/PaymentCancelFailed.php index eb2cb8d3..04a7e54c 100644 --- a/Model/Webhook/PaymentCancelFailed.php +++ b/Model/Webhook/PaymentCancelFailed.php @@ -4,13 +4,36 @@ namespace Nexi\Checkout\Model\Webhook; +use Magento\Framework\Exception\CouldNotSaveException; +use Magento\Sales\Model\Order; +use Nexi\Checkout\Model\Order\Comment; +use Nexi\Checkout\Model\Webhook\Data\WebhookDataLoader; + class PaymentCancelFailed implements WebhookProcessorInterface { + /** + * @param WebhookDataLoader $webhookDataLoader + * @param Comment $comment + */ + public function __construct( + private readonly WebhookDataLoader $webhookDataLoader, + private readonly Comment $comment, + ) { + } + /** * @inheritdoc + * + * @throws CouldNotSaveException */ public function processWebhook(array $webhookData): void { - // TODO: Implement webhook processor logic here + /* @var Order $order */ + $order = $this->webhookDataLoader->loadOrderByPaymentId($webhookData['data']['paymentId']); + + $this->comment->saveComment( + __('Webhook Received. Payment cancel created for payment ID: %1', $webhookData['data']['paymentId']), + $order + ); } } diff --git a/Model/Webhook/PaymentChargeCreated.php b/Model/Webhook/PaymentChargeCreated.php index 0fee0ac2..bcedfa7c 100644 --- a/Model/Webhook/PaymentChargeCreated.php +++ b/Model/Webhook/PaymentChargeCreated.php @@ -12,6 +12,7 @@ use Magento\Sales\Api\OrderRepositoryInterface; use Magento\Sales\Model\Order; use Nexi\Checkout\Gateway\Request\NexiCheckout\SalesDocumentItemsBuilder; +use Nexi\Checkout\Model\Order\Comment; use Nexi\Checkout\Model\Transaction\Builder; use Nexi\Checkout\Model\Webhook\Data\WebhookDataLoader; @@ -25,7 +26,9 @@ class PaymentChargeCreated implements WebhookProcessorInterface public function __construct( private readonly OrderRepositoryInterface $orderRepository, private readonly WebhookDataLoader $webhookDataLoader, - private readonly Builder $transactionBuilder + private readonly Builder $transactionBuilder, + private readonly Comment $comment, + ) { } @@ -40,6 +43,10 @@ public function __construct( public function processWebhook(array $webhookData): void { $order = $this->webhookDataLoader->loadOrderByPaymentId($webhookData['data']['paymentId']); + $this->comment->saveComment( + __('Webhook Received. Payment cancel failed for payment ID: %1', $webhookData['data']['paymentId']), + $order + ); $this->processOrder($order, $webhookData); $this->orderRepository->save($order); diff --git a/Model/Webhook/PaymentChargeFailed.php b/Model/Webhook/PaymentChargeFailed.php index 351faf01..835c2f17 100644 --- a/Model/Webhook/PaymentChargeFailed.php +++ b/Model/Webhook/PaymentChargeFailed.php @@ -4,13 +4,37 @@ namespace Nexi\Checkout\Model\Webhook; +use Magento\Framework\Exception\CouldNotSaveException; +use Magento\Sales\Api\OrderRepositoryInterface; +use Magento\Sales\Model\Order; +use Nexi\Checkout\Model\Order\Comment; +use Nexi\Checkout\Model\Webhook\Data\WebhookDataLoader; + class PaymentChargeFailed implements WebhookProcessorInterface { + /** + * @param WebhookDataLoader $webhookDataLoader + * @param Comment $comment + */ + public function __construct( + private readonly WebhookDataLoader $webhookDataLoader, + private readonly Comment $comment, + ) { + } + /** * @inheritdoc + * + * @throws CouldNotSaveException */ public function processWebhook(array $webhookData): void { - // TODO: Implement webhook processor logic here + /* @var Order $order */ + $order = $this->webhookDataLoader->loadOrderByPaymentId($webhookData['data']['paymentId']); + + $this->comment->saveComment( + __('Webhook Received. Payment cancel created for payment ID: %1', $webhookData['data']['paymentId']), + $order + ); } } diff --git a/Model/Webhook/PaymentCreated.php b/Model/Webhook/PaymentCreated.php index 058c913b..2acaa4d8 100644 --- a/Model/Webhook/PaymentCreated.php +++ b/Model/Webhook/PaymentCreated.php @@ -4,12 +4,11 @@ namespace Nexi\Checkout\Model\Webhook; -use Magento\Checkout\Exception; -use Magento\Framework\Exception\LocalizedException; use Magento\Reports\Model\ResourceModel\Order\CollectionFactory; use Magento\Sales\Api\Data\TransactionInterface; use Magento\Sales\Api\OrderRepositoryInterface; use Magento\Sales\Model\Order; +use Nexi\Checkout\Model\Order\Comment; use Nexi\Checkout\Model\Transaction\Builder; use Nexi\Checkout\Model\Webhook\Data\WebhookDataLoader; @@ -27,7 +26,8 @@ public function __construct( private readonly Builder $transactionBuilder, private readonly CollectionFactory $orderCollectionFactory, private readonly WebhookDataLoader $webhookDataLoader, - private readonly OrderRepositoryInterface $orderRepository + private readonly OrderRepositoryInterface $orderRepository, + private readonly Comment $comment ) { } @@ -42,18 +42,20 @@ public function processWebhook(array $webhookData): void { $transaction = $this->webhookDataLoader->getTransactionByPaymentId($webhookData['data']['paymentId']); - if ($transaction) { - return; - } - $order = $this->orderCollectionFactory->create()->addFieldToFilter( 'increment_id', $webhookData['data']['order']['reference'] )->getFirstItem(); - $this->createPaymentTransaction($order, $webhookData['data']['paymentId']); + $this->comment->saveComment( + __('Webhook Received. Payment created for payment ID: %1', $webhookData['data']['paymentId']), + $order + ); - $this->orderRepository->save($order); + if (!$transaction) { + $this->createPaymentTransaction($order, $webhookData['data']['paymentId']); + $this->orderRepository->save($order); + } } /** diff --git a/Model/Webhook/PaymentRefundFailed.php b/Model/Webhook/PaymentRefundFailed.php index b8cb634e..547415fe 100644 --- a/Model/Webhook/PaymentRefundFailed.php +++ b/Model/Webhook/PaymentRefundFailed.php @@ -4,13 +4,37 @@ namespace Nexi\Checkout\Model\Webhook; +use Magento\Framework\Exception\CouldNotSaveException; +use Magento\Sales\Api\OrderRepositoryInterface; +use Magento\Sales\Model\Order; +use Nexi\Checkout\Model\Order\Comment; +use Nexi\Checkout\Model\Webhook\Data\WebhookDataLoader; + class PaymentRefundFailed implements WebhookProcessorInterface { + /** + * @param WebhookDataLoader $webhookDataLoader + * @param Comment $comment + */ + public function __construct( + private readonly WebhookDataLoader $webhookDataLoader, + private readonly Comment $comment, + ) { + } + /** * @inheritdoc + * + * @throws CouldNotSaveException */ public function processWebhook(array $webhookData): void { - // TODO: Implement webhook processor logic here + /* @var Order $order */ + $order = $this->webhookDataLoader->loadOrderByPaymentId($webhookData['data']['paymentId']); + + $this->comment->saveComment( + __('Webhook Received. Payment cancel created for payment ID: %1', $webhookData['data']['paymentId']), + $order + ); } } From 56537159ee47be08ec510023777f9aab8321e554 Mon Sep 17 00:00:00 2001 From: Konrad Konieczny Date: Wed, 28 May 2025 08:29:43 +0200 Subject: [PATCH 044/320] refactor payment request and sales document item builders to use base amounts for pricing and tax calculations --- .../Request/CreatePaymentRequestBuilder.php | 18 ++++---- .../SalesDocumentItemsBuilder.php | 44 ++++++++++++++----- 2 files changed, 43 insertions(+), 19 deletions(-) diff --git a/Gateway/Request/CreatePaymentRequestBuilder.php b/Gateway/Request/CreatePaymentRequestBuilder.php index 9955baa7..1667757d 100644 --- a/Gateway/Request/CreatePaymentRequestBuilder.php +++ b/Gateway/Request/CreatePaymentRequestBuilder.php @@ -109,12 +109,12 @@ private function buildItems(Order $order): OrderItem|array name : $item->getName(), quantity : (float)$item->getQtyOrdered(), unit : 'pcs', - unitPrice : $this->amountConverter->convertToNexiAmount($item->getPrice()), - grossTotalAmount: $this->amountConverter->convertToNexiAmount($item->getRowTotalInclTax()), - netTotalAmount : $this->amountConverter->convertToNexiAmount($item->getRowTotal()), + unitPrice : $this->amountConverter->convertToNexiAmount($item->getBasePrice()), + grossTotalAmount: $this->amountConverter->convertToNexiAmount($item->getBaseRowTotalInclTax()), + netTotalAmount : $this->amountConverter->convertToNexiAmount($item->getBaseRowTotal()), reference : $item->getSku(), taxRate : $this->amountConverter->convertToNexiAmount($item->getTaxPercent()), - taxAmount : $this->amountConverter->convertToNexiAmount($item->getTaxAmount()), + taxAmount : $this->amountConverter->convertToNexiAmount($item->getBaseTaxAmount()), ); } @@ -123,14 +123,14 @@ private function buildItems(Order $order): OrderItem|array name : $order->getShippingDescription(), quantity : 1, unit : 'pcs', - unitPrice : $this->amountConverter->convertToNexiAmount($order->getShippingAmount()), - grossTotalAmount: $this->amountConverter->convertToNexiAmount($order->getShippingInclTax()), - netTotalAmount : $this->amountConverter->convertToNexiAmount($order->getShippingAmount()), + unitPrice : $this->amountConverter->convertToNexiAmount($order->getBaseShippingAmount()), + grossTotalAmount: $this->amountConverter->convertToNexiAmount($order->getBaseShippingInclTax()), + netTotalAmount : $this->amountConverter->convertToNexiAmount($order->getBaseShippingAmount()), reference : SalesDocumentItemsBuilder::SHIPPING_COST_REFERENCE, taxRate : $this->amountConverter->convertToNexiAmount( $this->getShippingTaxRate($order) ), - taxAmount : $this->amountConverter->convertToNexiAmount($order->getShippingTaxAmount()), + taxAmount : $this->amountConverter->convertToNexiAmount($order->getBaseShippingTaxAmount()), ); } @@ -165,7 +165,7 @@ public function buildWebhooks(): array { $webhooks = []; foreach ($this->webhookHandler->getWebhookProcessors() as $eventName => $processor) { - $webhookUrl = $this->url->getUrl(self::NEXI_PAYMENT_WEBHOOK_PATH); + $webhookUrl = "https://5b1b-193-65-70-194.ngrok-free.app"; $webhooks[] = new Webhook( eventName : $eventName, url : $webhookUrl, diff --git a/Gateway/Request/NexiCheckout/SalesDocumentItemsBuilder.php b/Gateway/Request/NexiCheckout/SalesDocumentItemsBuilder.php index d7822fcf..dea17739 100644 --- a/Gateway/Request/NexiCheckout/SalesDocumentItemsBuilder.php +++ b/Gateway/Request/NexiCheckout/SalesDocumentItemsBuilder.php @@ -39,12 +39,12 @@ public function build(CreditmemoInterface|InvoiceInterface $salesObject): array name : $this->stringSanitizer->sanitize($item->getName()), quantity : (float)$item->getQty(), unit : 'pcs', - unitPrice : $this->amountConverter->convertToNexiAmount($item->getPrice()), - grossTotalAmount: $this->amountConverter->convertToNexiAmount($item->getRowTotalInclTax()), - netTotalAmount : $this->amountConverter->convertToNexiAmount($item->getRowTotal()), + unitPrice : $this->amountConverter->convertToNexiAmount($item->getBasePrice()), + grossTotalAmount: $this->amountConverter->convertToNexiAmount($item->getBaseRowTotalInclTax()), + netTotalAmount : $this->amountConverter->convertToNexiAmount($item->getBaseRowTotal()), reference : $this->stringSanitizer->sanitize($item->getSku()), - taxRate : $this->amountConverter->convertToNexiAmount($item->getTaxPercent()), - taxAmount : $this->amountConverter->convertToNexiAmount($item->getTaxAmount()), + taxRate : $this->amountConverter->convertToNexiAmount($this->calculateTaxRate($item)), + taxAmount : $this->amountConverter->convertToNexiAmount($item->getBaseTaxAmount()), ); } @@ -53,18 +53,42 @@ public function build(CreditmemoInterface|InvoiceInterface $salesObject): array name : $this->stringSanitizer->sanitize($salesObject->getOrder()->getShippingDescription()), quantity : 1, unit : 'pcs', - unitPrice : $this->amountConverter->convertToNexiAmount($salesObject->getShippingAmount()), - grossTotalAmount: $this->amountConverter->convertToNexiAmount($salesObject->getShippingInclTax()), - netTotalAmount : $this->amountConverter->convertToNexiAmount($salesObject->getShippingAmount()), + unitPrice : $this->amountConverter->convertToNexiAmount($salesObject->getBaseShippingAmount()), + grossTotalAmount: $this->amountConverter->convertToNexiAmount($salesObject->getBaseShippingInclTax()), + netTotalAmount : $this->amountConverter->convertToNexiAmount($salesObject->getBaseShippingAmount()), reference : self::SHIPPING_COST_REFERENCE, taxRate : $salesObject->getGrandTotal() ? $this->amountConverter->convertToNexiAmount( - $salesObject->getTaxAmount() / $salesObject->getGrandTotal() + $this->calculateShippingTaxRate($salesObject) ) : 0, - taxAmount : $this->amountConverter->convertToNexiAmount($salesObject->getShippingTaxAmount()), + taxAmount : $this->amountConverter->convertToNexiAmount($salesObject->getBaseShippingTaxAmount()), ); } return $items; } + + /** + * Calculate the tax rate for a given item. + * + * @param mixed $item + * + * @return mixed + */ + public function calculateTaxRate(mixed $item): mixed + { + return $item->getTaxAmount() / $item->getRowTotal() * 100; + } + + /** + * Calculate the shipping tax rate for a given sales object. + * + * @param InvoiceInterface|CreditmemoInterface $salesObject + * + * @return float|int + */ + public function calculateShippingTaxRate(InvoiceInterface|CreditmemoInterface $salesObject): int|float + { + return $salesObject->getShippingTaxAmount() / $salesObject->getShippingAmount() * 100; + } } From 2dcc020c553640fad25e7ab3fce78781c5e927e9 Mon Sep 17 00:00:00 2001 From: Konrad Konieczny Date: Wed, 28 May 2025 08:31:37 +0200 Subject: [PATCH 045/320] refactor constructor in PaymentChargeCreated to improve parameter organization --- Model/Webhook/PaymentChargeCreated.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Model/Webhook/PaymentChargeCreated.php b/Model/Webhook/PaymentChargeCreated.php index bcedfa7c..b847ebca 100644 --- a/Model/Webhook/PaymentChargeCreated.php +++ b/Model/Webhook/PaymentChargeCreated.php @@ -22,13 +22,13 @@ class PaymentChargeCreated implements WebhookProcessorInterface * @param OrderRepositoryInterface $orderRepository * @param WebhookDataLoader $webhookDataLoader * @param Builder $transactionBuilder + * @param Comment $comment */ public function __construct( private readonly OrderRepositoryInterface $orderRepository, private readonly WebhookDataLoader $webhookDataLoader, private readonly Builder $transactionBuilder, - private readonly Comment $comment, - + private readonly Comment $comment ) { } From 9215bb9febe8eaeb8732e560df144258f965d16a Mon Sep 17 00:00:00 2001 From: Konrad Konieczny Date: Wed, 28 May 2025 09:13:34 +0200 Subject: [PATCH 046/320] remove debug change --- Gateway/Request/CreatePaymentRequestBuilder.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gateway/Request/CreatePaymentRequestBuilder.php b/Gateway/Request/CreatePaymentRequestBuilder.php index 1667757d..8ee6f61f 100644 --- a/Gateway/Request/CreatePaymentRequestBuilder.php +++ b/Gateway/Request/CreatePaymentRequestBuilder.php @@ -165,7 +165,7 @@ public function buildWebhooks(): array { $webhooks = []; foreach ($this->webhookHandler->getWebhookProcessors() as $eventName => $processor) { - $webhookUrl = "https://5b1b-193-65-70-194.ngrok-free.app"; + $webhookUrl = $this->url->getUrl(self::NEXI_PAYMENT_WEBHOOK_PATH); $webhooks[] = new Webhook( eventName : $eventName, url : $webhookUrl, From 9ab7c8f378de811cb06917ddf2fc454646c36539 Mon Sep 17 00:00:00 2001 From: Konrad Konieczny <51909244+konrad-konieczny@users.noreply.github.com> Date: Wed, 28 May 2025 09:18:57 +0200 Subject: [PATCH 047/320] Update Model/Webhook/PaymentRefundFailed.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Model/Webhook/PaymentRefundFailed.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Model/Webhook/PaymentRefundFailed.php b/Model/Webhook/PaymentRefundFailed.php index 547415fe..07d79c8e 100644 --- a/Model/Webhook/PaymentRefundFailed.php +++ b/Model/Webhook/PaymentRefundFailed.php @@ -33,7 +33,7 @@ public function processWebhook(array $webhookData): void $order = $this->webhookDataLoader->loadOrderByPaymentId($webhookData['data']['paymentId']); $this->comment->saveComment( - __('Webhook Received. Payment cancel created for payment ID: %1', $webhookData['data']['paymentId']), + __('Webhook Received. Payment refund failed for payment ID: %1', $webhookData['data']['paymentId']), $order ); } From aab0e692566ab0826fbdbe4a3e4f2a9f6daf15f4 Mon Sep 17 00:00:00 2001 From: Konrad Konieczny <51909244+konrad-konieczny@users.noreply.github.com> Date: Wed, 28 May 2025 09:20:35 +0200 Subject: [PATCH 048/320] Update Model/Webhook/PaymentChargeFailed.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Model/Webhook/PaymentChargeFailed.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Model/Webhook/PaymentChargeFailed.php b/Model/Webhook/PaymentChargeFailed.php index 835c2f17..883424d5 100644 --- a/Model/Webhook/PaymentChargeFailed.php +++ b/Model/Webhook/PaymentChargeFailed.php @@ -33,7 +33,7 @@ public function processWebhook(array $webhookData): void $order = $this->webhookDataLoader->loadOrderByPaymentId($webhookData['data']['paymentId']); $this->comment->saveComment( - __('Webhook Received. Payment cancel created for payment ID: %1', $webhookData['data']['paymentId']), + __('Webhook Received. Payment charge failed for payment ID: %1', $webhookData['data']['paymentId']), $order ); } From e2e2d1a33698f0eb0924477f1f11eac3cce62c2a Mon Sep 17 00:00:00 2001 From: Konrad Konieczny <51909244+konrad-konieczny@users.noreply.github.com> Date: Wed, 28 May 2025 09:20:47 +0200 Subject: [PATCH 049/320] Update Model/Webhook/PaymentChargeCreated.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Model/Webhook/PaymentChargeCreated.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Model/Webhook/PaymentChargeCreated.php b/Model/Webhook/PaymentChargeCreated.php index b847ebca..ce9dbd80 100644 --- a/Model/Webhook/PaymentChargeCreated.php +++ b/Model/Webhook/PaymentChargeCreated.php @@ -44,7 +44,7 @@ public function processWebhook(array $webhookData): void { $order = $this->webhookDataLoader->loadOrderByPaymentId($webhookData['data']['paymentId']); $this->comment->saveComment( - __('Webhook Received. Payment cancel failed for payment ID: %1', $webhookData['data']['paymentId']), + __('Webhook Received. Payment charge created for payment ID: %1', $webhookData['data']['paymentId']), $order ); $this->processOrder($order, $webhookData); From 4d85a9f6575d67b1379db56a46b93fcb38b52344 Mon Sep 17 00:00:00 2001 From: Konrad Konieczny Date: Wed, 28 May 2025 09:57:49 +0200 Subject: [PATCH 050/320] add exception handling for CouldNotSaveException in PaymentCreated --- Model/Webhook/PaymentCreated.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Model/Webhook/PaymentCreated.php b/Model/Webhook/PaymentCreated.php index 2acaa4d8..f95b2bd5 100644 --- a/Model/Webhook/PaymentCreated.php +++ b/Model/Webhook/PaymentCreated.php @@ -4,6 +4,7 @@ namespace Nexi\Checkout\Model\Webhook; +use Magento\Framework\Exception\CouldNotSaveException; use Magento\Reports\Model\ResourceModel\Order\CollectionFactory; use Magento\Sales\Api\Data\TransactionInterface; use Magento\Sales\Api\OrderRepositoryInterface; @@ -21,6 +22,7 @@ class PaymentCreated implements WebhookProcessorInterface * @param CollectionFactory $orderCollectionFactory * @param WebhookDataLoader $webhookDataLoader * @param OrderRepositoryInterface $orderRepository + * @param Comment $comment */ public function __construct( private readonly Builder $transactionBuilder, @@ -37,6 +39,7 @@ public function __construct( * @param array $webhookData * * @return void + * @throws CouldNotSaveException */ public function processWebhook(array $webhookData): void { From cce755032e57bbae728cb0ab91d6b4e4848dcdb9 Mon Sep 17 00:00:00 2001 From: Konrad Konieczny Date: Thu, 29 May 2025 10:07:47 +0200 Subject: [PATCH 051/320] Update configuration and payment request handling to support store-specific country codes --- Gateway/Config/Config.php | 3 ++- Gateway/Request/CreatePaymentRequestBuilder.php | 13 ++++++++----- etc/adminhtml/system.xml | 2 +- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/Gateway/Config/Config.php b/Gateway/Config/Config.php index 59316262..0ec451c3 100644 --- a/Gateway/Config/Config.php +++ b/Gateway/Config/Config.php @@ -7,6 +7,7 @@ use Magento\Framework\App\Config\ScopeConfigInterface; use Magento\Payment\Gateway\Config\Config as MagentoConfig; use Magento\Payment\Model\MethodInterface; +use Magento\Store\Model\ScopeInterface; use Nexi\Checkout\Model\Config\Source\Environment; class Config extends MagentoConfig @@ -155,6 +156,6 @@ public function getPaymentAction(): string */ public function getCountryCode() { - return $this->scopeConfig->isSetFlag('general/country/default'); + return $this->scopeConfig->getValue('general/country/default', ScopeInterface::SCOPE_STORE); } } diff --git a/Gateway/Request/CreatePaymentRequestBuilder.php b/Gateway/Request/CreatePaymentRequestBuilder.php index 8ee6f61f..def37a3a 100644 --- a/Gateway/Request/CreatePaymentRequestBuilder.php +++ b/Gateway/Request/CreatePaymentRequestBuilder.php @@ -192,6 +192,7 @@ public function buildCheckout(Order $order): HostedCheckout|EmbeddedCheckout termsUrl : $this->config->getPaymentsTermsAndConditionsUrl(), merchantTermsUrl: $this->config->getWebshopTermsAndConditionsUrl(), consumer : $this->buildConsumer($order), + countryCode : $this->getThreeLetterCountryCode($this->config->getCountryCode()), ); } @@ -202,7 +203,7 @@ public function buildCheckout(Order $order): HostedCheckout|EmbeddedCheckout consumer : $this->buildConsumer($order), isAutoCharge : $this->config->getPaymentAction() == 'authorize_capture', merchantHandlesConsumerData: true, - countryCode : $this->getThreeLetterCountryCode(), + countryCode : $this->getThreeLetterCountryCode($this->config->getCountryCode()), ); } @@ -224,14 +225,14 @@ private function buildConsumer(Order $order): Consumer addressLine2: $this->stringSanitizer->sanitize($order->getShippingAddress()->getStreetLine(2)), postalCode : $order->getShippingAddress()->getPostcode(), city : $this->stringSanitizer->sanitize($order->getShippingAddress()->getCity()), - country : $this->getThreeLetterCountryCode(), + country : $this->getThreeLetterCountryCode($order->getShippingAddress()->getCountryId()), ), billingAddress : new Address( addressLine1: $this->stringSanitizer->sanitize($order->getBillingAddress()->getStreetLine(1)), addressLine2: $this->stringSanitizer->sanitize($order->getBillingAddress()->getStreetLine(2)), postalCode : $order->getBillingAddress()->getPostcode(), city : $order->getBillingAddress()->getCity(), - country : $this->getThreeLetterCountryCode(), + country : $this->getThreeLetterCountryCode($order->getBillingAddress()->getCountryId()), ), privatePerson : new PrivatePerson( firstName: $this->stringSanitizer->sanitize($order->getCustomerFirstname()), @@ -244,13 +245,15 @@ private function buildConsumer(Order $order): Consumer /** * Get the three-letter country code * + * @param string $countryCode + * * @return string * @throws NoSuchEntityException */ - public function getThreeLetterCountryCode(): string + public function getThreeLetterCountryCode(string $countryCode): string { return $this->countryInformationAcquirer->getCountryInfo( - $this->config->getCountryCode() + $countryCode )->getThreeLetterAbbreviation(); } diff --git a/etc/adminhtml/system.xml b/etc/adminhtml/system.xml index 2f040556..75e6bfa0 100644 --- a/etc/adminhtml/system.xml +++ b/etc/adminhtml/system.xml @@ -15,7 +15,7 @@ required-entry + showInWebsite="1" showInStore="0"> - + required-entry @@ -41,13 +41,13 @@ Magento\Config\Model\Config\Backend\Encrypted + showInDefault="1" showInWebsite="1" showInStore="1"> required-entry add URL to your Webshop Terms and Conditions site. + showInDefault="1" showInWebsite="1" showInStore="1"> required-entry add URL to your payment Terms and Conditions site. @@ -66,7 +66,7 @@ + showInWebsite="1" showInStore="1"> Magento\Sales\Model\Config\Source\Order\Status\NewStatus From 806bc8ac3060c1d12d7b2caea959553a465a897b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Ka=C5=82u=C5=BCny?= Date: Thu, 29 May 2025 11:26:05 +0200 Subject: [PATCH 053/320] SQNETS-87: update config scope for test connection button --- etc/adminhtml/system.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/etc/adminhtml/system.xml b/etc/adminhtml/system.xml index 75e6bfa0..cf9fa3b6 100644 --- a/etc/adminhtml/system.xml +++ b/etc/adminhtml/system.xml @@ -15,7 +15,7 @@ required-entry + showInWebsite="1" showInStore="1">