diff --git a/.github/workflows/code-validation.yml b/.github/workflows/code-validation.yml new file mode 100644 index 00000000..7899453d --- /dev/null +++ b/.github/workflows/code-validation.yml @@ -0,0 +1,84 @@ +name: Run code validation checks +on: + pull_request: +jobs: + changed-files: + runs-on: ubuntu-latest + name: Gather Changelist + strategy: + matrix: + fetch-depth: + - 2 + base-branch: + - "master" + outputs: + all: ${{ steps.changes.outputs.all }} + php: ${{ steps.changes.outputs.php }} + steps: + - name: Check out code + uses: actions/checkout@v4 + with: + fetch-depth: ${{ matrix.fetch-depth }} + - name: Get changed files + id: changes + run: | + BASE_SHA="${{ github.event.before }}" + if [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then + BASE_SHA="${{ matrix.base-branch }}" + fi + if [[ ! -z "${{ github.event.pull_request.base.sha }}" ]]; then + BASE_SHA="${{ github.event.pull_request.base.sha }}" + fi + echo "all=$(git diff --name-only --diff-filter=ACMRT $BASE_SHA ${{ github.sha }} | xargs)" >> $GITHUB_OUTPUT + echo "php=$(git diff --name-only --diff-filter=ACMRT $BASE_SHA ${{ github.sha }} | grep -E '.ph(p|tml)$' | xargs)" >> $GITHUB_OUTPUT + validate-php: + runs-on: ubuntu-latest + name: Run php code validation and Unit tests + needs: changed-files + if: ${{needs.changed-files.outputs.php}} + strategy: + matrix: + node-version: + - 20 + php-version: ["8.1", "8.2", "8.3"] + dependencies: + - "highest" + composer-options: + - "--no-plugins --no-progress" + steps: + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node_version }} + - name: Check out code + uses: actions/checkout@v4 + - name: Setup Php + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + ini-file: development + tools: composer:2.2, cs2pr + env: + COMPOSER_AUTH_JSON: | + { + "http-basic": { + "repo.magento.com": { + "username": "${{ secrets.REPO_MAGENTO_USER }}", + "password": "${{ secrets.REPO_MAGENTO_PASSWORD }}" + } + } + } + - name: Validate Composer Files + run: composer validate + - name: Run Composer install + uses: "ramsey/composer-install@v1" + with: + dependency-versions: "${{ matrix.dependencies }}" + composer-options: "${{ matrix.composer-options }}" + - name: Detect PhpCs violations + run: | + vendor/bin/phpcs --config-set installed_paths ../../magento/magento-coding-standard/,../../phpcompatibility/php-compatibility,../../magento/php-compatibility-fork + vendor/bin/phpcs --standard=Magento2 -q --report=checkstyle ${{needs.changed-files.outputs.php}} | cs2pr --graceful-warnings + - name: Run Unit test + run: | + vendor/bin/phpunit Test/Unit/ diff --git a/Block/Adminhtml/System/Config/TestConnection.php b/Block/Adminhtml/System/Config/TestConnection.php new file mode 100644 index 00000000..5580b089 --- /dev/null +++ b/Block/Adminhtml/System/Config/TestConnection.php @@ -0,0 +1,99 @@ +unsScope()->unsCanUseWebsiteValue()->unsCanUseDefaultValue(); + + return parent::render($element); + } + + /** + * Set template to itself + * + * @return $this + * @since 100.1.0 + */ + protected function _prepareLayout() + { + parent::_prepareLayout(); + $this->setTemplate('Nexi_Checkout::system/config/testconnection.phtml'); + + return $this; + } + + /** + * Get HTML for the element + * + * @param AbstractElement $element + * + * @return string + */ + protected function _getElementHtml(AbstractElement $element) + { + $originalData = $element->getOriginalData(); + $this->addData( + [ + '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())) + ] + ); + + return $this->_toHtml(); + } + + /** + * Get configuration field mapping + * + * @return string[] + */ + private function getFieldMapping(): array + { + $apiKeyPath = $this->configStructure->getElementByConfigPath('payment/nexi/secret_key'); + $testApiKeyPath = $this->configStructure->getElementByConfigPath('payment/nexi/test_secret_key'); + $environmentPath = $this->configStructure->getElementByConfigPath('payment/nexi/environment'); + + return [ + 'environment' => str_replace('/', '_', $environmentPath->getPath()), + 'secret_key' => str_replace('/', '_', $apiKeyPath->getPath()), + 'test_secret_key' => str_replace('/', '_', $testApiKeyPath->getPath()) + ]; + } +} diff --git a/Controller/Adminhtml/System/Config/TestConnection.php b/Controller/Adminhtml/System/Config/TestConnection.php new file mode 100644 index 00000000..18921c24 --- /dev/null +++ b/Controller/Adminhtml/System/Config/TestConnection.php @@ -0,0 +1,117 @@ + true, + 'errorMessage' => '', + ]; + $options = $this->getRequest()->getParams(); + $isLiveMode = $options['environment'] == Environment::LIVE; + + $apiKey = $isLiveMode ? $options['secret_key'] : $options['test_secret_key']; + + if ($apiKey == '******') { + $apiKey = $isLiveMode ? $this->config->getApiKey() : $this->config->getTestApiKey(); + } + + try { + $api = $this->paymentApiFactory->create( + secretKey : $apiKey, + isLiveMode: $isLiveMode + ); + $currency = $this->scopeConfig->getValue( + 'currency/options/default', + ScopeInterface::SCOPE_STORE + ); + + $payment = $api->createEmbeddedPayment( + new Payment( + new Order( + [ + new Item('test', 1, 'pcs', 1, 1, 1, 'test') + ], + $currency, + 1 + ), + new Payment\EmbeddedCheckout( + $this->url->getUrl('checkout/onepage/success'), + 'terms_url' + ) + ) + ); + + if ($payment->getPaymentId()) { + $api->terminate($payment->getPaymentId()); + } + + } catch (PaymentApiException $e) { + $message = $e->getMessage(); + $result['success'] = false; + $result['errorMessage'] = $this->tagFilter->filter($message) . ' ' + . __('Please check your API key and environment.'); + } + + $resultJson = $this->resultJsonFactory->create(); + + return $resultJson->setData($result); + } +} diff --git a/Controller/Hpp/CancelAction.php b/Controller/Hpp/CancelAction.php new file mode 100644 index 00000000..51aad0f8 --- /dev/null +++ b/Controller/Hpp/CancelAction.php @@ -0,0 +1,42 @@ +checkoutSession->restoreQuote(); + $this->messageManager->addNoticeMessage(__('The payment has been canceled.')); + + return $this->resultRedirectFactory->create()->setUrl( + $this->url->getUrl('checkout/cart/index', ['_secure' => true]) + ); + } +} diff --git a/Controller/Payment/Webhook.php b/Controller/Payment/Webhook.php new file mode 100644 index 00000000..321004db --- /dev/null +++ b/Controller/Payment/Webhook.php @@ -0,0 +1,123 @@ +isAuthorized()) { + return $this->_response + ->setHttpResponseCode(401) + ->setBody('Unauthorized'); + } + + try { + $content = $this->serializer->unserialize($this->getRequest()->getContent()); + + if (!isset($content['event'])) { + return $this->_response + ->setHttpResponseCode(400) + ->setBody('Missing event name'); + } + + $this->webhookHandler->handle($content); + + $this->logger->info( + 'Webhook called:', + [ + 'webhook_data' => json_encode($this->getRequest()->getContent()), + 'payment_id' => $this->getRequest()->getParam('payment_id'), + ] + ); + $this->_response->setHttpResponseCode(200); + } catch (Exception $e) { + $this->logger->error($e->getMessage(), ['stacktrace' => $e->getTrace()]); + $this->_response->setHttpResponseCode(500); + } + } + + /** + * Allow all requests to this action + * + * @param RequestInterface $request + * + * @return InvalidRequestException|null + */ + public function createCsrfValidationException(RequestInterface $request): ?InvalidRequestException + { + return null; + } + + /** + * No form key validation needed + * + * @param RequestInterface $request + * + * @return bool|null + */ + public function validateForCsrf(RequestInterface $request): ?bool + { + return true; + } + + /** + * Check the authorisation header + * + * @return bool + */ + public function isAuthorized(): bool + { + $authString = $this->getRequest()->getHeader('Authorization'); + + if (empty($authString)) { + return false; + } + + $hash = $this->encryptor->hash( + $this->config->getWebhookSecret(), + ); + + return hash_equals($hash, $authString); + } +} diff --git a/Gateway/AmountConverter.php b/Gateway/AmountConverter.php new file mode 100644 index 00000000..612dff82 --- /dev/null +++ b/Gateway/AmountConverter.php @@ -0,0 +1,26 @@ +subjectReader->readPayment($commandSubject); + $stateObject = $this->subjectReader->readStateObject($commandSubject); + + /** @var InfoInterface $payment */ + $payment = $paymentData->getPayment(); + $payment->setIsTransactionPending(true); + $payment->setIsTransactionIsClosed(false); + + $order = $payment->getOrder(); + $order->setCanSendNewEmailFlag(false); + + $stateObject->setState(Order::STATE_NEW); + $stateObject->setStatus(self::STATUS_PENDING); + $stateObject->setIsNotified(false); + + $this->cratePayment($paymentData); + + $transactionId = $payment->getAdditionalInformation('payment_id'); + $orderTransaction = $this->transactionBuilder->build( + $transactionId, + $order, + ['payment_id' => $transactionId], + TransactionInterface::TYPE_PAYMENT + ); + + $payment->addTransactionCommentsToOrder( + $orderTransaction, + __('Payment created in Nexi Gateway.') + ); + } + + /** + * Create payment in Nexi Gateway + * + * @param PaymentDataObjectInterface $payment + * + * @return ResultInterface|null + * @throws LocalizedException + */ + public function cratePayment(PaymentDataObjectInterface $payment): ?ResultInterface + { + try { + $commandPool = $this->commandManagerPool->get(Config::CODE); + $result = $commandPool->executeByCode( + commandCode: 'create_payment', + arguments : ['payment' => $payment,] + ); + } catch (Exception $e) { + $this->logger->error($e->getMessage(), ['stacktrace' => $e->getTrace()]); + throw new LocalizedException(__('An error occurred during the payment process. Please try again later.')); + } + + return $result; + } +} diff --git a/Gateway/Config/Config.php b/Gateway/Config/Config.php new file mode 100644 index 00000000..59316262 --- /dev/null +++ b/Gateway/Config/Config.php @@ -0,0 +1,160 @@ +getValue('environment'); + } + + /** + * Check if the environment is live + * + * @return bool + */ + public function isLiveMode(): bool + { + return $this->getEnvironment() === Environment::LIVE; + } + + /** + * Is the payment method active + * + * @return bool + */ + public function isActive(): bool + { + return (bool)$this->getValue('active'); + } + + /** + * Get secret key + * + * @return string|null + */ + public function getApiKey(): ?string + { + if ($this->isLiveMode()) { + return $this->getValue('secret_key'); + } + + return $this->getTestApiKey(); + } + + /** + * Get test secret key + * + * @return mixed|null + */ + public function getTestApiKey() + { + return $this->getValue('test_secret_key'); + } + + /** + * Get api identifier + * + * @return mixed|null + */ + public function getCheckoutKey() + { + if ($this->isLiveMode()) { + return $this->getValue('checkout_key'); + } + + return $this->getValue('test_checkout_key'); + } + + /** + * Get webshop terms and conditions url + * + * @return string + */ + public function getWebshopTermsAndConditionsUrl(): string + { + return (string)$this->getValue('webshop_terms_and_conditions_url'); + } + + /** + * Get payments terms and conditions url + * + * @return string + */ + public function getPaymentsTermsAndConditionsUrl(): string + { + return (string)$this->getValue('payment_terms_and_conditions_url'); + } + + /** + * Get integration type + * + * @return string + */ + public function getIntegrationType(): string + { + return $this->getValue('integration_type'); + } + + /** + * Get webhook secret + * + * @return string + */ + public function getWebhookSecret(): string + { + return $this->getValue('webhook_secret'); + } + + /** + * Get payment action: authorize, authorize_capture + * + * @return string + */ + public function getPaymentAction(): string + { + return $this->getValue('is_auto_capture') ? + MethodInterface::ACTION_AUTHORIZE_CAPTURE : + MethodInterface::ACTION_AUTHORIZE; + } + + /** + * Get the country code + * + * @return mixed + */ + public function getCountryCode() + { + return $this->scopeConfig->isSetFlag('general/country/default'); + } +} diff --git a/Gateway/Handler/Capture.php b/Gateway/Handler/Capture.php new file mode 100644 index 00000000..e2f8296a --- /dev/null +++ b/Gateway/Handler/Capture.php @@ -0,0 +1,40 @@ +subjectReader->readPayment($handlingSubject); + $payment = $paymentDO->getPayment(); + + /** @var ChargeResult[] $response */ + $chargeResult = reset($response); + + $chargeId = $chargeResult->getChargeId(); + + $payment->setAdditionalInformation('charge_id', $chargeId); + $payment->setLastTransId($chargeId); + $payment->setTransactionId($chargeId); + } +} diff --git a/Gateway/Handler/CreatePayment.php b/Gateway/Handler/CreatePayment.php new file mode 100644 index 00000000..869f2624 --- /dev/null +++ b/Gateway/Handler/CreatePayment.php @@ -0,0 +1,33 @@ +subjectReader->readPayment($handlingSubject); + $payment = $paymentDO->getPayment(); + + $response = reset($response); + + $payment->setAdditionalInformation('payment_id', $response->getPaymentId()); + $payment->setAdditionalInformation('redirect_url', $response->getHostedPaymentPageUrl()); + } +} diff --git a/Gateway/Handler/RefundCharge.php b/Gateway/Handler/RefundCharge.php new file mode 100644 index 00000000..a0398a1a --- /dev/null +++ b/Gateway/Handler/RefundCharge.php @@ -0,0 +1,35 @@ +subjectReader->readPayment($handlingSubject); + $payment = $paymentDO->getPayment(); + + /** @var RefundChargeResult $response */ + $response = reset($response); + + $payment->setLastTransId($response->getRefundId()); + $payment->setTransactionId($response->getRefundId()); + } +} diff --git a/Gateway/Http/Client.php b/Gateway/Http/Client.php new file mode 100644 index 00000000..af37de50 --- /dev/null +++ b/Gateway/Http/Client.php @@ -0,0 +1,80 @@ +getPaymentApi(); + $nexiMethod = $transferObject->getUri(); + $this->logger->debug( + 'Nexi Client request: ', + [ + 'method' => $nexiMethod, + 'request' => $transferObject->getBody() + ] + ); + if (is_array($transferObject->getBody())) { + $response = $paymentApi->$nexiMethod(...$transferObject->getBody()); + } else { + $response = $paymentApi->$nexiMethod($transferObject->getBody()); + } + + $this->logger->debug( + 'Nexi Client response: ', + ['response' => var_export($response, true)] + ); + } catch (PaymentApiException|\Exception $e) { + $this->logger->error($e->getMessage(), [$e]); + throw new LocalizedException(__('An error occurred during the payment process. Please try again later.')); + } + + return [$response]; + } + + /** + * Get Payment API Client + * + * @return PaymentApi + */ + public function getPaymentApi(): PaymentApi + { + return $this->paymentApiFactory->create( + (string)$this->config->getApiKey(), + $this->config->isLiveMode() + ); + } +} diff --git a/Gateway/Http/TransferFactory.php b/Gateway/Http/TransferFactory.php new file mode 100644 index 00000000..966b4f49 --- /dev/null +++ b/Gateway/Http/TransferFactory.php @@ -0,0 +1,39 @@ +transferBuilder + ->setBody($request['body']) + ->setUri($nexiMethod) + ->build(); + } +} diff --git a/Gateway/Request/CaptureRequestBuilder.php b/Gateway/Request/CaptureRequestBuilder.php new file mode 100644 index 00000000..d022795b --- /dev/null +++ b/Gateway/Request/CaptureRequestBuilder.php @@ -0,0 +1,49 @@ +getPayment(); + + $invoice = $payment->getOrder()->getInvoiceCollection()->getLastItem(); + + return [ + 'nexi_method' => 'charge', + 'body' => [ + 'paymentId' => $payment->getAdditionalInformation('payment_id'), + 'charge' => new PartialCharge( + $this->documentItemsBuilder->build($invoice), + ) + ] + ]; + } +} diff --git a/Gateway/Request/CreatePaymentRequestBuilder.php b/Gateway/Request/CreatePaymentRequestBuilder.php new file mode 100644 index 00000000..5224a43f --- /dev/null +++ b/Gateway/Request/CreatePaymentRequestBuilder.php @@ -0,0 +1,278 @@ +getPayment()->getOrder(); + + return [ + 'nexi_method' => 'createHostedPayment', + 'body' => [ + 'payment' => $this->buildPayment($order), + ] + ]; + } + + /** + * Build the Sdk order object + * + * @param Order $order + * + * @return NexiRequestOrder + */ + public function buildOrder(Order $order): NexiRequestOrder + { + return new NexiRequestOrder( + items : $this->buildItems($order), + currency : $order->getBaseCurrencyCode(), + amount : $this->amountConverter->convertToNexiAmount($order->getBaseGrandTotal()), + reference: $order->getIncrementId() + ); + } + + /** + * Build the Sdk items object + * + * @param Order $order + * + * @return OrderItem|array + */ + private function buildItems(Order $order): OrderItem|array + { + /** @var OrderItem $items */ + foreach ($order->getAllVisibleItems() as $item) { + $items[] = new Item( + 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()), + reference : $item->getSku(), + taxRate : $this->amountConverter->convertToNexiAmount($item->getTaxPercent()), + taxAmount : $this->amountConverter->convertToNexiAmount($item->getTaxAmount()), + ); + } + + if ($order->getShippingAmount()) { + $items[] = new Item( + 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()), + reference : SalesDocumentItemsBuilder::SHIPPING_COST_REFERENCE, + taxRate : $this->amountConverter->convertToNexiAmount( + $order->getTaxAmount() / $order->getGrandTotal() + ), + taxAmount : $this->amountConverter->convertToNexiAmount($order->getShippingTaxAmount()), + ); + } + + return $items; + } + + /** + * Build The Sdk payment object + * + * @param Order $order + * + * @return Payment + * @throws NoSuchEntityException + */ + private function buildPayment(Order $order): Payment + { + return new Payment( + order : $this->buildOrder($order), + checkout : $this->buildCheckout($order), + notification: new Notification($this->buildWebhooks()), + ); + } + + /** + * Build the webhooks for the payment + * + * @return array + * + * added all for now, we need to check wh + */ + public function buildWebhooks(): array + { + $webhooks = []; + foreach ($this->webhookHandler->getWebhookProcessors() as $eventName => $processor) { + $webhookUrl = $this->url->getUrl(self::NEXI_PAYMENT_WEBHOOK_PATH); + $webhooks[] = new Webhook( + eventName : $eventName, + url : $webhookUrl, + authorization: $this->encryptor->hash($this->config->getWebhookSecret()) + ); + } + + return $webhooks; + } + + /** + * Build the checkout object + * + * @param Order $order + * + * @return HostedCheckout|EmbeddedCheckout + * @throws NoSuchEntityException + */ + public function buildCheckout(Order $order): HostedCheckout|EmbeddedCheckout + { + if ($this->config->getIntegrationType() == IntegrationTypeEnum::EmbeddedCheckout) { + return new EmbeddedCheckout( + url : $this->url->getUrl('checkout'), + termsUrl : $this->config->getPaymentsTermsAndConditionsUrl(), + merchantTermsUrl: $this->config->getWebshopTermsAndConditionsUrl(), + consumer : $this->buildConsumer($order), + ); + } + + return new HostedCheckout( + returnUrl : $this->url->getUrl('checkout/onepage/success'), + cancelUrl : $this->url->getUrl('nexi/hpp/cancelaction'), + termsUrl : $this->config->getWebshopTermsAndConditionsUrl(), + consumer : $this->buildConsumer($order), + isAutoCharge : $this->config->getPaymentAction() == 'authorize_capture', + merchantHandlesConsumerData: true, + countryCode : $this->getThreeLetterCountryCode(), + ); + } + + /** + * Build the consumer object + * + * @param Order $order + * + * @return Consumer + * @throws NoSuchEntityException + */ + private function buildConsumer(Order $order): Consumer + { + return new Consumer( + email : $order->getCustomerEmail(), + reference : $order->getCustomerId(), + shippingAddress: new Address( + addressLine1: $this->stringSanitizer->sanitize($order->getShippingAddress()->getStreetLine(1)), + addressLine2: $this->stringSanitizer->sanitize($order->getShippingAddress()->getStreetLine(2)), + postalCode : $order->getShippingAddress()->getPostcode(), + city : $this->stringSanitizer->sanitize($order->getShippingAddress()->getCity()), + country : $this->getThreeLetterCountryCode(), + ), + 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(), + ), + privatePerson : new PrivatePerson( + firstName: $this->stringSanitizer->sanitize($order->getCustomerFirstname()), + lastName : $this->stringSanitizer->sanitize($order->getCustomerLastname()), + ), + phoneNumber : $this->getNumber($order) + ); + } + + /** + * Get the three-letter country code + * + * @return string + * @throws NoSuchEntityException + */ + public function getThreeLetterCountryCode(): string + { + return $this->countryInformationAcquirer->getCountryInfo( + $this->config->getCountryCode() + )->getThreeLetterAbbreviation(); + } + + /** + * Build phone number object for the payment + * + * @param Order $order + * + * @return PhoneNumber + * @throws NumberParseException + */ + public function getNumber(Order $order): PhoneNumber + { + $lib = PhoneNumberUtil::getInstance(); + + $number = $lib->parse( + $order->getShippingAddress()->getTelephone(), + $order->getShippingAddress()->getCountryId() + ); + + return new PhoneNumber( + prefix: '+' . $number->getCountryCode(), + number: (string)$number->getNationalNumber(), + ); + } +} diff --git a/Gateway/Request/NexiCheckout/SalesDocumentItemsBuilder.php b/Gateway/Request/NexiCheckout/SalesDocumentItemsBuilder.php new file mode 100644 index 00000000..d7822fcf --- /dev/null +++ b/Gateway/Request/NexiCheckout/SalesDocumentItemsBuilder.php @@ -0,0 +1,70 @@ +getAllItems() as $item) { + $items[] = new Item( + 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()), + reference : $this->stringSanitizer->sanitize($item->getSku()), + taxRate : $this->amountConverter->convertToNexiAmount($item->getTaxPercent()), + taxAmount : $this->amountConverter->convertToNexiAmount($item->getTaxAmount()), + ); + } + + if ($salesObject->getShippingInclTax()) { + $items[] = new Item( + 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()), + reference : self::SHIPPING_COST_REFERENCE, + taxRate : $salesObject->getGrandTotal() ? + $this->amountConverter->convertToNexiAmount( + $salesObject->getTaxAmount() / $salesObject->getGrandTotal() + ) : 0, + taxAmount : $this->amountConverter->convertToNexiAmount($salesObject->getShippingTaxAmount()), + ); + } + + return $items; + } +} diff --git a/Gateway/Request/RefundRequestBuilder.php b/Gateway/Request/RefundRequestBuilder.php new file mode 100644 index 00000000..e40bb00b --- /dev/null +++ b/Gateway/Request/RefundRequestBuilder.php @@ -0,0 +1,47 @@ +getPayment(); + $creditmemo = $payment->getCreditmemo(); + + return [ + 'nexi_method' => 'refundCharge', + 'body' => [ + 'chargeId' => $payment->getRefundTransactionId(), + 'refund' => new PartialRefundCharge( + orderItems: $this->documentItemsBuilder->build($creditmemo), + myReference: $creditmemo->getIncrementId(), + ) + ] + ]; + } +} diff --git a/Gateway/StringSanitizer.php b/Gateway/StringSanitizer.php new file mode 100644 index 00000000..e1aaeac0 --- /dev/null +++ b/Gateway/StringSanitizer.php @@ -0,0 +1,30 @@ +, ', ", &, \ + * + * @param string $string + * @param int $maxLength + * + * @return string + */ + public function sanitize(string $string, $maxLength = 128) + { + $string = preg_replace('/[<>\'"&\\\\]/', '-', $string); + + if (strlen($string) > $maxLength) { + return substr($string, 0, $maxLength); + } + + return $string; + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..dd19a0f5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Nexi | Nets + +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/Config/Source/Environment.php b/Model/Config/Source/Environment.php new file mode 100644 index 00000000..c194e435 --- /dev/null +++ b/Model/Config/Source/Environment.php @@ -0,0 +1,26 @@ + self::TEST, 'label' => __('Test')], + ['value' => self::LIVE, 'label' => __('Live')] + ]; + } +} diff --git a/Model/Config/Source/IntegrationType.php b/Model/Config/Source/IntegrationType.php new file mode 100644 index 00000000..ec39cc5b --- /dev/null +++ b/Model/Config/Source/IntegrationType.php @@ -0,0 +1,28 @@ + IntegrationTypeEnum::EmbeddedCheckout->name, + 'label' => __('Embedded Checkout'), + ], + [ + 'value' => IntegrationTypeEnum::HostedPaymentPage->name, + 'label' => __('Hosted Checkout'), + ] + ]; + } +} diff --git a/Model/Transaction/Builder.php b/Model/Transaction/Builder.php new file mode 100644 index 00000000..0803f0c8 --- /dev/null +++ b/Model/Transaction/Builder.php @@ -0,0 +1,46 @@ +transactionBuilder->setOrder($order) + ->setPayment($order->getPayment()) + ->setTransactionId($transactionId) + ->setAdditionalInformation( + [\Magento\Sales\Model\Order\Payment\Transaction::RAW_DETAILS => $transactionData] + ) + ->setFailSafe(true) + ->setMessage('Payment transaction - return action.') + ->build($action ?: $this->config->getPaymentAction()); + } +} diff --git a/Model/Ui/ConfigProvider.php b/Model/Ui/ConfigProvider.php new file mode 100644 index 00000000..5e25f360 --- /dev/null +++ b/Model/Ui/ConfigProvider.php @@ -0,0 +1,47 @@ +config->isActive()) { + return []; + } + + return [ + 'payment' => [ + Config::CODE => [ + 'isActive' => $this->config->isActive(), + 'environment' => $this->config->getEnvironment(), + 'label' => $this->paymentHelper->getMethodInstance(Config::CODE)->getTitle(), + 'integrationType' => $this->config->getIntegrationType(), + ] + ] + ]; + } +} diff --git a/Model/Webhook/Data/WebhookDataLoader.php b/Model/Webhook/Data/WebhookDataLoader.php new file mode 100644 index 00000000..9d9c9d9a --- /dev/null +++ b/Model/Webhook/Data/WebhookDataLoader.php @@ -0,0 +1,92 @@ +searchCriteriaBuilder + ->addFilter('txn_id', $txnId, 'eq') + ->addFilter('txn_type', $txnType, 'eq') + ->create(); + + $transactions = $this->transactionRepository->getList($searchCriteria)->getItems(); + + if (count($transactions) !== 1) { + return null; + } + + return reset($transactions); + } + + /** + * LoadTransactionOrderId function + * + * @param int $orderId + * @param string $txnType + * + * @return TransactionInterface + * @throws NotFoundException + */ + public function getTransactionByOrderId( + int $orderId, + string $txnType = TransactionInterface::TYPE_PAYMENT + ): TransactionInterface { + $searchCriteria = $this->searchCriteriaBuilder + ->addFilter('order_id', $orderId, 'eq') + ->addFilter('txn_type', $txnType, 'eq') + ->create(); + + $transactions = $this->transactionRepository->getList($searchCriteria)->getItems(); + + if (count($transactions) !== 1) { + throw new NotFoundException(__('Transaction not found or multiple transactions found for payment ID.')); + } + + return reset($transactions); + } + + /** + * LoadOrderByPaymentId function. + * + * @param string $paymentId + * + * @return mixed + */ + public function loadOrderByPaymentId(string $paymentId): Order + { + $transaction = $this->getTransactionByPaymentId($paymentId); + $order = $transaction->getOrder(); + + return $order; + } +} diff --git a/Model/Webhook/PaymentCancelCreated.php b/Model/Webhook/PaymentCancelCreated.php new file mode 100644 index 00000000..9586784c --- /dev/null +++ b/Model/Webhook/PaymentCancelCreated.php @@ -0,0 +1,16 @@ +webhookDataLoader->loadOrderByPaymentId($webhookData['data']['paymentId']); + $this->processOrder($order, $webhookData); + + $this->orderRepository->save($order); + } + + /** + * ProcessOrder function. + * + * @param Order $order + * @param array $webhookData + * + * @return void + * @throws AlreadyExistsException + * @throws LocalizedException + * @throws NotFoundException + */ + private function processOrder(Order $order, array $webhookData): void + { + $reservationTxn = $this->webhookDataLoader->getTransactionByOrderId( + $order->getId(), + TransactionInterface::TYPE_AUTH + ); + + if ($order->getState() !== Order::STATE_PENDING_PAYMENT) { + throw new Exception('Order state is not pending payment.'); + } + + $chargeTxnId = $webhookData['data']['chargeId']; + + if ($this->webhookDataLoader->getTransactionByPaymentId($chargeTxnId, TransactionInterface::TYPE_CAPTURE)) { + throw new AlreadyExistsException(__('Transaction already exists.')); + } + + $chargeTransaction = $this->transactionBuilder + ->build( + $chargeTxnId, + $order, + [ + 'payment_id' => $webhookData['data']['paymentId'], + 'webhook' => json_encode($webhookData, JSON_PRETTY_PRINT), + ], + TransactionInterface::TYPE_CAPTURE + )->setParentId($reservationTxn->getTransactionId()) + ->setParentTxnId($reservationTxn->getTxnId()); + + $order->getPayment()->addTransactionCommentsToOrder( + $chargeTransaction, + __( + 'Payment charge created, amount: %1 %2', + $webhookData['data']['amount']['amount'] / 100, + $webhookData['data']['amount']['currency'] + ) + ); + + if ($this->isFullCharge($webhookData, $order)) { + $this->fullInvoice($order, $chargeTxnId); + } else { + $this->partialInvoice($order, $chargeTxnId, $webhookData['data']['orderItems']); + } + $order->setState(Order::STATE_PROCESSING)->setStatus(Order::STATE_PROCESSING); + } + + /** + * Validate charge transaction. + * + * @param array $webhookData + * @param Order $order + * + * @return bool + */ + private function isFullCharge(array $webhookData, Order $order): bool + { + return (int)($order->getBaseGrandTotal() * 100) === $webhookData['data']['amount']['amount']; + } + + /** + * Process + * + * @param Order $order + * @param string $chargeTxnId + * + * @return void + * @throws LocalizedException + */ + public function fullInvoice(Order $order, string $chargeTxnId): void + { + if (!$order->canInvoice()) { + return; + } + + $invoice = $order->prepareInvoice(); + $invoice->register(); + $invoice->setTransactionId($chargeTxnId); + $invoice->pay(); + + $order->addRelatedObject($invoice); + } + + /** + * 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 + * + * @return void + * @throws LocalizedException + */ + private function partialInvoice(Order $order, string $chargeTxnId, array $webhookItems): void + { + if ($order->canInvoice()) { + $qtys = []; + $shippingItem = null; + foreach ($webhookItems as $webhookItem) { + + if ($webhookItem['reference'] === SalesDocumentItemsBuilder::SHIPPING_COST_REFERENCE) { + $shippingItem = $webhookItem; + continue; + } + + foreach ($order->getAllItems() as $item) { + if ($item->getSku() === $webhookItem['reference']) { + $qtys[$item->getId()] = (int)$webhookItem['quantity']; + } + } + } + $invoice = $order->prepareInvoice($qtys); + $invoice->setTransactionId($chargeTxnId); + if ($shippingItem) { + $invoice->setShippingAmount($shippingItem['netTotalAmount'] / 100); + $invoice->setShippingInclTax($shippingItem['grossTotalAmount'] / 100); + $invoice->setShippingTaxAmount($shippingItem['taxAmount'] / 100); + } + + $invoice->pay(); + + $invoice->register(); + $order->addRelatedObject($invoice); + } + } +} diff --git a/Model/Webhook/PaymentChargeFailed.php b/Model/Webhook/PaymentChargeFailed.php new file mode 100644 index 00000000..351faf01 --- /dev/null +++ b/Model/Webhook/PaymentChargeFailed.php @@ -0,0 +1,16 @@ +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->orderRepository->save($order); + } + + /** + * ProcessOrder function. + * + * @param Order $order + * @param string $paymentId + * + * @return void + */ + private function createPaymentTransaction(Order $order, string $paymentId): void + { + if ($order->getState() !== Order::STATE_NEW) { + return; + } + $order->setState(Order::STATE_PENDING_PAYMENT)->setStatus(Order::STATE_PENDING_PAYMENT); + $paymentTransaction = $this->transactionBuilder + ->build( + $paymentId, + $order, + [ + 'payment_id' => $paymentId + ], + TransactionInterface::TYPE_PAYMENT + ); + $order->getPayment()->addTransactionCommentsToOrder( + $paymentTransaction, + __('Payment created in Nexi Gateway.') + ); + } +} diff --git a/Model/Webhook/PaymentRefundCompleted.php b/Model/Webhook/PaymentRefundCompleted.php new file mode 100644 index 00000000..cc00e52f --- /dev/null +++ b/Model/Webhook/PaymentRefundCompleted.php @@ -0,0 +1,100 @@ +webhookDataLoader->loadOrderByPaymentId($webhookData['data']['paymentId']); + + $refund = $this->transactionBuilder + ->build( + $webhookData['id'], + $order, + ['payment_id' => $webhookData['data']['paymentId']], + TransactionInterface::TYPE_REFUND + )->setParentTxnId($webhookData['data']['paymentId']) + ->setAdditionalInformation('details', json_encode($webhookData)); + + if ($this->isFullRefund($webhookData, $order)) { + $this->processFullRefund($webhookData, $order); + } + + $order->getPayment()->addTransactionCommentsToOrder( + $refund, + __('Payment refund created, amount: %1', $webhookData['data']['amount']['amount'] / 100) + ); + + $this->orderRepository->save($order); + } + + /** + * Create creditmemo for whole order + * + * @param array $webhookData + * @param Order $order + * + * @return void + */ + public function processFullRefund(array $webhookData, Order $order): void + { + $creditmemo = $this->creditmemoFactory->createByOrder($order); + $creditmemo->setTransactionId($webhookData['id']); + + $this->creditmemoManagement->refund($creditmemo); + } + + /** + * Amount check + * + * @param array $webhookData + * @param Order $order + * + * @return bool + */ + private function isFullRefund(array $webhookData, Order $order): bool + { + $GrandTotal = $this->amountConverter->convertToNexiAmount($order->getGrandTotal()); + + return $GrandTotal == $webhookData['data']['amount']['amount']; + } +} diff --git a/Model/Webhook/PaymentRefundFailed.php b/Model/Webhook/PaymentRefundFailed.php new file mode 100644 index 00000000..b8cb634e --- /dev/null +++ b/Model/Webhook/PaymentRefundFailed.php @@ -0,0 +1,16 @@ +webhookDataLoader->getTransactionByPaymentId($paymentId); + if (!$paymentTransaction) { + throw new NotFoundException(__('Payment transaction not found for %1.', $paymentId)); + } + + /** @var \Magento\Sales\Model\Order $order */ + $order = $paymentTransaction->getOrder(); + + $order->setState(Order::STATE_PENDING_PAYMENT)->setStatus(Order::STATE_PENDING_PAYMENT); + $reservationTransaction = $this->transactionBuilder->build( + $webhookData['id'], + $order, + ['payment_id' => $paymentId], + TransactionInterface::TYPE_AUTH + ); + $reservationTransaction->setIsClosed(0); + $reservationTransaction->setParentTxnId($paymentId); + $reservationTransaction->setParentId($paymentTransaction->getTransactionId()); + + $order->getPayment()->addTransactionCommentsToOrder( + $reservationTransaction, + __('Payment reservation created.') + ); + + $this->orderRepository->save($order); + } +} diff --git a/Model/Webhook/WebhookProcessorInterface.php b/Model/Webhook/WebhookProcessorInterface.php new file mode 100644 index 00000000..b751ccc6 --- /dev/null +++ b/Model/Webhook/WebhookProcessorInterface.php @@ -0,0 +1,15 @@ +webhookProcessors)) { + $this->webhookProcessors[$event]->processWebhook($webhookData); + } + } + + /** + * Get all registered webhook processors. + * + * @return WebhookProcessorInterface[] + */ + public function getWebhookProcessors(): array + { + return $this->webhookProcessors; + } +} diff --git a/Plugin/PaymentInformationManagementPlugin.php b/Plugin/PaymentInformationManagementPlugin.php new file mode 100644 index 00000000..20ff006e --- /dev/null +++ b/Plugin/PaymentInformationManagementPlugin.php @@ -0,0 +1,57 @@ +getRedirectUrl(); + + if ($redirectUrl) { + $result = json_encode(['result' => $result, 'redirect_url' => $redirectUrl]); + } + + return $result; + } + + /** + * Get the redirect URL from the order payment information. + * + * @return string[] + */ + private function getRedirectUrl() + { + $order = $this->checkoutSession->getLastRealOrder(); + $payment = $order->getPayment(); + + return $payment->getAdditionalInformation('redirect_url'); + } +} diff --git a/README.md b/README.md index 867fba98..bfaf0f76 100644 --- a/README.md +++ b/README.md @@ -1 +1,2 @@ -Nexi Checkout - Magento2 module +# magento-nexi-checkout +Nexi payment module for Adobe Commerce (Magento 2) diff --git a/Setup/Patch/Data/GenerateWebhookSecret.php b/Setup/Patch/Data/GenerateWebhookSecret.php new file mode 100644 index 00000000..13a9e3d9 --- /dev/null +++ b/Setup/Patch/Data/GenerateWebhookSecret.php @@ -0,0 +1,59 @@ +encryptor->encrypt($randomApiKey); + + // Save the API key to the configuration + $this->configWriter->save(self::CONFIG_PATH_API_KEY, $encryptedApiKey); + } + + /** + * @inheritDoc + */ + public static function getDependencies(): array + { + return []; + } + + /** + * @inheritDoc + */ + public function getAliases(): array + { + return []; + } +} diff --git a/Test/Unit/EnvironmentTest.php b/Test/Unit/EnvironmentTest.php new file mode 100644 index 00000000..e2e0987c --- /dev/null +++ b/Test/Unit/EnvironmentTest.php @@ -0,0 +1,18 @@ +assertEquals([ + ['value' => 'test', 'label' => __('Test')], + ['value' => 'live', 'label' => __('Live')] + ], $environment->toOptionArray()); + } +} diff --git a/composer.json b/composer.json new file mode 100644 index 00000000..1af20453 --- /dev/null +++ b/composer.json @@ -0,0 +1,47 @@ +{ + "name": "nexi-checkout/adobe-commerce-checkout", + "description": "Nets Easy Checkout", + "require": { + "php": "^8.0", + "nexi-checkout/php-payment-sdk": "^0.4.1", + "magento/framework": ">=102.0.0", + "ext-curl": "*", + "giggsey/libphonenumber-for-php-lite": "^9.0.5" + }, + "require-dev": { + "magento/magento-coding-standard": "*", + "phpunit/phpunit": "^9.6", + "magento/product-community-edition": ">2.4.7" + }, + "type": "magento2-module", + "version": "0.0.1", + "license": "MIT", + "autoload": { + "files": [ + "registration.php" + ], + "psr-4": { + "Nexi\\Checkout\\": "" + } + }, + "repositories": [ + { + "type": "composer", + "url": "https://repo.magento.com/" + } + ], + "keywords": [ + "magento 2", + "adobe commerce", + "payment gateway", + "nexi" + ], + "config": { + "allow-plugins": { + "magento/composer-dependency-version-audit-plugin": false, + "magento/magento-composer-installer": false, + "magento/inventory-composer-installer": false, + "dealerdirect/phpcodesniffer-composer-installer": true + } + } +} diff --git a/etc/adminhtml/routes.xml b/etc/adminhtml/routes.xml new file mode 100644 index 00000000..7d76a081 --- /dev/null +++ b/etc/adminhtml/routes.xml @@ -0,0 +1,14 @@ + + + + + + + + + diff --git a/etc/adminhtml/system.xml b/etc/adminhtml/system.xml new file mode 100644 index 00000000..2f040556 --- /dev/null +++ b/etc/adminhtml/system.xml @@ -0,0 +1,81 @@ + + + +
+ + + + + Magento\Config\Model\Config\Source\Yesno + + + + required-entry + + + + + + required-entry + Magento\Config\Model\Config\Backend\Encrypted + + + + required-entry + Magento\Config\Model\Config\Backend\Encrypted + + + + Magento\Config\Model\Config\Backend\Encrypted + + + + Magento\Config\Model\Config\Backend\Encrypted + + + + required-entry + add URL to your Webshop Terms and Conditions site. + + + + required-entry + add URL to your payment Terms and Conditions site. + + + + Nexi\Checkout\Model\Config\Source\IntegrationType + + + + Magento\Config\Model\Config\Source\Yesno + If set to "Yes", the transaction will be charged automatically after the + reservation has been accepted. + + + + + Magento\Sales\Model\Config\Source\Order\Status\NewStatus + + + + Nexi\Checkout\Model\Config\Source\Environment + + +
+
+
diff --git a/etc/config.xml b/etc/config.xml new file mode 100644 index 00000000..7f3953c8 --- /dev/null +++ b/etc/config.xml @@ -0,0 +1,46 @@ + + + + + NexiFacade + Nexi Payments + authorize_capture + 0 + 0 + 1 + 1 + 0 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + processing + test + 0 + + + + + cvv,number + avsPostalCodeResponseCode,avsStreetAddressResponseCode,cvvResponseCode,processorAuthorizationCode,processorResponseCode,processorResponseText,liabilityShifted,liabilityShiftPossible,riskDataId,riskDataDecision + cc_type,cc_number,avsPostalCodeResponseCode,avsStreetAddressResponseCode,cvvResponseCode,processorAuthorizationCode,processorResponseCode,processorResponseText,liabilityShifted,liabilityShiftPossible + nexi_group + + + + + + HostedPaymentPage + + + + diff --git a/etc/di.xml b/etc/di.xml new file mode 100644 index 00000000..efc36cba --- /dev/null +++ b/etc/di.xml @@ -0,0 +1,205 @@ + + + + + + Nexi\Checkout\Gateway\Config\Config::CODE + Magento\Payment\Block\Form + Magento\Payment\Block\Form + NexiValueHandlerPool + Magento\Payment\Gateway\Validator\ValidatorPool + NexiCommandPool + + + + + + + NexiConfigValueHandler + + + + + + + NexiConfig + + + + + + Nexi\Checkout\Gateway\Config\Config::CODE + + + + + + Nexi\Checkout\Gateway\Config\Config::CODE + + + + + + NexiConfig + + + + + + + NexiCommandManager + + + + + + + + NexiCommandPool + + + + + + + NexiCommandCreatePayment + Nexi\Checkout\Gateway\Command\Initialize + NexiCommandCapture + NexiCommandRefund + + + + + + + Nexi\Checkout\Gateway\Request\CreatePaymentRequestBuilder + Nexi\Checkout\Gateway\Http\TransferFactory + Nexi\Checkout\Gateway\Http\Client + Nexi\Checkout\Gateway\Handler\CreatePayment + + + + + + Nexi\Checkout\Gateway\Request\CaptureRequestBuilder + Nexi\Checkout\Gateway\Http\TransferFactory + Nexi\Checkout\Gateway\Http\Client + Nexi\Checkout\Gateway\Handler\Capture + + + + + + Nexi\Checkout\Gateway\Request\RefundRequestBuilder + Nexi\Checkout\Gateway\Http\TransferFactory + Nexi\Checkout\Gateway\Http\Client + Nexi\Checkout\Gateway\Handler\RefundCharge + + + + + + NexiConfig + + + + + + GuzzleHttp\Client + GuzzleHttp\Psr7\HttpFactory + GuzzleHttp\Psr7\HttpFactory + + + + + + NexiClientFactory + NexiCheckout\Factory\Provider\HttpClientConfigurationProvider + + + + + + NexiPaymentApiFactory + + + + + + + /var/log/nexi_payments.log + + + + + + NexiVirtualLoggerHandler + + + + + + + NexiVirtualLogger + NexiPaymentApiFactory + + + + + + NexiVirtualLogger + Magento\Checkout\Model\Session\Proxy + + + + + + + + + + + + + + Magento\Checkout\Model\Session\Proxy + + + + + Magento\Checkout\Model\Session\Proxy + + + + + Magento\Checkout\Model\Session\Proxy + + + + + + + + Nexi\Checkout\Model\Webhook\PaymentCancelFailed + Nexi\Checkout\Model\Webhook\PaymentCancelCreated + Nexi\Checkout\Model\Webhook\PaymentChargeCreated + Nexi\Checkout\Model\Webhook\PaymentChargeFailed + Nexi\Checkout\Model\Webhook\PaymentCreated + Nexi\Checkout\Model\Webhook\PaymentRefundCompleted + Nexi\Checkout\Model\Webhook\PaymentRefundFailed + Nexi\Checkout\Model\Webhook\PaymentReservationCreated + + + + + + Nexi\Checkout\Model\WebhookHandler + NexiVirtualLogger + + + diff --git a/etc/frontend/di.xml b/etc/frontend/di.xml new file mode 100644 index 00000000..c1d044c3 --- /dev/null +++ b/etc/frontend/di.xml @@ -0,0 +1,14 @@ + + + + + + + Nexi\Checkout\Model\Ui\ConfigProvider + + + + + diff --git a/etc/frontend/routes.xml b/etc/frontend/routes.xml new file mode 100644 index 00000000..11f64bee --- /dev/null +++ b/etc/frontend/routes.xml @@ -0,0 +1,14 @@ + + + + + + + + + diff --git a/etc/module.xml b/etc/module.xml new file mode 100644 index 00000000..8da02ee3 --- /dev/null +++ b/etc/module.xml @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/etc/payment.xml b/etc/payment.xml new file mode 100755 index 00000000..f56d404b --- /dev/null +++ b/etc/payment.xml @@ -0,0 +1,13 @@ + + + + + + + + + + 1 + + + diff --git a/etc/webapi.xml b/etc/webapi.xml new file mode 100644 index 00000000..3fa88589 --- /dev/null +++ b/etc/webapi.xml @@ -0,0 +1,5 @@ + + + + diff --git a/registration.php b/registration.php new file mode 100644 index 00000000..83008037 --- /dev/null +++ b/registration.php @@ -0,0 +1,5 @@ + + diff --git a/view/adminhtml/web/js/testnexiconnection.js b/view/adminhtml/web/js/testnexiconnection.js new file mode 100644 index 00000000..efb60458 --- /dev/null +++ b/view/adminhtml/web/js/testnexiconnection.js @@ -0,0 +1,66 @@ +define([ + 'jquery', + 'Magento_Ui/js/modal/alert', + 'jquery/ui' +], function ($, alert) { + 'use strict'; + + $.widget('mage.testNexiConnection', { + options: { + url: '', + elementId: '', + successText: '', + failedText: '', + fieldMapping: '',}, + + /** + * Bind handlers to events + */ + _create: function () { + this._on({ + 'click': $.proxy(this._connect, this) + }); + }, + + /** + * Method triggers an AJAX request to check search engine connection + * @private + */ + _connect: function () { + var result = this.options.failedText, + element = $('#' + this.options.elementId), + self = this, + params = {}, + msg = '', + fieldToCheck = this.options.fieldToCheck || 'success'; + + element.removeClass('success').addClass('fail'); + $.each(JSON.parse(this.options.fieldMapping), function (key, el) { + params[key] = $('#' + el).val(); + }); + $.ajax({ + url: this.options.url, + showLoader: true, + data: params, + headers: this.options.headers || {} + }).done(function (response) { + if (response[fieldToCheck]) { + element.removeClass('fail').addClass('success'); + result = self.options.successText; + } else { + msg = response.errorMessage; + + if (msg) { + alert({ + content: msg + }); + } + } + }).always(function () { + $('#' + self.options.elementId + '_result').text(result); + }); + } + }); + + return $.mage.testNexiConnection; +}); diff --git a/view/frontend/layout/checkout_index_index.xml b/view/frontend/layout/checkout_index_index.xml new file mode 100644 index 00000000..023f2cf0 --- /dev/null +++ b/view/frontend/layout/checkout_index_index.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + Nexi_Checkout/js/view/payment/nexi-payment + + + false + + + + + + + + + + + + + + + + + + + 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 new file mode 100755 index 00000000..93ff865d --- /dev/null +++ b/view/frontend/web/js/view/payment/method-renderer/nexi-method.js @@ -0,0 +1,70 @@ +define( + [ + 'ko', + 'jquery', + 'underscore', + 'mage/storage', + 'Magento_Checkout/js/view/payment/default', + 'Magento_Checkout/js/action/place-order', + 'Magento_Checkout/js/action/select-payment-method', + 'Magento_Checkout/js/model/payment/additional-validators', + 'Magento_Checkout/js/model/quote', + 'Magento_Checkout/js/action/get-totals', + 'Magento_Checkout/js/model/url-builder', + 'mage/url', + 'Magento_Checkout/js/model/full-screen-loader', + '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' + ], + function (ko, + $, + _, + storage, + Component, + placeOrderAction, + selectPaymentMethodAction, + additionalValidators, + quote, + getTotalsAction, + urlBuilder, + url, + fullScreenLoader, + customer, + checkoutData, + totals, + messageList, + $t, + modal + ) { + 'use strict'; + + return Component.extend({ + defaults: { + template: window.checkoutConfig.payment.nexi.integrationType ? 'Nexi_Checkout/payment/nexi-hosted' : 'Nexi_Checkout/payment/nexi-embedded.html', + config: window.checkoutConfig.payment.nexi + }, + placeOrder: function (data, event) { + let placeOrder = placeOrderAction(this.getData(), false, this.messageContainer); + + $.when(placeOrder).done(function (response) { + this.afterPlaceOrder(response); + }.bind(this)); + }, + afterPlaceOrder: function (response) { + if (this.isHosted()) { + let redirectUrl = JSON.parse(response).redirect_url; + if (redirectUrl) { + window.location.href = redirectUrl; + } + } + }, + isHosted: function () { + return this.config.integrationType === 'HostedPaymentPage'; + }, + }); + } +); diff --git a/view/frontend/web/js/view/payment/nexi-payment.js b/view/frontend/web/js/view/payment/nexi-payment.js new file mode 100755 index 00000000..a462d1b0 --- /dev/null +++ b/view/frontend/web/js/view/payment/nexi-payment.js @@ -0,0 +1,21 @@ +/*browser:true*/ +/*global define*/ +define( + [ + 'uiComponent', + 'Magento_Checkout/js/model/payment/renderer-list' + ], + function ( + Component, + rendererList + ) { + 'use strict'; + rendererList.push( + { + type: 'nexi', + component: 'Nexi_Checkout/js/view/payment/method-renderer/nexi-method' + } + ); + return Component.extend({}); + } +); diff --git a/view/frontend/web/template/payment/nexi-embedded.html b/view/frontend/web/template/payment/nexi-embedded.html new file mode 100644 index 00000000..21e4ae90 --- /dev/null +++ b/view/frontend/web/template/payment/nexi-embedded.html @@ -0,0 +1,36 @@ + + +
+
+ + +
+
+ + + +
+ + + +
+
+
+ +
+
+
+
diff --git a/view/frontend/web/template/payment/nexi-hosted.html b/view/frontend/web/template/payment/nexi-hosted.html new file mode 100644 index 00000000..b20743a3 --- /dev/null +++ b/view/frontend/web/template/payment/nexi-hosted.html @@ -0,0 +1,34 @@ +
+
+ + +
+
+ + + +
+ + + +
+
+
+ +
+
+
+