diff --git a/.github/workflows/code-validation.yml b/.github/workflows/code-validation.yml index 7899453d..cd9f45e0 100644 --- a/.github/workflows/code-validation.yml +++ b/.github/workflows/code-validation.yml @@ -40,7 +40,7 @@ jobs: matrix: node-version: - 20 - php-version: ["8.1", "8.2", "8.3"] + php-version: ["8.1", "8.2", "8.3", "8.4"] dependencies: - "highest" composer-options: @@ -59,7 +59,7 @@ jobs: ini-file: development tools: composer:2.2, cs2pr env: - COMPOSER_AUTH_JSON: | + COMPOSER_AUTH: | { "http-basic": { "repo.magento.com": { @@ -70,11 +70,31 @@ jobs: } - name: Validate Composer Files run: composer validate + env: + COMPOSER_AUTH: | + { + "http-basic": { + "repo.magento.com": { + "username": "${{ secrets.REPO_MAGENTO_USER }}", + "password": "${{ secrets.REPO_MAGENTO_PASSWORD }}" + } + } + } - name: Run Composer install uses: "ramsey/composer-install@v1" with: dependency-versions: "${{ matrix.dependencies }}" composer-options: "${{ matrix.composer-options }}" + env: + COMPOSER_AUTH: | + { + "http-basic": { + "repo.magento.com": { + "username": "${{ secrets.REPO_MAGENTO_USER }}", + "password": "${{ secrets.REPO_MAGENTO_PASSWORD }}" + } + } + } - name: Detect PhpCs violations run: | vendor/bin/phpcs --config-set installed_paths ../../magento/magento-coding-standard/,../../phpcompatibility/php-compatibility,../../magento/php-compatibility-fork diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..ef013868 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ + +# Playwright +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +auth.json +.github +.idea diff --git a/Api/Data/SubscriptionInterface.php b/Api/Data/SubscriptionInterface.php new file mode 100644 index 00000000..48a29dcd --- /dev/null +++ b/Api/Data/SubscriptionInterface.php @@ -0,0 +1,172 @@ +urlBuilder = $context->getUrlBuilder(); + $this->request = $request; + } + + /** + * Return the synonyms group Id. + * + * @return int|null + */ + public function getId() + { + return $this->request->getParam('id'); + } + + /** + * Get the URL for the given route and parameters. + * + * @param string $route + * @param array $params + * + * @return string + */ + public function getUrl($route = '', $params = []) + { + return $this->urlBuilder->getUrl($route, $params); + } + + /** + * Retrieve button data + * + * @return array|null + */ + abstract public function getButtonData(); +} diff --git a/Block/Adminhtml/Subscription/Edit/BackButton.php b/Block/Adminhtml/Subscription/Edit/BackButton.php new file mode 100644 index 00000000..75693dc6 --- /dev/null +++ b/Block/Adminhtml/Subscription/Edit/BackButton.php @@ -0,0 +1,32 @@ + __('Back'), + 'on_click' => sprintf("location.href = '%s';", $this->getBackUrl()), + 'class' => 'back', + 'sort_order' => 10 + ]; + } + + /** + * Get URL for back (reset) button + * + * @return string + */ + public function getBackUrl() + { + return $this->getUrl('*/*/'); + } +} diff --git a/Block/Adminhtml/Subscription/Edit/DeleteButton.php b/Block/Adminhtml/Subscription/Edit/DeleteButton.php new file mode 100644 index 00000000..08fc04c7 --- /dev/null +++ b/Block/Adminhtml/Subscription/Edit/DeleteButton.php @@ -0,0 +1,39 @@ +getId()) { + $data = [ + 'label' => __('Delete'), + 'class' => 'delete', + 'on_click' => 'deleteConfirm(\'' + . __('Delete this entry?') + . '\', \'' . $this->getDeleteUrl() . '\', {data: {}})', + 'sort_order' => 20, + ]; + } + + return $data; + } + + /** + * Generates and returns the URL for the delete action, using the current object's identifier. + * + * @return string + */ + public function getDeleteUrl() + { + return $this->getUrl('*/*/delete', ['id' => $this->getId()]); + } +} diff --git a/Block/Adminhtml/Subscription/Edit/ResetButton.php b/Block/Adminhtml/Subscription/Edit/ResetButton.php new file mode 100644 index 00000000..85bfb317 --- /dev/null +++ b/Block/Adminhtml/Subscription/Edit/ResetButton.php @@ -0,0 +1,22 @@ + __('Reset'), + 'class' => 'reset', + 'on_click' => 'location.reload();', + 'sort_order' => 30 + ]; + } +} diff --git a/Block/Adminhtml/Subscription/Edit/SaveButton.php b/Block/Adminhtml/Subscription/Edit/SaveButton.php new file mode 100644 index 00000000..07f51c86 --- /dev/null +++ b/Block/Adminhtml/Subscription/Edit/SaveButton.php @@ -0,0 +1,25 @@ + __('Save'), + 'class' => 'save primary', + 'data_attribute' => [ + 'mage-init' => ['button' => ['event' => 'save']], + 'form-role' => 'save', + ], + 'sort_order' => 90, + ]; + } +} diff --git a/Block/Adminhtml/Subscription/Edit/StopButton.php b/Block/Adminhtml/Subscription/Edit/StopButton.php new file mode 100644 index 00000000..64ea91be --- /dev/null +++ b/Block/Adminhtml/Subscription/Edit/StopButton.php @@ -0,0 +1,44 @@ +getId()) { + $data = [ + 'label' => __('Stop schedule'), + 'class' => 'delete', + 'on_click' => 'deleteConfirm(\'' + . __('Cancel any unpaid orders and prevent new recurring payments from being made?') + . '\', \'' . $this->getStopScheduleUrl() . '\')', + 'sort_order' => 40, + ]; + } + + return $data; + } + + /** + * Generates and returns the URL for stopping the schedule, using the current object's identifier. + * + * @return string + */ + private function getStopScheduleUrl() + { + return $this->getUrl( + '*/*/stopSchedule', + [ + 'id' => $this->getId() + ] + ); + } +} diff --git a/Block/Info/Nexi.php b/Block/Info/Nexi.php new file mode 100644 index 00000000..b919448a --- /dev/null +++ b/Block/Info/Nexi.php @@ -0,0 +1,79 @@ +gatewayConfig->getNexiLogo(); + } + + /** + * Get the title to be displayed in the payment method block. + * + * @return mixed|null + */ + public function getTitle() + { + return $this->gatewayConfig->getNexiTitle(); + } + + /** + * Get payment selected method data. + * + * @return string + */ + public function getSelectedPaymentMethodData(): array + { + try { + $payment = $this->orderRepository->get($this->getInfo()->getOrder()->getId())->getPayment(); + + return [ + self::SELECTED_PATMENT_METHOD => $payment->getAdditionalInformation(self::SELECTED_PATMENT_METHOD), + self::SELECTED_PATMENT_TYPE => $payment->getAdditionalInformation(self::SELECTED_PATMENT_TYPE), + ]; + } catch (LocalizedException $e) { + $this->logger->critical($e); + } + + return []; + } +} diff --git a/Block/Order/Payments.php b/Block/Order/Payments.php new file mode 100644 index 00000000..93efa473 --- /dev/null +++ b/Block/Order/Payments.php @@ -0,0 +1,231 @@ +pageConfig->getTitle()->set(__('My Subscriptions')); + } + + /** + * Is Subscriptions functionality is enabled. + * + * @return bool + */ + public function isSubscriptionsEnabled(): bool + { + return $this->totalConfigProvider->isSubscriptionsEnabled(); + } + + /** + * Get recurring payments (subscriptions). + * + * @return SubscriptionCollection + */ + public function getCustomerSubscriptions() + { + return $this->customerSubscriptionProvider->getCustomerSubscriptions(); + } + + /** + * Get closed subscriptions. + * + * @return SubscriptionCollection + */ + public function getCustomerClosedSubscriptions() + { + return $this->customerSubscriptionProvider->getCustomerClosedSubscriptions(); + } + + /** + * 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/payment/stop', ['payment_id' => $recurringPayment->getId()]); + } + + /** + * Get empty recurring payment message. + * + * @return Phrase + */ + public function getEmptyRecurringPaymentsMessage(): Phrase + { + return __('You have no payments to display.'); + } + + /** + * 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/Block/Order/Print/InvoicePrint.php b/Block/Order/Print/InvoicePrint.php new file mode 100644 index 00000000..e884c19f --- /dev/null +++ b/Block/Order/Print/InvoicePrint.php @@ -0,0 +1,26 @@ +getOrder()->getPayment(); + if ($payment->getMethod() === Config::CODE) { + return "

" . $payment->getAdditionalInformation()['method_title'] . "

" + . "

" . $payment->getAdditionalInformation(Nexi::SELECTED_PATMENT_TYPE) . " - " + . $payment->getAdditionalInformation(Nexi::SELECTED_PATMENT_METHOD) . "

"; + } + return $this->getChildHtml('payment_info'); + } +} diff --git a/Console/Command/Bill.php b/Console/Command/Bill.php new file mode 100644 index 00000000..1e07f7b1 --- /dev/null +++ b/Console/Command/Bill.php @@ -0,0 +1,56 @@ +setName('nexi:subscription:bill'); + $this->setDescription('Invoice customers of subscription 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..0b5c868e --- /dev/null +++ b/Console/Command/Notify.php @@ -0,0 +1,54 @@ +setName('nexi:subscription:notify'); + $this->setDescription('Send subscription 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/Adminhtml/Profile/Delete.php b/Controller/Adminhtml/Profile/Delete.php new file mode 100644 index 00000000..95a76262 --- /dev/null +++ b/Controller/Adminhtml/Profile/Delete.php @@ -0,0 +1,49 @@ +context->getRequest()->getParam('id'); + $resultRedirect = $this->context->getResultFactory()->create(ResultFactory::TYPE_REDIRECT); + + try { + $profile = $this->subscriptionProfileRepository->get($id); + $this->subscriptionProfileRepository->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..c61be6cd --- /dev/null +++ b/Controller/Adminhtml/Profile/Edit.php @@ -0,0 +1,53 @@ +initialize(); + $title = $this->context->getRequest()->getParam('id') + ? __('Edit Subscription profile') + : __('Add new profile'); + $page->getConfig()->getTitle()->prepend($title); + + return $page; + } + + /** + * Initializes and configures the result page with the active menu. + * + * @return \Magento\Framework\View\Result\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..01c902d8 --- /dev/null +++ b/Controller/Adminhtml/Profile/Index.php @@ -0,0 +1,48 @@ +initialize(); + $page->getConfig()->getTitle()->prepend(__('Subscription Profiles')); + + return $page; + } + + /** + * Initializes the result page and sets the active menu for sales orders. + * + * @return \Magento\Framework\View\Result\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..6ab9245f --- /dev/null +++ b/Controller/Adminhtml/Profile/MassDelete.php @@ -0,0 +1,60 @@ +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..9e5e3828 --- /dev/null +++ b/Controller/Adminhtml/Profile/Save.php @@ -0,0 +1,113 @@ +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/Controller/Adminhtml/Subscription/Delete.php b/Controller/Adminhtml/Subscription/Delete.php new file mode 100644 index 00000000..0525681c --- /dev/null +++ b/Controller/Adminhtml/Subscription/Delete.php @@ -0,0 +1,48 @@ +context->getRequest()->getParam('id'); + $resultRedirect = $this->context->getResultFactory()->create(ResultFactory::TYPE_REDIRECT); + + try { + $payment = $this->subscriptionRepository->get((int)$id); + $this->subscriptionRepository->delete($payment); + $resultRedirect->setPath('subscriptions/subscription'); + $this->context->getMessageManager()->addSuccessMessage('Subscription deleted'); + } catch (\Throwable $e) { + $this->context->getMessageManager()->addErrorMessage($e->getMessage()); + $resultRedirect->setPath('subscriptions/subscription/view', ['id' => $id]); + } + + return $resultRedirect; + } +} diff --git a/Controller/Adminhtml/Subscription/Index.php b/Controller/Adminhtml/Subscription/Index.php new file mode 100644 index 00000000..59582dfd --- /dev/null +++ b/Controller/Adminhtml/Subscription/Index.php @@ -0,0 +1,32 @@ +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/Adminhtml/Subscription/MassDelete.php b/Controller/Adminhtml/Subscription/MassDelete.php new file mode 100644 index 00000000..773a4136 --- /dev/null +++ b/Controller/Adminhtml/Subscription/MassDelete.php @@ -0,0 +1,40 @@ +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/Adminhtml/Subscription/Save.php b/Controller/Adminhtml/Subscription/Save.php new file mode 100644 index 00000000..2b07b8c7 --- /dev/null +++ b/Controller/Adminhtml/Subscription/Save.php @@ -0,0 +1,59 @@ +context->getRequest()->getParam('entity_id'); + + if ($id) { + $payment = $this->paymentRepositoryInterface->get($id); + } else { + $payment = $this->factory->create(); + } + + $data = $this->getRequest()->getParams(); + $payment->setData($data); + $resultRedirect = $this->context->getResultFactory()->create(ResultFactory::TYPE_REDIRECT); + try { + $this->paymentRepositoryInterface->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/Adminhtml/Subscription/StopSchedule.php b/Controller/Adminhtml/Subscription/StopSchedule.php new file mode 100644 index 00000000..8be891a8 --- /dev/null +++ b/Controller/Adminhtml/Subscription/StopSchedule.php @@ -0,0 +1,146 @@ +context->getResultFactory()->create(ResultFactory::TYPE_REDIRECT); + $resultRedirect->setPath($this->context->getRedirect()->getRefererUrl()); + $id = $this->context->getRequest()->getParam('id'); + + $subscription = $this->getRecurringPayment((int)$id); + if (!$subscription) { + return $resultRedirect; + } + $this->cancelOrder($subscription); + $this->updateRecurringStatus($subscription); + + return $resultRedirect; + } + + /** + * Retrieves the recurring payment subscription by its ID. + * + * @param int $subscriptionId + * @return false|SubscriptionInterface + */ + private function getRecurringPayment($subscriptionId) + { + try { + return $this->subscriptionRepository->get($subscriptionId); + } catch (NoSuchEntityException $e) { + $this->context->getMessageManager()->addErrorMessage( + \__( + 'Unable to load subscription with ID: %id', + ['id' => $subscriptionId] + ) + ); + } + + return false; + } + + /** + * Cancels the order associated with the subscription if it is unpaid. + * + * @param SubscriptionInterface $subscription + * @return void + */ + 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' => SubscriptionManagement::ORDER_PENDING_STATUS + ] + ) + ); + } + } catch (LocalizedException $exception) { + $this->context->getMessageManager()->addErrorMessage( + \__( + 'Error occurred while cancelling the order: %error', + ['error' => $exception->getMessage()] + ) + ); + } + } + + /** + * Updates the recurring status of a subscription to "closed". + * + * @param SubscriptionInterface $subscription + * @return void + */ + 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/Adminhtml/Subscription/View.php b/Controller/Adminhtml/Subscription/View.php new file mode 100644 index 00000000..0ab3cc33 --- /dev/null +++ b/Controller/Adminhtml/Subscription/View.php @@ -0,0 +1,34 @@ +initialize(); + $page->getConfig()->getTitle()->prepend(__('View Subscription')); + + 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/System/Config/TestConnection.php b/Controller/Adminhtml/System/Config/TestConnection.php index 18921c24..8026a9c2 100644 --- a/Controller/Adminhtml/System/Config/TestConnection.php +++ b/Controller/Adminhtml/System/Config/TestConnection.php @@ -53,10 +53,9 @@ public function __construct( } /** - * Check for connection to server + * Check for connection to the server * * @return Json - * @throws JsonException */ public function execute() { diff --git a/Controller/Hpp/CancelAction.php b/Controller/Hpp/CancelAction.php index 51aad0f8..c25e4d04 100644 --- a/Controller/Hpp/CancelAction.php +++ b/Controller/Hpp/CancelAction.php @@ -1,42 +1,79 @@ checkoutSession->restoreQuote(); + $this->cancelOrderById((int)$this->checkoutSession->getLastOrderId()); $this->messageManager->addNoticeMessage(__('The payment has been canceled.')); - + return $this->resultRedirectFactory->create()->setUrl( $this->url->getUrl('checkout/cart/index', ['_secure' => true]) ); } + + /** + * CancelOrderById function. + * + * @param int $orderId + * @return void + * @throws LocalizedException + */ + private function cancelOrderById($orderId): void + { + 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 LocalizedException(__( + 'Error while cancelling order. + Please contact customer support with order id: %id to release discount coupons.', + ['id' => $orderId] + )); + } + } } 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..fe1029dd --- /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('nexi/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('nexi/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('nexi/order/payments'); + } + + return $resultRedirect; + } +} diff --git a/Controller/Payment/Webhook.php b/Controller/Payment/Webhook.php index 321004db..66b2b126 100644 --- a/Controller/Payment/Webhook.php +++ b/Controller/Payment/Webhook.php @@ -15,6 +15,7 @@ use Magento\Framework\Serialize\SerializerInterface; use Nexi\Checkout\Gateway\Config\Config; use Nexi\Checkout\Model\WebhookHandler; +use NexiCheckout\Model\Webhook\WebhookBuilder; use Psr\Log\LoggerInterface; class Webhook extends Action implements CsrfAwareActionInterface, HttpPostActionInterface @@ -42,7 +43,6 @@ public function __construct( * Execute the webhook action * * @return void - * @throws Exception */ public function execute() { @@ -54,25 +54,14 @@ public function execute() try { $content = $this->serializer->unserialize($this->getRequest()->getContent()); + $this->logger->info('Webhook called:', ['webhook_data' => $content]); - if (!isset($content['event'])) { - return $this->_response - ->setHttpResponseCode(400) - ->setBody('Missing event name'); - } + $webhook = WebhookBuilder::fromJson($this->getRequest()->getContent()); - $this->webhookHandler->handle($content); - - $this->logger->info( - 'Webhook called:', - [ - 'webhook_data' => json_encode($this->getRequest()->getContent()), - 'payment_id' => $this->getRequest()->getParam('payment_id'), - ] - ); + $this->webhookHandler->handle($webhook); $this->_response->setHttpResponseCode(200); } catch (Exception $e) { - $this->logger->error($e->getMessage(), ['stacktrace' => $e->getTrace()]); + $this->logger->error($e->getMessage(), ['stacktrace' => $e->getTraceAsString()]); $this->_response->setHttpResponseCode(500); } } diff --git a/Cron/SubscriptionBill.php b/Cron/SubscriptionBill.php new file mode 100644 index 00000000..e5ba8d2e --- /dev/null +++ b/Cron/SubscriptionBill.php @@ -0,0 +1,35 @@ +totalConfigProvider->isSubscriptionsEnabled()) { + $this->bill->process(); + } + } +} diff --git a/Cron/SubscriptionNotify.php b/Cron/SubscriptionNotify.php new file mode 100644 index 00000000..e77fbe32 --- /dev/null +++ b/Cron/SubscriptionNotify.php @@ -0,0 +1,34 @@ +totalConfigProvider->isSubscriptionsEnabled()) { + $this->notify->process(); + } + } +} diff --git a/Exceptions/CheckoutException.php b/Exceptions/CheckoutException.php new file mode 100755 index 00000000..b09ae58e --- /dev/null +++ b/Exceptions/CheckoutException.php @@ -0,0 +1,14 @@ +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 ($this->config->isEmbedded() && $paymentData->getPayment()->getAdditionalInformation('payment_id')) { + return; + } + + if ($paymentData->getPayment()->getAdditionalInformation('subscription_id')) { + return; + } + /** @var InfoInterface $payment */ $payment = $paymentData->getPayment(); $payment->setIsTransactionPending(true); - $payment->setIsTransactionIsClosed(false); + $payment->setIsTransactionClosed(false); $order = $payment->getOrder(); $order->setCanSendNewEmailFlag(false); @@ -61,9 +69,9 @@ public function execute(array $commandSubject) $stateObject->setStatus(self::STATUS_PENDING); $stateObject->setIsNotified(false); - $this->cratePayment($paymentData); + $this->createPayment($paymentData); - $transactionId = $payment->getAdditionalInformation('payment_id'); + $transactionId = $payment->getAdditionalInformation('payment_id'); $orderTransaction = $this->transactionBuilder->build( $transactionId, $order, @@ -82,22 +90,31 @@ public function execute(array $commandSubject) * * @param PaymentDataObjectInterface $payment * - * @return ResultInterface|null * @throws LocalizedException */ - public function cratePayment(PaymentDataObjectInterface $payment): ?ResultInterface + public function createPayment(PaymentDataObjectInterface $payment): void { try { $commandPool = $this->commandManagerPool->get(Config::CODE); - $result = $commandPool->executeByCode( + $commandPool->executeByCode( commandCode: 'create_payment', - arguments : ['payment' => $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; + /** + * Check if payment is already created + * + * @param PaymentDataObjectInterface $payment + * + * @return bool + */ + private function isPaymentAlreadyCreated(PaymentDataObjectInterface $payment): bool + { + return (bool)$payment->getPayment()->getAdditionalInformation('payment_id'); } } diff --git a/Gateway/Config/Config.php b/Gateway/Config/Config.php index 59316262..7491bcec 100644 --- a/Gateway/Config/Config.php +++ b/Gateway/Config/Config.php @@ -7,7 +7,10 @@ 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; +use Nexi\Checkout\Model\Config\Source\PaymentTypesEnum; +use NexiCheckout\Model\Request\Payment\IntegrationTypeEnum; class Config extends MagentoConfig { @@ -126,6 +129,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 * @@ -155,6 +168,51 @@ public function getPaymentAction(): string */ public function getCountryCode() { - return $this->scopeConfig->isSetFlag('general/country/default'); + return $this->scopeConfig->getValue('general/country/default', ScopeInterface::SCOPE_STORE); + } + + /** + * Get Nexi logo. + * + * @return mixed|null + */ + public function getNexiLogo() + { + return $this->getValue('logo'); + } + + /** + * Get payment method title. + * + * @return mixed|null + */ + public function getNexiTitle() + { + return $this->getValue('title'); + } + + /** + * Get the value of pay_type_splitting. + * + * @return bool + */ + public function getPayTypeSplitting(): bool + { + return (bool)$this->getValue('pay_type_splitting'); + } + + /** + * Retrieve the payment type options + * + * @return PaymentTypesEnum[] + */ + public function getPayTypeOptions(): array + { + $values = explode(',', (string)$this->getValue('pay_type_options')); + + return array_map( + fn($value) => \Nexi\Checkout\Model\Config\Source\PaymentTypesEnum::from($value), + array_filter($values) + ); } } diff --git a/Gateway/Handler/Capture.php b/Gateway/Handler/Capture.php index e2f8296a..0089aa2d 100644 --- a/Gateway/Handler/Capture.php +++ b/Gateway/Handler/Capture.php @@ -11,8 +11,6 @@ class Capture implements HandlerInterface { /** - * Constructor - * * @param SubjectReader $subjectReader */ public function __construct( @@ -31,6 +29,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 869f2624..417f83d8 100644 --- a/Gateway/Handler/CreatePayment.php +++ b/Gateway/Handler/CreatePayment.php @@ -6,6 +6,8 @@ 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 HandlerInterface { @@ -24,10 +26,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()); - $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 a0398a1a..5b7660dd 100644 --- a/Gateway/Handler/RefundCharge.php +++ b/Gateway/Handler/RefundCharge.php @@ -25,11 +25,12 @@ 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/Handler/Retrieve.php b/Gateway/Handler/Retrieve.php new file mode 100644 index 00000000..7b66da82 --- /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/Handler/SubscriptionCharge.php b/Gateway/Handler/SubscriptionCharge.php new file mode 100644 index 00000000..6ea2f2db --- /dev/null +++ b/Gateway/Handler/SubscriptionCharge.php @@ -0,0 +1,35 @@ +subjectReader->readPayment($handlingSubject); + $payment = $paymentDO->getPayment(); + $paymentResult = reset($response); + if ($paymentResult instanceof PaymentResult) { + $payment->setAdditionalInformation('payment_id', $paymentResult->getPaymentId()); + + } + } +} diff --git a/Gateway/Http/Client.php b/Gateway/Http/Client.php index af37de50..55d271ac 100644 --- a/Gateway/Http/Client.php +++ b/Gateway/Http/Client.php @@ -10,12 +10,16 @@ use Nexi\Checkout\Gateway\Config\Config; use NexiCheckout\Api\Exception\PaymentApiException; use NexiCheckout\Api\PaymentApi; +use NexiCheckout\Api\SubscriptionApi; use NexiCheckout\Factory\PaymentApiFactory; +use NexiCheckout\Model\Request\BulkChargeSubscription; use Psr\Log\LoggerInterface; class Client implements ClientInterface { /** + * Client constructor. + * * @param PaymentApiFactory $paymentApiFactory * @param Config $config * @param LoggerInterface $logger @@ -23,7 +27,7 @@ class Client implements ClientInterface public function __construct( private readonly PaymentApiFactory $paymentApiFactory, private readonly Config $config, - private readonly LoggerInterface $logger + private readonly LoggerInterface $logger, ) { } @@ -38,7 +42,12 @@ public function __construct( public function placeRequest(TransferInterface $transferObject): array { try { - $paymentApi = $this->getPaymentApi(); + if (($transferObject->getBody() instanceof BulkChargeSubscription)) { + $paymentApi = $this->getSubscriptionApi(); + } else { + $paymentApi = $this->getPaymentApi(); + } + $nexiMethod = $transferObject->getUri(); $this->logger->debug( 'Nexi Client request: ', @@ -47,6 +56,7 @@ public function placeRequest(TransferInterface $transferObject): array 'request' => $transferObject->getBody() ] ); + if (is_array($transferObject->getBody())) { $response = $paymentApi->$nexiMethod(...$transferObject->getBody()); } else { @@ -57,6 +67,7 @@ public function placeRequest(TransferInterface $transferObject): array '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.')); @@ -77,4 +88,17 @@ public function getPaymentApi(): PaymentApi $this->config->isLiveMode() ); } + + /** + * Get Subscription API Client + * + * @return SubscriptionApi + */ + public function getSubscriptionApi(): SubscriptionApi + { + return $this->paymentApiFactory->createSubscriptionApi( + (string)$this->config->getApiKey(), + $this->config->isLiveMode() + ); + } } diff --git a/Gateway/Http/TransferFactory.php b/Gateway/Http/TransferFactory.php index 966b4f49..a21606c0 100644 --- a/Gateway/Http/TransferFactory.php +++ b/Gateway/Http/TransferFactory.php @@ -31,6 +31,7 @@ public function create(array $request): TransferInterface { $nexiMethod = $request['nexi_method']; unset($request['nexi_method']); + return $this->transferBuilder ->setBody($request['body']) ->setUri($nexiMethod) diff --git a/Gateway/Request/CreatePaymentRequestBuilder.php b/Gateway/Request/CreatePaymentRequestBuilder.php index 5224a43f..e322faad 100644 --- a/Gateway/Request/CreatePaymentRequestBuilder.php +++ b/Gateway/Request/CreatePaymentRequestBuilder.php @@ -4,19 +4,21 @@ namespace Nexi\Checkout\Gateway\Request; -use libphonenumber\NumberParseException; -use libphonenumber\PhoneNumberUtil; use Magento\Directory\Api\CountryInformationAcquirerInterface; -use Magento\Framework\Encryption\EncryptorInterface; +use Magento\Framework\Exception\LocalizedException; 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 Magento\Sales\Model\Order\Item as OrderItem; +use Nexi\Checkout\Gateway\AmountConverter as AmountConverter; use Nexi\Checkout\Gateway\Config\Config; +use Nexi\Checkout\Gateway\Request\NexiCheckout\GlobalRequestBuilder; use Nexi\Checkout\Gateway\Request\NexiCheckout\SalesDocumentItemsBuilder; use Nexi\Checkout\Gateway\StringSanitizer; -use Nexi\Checkout\Model\WebhookHandler; +use Nexi\Checkout\Model\Subscription\NextDateCalculator; +use Nexi\Checkout\Model\Subscription\TotalConfigProvider; use NexiCheckout\Model\Request\Item; use NexiCheckout\Model\Request\Payment; use NexiCheckout\Model\Request\Payment\Address; @@ -25,33 +27,31 @@ use NexiCheckout\Model\Request\Payment\HostedCheckout; use NexiCheckout\Model\Request\Payment\IntegrationTypeEnum; use NexiCheckout\Model\Request\Payment\PrivatePerson; -use NexiCheckout\Model\Request\Payment\PhoneNumber; use NexiCheckout\Model\Request\Shared\Notification; -use NexiCheckout\Model\Request\Shared\Notification\Webhook; -use NexiCheckout\Model\Request\Shared\Order as NexiRequestOrder; -use Nexi\Checkout\Gateway\AmountConverter as AmountConverter; class CreatePaymentRequestBuilder implements BuilderInterface { - public const NEXI_PAYMENT_WEBHOOK_PATH = 'nexi/payment/webhook'; - /** + * CreatePaymentRequestBuilder constructor. + * * @param UrlInterface $url * @param Config $config * @param CountryInformationAcquirerInterface $countryInformationAcquirer - * @param EncryptorInterface $encryptor - * @param WebhookHandler $webhookHandler * @param AmountConverter $amountConverter * @param StringSanitizer $stringSanitizer + * @param TotalConfigProvider $totalConfigProvider + * @param GlobalRequestBuilder $globalRequestBuilder + * @param NextDateCalculator $nextDateCalculator */ public function __construct( - private readonly UrlInterface $url, - private readonly Config $config, + private readonly UrlInterface $url, + private readonly Config $config, private readonly CountryInformationAcquirerInterface $countryInformationAcquirer, - private readonly EncryptorInterface $encryptor, - private readonly WebhookHandler $webhookHandler, - private readonly AmountConverter $amountConverter, - private readonly StringSanitizer $stringSanitizer, + private readonly AmountConverter $amountConverter, + private readonly StringSanitizer $stringSanitizer, + private readonly TotalConfigProvider $totalConfigProvider, + private readonly GlobalRequestBuilder $globalRequestBuilder, + private readonly NextDateCalculator $nextDateCalculator ) { } @@ -65,71 +65,59 @@ 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' => 'createHostedPayment', - 'body' => [ - 'payment' => $this->buildPayment($order), + 'nexi_method' => $this->isEmbedded() ? 'createEmbeddedPayment' : 'createHostedPayment', + 'body' => [ + 'payment' => $this->buildPayment($paymentSubject), ] ]; } - /** - * 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 + * @param Order|Quote $paymentSubject * * @return OrderItem|array */ - private function buildItems(Order $order): OrderItem|array + public function buildItems(Order|Quote $paymentSubject): 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()), - ); + $items = $this->globalRequestBuilder->getProductsData($paymentSubject); + + if ($paymentSubject instanceof Order) { + $shippingInfoHolder = $paymentSubject; + } else { + $shippingInfoHolder = $paymentSubject->getShippingAddress(); } - if ($order->getShippingAmount()) { + if ($shippingInfoHolder->getShippingInclTax()) { $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() + name: $shippingInfoHolder->getShippingDescription(), + quantity: 1, + unit: 'pcs', + unitPrice: $this->amountConverter->convertToNexiAmount( + $shippingInfoHolder->getBaseShippingAmount() + ), + grossTotalAmount: $this->amountConverter->convertToNexiAmount( + $shippingInfoHolder->getBaseShippingInclTax() + ), + netTotalAmount: $this->amountConverter->convertToNexiAmount( + $shippingInfoHolder->getBaseShippingAmount() + ), + reference: SalesDocumentItemsBuilder::SHIPPING_COST_REFERENCE, + taxRate: $this->amountConverter->convertToNexiAmount( + $this->globalRequestBuilder->getShippingTaxRate($paymentSubject) + ), + taxAmount: $this->amountConverter->convertToNexiAmount( + $shippingInfoHolder->getBaseShippingTaxAmount() ), - taxAmount : $this->amountConverter->convertToNexiAmount($order->getShippingTaxAmount()), ); } @@ -137,142 +125,162 @@ private function buildItems(Order $order): OrderItem|array } /** - * Build The Sdk payment object + * Build payment object for a request * - * @param Order $order + * @param Order|Quote $order * * @return Payment - * @throws NoSuchEntityException */ - private function buildPayment(Order $order): Payment + private function buildPayment(Order|Quote $order): Payment { return new Payment( - order : $this->buildOrder($order), - checkout : $this->buildCheckout($order), - notification: new Notification($this->buildWebhooks()), + order: $this->globalRequestBuilder->buildOrder($order), + checkout: $this->buildCheckout($order), + notification: new Notification($this->globalRequestBuilder->buildWebhooks()), + subscription: $this->getSubscriptionSetup($order), + paymentMethodsConfiguration: $this->globalRequestBuilder->buildPaymentMethodsConfiguration( + $order + ), ); } /** - * Build the webhooks for the payment + * Build Checkout request object * - * @return array + * @param Order|Quote $salesObject * - * added all for now, we need to check wh + * @return HostedCheckout|EmbeddedCheckout */ - public function buildWebhooks(): array + public function buildCheckout(Quote|Order $salesObject): HostedCheckout|EmbeddedCheckout { - $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; + return $this->isEmbedded() ? + $this->buildEmbeddedCheckout($salesObject) : + $this->buildHostedCheckout($salesObject); } /** - * Build the checkout object + * Build the consumer object * - * @param Order $order + * @param Order|Quote $salesObject * - * @return HostedCheckout|EmbeddedCheckout - * @throws NoSuchEntityException + * @return Consumer */ - public function buildCheckout(Order $order): HostedCheckout|EmbeddedCheckout + private function buildConsumer(Order|Quote $salesObject): Consumer { - 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), - ); - } + $customerId = $salesObject->getCustomerId(); + $shippingAddress = $salesObject->getShippingAddress(); + $billingAddress = $salesObject->getBillingAddress(); + $lastName = $salesObject->getCustomerLastname() ?: $shippingAddress->getLastname(); + $firstName = $salesObject->getCustomerFirstname() ?: $shippingAddress->getFirstname(); + $email = $salesObject->getCustomerEmail() ?: $shippingAddress->getEmail(); - 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', + return new Consumer( + email: $email, + reference: $customerId, + shippingAddress: new Address( + addressLine1: $this->stringSanitizer->sanitize($shippingAddress->getStreetLine(1)), + addressLine2: $this->stringSanitizer->sanitize($shippingAddress->getStreetLine(2)), + postalCode: $shippingAddress->getPostcode(), + city: $this->stringSanitizer->sanitize($shippingAddress->getCity()), + country: $this->getThreeLetterCountryCode($shippingAddress->getCountryId()), + ), + billingAddress: new Address( + addressLine1: $this->stringSanitizer->sanitize($billingAddress->getStreetLine(1)), + addressLine2: $this->stringSanitizer->sanitize($billingAddress->getStreetLine(2)), + postalCode: $billingAddress->getPostcode(), + city: $this->stringSanitizer->sanitize($billingAddress->getCity()), + country: $this->getThreeLetterCountryCode($billingAddress->getCountryId()), + ), + privatePerson: new PrivatePerson( + firstName: $this->stringSanitizer->sanitize($firstName), + lastName: $this->stringSanitizer->sanitize($lastName), + ), + phoneNumber: $this->globalRequestBuilder->getNumber($salesObject) + ); + } + + /** + * Check integration type + * + * @return bool + */ + public function isEmbedded(): bool + { + return $this->config->getIntegrationType() === IntegrationTypeEnum::EmbeddedCheckout->name; + } + + /** + * Build Embedded Checkout request object + * + * @param Quote|Order $salesObject + * + * @return EmbeddedCheckout + */ + public function buildEmbeddedCheckout(Quote|Order $salesObject): EmbeddedCheckout + { + return new EmbeddedCheckout( + url: $this->url->getUrl('checkout/onepage/success'), + termsUrl: $this->config->getPaymentsTermsAndConditionsUrl(), + consumer: $this->buildConsumer($salesObject), + isAutoCharge: $this->config->getPaymentAction() == 'authorize_capture', merchantHandlesConsumerData: true, - countryCode : $this->getThreeLetterCountryCode(), + countryCode: $this->getThreeLetterCountryCode($this->config->getCountryCode()), ); } /** - * Build the consumer object + * Build the checkout for hosted integration type * - * @param Order $order + * @param Quote|Order $salesObject * - * @return Consumer - * @throws NoSuchEntityException + * @return HostedCheckout */ - private function buildConsumer(Order $order): Consumer + public function buildHostedCheckout(Quote|Order $salesObject): HostedCheckout { - 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) + return new HostedCheckout( + returnUrl: $this->url->getUrl('checkout/onepage/success'), + cancelUrl: $this->url->getUrl('nexi/hpp/cancelaction'), + termsUrl: $this->config->getWebshopTermsAndConditionsUrl(), + consumer: $this->buildConsumer($salesObject), + isAutoCharge: $this->config->getPaymentAction() == 'authorize_capture', + merchantHandlesConsumerData: true, + countryCode: $this->getThreeLetterCountryCode($this->config->getCountryCode()), ); } /** * 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(); } /** - * Build phone number object for the payment - * - * @param Order $order + * Set subscription setup for the payment. * * @return PhoneNumber - * @throws NumberParseException */ - public function getNumber(Order $order): PhoneNumber + private function getSubscriptionSetup(): ?Payment\Subscription { - $lib = PhoneNumberUtil::getInstance(); + if ($this->totalConfigProvider->isSubscriptionScheduled() + && $this->totalConfigProvider->isSubscriptionsEnabled() + ) { + $subscriptionProfileId = $this->totalConfigProvider->getSubscriptionProfileId(); - $number = $lib->parse( - $order->getShippingAddress()->getTelephone(), - $order->getShippingAddress()->getCountryId() - ); + return new Payment\Subscription( + subscriptionId: null, + endDate: new \DateTime((int)date('Y') + 100 . '-01-01'), + interval: $this->nextDateCalculator->getDaysInterval((int)$subscriptionProfileId), + ); + } - return new PhoneNumber( - prefix: '+' . $number->getCountryCode(), - number: (string)$number->getNationalNumber(), - ); + return null; } } diff --git a/Gateway/Request/NexiCheckout/GlobalRequestBuilder.php b/Gateway/Request/NexiCheckout/GlobalRequestBuilder.php new file mode 100644 index 00000000..2b094eb5 --- /dev/null +++ b/Gateway/Request/NexiCheckout/GlobalRequestBuilder.php @@ -0,0 +1,350 @@ +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; + } + + /** + * Get Phone number from the address + * + * @param Order|Quote $salesObject + * + * @return PhoneNumber + */ + public function getNumber(Order|Quote $salesObject): PhoneNumber + { + $lib = PhoneNumberUtil::getInstance(); + + $telephone = $salesObject->getShippingAddress()->getTelephone(); + $countryId = $salesObject->getShippingAddress()->getCountryId(); + + $number = $lib->parse( + $telephone, + $countryId + ); + + return new PhoneNumber( + prefix: '+' . $number->getCountryCode(), + number: (string)$number->getNationalNumber(), + ); + } + + /** + * Build the Sdk order object + * + * @param Quote|Order $order + * + * @return NexiRequestOrder + */ + public function buildOrder(Quote|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|Quote $paymentSubject + * + * @return OrderItem|array + */ + private function buildItems(Order|Quote $paymentSubject): OrderItem|array + { + $items = $this->getProductsData($paymentSubject); + + if ($paymentSubject instanceof Order) { + $shippingInfoHolder = $paymentSubject; + } else { + $shippingInfoHolder = $paymentSubject->getShippingAddress(); + } + + if ($shippingInfoHolder->getShippingInclTax()) { + $items[] = new Item( + name : $shippingInfoHolder->getShippingDescription(), + quantity : 1, + unit : 'pcs', + unitPrice : $this->amountConverter->convertToNexiAmount( + $shippingInfoHolder->getBaseShippingAmount() + ), + grossTotalAmount: $this->amountConverter->convertToNexiAmount( + $shippingInfoHolder->getBaseShippingInclTax() + ), + netTotalAmount : $this->amountConverter->convertToNexiAmount( + $shippingInfoHolder->getBaseShippingAmount() + ), + reference : SalesDocumentItemsBuilder::SHIPPING_COST_REFERENCE, + taxRate : $this->amountConverter->convertToNexiAmount( + $this->getShippingTaxRate($paymentSubject) + ), + taxAmount : $this->amountConverter->convertToNexiAmount( + $shippingInfoHolder->getBaseShippingTaxAmount() + ), + ); + } + + return $items; + } + + /** + * Get shipping tax rate from the order + * + * @param Order|Quote $paymentSubject + * + * @return float + */ + public function getShippingTaxRate(Order|Quote $paymentSubject) + { + if ($paymentSubject instanceof Order) { + foreach ($paymentSubject->getExtensionAttributes()?->getItemAppliedTaxes() as $tax) { + if ($tax->getType() == CommonTaxCollector::ITEM_TYPE_SHIPPING) { + $appliedTaxes = $tax->getAppliedTaxes(); + return reset($appliedTaxes)->getPercent(); + } + } + } + if ($paymentSubject instanceof Quote) { + if (!(float)$paymentSubject->getShippingAddress()->getBaseShippingAmount()) { + return 0.0; + } + $shippingTaxRate = $paymentSubject->getShippingAddress()->getBaseShippingTaxAmount() / + $paymentSubject->getShippingAddress()->getBaseShippingAmount() * 100; + if ($shippingTaxRate) { + return $shippingTaxRate; + } + } + + return 0.0; + } + + /** + * Build payload with order items data based on product type + * + * @param Order|Quote $paymentSubject + * + * @return array + */ + public function getProductsData(Order|Quote $paymentSubject): array + { + $items = []; + /** @var OrderItem|Quote\Item $item */ + foreach ($paymentSubject->getAllVisibleItems() as $item) { + + if ($item->getParentItem()) { + continue; + } + + switch ($item->getProductType()) { + case ConfigurableType::TYPE_CODE: + $children = $this->getChildren($item); + foreach ($children as $childItem) { + $base = $this->createItemBaseData($childItem); + $enriched = $this->appendPriceData($base, $item); + $items[] = $this->createFinalItem($enriched); + } + break; + case BundleType::TYPE_CODE: + $isDynamicPrice = $item->getProduct()->getPriceType() == Price::PRICE_TYPE_DYNAMIC; + $children = $this->getChildren($item); + if ($isDynamicPrice) { + foreach ($children as $childItem) { + $base = $this->createItemBaseData($childItem); + $enriched = $this->appendPriceData($base, $childItem); + $items[] = $this->createFinalItem($enriched); + } + } else { + $base = $this->createItemBaseData($item); + $enriched = $this->appendPriceData($base, $item); + $items[] = $this->createFinalItem($enriched); + } + break; + default: + $base = $this->createItemBaseData($item); + $enriched = $this->appendPriceData($base, $item); + $items[] = $this->createFinalItem($enriched); + } + } + + return $items; + } + + /** + * Create the nexi SDK item + * + * @param array $data + * + * @return Item + */ + private function createFinalItem(array $data): Item + { + return new Item( + name : $data['name'], + quantity : $data['quantity'], + unit : $data['unit'], + unitPrice : $data['unitPrice'], + grossTotalAmount: $data['grossTotalAmount'], + netTotalAmount : $data['netTotalAmount'], + reference : $data['reference'], + taxRate : $data['taxRate'], + taxAmount : $data['taxAmount'], + ); + } + + /** + * Creates base data array for an item including name, SKU, quantity, and unit. + * + * @param mixed $item + * + * @return array + */ + private function createItemBaseData(mixed $item): array + { + return [ + 'name' => $item->getName(), + 'reference' => $item->getSku(), + 'quantity' => $this->getQuantity($item), + 'unit' => 'pcs' + ]; + } + + /** + * Get children items of a given order item or quote item. + * + * @param OrderItem|Quote\Item $item + * + * @return array|Quote\Item\AbstractItem[] + */ + private function getChildren(OrderItem|Quote\Item $item): array + { + $children = $item instanceof OrderItem ? $item->getChildrenItems() : $item->getChildren(); + + return $children; + } + + /** + * Returns the quantity of the item. + * + * @param mixed $item + * + * @return float + */ + private function getQuantity(mixed $item): float + { + $qtyOrdered = $item instanceof OrderItem ? $item->getQtyOrdered() : $item->getQty(); + + return (float)$qtyOrdered; + } + + /** + * Appends pricing and tax data to the given item data array. + * + * @param array $data + * @param mixed $item + * + * @return array + */ + private function appendPriceData(array $data, mixed $item): array + { + $data['unitPrice'] = $this->amountConverter->convertToNexiAmount( + $item->getBasePrice() - $item->getBaseDiscountAmount() / $this->getQuantity($item) + ); + $data['grossTotalAmount'] = $this->amountConverter->convertToNexiAmount( + $item->getBaseRowTotal() - $item->getBaseDiscountAmount() + $item->getBaseTaxAmount() + ); + $data['netTotalAmount'] = $this->amountConverter->convertToNexiAmount( + $item->getBaseRowTotal() - $item->getBaseDiscountAmount() + ); + $data['taxRate'] = $this->amountConverter->convertToNexiAmount($item->getTaxPercent()); + $data['taxAmount'] = $this->amountConverter->convertToNexiAmount($item->getBaseTaxAmount()); + + return $data; + } + + /** + * Build the payment methods configuration for the order or quote. + * + * @param Quote|Order $salesObject + * + * @return MethodConfiguration[] + */ + public function buildPaymentMethodsConfiguration(Quote|Order $salesObject): array + { + $subselection = $salesObject->getPayment()->getAdditionalInformation('subselection'); + + if (!$this->config->getPayTypeSplitting() || !$subselection) { + return []; + } + + return [ + new MethodConfiguration( + name : $subselection, + enabled: true + ) + ]; + } +} diff --git a/Gateway/Request/NexiCheckout/SalesDocumentItemsBuilder.php b/Gateway/Request/NexiCheckout/SalesDocumentItemsBuilder.php index d7822fcf..c9852778 100644 --- a/Gateway/Request/NexiCheckout/SalesDocumentItemsBuilder.php +++ b/Gateway/Request/NexiCheckout/SalesDocumentItemsBuilder.php @@ -15,6 +15,8 @@ class SalesDocumentItemsBuilder public const SHIPPING_COST_REFERENCE = 'shipping_cost_ref'; /** + * SalesDocumentItemsBuilder constructor. + * * @param AmountConverter $amountConverter * @param StringSanitizer $stringSanitizer */ @@ -35,16 +37,19 @@ public function build(CreditmemoInterface|InvoiceInterface $salesObject): array { $items = []; foreach ($salesObject->getAllItems() as $item) { + if ((double)$item->getBasePrice() === 0.0) { + continue; + } $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()), + 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 +58,46 @@ 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 + */ + private 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 + */ + private function calculateShippingTaxRate(InvoiceInterface|CreditmemoInterface $salesObject): int|float + { + if ($salesObject->getShippingAmount() == 0) { + return 0; + } + + return $salesObject->getShippingTaxAmount() / $salesObject->getShippingAmount() * 100; + } } diff --git a/Gateway/Request/RetrieveRequestBuilder.php b/Gateway/Request/RetrieveRequestBuilder.php new file mode 100644 index 00000000..fd36515f --- /dev/null +++ b/Gateway/Request/RetrieveRequestBuilder.php @@ -0,0 +1,43 @@ +getPayment(); + $creditmemo = $payment->getCreditmemo(); + + return [ + 'nexi_method' => 'retrievePayment', + 'body' => [ + 'paymentId' => $payment->getAdditionalInformation('payment_id') + ] + ]; + } +} diff --git a/Gateway/Request/SubscriptionChargeRequestBuilder.php b/Gateway/Request/SubscriptionChargeRequestBuilder.php new file mode 100644 index 00000000..174c1ee7 --- /dev/null +++ b/Gateway/Request/SubscriptionChargeRequestBuilder.php @@ -0,0 +1,61 @@ +getPayment()->getQuote(); + } + + $subscription = $this->subscriptionLinkRepository->getSubscriptionFromOrderId($paymentSubject->getId()); + + return [ + 'body' => new BulkChargeSubscription( + externalBulkChargeId: 'bulkChargeId_' . $paymentSubject->getIncrementId(), + notification: new Notification($this->globalRequestBuilder->buildWebhooks()), + subscriptions: [ + new Subscription( + subscriptionId: $subscription->getNexiSubscriptionId(), + externalReference: $paymentSubject->getIncrementId(), + order: $this->globalRequestBuilder->buildOrder($paymentSubject) + ) + ] + ), + 'nexi_method' => 'bulkChargeSubscription', + ]; + } +} diff --git a/Gateway/Request/UpdateOrderRequestBuilder.php b/Gateway/Request/UpdateOrderRequestBuilder.php new file mode 100644 index 00000000..2e98f5fa --- /dev/null +++ b/Gateway/Request/UpdateOrderRequestBuilder.php @@ -0,0 +1,59 @@ +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 : $this->amountConverter->convertToNexiAmount($paymentSubject->getGrandTotal()), + items : $this->createPaymentRequestBuilder->buildItems($paymentSubject), + shipping : new UpdateOrder\Shipping(costSpecified: true), + paymentMethods: [], + ) + ] + ]; + } +} diff --git a/Gateway/Request/UpdateReferenceRequestBuilder.php b/Gateway/Request/UpdateReferenceRequestBuilder.php new file mode 100644 index 00000000..3becca1a --- /dev/null +++ b/Gateway/Request/UpdateReferenceRequestBuilder.php @@ -0,0 +1,47 @@ +getPayment(); + $incrementId = $order->getIncrementId(); + + return [ + 'nexi_method' => 'updateReferenceInformation', + 'body' => [ + 'paymentId' => $payment->getAdditionalInformation('payment_id'), + 'referenceInformation' => new ReferenceInformation( + checkoutUrl: $this->url->getUrl('checkout/onepage/success'), + reference: $incrementId, + ) + ] + ]; + } +} diff --git a/Gateway/Request/VoidRequestBuilder.php b/Gateway/Request/VoidRequestBuilder.php new file mode 100644 index 00000000..3dc92b45 --- /dev/null +++ b/Gateway/Request/VoidRequestBuilder.php @@ -0,0 +1,47 @@ +getPayment(); + + return [ + 'nexi_method' => 'cancel', + 'body' => [ + 'paymentId' => $payment->getAdditionalInformation('payment_id'), + 'cancel' => new Cancel( + amount: $this->amountConverter->convertToNexiAmount( + $payment->getBaseAmountAuthorized() + ) + ) + ] + ]; + } +} diff --git a/Model/Api/ShowSubscriptionsDataProvider.php b/Model/Api/ShowSubscriptionsDataProvider.php new file mode 100644 index 00000000..28b2b7ea --- /dev/null +++ b/Model/Api/ShowSubscriptionsDataProvider.php @@ -0,0 +1,49 @@ +subscriptionLinkRepository = $subscriptionLinkRepository; + $this->orderRepository = $orderRepository; + } + + /** + * @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/Attribute/SelectData.php b/Model/Attribute/SelectData.php new file mode 100644 index 00000000..cf644992 --- /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_SUBSCRIPTION_VALUE]; + } + + return $this->_options; + } +} diff --git a/Model/Config/Source/Order/NewStatus.php b/Model/Config/Source/Order/NewStatus.php new file mode 100644 index 00000000..8a7cb4c9 --- /dev/null +++ b/Model/Config/Source/Order/NewStatus.php @@ -0,0 +1,42 @@ +stateStatuses + ? $this->orderConfig->getStateStatuses($this->stateStatuses) + : $this->orderConfig->getStatuses(); + + $options = []; + foreach ($statuses as $code => $label) { + $options[] = ['value' => $code, 'label' => $label]; + } + return $options; + } +} diff --git a/Model/Config/Source/PayTypeOptions.php b/Model/Config/Source/PayTypeOptions.php new file mode 100644 index 00000000..e62d7992 --- /dev/null +++ b/Model/Config/Source/PayTypeOptions.php @@ -0,0 +1,27 @@ + $case->value, + 'label' => __($case->value) + ]; + } + return $options; + } +} diff --git a/Model/Config/Source/PaymentTypesEnum.php b/Model/Config/Source/PaymentTypesEnum.php new file mode 100644 index 00000000..a0f8fc50 --- /dev/null +++ b/Model/Config/Source/PaymentTypesEnum.php @@ -0,0 +1,29 @@ +localeResolver->getLocale()); + + return in_array($locale, self::SUPPORTED_LOCALES) ? $locale : self::DEFAULT_LOCALE; + } +} diff --git a/Model/OptionSource/IntervalUnits.php b/Model/OptionSource/IntervalUnits.php new file mode 100644 index 00000000..dd8ef4c5 --- /dev/null +++ b/Model/OptionSource/IntervalUnits.php @@ -0,0 +1,29 @@ + '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..9ed82622 --- /dev/null +++ b/Model/OptionSource/ProfileOptions.php @@ -0,0 +1,43 @@ +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/SubscriptionStatus.php b/Model/OptionSource/SubscriptionStatus.php new file mode 100644 index 00000000..ecd77cbd --- /dev/null +++ b/Model/OptionSource/SubscriptionStatus.php @@ -0,0 +1,35 @@ + 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/Order/Comment.php b/Model/Order/Comment.php new file mode 100644 index 00000000..cdee756d --- /dev/null +++ b/Model/Order/Comment.php @@ -0,0 +1,41 @@ +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/PaymentInitialize.php b/Model/PaymentInitialize.php new file mode 100644 index 00000000..f3975c44 --- /dev/null +++ b/Model/PaymentInitialize.php @@ -0,0 +1,89 @@ +quoteIdMaskFactory->create()->load($cartId, 'masked_id'); + $cartId = $quoteIdMask->getQuoteId(); + } + $quote = $this->quoteRepository->get($cartId); + + if (!$quote->getIsActive()) { + $this->checkoutSession->restoreQuote(); + } + $quotePayment = $quote->getPayment(); + if (!$quotePayment) { + throw new LocalizedException(__('No payment method found for the quote')); + } + + $paymentData = $this->paymentDataObjectFactory->create($quotePayment); + + if (isset($paymentMethod->getAdditionalData()['subselection'])) { + $paymentData->getPayment()->setAdditionalInformation( + 'subselection', + $paymentMethod->getAdditionalData()['subselection'] + ); + } + + $this->initializeCommand->createPayment($paymentData); + $this->quoteRepository->save($quote); + + return json_encode([ + 'paymentId' => $quotePayment->getAdditionalInformation('payment_id'), + 'checkoutKey' => $this->config->getCheckoutKey() + ]); + } catch (\Exception $e) { + $this->logger->error( + 'Error initializing payment:', + [ + 'exception' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ] + ); + throw new LocalizedException(__('Could not initialize payment.'), $e); + } + } +} diff --git a/Model/PaymentValidate.php b/Model/PaymentValidate.php new file mode 100644 index 00000000..e24e4f82 --- /dev/null +++ b/Model/PaymentValidate.php @@ -0,0 +1,112 @@ +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')); + } + + $quotePaymentId = $paymentMethod->getAdditionalInformation('payment_id'); + + if ($quotePaymentId !== $paymentId) { + throw new LocalizedException(__('Payment ID does not match the current cart payment ID.')); + } + + $this->commandManagerPool->get(Config::CODE)->executeByCode( + 'retrieve', + $paymentMethod, + [ + 'quote' => $quote + ] + ); + + $this->compareAmounts($paymentMethod->getData('retrieved_payment'), $quote); + + return $this->json->serialize([ + 'payment_id' => $quotePaymentId, + 'success' => true + ]); + + } catch (\Exception $e) { + $this->logger->error( + 'Error initializing payment:', + [ + 'exception' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ] + ); + throw new LocalizedException(__('Could not validate the payment.'), $e); + } + } + + /** + * Compare items and amounts between retrieved payment and quote + * + * @param RetrievePaymentResult $retrievedPayment + * @param CartInterface $quote + * + * @return void + * @throws LocalizedException + */ + private function compareAmounts(RetrievePaymentResult $retrievedPayment, CartInterface $quote): void + { + $quoteTotal = $this->amountConverter->convertToNexiAmount($quote->getGrandTotal()); + $retrievedTotal = $retrievedPayment->getPayment()->getOrderDetails()->getAmount(); + + if ($quoteTotal !== $retrievedTotal) { + throw new LocalizedException(__('The payment amount does not match the quote total.')); + } + } +} diff --git a/Model/ResourceModel/Subscription.php b/Model/ResourceModel/Subscription.php new file mode 100644 index 00000000..dd7d3dcd --- /dev/null +++ b/Model/ResourceModel/Subscription.php @@ -0,0 +1,160 @@ +_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', + [] + ); + + return $connection->fetchPairs($select); + } +} diff --git a/Model/ResourceModel/Subscription/Collection.php b/Model/ResourceModel/Subscription/Collection.php new file mode 100644 index 00000000..21653c0f --- /dev/null +++ b/Model/ResourceModel/Subscription/Collection.php @@ -0,0 +1,101 @@ +_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'] + ); + + return $this; + } +} diff --git a/Model/ResourceModel/Subscription/Profile.php b/Model/ResourceModel/Subscription/Profile.php new file mode 100644 index 00000000..44bbc2ca --- /dev/null +++ b/Model/ResourceModel/Subscription/Profile.php @@ -0,0 +1,38 @@ +_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..c6df7d4f --- /dev/null +++ b/Model/ResourceModel/Subscription/Profile/Collection.php @@ -0,0 +1,80 @@ +_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..ccf110be --- /dev/null +++ b/Model/ResourceModel/Subscription/SubscriptionLink.php @@ -0,0 +1,19 @@ +_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..72363ed1 --- /dev/null +++ b/Model/ResourceModel/Subscription/SubscriptionLink/Collection.php @@ -0,0 +1,86 @@ +_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/ResourceModel/Subscriptions/Profile.php b/Model/ResourceModel/Subscriptions/Profile.php new file mode 100644 index 00000000..c079243d --- /dev/null +++ b/Model/ResourceModel/Subscriptions/Profile.php @@ -0,0 +1,38 @@ +_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..b03c3065 --- /dev/null +++ b/Model/ResourceModel/Subscriptions/Profile/Collection.php @@ -0,0 +1,80 @@ +_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; + } +} diff --git a/Model/Subscription.php b/Model/Subscription.php new file mode 100644 index 00000000..d21a8862 --- /dev/null +++ b/Model/Subscription.php @@ -0,0 +1,109 @@ +_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 (int)$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 getNexiSubscriptionId(): string + { + return $this->getData(SubscriptionInterface::FIELD_NEXI_SUBSCRIPTION_ID); + } + + 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 setNexiSubscriptionId(string $subscriptionId): SubscriptionInterface + { + return $this->setData(SubscriptionInterface::FIELD_NEXI_SUBSCRIPTION_ID, $subscriptionId); + } +} diff --git a/Model/Subscription/ActiveOrderProvider.php b/Model/Subscription/ActiveOrderProvider.php new file mode 100644 index 00000000..25337975 --- /dev/null +++ b/Model/Subscription/ActiveOrderProvider.php @@ -0,0 +1,65 @@ +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/Bill.php b/Model/Subscription/Bill.php new file mode 100644 index 00000000..38cd2af5 --- /dev/null +++ b/Model/Subscription/Bill.php @@ -0,0 +1,55 @@ +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/Subscription/CustomerSubscriptionProvider.php b/Model/Subscription/CustomerSubscriptionProvider.php new file mode 100644 index 00000000..56af6a00 --- /dev/null +++ b/Model/Subscription/CustomerSubscriptionProvider.php @@ -0,0 +1,88 @@ +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 customer closed subscriptions. + * + * @return SubscriptionCollection + */ + public function getCustomerClosedSubscriptions() + { + $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; + } +} diff --git a/Model/Subscription/Email.php b/Model/Subscription/Email.php new file mode 100644 index 00000000..86a9ef01 --- /dev/null +++ b/Model/Subscription/Email.php @@ -0,0 +1,179 @@ +notify($order); + } + } + + /** + * Notify customer about the cloned order + * + * @param $order + * @return void + */ + 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()); + } + } + + /** + * Get email template ID from configuration + * + * @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() + ); + } + + /** + * Prepare template variables for email + * + * @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'); + } + + /** + * Get template options for email + * + * @param Order $order + * @return array + */ + private function getTemplateOptions(Order $order): array + { + return [ + 'area' => \Magento\Framework\App\Area::AREA_FRONTEND, + 'store' => $order->getStoreId(), + ]; + } + + /** + * Retrieve the warning period configuration value + * + * @param Order $order + * @return mixed + */ + 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..4ec7857a --- /dev/null +++ b/Model/Subscription/NextDateCalculator.php @@ -0,0 +1,182 @@ +getProfileById($profileId); + + return $this->calculateNextDate($profile->getSchedule(), $startDate); + } + + /** + * Calculate the number of days interval until the next date for a profile. + * + * @param int $profileId + * @param string $startDate + * + * @return int + * @throws NoSuchEntityException + * @throws Exception + */ + public function getDaysInterval($profileId, $startDate = 'now'): int + { + $nextDate = $this->getNextDate($profileId, $startDate); + + return (int)abs($nextDate->diffInDays($startDate)); + } + + /** + * Calculate next date based on the schedule. + * + * @param string $schedule + * @param string $startDate + * + * @return Carbon + * @throws Exception + */ + private function calculateNextDate($schedule, $startDate): Carbon + { + $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((int)$schedule['interval']); + break; + case 'W': + $nextDate = $carbonDate->addWeeks((int)$schedule['interval']); + break; + case 'M': + $nextDate = $this->addMonthsNoOverflow($carbonDate, (int)$schedule['interval']); + break; + case 'Y': + $nextDate = $carbonDate->addYearsNoOverflow((int)$schedule['interval']); + break; + default: + throw new LocalizedException(__('Schedule type not supported')); + } + + if ($this->isForceWeekdays()) { + $nextDate = $this->getNextWeekday($nextDate); + } + + return $nextDate; + } + + /** + * Get profile by id + * + * @param int $profileId + * + * @return SubscriptionProfileInterface + * @throws NoSuchEntityException + */ + private function getProfileById($profileId): SubscriptionProfileInterface + { + if (!isset($this->profiles[$profileId])) { + $this->profiles[$profileId] = $this->profileRepositoryInterface->get($profileId); + } + + return $this->profiles[$profileId]; + } + + /** + * Get force weekdays config + * + * @return bool + */ + private function isForceWeekdays(): bool + { + 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): Carbon + { + $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): Carbon + { + $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/Notify.php b/Model/Subscription/Notify.php new file mode 100644 index 00000000..d3f569cb --- /dev/null +++ b/Model/Subscription/Notify.php @@ -0,0 +1,104 @@ +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/Subscription/OrderBiller.php b/Model/Subscription/OrderBiller.php new file mode 100644 index 00000000..00ab9fad --- /dev/null +++ b/Model/Subscription/OrderBiller.php @@ -0,0 +1,171 @@ +chargeSubscription($orderId); + if (!$paymentSuccess) { + /** @var Subscription $subscription */ + $subscription = $this->subscriptionLinkRepository->getSubscriptionFromOrderId($orderId); + $this->paymentCount->reduceFailureRetryCount($subscription); + continue; + } + /** @var Collection $subscriptionsToCharge */ + $subscriptionsToCharge = $this->collectionFactory->create(); + $subscriptionsToCharge->getBillingCollectionByOrderIds($orderIds); + $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))); + } + + /** + * Create MIT payment request. + * + * @param string $orderId + * + * @return bool + * For subscription param @see Collection::getBillingCollectionByOrderIds + */ + private function chargeSubscription($orderId): bool + { + $paymentSuccess = false; + try { + $order = $this->orderRepository->get($orderId); + $commandExecutor = $this->commandManagerPool->get(Config::CODE); + + $commandExecutor->executeByCode( + commandCode: 'subscription_charge', + arguments: ['order' => $order] + ); + } catch (LocalizedException $e) { + $this->logger->error( + \__( + "Subscription: Unable to create a charge to customer's subscription 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() + )->format('Y-m-d') + ); + + $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..ff49f5ff --- /dev/null +++ b/Model/Subscription/OrderCloner.php @@ -0,0 +1,176 @@ +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); + $quote->getPayment()->setAdditionalInformation('subscription_id', $oldOrder->getPayment()->getAdditionalInformation('subscription_id')); + + return $this->quoteManagement->submit($quote); + } + + /** + * @param $quote + * @return void + */ + private function removeNonScheduledProducts($quote): void + { + foreach ($quote->getAllVisibleItems() as $quoteItem) { + if (!$quoteItem->getProduct()->getSubscriptionSchedule()) { + $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..53894d16 --- /dev/null +++ b/Model/Subscription/PaymentCount.php @@ -0,0 +1,53 @@ +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..2d46cca7 --- /dev/null +++ b/Model/Subscription/Profile.php @@ -0,0 +1,67 @@ +_init(\Nexi\Checkout\Model\ResourceModel\Subscription\Profile::class); + } + + /** + * @return int + */ + public function getId() + { + return $this->getData(SubscriptionProfileInterface::FIELD_PROFILE_ID); + } + + /** + * @return string + */ + public function getName() + { + return $this->getData(SubscriptionProfileInterface::FIELD_NAME); + } + + /** + * @return string + */ + public function getDescription() + { + return $this->getData(SubscriptionProfileInterface::FIELD_DESCRIPTION); + } + + /** + * @return string + */ + public function getSchedule() + { + return $this->getData(SubscriptionProfileInterface::FIELD_SCHEDULE); + } + + public function setId($profileId): self + { + return $this->setData(SubscriptionProfileInterface::FIELD_PROFILE_ID, $profileId); + } + + public function setName($name): self + { + return $this->setData(SubscriptionProfileInterface::FIELD_NAME, $name); + } + + public function setDescription($description): self + { + return $this->setData(SubscriptionProfileInterface::FIELD_DESCRIPTION, $description); + } + + public function setSchedule($schedule): self + { + return $this->setData(SubscriptionProfileInterface::FIELD_SCHEDULE, $schedule); + } +} diff --git a/Model/Subscription/ProfileRepository.php b/Model/Subscription/ProfileRepository.php new file mode 100644 index 00000000..e88d8b2a --- /dev/null +++ b/Model/Subscription/ProfileRepository.php @@ -0,0 +1,111 @@ +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\SubscriptionProfileInterface $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\SubscriptionProfileInterface $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 + ) : SubscriptionProfileSearchResultInterface { + /** @var Data\RecurringProfileSearchResultInterface $searchResult */ + $searchResult = $this->profileResultFactory->create(); + $this->collectionProcessor->process($searchCriteria, $searchResult); + $searchResult->setSearchCriteria($searchCriteria); + + return $searchResult; + } +} diff --git a/Model/Subscription/SubscriptionLink.php b/Model/Subscription/SubscriptionLink.php new file mode 100644 index 00000000..7fbd3fc4 --- /dev/null +++ b/Model/Subscription/SubscriptionLink.php @@ -0,0 +1,69 @@ +_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..d99a0f4b --- /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/Subscription/TotalConfigProvider.php b/Model/Subscription/TotalConfigProvider.php new file mode 100644 index 00000000..ed2dfae4 --- /dev/null +++ b/Model/Subscription/TotalConfigProvider.php @@ -0,0 +1,126 @@ +scopeConfig->getValue( + self::IS_SUBSCRIPTIONS_ENABLED, + ScopeInterface::SCOPE_STORE + ); + } + + /** + * Get subscription values to config. + * + * @return array + * @throws LocalizedException + * @throws NoSuchEntityException + */ + public function getConfig(): array + { + return [ + 'isRecurringScheduled' => $this->isSubscriptionScheduled(), + 'recurringSubtotal' => $this->getSubscriptionSubtotal() + ]; + } + + /** + * Is cart has subscription schedule products. + * + * @return bool + * @throws LocalizedException + * @throws NoSuchEntityException + */ + public function isSubscriptionScheduled(): bool + { + $quoteItems = $this->checkoutSession->getQuote()->getAllItems(); + if ($quoteItems) { + foreach ($quoteItems as $item) { + if ($item->getProduct()->getCustomAttribute('subscription_schedule') != self::NO_SCHEDULE_VALUE) { + return true; + } + } + } + + return false; + } + + /** + * Get subscription profile ID. + * + * @return string + * @throws LocalizedException + * @throws NoSuchEntityException + */ + public function getSubscriptionProfileId(): string + { + $profileId = ''; + $quoteItems = $this->checkoutSession->getQuote()->getAllItems(); + if ($quoteItems) { + foreach ($quoteItems as $item) { + if ($item->getProduct()->getCustomAttribute('subscription_schedule')) { + $profileId = $item->getProduct()->getCustomAttribute('subscription_schedule')->getValue(); + } + } + } + + return $profileId; + } + + /** + * Get subscription cart subtotal value. + * + * @return float + * @throws LocalizedException + * @throws NoSuchEntityException + */ + private function getSubscriptionSubtotal(): float + { + $recurringSubtotal = 0.00; + if ($this->isSubscriptionsEnabled()) { + + if ($this->isSubscriptionScheduled()) { + $quoteItems = $this->checkoutSession->getQuote()->getAllItems(); + foreach ($quoteItems as $item) { + if ($item->getProduct() + ->getCustomAttribute('subscription_schedule') != self::NO_SCHEDULE_VALUE) { + $recurringSubtotal = $recurringSubtotal + ($item->getPrice() * $item->getQty()); + } + } + } + } + + return $recurringSubtotal; + } +} diff --git a/Model/SubscriptionManagement.php b/Model/SubscriptionManagement.php new file mode 100644 index 00000000..2c0095eb --- /dev/null +++ b/Model/SubscriptionManagement.php @@ -0,0 +1,297 @@ +getSubscriptionSchedule($order); + if (empty($orderSchedule)) { + throw new CouldNotSaveException(__('No valid subscription schedule found for order.')); + } + $subscription = $this->createSubscription($order, $orderSchedule, $nexiSubscriptionId); + $order->getPayment()->setAdditionalInformation( + 'subscription_id', + $nexiSubscriptionId + ); + + $this->subscriptionRepository->save($subscription); + + return $subscription; + } + + /** + * Create a new subscription based on the order and its schedule. + * + * @param Order $order + * @param array $orderSchedule + * @param string $subscriptionId + * @return SubscriptionInterface + * @throws CouldNotSaveException + */ + public function createSubscription($order, $orderSchedule, $subscriptionId): SubscriptionInterface + { + try { + $subscription = $this->subscriptionInterfaceFactory->create(); + $subscription->setStatus(SubscriptionInterface::STATUS_ACTIVE); + $subscription->setCustomerId($order->getCustomerId()); + $subscription->setNextOrderDate($this->dateCalculator->getNextDate(reset($orderSchedule))->format('Y-m-d')); + $subscription->setRecurringProfileId((int)reset($orderSchedule)); + $subscription->setRepeatCountLeft(self::REPEAT_COUNT_STATIC_VALUE); + $subscription->setRetryCount(self::REPEAT_COUNT_STATIC_VALUE); + $subscription->setNexiSubscriptionId($subscriptionId); + + $this->subscriptionRepository->save($subscription); + + $this->subscriptionLinkRepository->linkOrderToSubscription($order->getId(), $subscription->getId()); + + return $subscription; + } catch (\Exception $exception) { + throw new CouldNotSaveException(__($exception->getMessage())); + } + } + + /** + * Cancel a subscription by its ID. + * + * @param int $subscriptionId + * @return string + * @throws LocalizedException + */ + public function cancelSubscription($subscriptionId) + { + $customerId = $this->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(); + } + + /** + * Show subscriptions for the logged-in customer based on search criteria. + * + * @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(); + + 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(), + '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")); + } + + /** + * Get the subscription schedule from the order items. + * + * @param Order $order + * @return array + */ + public function getSubscriptionSchedule($order): array + { + $orderSchedule = []; + try { + foreach ($order->getItems() as $item) { + $product = $this->productRepository->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; + } + + /** + * Filter search criteria by customer ID. + * + * @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 subscription + * + * @param string $subscriptionId + * + * @return bool + * @throws LocalizedException + * @throws NoSuchEntityException + */ + public function changeSubscription(string $subscriptionId): bool + { + $subscription = $this->subscriptionRepository->get((int)$subscriptionId); + + $customerId = (int)$this->userContext->getUserId(); + + $this->customerData->validateSubscriptionsCustomer($subscription, $customerId); + + return $this->save($subscription); + } + + /** + * Save the subscription entity. + * + * @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..f9e1a9a9 --- /dev/null +++ b/Model/SubscriptionRepository.php @@ -0,0 +1,87 @@ +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/Transaction/Builder.php b/Model/Transaction/Builder.php index 0803f0c8..7ad490b6 100644 --- a/Model/Transaction/Builder.php +++ b/Model/Transaction/Builder.php @@ -6,6 +6,7 @@ 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; @@ -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()); diff --git a/Model/Ui/ConfigProvider.php b/Model/Ui/ConfigProvider.php index 5e25f360..7d0a1958 100644 --- a/Model/Ui/ConfigProvider.php +++ b/Model/Ui/ConfigProvider.php @@ -6,42 +6,138 @@ use Magento\Checkout\Model\ConfigProviderInterface; use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\Exception\NoSuchEntityException; +use Magento\Framework\View\Asset\Repository; use Nexi\Checkout\Gateway\Config\Config; use Magento\Payment\Helper\Data as PaymentHelper; +use Nexi\Checkout\Model\Config\Source\PaymentTypesEnum; +use Nexi\Checkout\Model\Subscription\TotalConfigProvider; class ConfigProvider implements ConfigProviderInterface { + /** + * Payment methods that could be used for subscription payment. + */ + public const SUBSCRIPTION_PAYMENT_TYPES = [ + PaymentTypesEnum::CARD + ]; + /** * @param Config $config * @param PaymentHelper $paymentHelper + * @param TotalConfigProvider $totalConfigProvider + * @param Repository $assetRepo */ public function __construct( private readonly Config $config, private readonly PaymentHelper $paymentHelper, + private readonly TotalConfigProvider $totalConfigProvider, + private readonly Repository $assetRepo ) { } /** * Returns Nexi configuration values. * - * @return array|\array[][] - * @throws LocalizedException + * @return array */ - public function getConfig() + public function getConfig(): array { if (!$this->config->isActive()) { 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(), + 'isActive' => $this->config->isActive(), + 'environment' => $this->config->getEnvironment(), + 'label' => $this->paymentHelper->getMethodInstance(Config::CODE)->getTitle(), + 'integrationType' => $this->config->getIntegrationType(), + 'payTypeSplitting' => $this->config->getPayTypeSplitting(), + 'subselections' => $this->getSubselections(), + 'methodIcons' => $this->getMethodIcons(), ] ] ]; + + if ($this->config->isEmbedded()) { + $config['payment'][Config::CODE]['checkoutKey'] = $this->config->getCheckoutKey(); + } + + return $config; + } + + /** + * Get subselections for payment types. + * + * @return array + * @throws LocalizedException + * @throws NoSuchEntityException + */ + private function getSubselections(): array + { + if (!$this->config->getPayTypeSplitting()) { + return []; + } + + $subselections = []; + $payTypeOptions = $this->config->getPayTypeOptions(); + + if ($this->totalConfigProvider->isSubscriptionScheduled()) { + $payTypeOptions = $this->filterSubselectionsForSubscription($payTypeOptions); + } + + /** @var PaymentTypesEnum $option */ + foreach ($payTypeOptions as $option) { + $subselections[] = [ + 'value' => $option->value, + 'label' => __($option->value), + ]; + } + + return $subselections; + } + + /** + * Filter payment types for subscription. + * + * @param array $payTypeOptions + * + * @return array + */ + private function filterSubselectionsForSubscription(array $payTypeOptions): array + { + return array_filter($payTypeOptions, function ($type) { + return in_array($type, self::SUBSCRIPTION_PAYMENT_TYPES, true); + }); + } + + /** + * Get icons for payment methods. + * + * @return string[] + */ + public function getMethodIcons(): array + { + $icons = [ + PaymentTypesEnum::CARD->value => 'nexi-cards.png', + PaymentTypesEnum::PAYPAL->value => 'paypal.png', + PaymentTypesEnum::VIPPS->value => 'vipps.png', + PaymentTypesEnum::MOBILE_PAY->value => 'mobilepay.png', + PaymentTypesEnum::SWISH->value => 'swish.png', + PaymentTypesEnum::RATE_PAY_INVOICE->value => 'ratepay.png', + PaymentTypesEnum::RATE_PAY_INSTALLMENT->value => 'ratepay.png', + PaymentTypesEnum::RATE_PAY_SEPA->value => 'ratepay.png', + PaymentTypesEnum::SOFORT->value => 'sofort.png', + PaymentTypesEnum::TRUSTLY->value => 'trustly.png', + PaymentTypesEnum::APPLE_PAY->value => 'applepay.png', + PaymentTypesEnum::KLARNA->value => 'klarna.png', + PaymentTypesEnum::GOOGLE_PAY->value => 'googlepay.png', + ]; + + return array_map(function ($image) { + return $this->assetRepo->getUrl('Nexi_Checkout::images/' . $image); + }, $icons); } } 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..91f41007 --- /dev/null +++ b/Model/Ui/DataProvider/Product/Form/Modifier/Attributes.php @@ -0,0 +1,70 @@ +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_subscription_schedule'])) { + $attribute = 'subscription_schedule'; + $path = $this->arrayManager->findPath($attribute, $meta, null, 'children'); + + if (!$this->totalConfigProvider->isSubscriptionsEnabled()) { + $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..12fdc00f --- /dev/null +++ b/Model/Validation/CustomerData.php @@ -0,0 +1,23 @@ +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..f7183698 --- /dev/null +++ b/Model/Validation/PreventAdminActions.php @@ -0,0 +1,37 @@ +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/Model/Webhook/Data/WebhookDataLoader.php b/Model/Webhook/Data/WebhookDataLoader.php index 9d9c9d9a..befe790d 100644 --- a/Model/Webhook/Data/WebhookDataLoader.php +++ b/Model/Webhook/Data/WebhookDataLoader.php @@ -41,7 +41,7 @@ public function getTransactionByPaymentId( $transactions = $this->transactionRepository->getList($searchCriteria)->getItems(); - if (count($transactions) !== 1) { + if ($transactions === null || count($transactions) !== 1) { return null; } @@ -68,8 +68,8 @@ public function getTransactionByOrderId( $transactions = $this->transactionRepository->getList($searchCriteria)->getItems(); - if (count($transactions) !== 1) { - throw new NotFoundException(__('Transaction not found or multiple transactions found for payment ID.')); + if ($transactions === null || count($transactions) === 0) { + throw new NotFoundException(__('Transaction not found for payment ID.')); } return reset($transactions); @@ -80,12 +80,16 @@ public function getTransactionByOrderId( * * @param string $paymentId * - * @return mixed + * @return Order + * @throws NotFoundException */ public function loadOrderByPaymentId(string $paymentId): Order { $transaction = $this->getTransactionByPaymentId($paymentId); - $order = $transaction->getOrder(); + if ($transaction === null) { + throw new NotFoundException(__('Transaction not found for payment ID: %1', $paymentId)); + } + $order = $transaction->getOrder(); return $order; } diff --git a/Model/Webhook/PaymentCancelCreated.php b/Model/Webhook/PaymentCancelCreated.php index 9586784c..1e251ee1 100644 --- a/Model/Webhook/PaymentCancelCreated.php +++ b/Model/Webhook/PaymentCancelCreated.php @@ -4,13 +4,34 @@ namespace Nexi\Checkout\Model\Webhook; +use Magento\Sales\Model\Order; +use Nexi\Checkout\Model\Order\Comment; +use Nexi\Checkout\Model\Webhook\Data\WebhookDataLoader; +use NexiCheckout\Model\Webhook\WebhookInterface; + class PaymentCancelCreated implements WebhookProcessorInterface { + /** + * @param WebhookDataLoader $webhookDataLoader + * @param Comment $comment + */ + public function __construct( + private readonly WebhookDataLoader $webhookDataLoader, + private readonly Comment $comment, + ) { + } + /** * @inheritdoc */ - public function processWebhook(array $webhookData): void + public function processWebhook(WebhookInterface $webhook): void { - // TODO: Implement webhook processor logic here + /* @var Order $order */ + $order = $this->webhookDataLoader->loadOrderByPaymentId($webhook->getData()->getPaymentId()); + + $this->comment->saveComment( + __('Webhook Received. Payment cancel created for payment ID: %1', $webhook->getData()->getPaymentId()), + $order + ); } } diff --git a/Model/Webhook/PaymentCancelFailed.php b/Model/Webhook/PaymentCancelFailed.php index eb2cb8d3..59a171bc 100644 --- a/Model/Webhook/PaymentCancelFailed.php +++ b/Model/Webhook/PaymentCancelFailed.php @@ -4,13 +4,34 @@ namespace Nexi\Checkout\Model\Webhook; +use Magento\Sales\Model\Order; +use Nexi\Checkout\Model\Order\Comment; +use Nexi\Checkout\Model\Webhook\Data\WebhookDataLoader; +use NexiCheckout\Model\Webhook\WebhookInterface; + class PaymentCancelFailed implements WebhookProcessorInterface { + /** + * @param WebhookDataLoader $webhookDataLoader + * @param Comment $comment + */ + public function __construct( + private readonly WebhookDataLoader $webhookDataLoader, + private readonly Comment $comment, + ) { + } + /** * @inheritdoc */ - public function processWebhook(array $webhookData): void + public function processWebhook(WebhookInterface $webhook): void { - // TODO: Implement webhook processor logic here + /* @var Order $order */ + $order = $this->webhookDataLoader->loadOrderByPaymentId($webhook->getData()->getPaymentId()); + + $this->comment->saveComment( + __('Webhook Received. Payment cancel failed for payment ID: %1', $webhook->getData()->getPaymentId()), + $order + ); } } diff --git a/Model/Webhook/PaymentChargeCreated.php b/Model/Webhook/PaymentChargeCreated.php index e608d9a2..fd508d57 100644 --- a/Model/Webhook/PaymentChargeCreated.php +++ b/Model/Webhook/PaymentChargeCreated.php @@ -5,15 +5,19 @@ namespace Nexi\Checkout\Model\Webhook; use Exception; -use Magento\Framework\Exception\AlreadyExistsException; -use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\Exception\CouldNotSaveException; use Magento\Framework\Exception\NotFoundException; use Magento\Sales\Api\Data\TransactionInterface; use Magento\Sales\Api\OrderRepositoryInterface; use Magento\Sales\Model\Order; -use Nexi\Checkout\Gateway\Request\NexiCheckout\SalesDocumentItemsBuilder; +use Nexi\Checkout\Gateway\AmountConverter; +use Nexi\Checkout\Model\Order\Comment; use Nexi\Checkout\Model\Transaction\Builder; use Nexi\Checkout\Model\Webhook\Data\WebhookDataLoader; +use Nexi\Checkout\Setup\Patch\Data\AddPaymentAuthorizedOrderStatus; +use NexiCheckout\Model\Webhook\ChargeCreated; +use NexiCheckout\Model\Webhook\Shared\Data; +use NexiCheckout\Model\Webhook\WebhookInterface; class PaymentChargeCreated implements WebhookProcessorInterface { @@ -21,27 +25,30 @@ class PaymentChargeCreated implements WebhookProcessorInterface * @param OrderRepositoryInterface $orderRepository * @param WebhookDataLoader $webhookDataLoader * @param Builder $transactionBuilder + * @param Comment $comment + * @param AmountConverter $amountConverter */ public function __construct( private readonly OrderRepositoryInterface $orderRepository, private readonly WebhookDataLoader $webhookDataLoader, - private readonly Builder $transactionBuilder + private readonly Builder $transactionBuilder, + private readonly Comment $comment, + private readonly AmountConverter $amountConverter, ) { } /** * ProcessWebhook function for 'payment.charge.created.v2' event. * - * @param array $webhookData + * @param WebhookInterface $webhook * * @return void - * @throws LocalizedException + * @throws NotFoundException */ - public function processWebhook(array $webhookData): void + public function processWebhook(WebhookInterface $webhook): void { - $order = $this->webhookDataLoader->loadOrderByPaymentId($webhookData['data']['paymentId']); - $this->processOrder($order, $webhookData); - + $order = $this->webhookDataLoader->loadOrderByPaymentId($webhook->getData()->getPaymentId()); + $this->processOrder($order, $webhook); $this->orderRepository->save($order); } @@ -49,70 +56,76 @@ public function processWebhook(array $webhookData): void * ProcessOrder function. * * @param Order $order - * @param array $webhookData + * @param ChargeCreated $webhook * * @return void - * @throws AlreadyExistsException - * @throws LocalizedException + * @throws CouldNotSaveException * @throws NotFoundException */ - private function processOrder(Order $order, array $webhookData): void + private function processOrder(Order $order, ChargeCreated $webhook): void { $reservationTxn = $this->webhookDataLoader->getTransactionByOrderId( - $order->getId(), + (int)$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']; + $chargeTxnId = $webhook->getData()->getChargeId(); if ($this->webhookDataLoader->getTransactionByPaymentId($chargeTxnId, TransactionInterface::TYPE_CAPTURE)) { - throw new AlreadyExistsException(__('Transaction already exists.')); + return; } - $chargeTransaction = $this->transactionBuilder + $this->transactionBuilder ->build( $chargeTxnId, $order, [ - 'payment_id' => $webhookData['data']['paymentId'], - 'webhook' => json_encode($webhookData, JSON_PRETTY_PRINT), + 'payment_id' => $webhook->getData()->getPaymentId(), + 'webhook' => json_encode( + [ + 'webhook_id' => $webhook->getId(), + 'charge_id' => $webhook->getData()->getChargeId(), + 'amount' => $webhook->getData()->getAmount()->getAmount(), + 'currency' => $webhook->getData()->getAmount()->getCurrency(), + ], + 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'] - ) - ); + $this->saveOrderHistoryComment($webhook->getData(), $order); - if ($this->isFullCharge($webhookData, $order)) { + if ($this->isFullCharge($webhook, $order)) { + if ($order->getStatus() !== AddPaymentAuthorizedOrderStatus::STATUS_NEXI_AUTHORIZED) { + throw new Exception('Order status is not authorized.'); + } $this->fullInvoice($order, $chargeTxnId); } else { - $this->partialInvoice($order, $chargeTxnId, $webhookData['data']['orderItems']); + $order->addCommentToStatusHistory( + 'Partial charge received from the Nexi | Nets Portal gateway. ' . + 'The order processing could not be completed automatically. ' + ); } + $order->setState(Order::STATE_PROCESSING)->setStatus(Order::STATE_PROCESSING); } /** * Validate charge transaction. * - * @param array $webhookData + * @param ChargeCreated $webhook * @param Order $order * * @return bool */ - private function isFullCharge(array $webhookData, Order $order): bool + private function isFullCharge(ChargeCreated $webhook, Order $order): bool { - return (int)($order->getBaseGrandTotal() * 100) === $webhookData['data']['amount']['amount']; + $grandTotalConverted = (int)$this->amountConverter->convertToNexiAmount($order->getGrandTotal()); + $webhookAmount = (int)$webhook->getData()->getAmount()->getAmount(); + + return $grandTotalConverted === $webhookAmount; } /** @@ -122,7 +135,6 @@ private function isFullCharge(array $webhookData, Order $order): bool * @param string $chargeTxnId * * @return void - * @throws LocalizedException */ public function fullInvoice(Order $order, string $chargeTxnId): void { @@ -139,48 +151,26 @@ 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) + * Save order history comment. * + * @param Data $data * @param Order $order - * @param string $chargeTxnId - * @param array $webhookItems * * @return void - * @throws LocalizedException */ - private function partialInvoice(Order $order, string $chargeTxnId, array $webhookItems): void + private function saveOrderHistoryComment(Data $data, Order $order): 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); - } + $this->comment->saveComment( + __( + 'Webhook Received. Payment charge created for payment ID: %1' + . '
Charge ID: %2' + . '
Amount: %3 %4.', + $data->getPaymentId(), + $data->getChargeId(), + number_format($data->getAmount()->getAmount() / 100, 2, '.', ''), + $data->getAmount()->getCurrency() + ), + $order + ); } } diff --git a/Model/Webhook/PaymentChargeFailed.php b/Model/Webhook/PaymentChargeFailed.php index 351faf01..bb3eb756 100644 --- a/Model/Webhook/PaymentChargeFailed.php +++ b/Model/Webhook/PaymentChargeFailed.php @@ -4,13 +4,35 @@ namespace Nexi\Checkout\Model\Webhook; +use Magento\Sales\Model\Order; +use Nexi\Checkout\Model\Order\Comment; +use Nexi\Checkout\Model\Webhook\Data\WebhookDataLoader; +use NexiCheckout\Model\Webhook\WebhookInterface; + class PaymentChargeFailed implements WebhookProcessorInterface { + /** + * @param WebhookDataLoader $webhookDataLoader + * @param Comment $comment + */ + public function __construct( + private readonly WebhookDataLoader $webhookDataLoader, + private readonly Comment $comment, + ) { + } + /** * @inheritdoc */ - public function processWebhook(array $webhookData): void + public function processWebhook(WebhookInterface $webhook): void { - // TODO: Implement webhook processor logic here + $paymentId = $webhook->getData()->getPaymentId(); + + $order = $this->webhookDataLoader->loadOrderByPaymentId($paymentId); + + $this->comment->saveComment( + __('Webhook Received. Payment charge failed for payment ID: %1', $paymentId), + $order + ); } } diff --git a/Model/Webhook/PaymentCreated.php b/Model/Webhook/PaymentCreated.php index 058c913b..3f69d561 100644 --- a/Model/Webhook/PaymentCreated.php +++ b/Model/Webhook/PaymentCreated.php @@ -4,14 +4,17 @@ namespace Nexi\Checkout\Model\Webhook; -use Magento\Checkout\Exception; -use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\Exception\NotFoundException; 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 Magento\Sales\Model\ResourceModel\Order\Payment\CollectionFactory as PaymentCollectionFactory; use Nexi\Checkout\Model\Transaction\Builder; use Nexi\Checkout\Model\Webhook\Data\WebhookDataLoader; +use NexiCheckout\Model\Webhook\Data\PaymentCreatedData; +use NexiCheckout\Model\Webhook\WebhookInterface; class PaymentCreated implements WebhookProcessorInterface { @@ -22,38 +25,79 @@ class PaymentCreated implements WebhookProcessorInterface * @param CollectionFactory $orderCollectionFactory * @param WebhookDataLoader $webhookDataLoader * @param OrderRepositoryInterface $orderRepository + * @param PaymentCollectionFactory $paymentCollectionFactory + * @param Comment $comment */ 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 PaymentCollectionFactory $paymentCollectionFactory, + private readonly Comment $comment, ) { } /** * PaymentCreated webhook service. * - * @param array $webhookData + * @param WebhookInterface $webhook * * @return void + * @throws NotFoundException */ - public function processWebhook(array $webhookData): void + public function processWebhook(WebhookInterface $webhook): void { - $transaction = $this->webhookDataLoader->getTransactionByPaymentId($webhookData['data']['paymentId']); + /** @var PaymentCreatedData $data */ + $data = $webhook->getData(); + $paymentId = $data->getPaymentId(); + $transaction = $this->webhookDataLoader->getTransactionByPaymentId($paymentId); - if ($transaction) { - return; + $orderReference = $data->getOrder()->getReference(); + + if ($orderReference === null) { + $order = $this->getOrderByPaymentId($paymentId); + if (!$order->getId()) { + throw new NotFoundException(__('Order not found for payment ID: %1', $paymentId)); + } + } else { + $order = $this->orderCollectionFactory->create()->addFieldToFilter( + 'increment_id', + $orderReference + )->getFirstItem(); } - $order = $this->orderCollectionFactory->create()->addFieldToFilter( - 'increment_id', - $webhookData['data']['order']['reference'] - )->getFirstItem(); + if (!$transaction) { + $this->createPaymentTransaction($order, $data->getPaymentId()); + $this->orderRepository->save($order); + $this->comment->saveComment( + __( + 'Webhook Received. Payment created for Payment ID: %1' + . '
Amount: %2 %3.', + $data->getPaymentId(), + number_format($data->getOrder()->getAmount()->getAmount() / 100, 2, '.', ''), + $data->getOrder()->getAmount()->getCurrency() + ), + $order + ); + } + } - $this->createPaymentTransaction($order, $webhookData['data']['paymentId']); + /** + * Get order by payment id. + * + * @param string $paymentId + * + * @return Order + */ + private function getOrderByPaymentId(string $paymentId) + { + $payment = $this->paymentCollectionFactory->create() + ->addFieldToFilter('last_trans_id', $paymentId) + ->getFirstItem(); + $orderId = $payment->getParentId(); - $this->orderRepository->save($order); + return $this->orderCollectionFactory->create()->addFieldToFilter('entity_id', $orderId)->getFirstItem(); } /** @@ -70,7 +114,7 @@ private function createPaymentTransaction(Order $order, string $paymentId): void return; } $order->setState(Order::STATE_PENDING_PAYMENT)->setStatus(Order::STATE_PENDING_PAYMENT); - $paymentTransaction = $this->transactionBuilder + $this->transactionBuilder ->build( $paymentId, $order, @@ -79,9 +123,5 @@ private function createPaymentTransaction(Order $order, string $paymentId): void ], TransactionInterface::TYPE_PAYMENT ); - $order->getPayment()->addTransactionCommentsToOrder( - $paymentTransaction, - __('Payment created in Nexi Gateway.') - ); } } diff --git a/Model/Webhook/PaymentRefundCompleted.php b/Model/Webhook/PaymentRefundCompleted.php index cc00e52f..0fa77b85 100644 --- a/Model/Webhook/PaymentRefundCompleted.php +++ b/Model/Webhook/PaymentRefundCompleted.php @@ -5,14 +5,19 @@ namespace Nexi\Checkout\Model\Webhook; use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\Exception\NotFoundException; +use Magento\Framework\Phrase; use Magento\Sales\Api\Data\TransactionInterface; use Magento\Sales\Api\OrderRepositoryInterface; use Magento\Sales\Api\CreditmemoManagementInterface; use Magento\Sales\Model\Order; use Magento\Sales\Model\Order\CreditmemoFactory; use Nexi\Checkout\Gateway\AmountConverter; +use Nexi\Checkout\Model\Order\Comment; use Nexi\Checkout\Model\Transaction\Builder; use Nexi\Checkout\Model\Webhook\Data\WebhookDataLoader; +use NexiCheckout\Model\Webhook\Data\RefundCompletedData; +use NexiCheckout\Model\Webhook\WebhookInterface; class PaymentRefundCompleted implements WebhookProcessorInterface { @@ -23,6 +28,7 @@ class PaymentRefundCompleted implements WebhookProcessorInterface * @param CreditmemoFactory $creditmemoFactory * @param CreditmemoManagementInterface $creditmemoManagement * @param AmountConverter $amountConverter + * @param Comment $comment */ public function __construct( private readonly WebhookDataLoader $webhookDataLoader, @@ -31,54 +37,122 @@ public function __construct( private readonly CreditmemoFactory $creditmemoFactory, private readonly CreditmemoManagementInterface $creditmemoManagement, private readonly AmountConverter $amountConverter, + private readonly Comment $comment, ) { } /** * ProcessWebhook function for 'payment.refund.completed' event. * - * @param array $webhookData + * @param WebhookInterface $webhook * * @return void - * @throws LocalizedException */ - public function processWebhook(array $webhookData): void + public function processWebhook(WebhookInterface $webhook): void { - $order = $this->webhookDataLoader->loadOrderByPaymentId($webhookData['data']['paymentId']); + /** @var RefundCompletedData $data */ + $data = $webhook->getData(); + $paymentId = $data->getPaymentId(); + $refundId = $data->getRefundId(); + $refundAmount = number_format($data->getAmount()->getAmount() / 100, 2, '.', ''); + $refundCurrency = $data->getAmount()->getCurrency(); - $refund = $this->transactionBuilder - ->build( - $webhookData['id'], - $order, - ['payment_id' => $webhookData['data']['paymentId']], - TransactionInterface::TYPE_REFUND - )->setParentTxnId($webhookData['data']['paymentId']) - ->setAdditionalInformation('details', json_encode($webhookData)); + $order = $this->webhookDataLoader->loadOrderByPaymentId($paymentId); - if ($this->isFullRefund($webhookData, $order)) { - $this->processFullRefund($webhookData, $order); + if ($this->findRefundTransaction($refundId)) { + return; } - $order->getPayment()->addTransactionCommentsToOrder( - $refund, - __('Payment refund created, amount: %1', $webhookData['data']['amount']['amount'] / 100) - ); + $this->validateChargeTransactionExists($order); + + $refundTransaction = $this->createRefundTransaction($refundId, $order, $paymentId, $data); + $order->getPayment()->addTransaction($refundTransaction); + + if ($this->canRefund($data, $order) && $order->canCreditmemo()) { + $this->processFullRefund($data, $order); + } else { + $order->addCommentToStatusHistory( + 'Partial refund created for payment. ' . + 'Automatic credit memo processing is not supported for this case. ' . + 'You can still create a credit memo manually with offline refund.' + ); + } $this->orderRepository->save($order); + + $this->comment->saveComment( + $this->createRefundComment($paymentId, $refundId, $refundAmount, $refundCurrency), + $order + ); + } + + /** + * Create a refund comment for the order. + * + * @param string $paymentId + * @param string $refundId + * @param string $refundAmount + * @param string $currency + * + * @return Phrase + */ + private function createRefundComment( + string $paymentId, + string $refundId, + string $refundAmount, + string $currency + ): Phrase { + return __( + 'Webhook Received. Refund created for payment ID: %1' + . '
Refund ID: %2' + . '
Amount: %3 %4', + $paymentId, + $refundId, + $refundAmount, + $currency + ); + } + + /** + * Build a refund transaction based on webhook data. + * + * @param string $refundId + * @param Order $order + * @param string $paymentId + * @param RefundCompletedData $webhookData + * + * @return TransactionInterface + * @throws LocalizedException + */ + private function createRefundTransaction( + string $refundId, + Order $order, + string $paymentId, + RefundCompletedData $webhookData + ): TransactionInterface { + return $this->transactionBuilder + ->build( + $refundId, + $order, + ['payment_id' => $paymentId], + TransactionInterface::TYPE_REFUND + ) + ->setParentTxnId($paymentId) + ->setAdditionalInformation('details', json_encode($webhookData)); } /** * Create creditmemo for whole order * - * @param array $webhookData + * @param RefundCompletedData $webhookData * @param Order $order * * @return void */ - public function processFullRefund(array $webhookData, Order $order): void + private function processFullRefund(RefundCompletedData $webhookData, Order $order): void { $creditmemo = $this->creditmemoFactory->createByOrder($order); - $creditmemo->setTransactionId($webhookData['id']); + $creditmemo->setTransactionId($webhookData->getRefundId()); $this->creditmemoManagement->refund($creditmemo); } @@ -86,15 +160,45 @@ public function processFullRefund(array $webhookData, Order $order): void /** * Amount check * - * @param array $webhookData + * @param RefundCompletedData $webhookData * @param Order $order * * @return bool */ - private function isFullRefund(array $webhookData, Order $order): bool + private function canRefund(RefundCompletedData $webhookData, Order $order): bool { - $GrandTotal = $this->amountConverter->convertToNexiAmount($order->getGrandTotal()); + $grandTotal = $this->amountConverter->convertToNexiAmount($order->getGrandTotal()); + $totalRefunded = $order->getTotalRefunded(); - return $GrandTotal == $webhookData['data']['amount']['amount']; + return $grandTotal === $webhookData->getAmount()->getAmount() + && 0 == $totalRefunded; + } + + /** + * Finds and retrieves a refund transaction based on the provided payment ID. + * + * @param string $id + * + * @return TransactionInterface|null + */ + private function findRefundTransaction(string $id): ?TransactionInterface + { + return $this->webhookDataLoader->getTransactionByPaymentId($id, TransactionInterface::TYPE_REFUND); + } + + /** + * Check if charge transaction exists for the given payment ID. + * + * @param Order $order + * + * @return void + * @throws NotFoundException + */ + private function validateChargeTransactionExists(Order $order): void + { + $this->webhookDataLoader->getTransactionByOrderId( + (int)$order->getId(), + TransactionInterface::TYPE_CAPTURE + ); } } diff --git a/Model/Webhook/PaymentRefundFailed.php b/Model/Webhook/PaymentRefundFailed.php index b8cb634e..d5d5c803 100644 --- a/Model/Webhook/PaymentRefundFailed.php +++ b/Model/Webhook/PaymentRefundFailed.php @@ -4,13 +4,33 @@ namespace Nexi\Checkout\Model\Webhook; +use Nexi\Checkout\Model\Order\Comment; +use Nexi\Checkout\Model\Webhook\Data\WebhookDataLoader; +use NexiCheckout\Model\Webhook\WebhookInterface; + class PaymentRefundFailed implements WebhookProcessorInterface { + /** + * @param WebhookDataLoader $webhookDataLoader + * @param Comment $comment + */ + public function __construct( + private readonly WebhookDataLoader $webhookDataLoader, + private readonly Comment $comment, + ) { + } + /** * @inheritdoc */ - public function processWebhook(array $webhookData): void + public function processWebhook(WebhookInterface $webhook): void { - // TODO: Implement webhook processor logic here + $paymentId = $webhook->getData()->getPaymentId(); + $order = $this->webhookDataLoader->loadOrderByPaymentId($paymentId); + + $this->comment->saveComment( + __('Webhook Received. Payment refund failed for payment ID: %1', $paymentId), + $order + ); } } diff --git a/Model/Webhook/PaymentReservationCreated.php b/Model/Webhook/PaymentReservationCreated.php index 56dbb5f5..b60daa86 100644 --- a/Model/Webhook/PaymentReservationCreated.php +++ b/Model/Webhook/PaymentReservationCreated.php @@ -4,12 +4,21 @@ namespace Nexi\Checkout\Model\Webhook; +use Magento\Framework\Exception\CouldNotSaveException; use Magento\Framework\Exception\NotFoundException; use Magento\Sales\Api\Data\TransactionInterface; use Magento\Sales\Api\OrderRepositoryInterface; use Magento\Sales\Model\Order; +use Nexi\Checkout\Block\Info\Nexi; +use Nexi\Checkout\Model\Order\Comment; +use Nexi\Checkout\Model\SubscriptionManagement; use Nexi\Checkout\Model\Transaction\Builder; use Nexi\Checkout\Model\Webhook\Data\WebhookDataLoader; +use Nexi\Checkout\Setup\Patch\Data\AddPaymentAuthorizedOrderStatus; +use NexiCheckout\Model\Webhook\Data\ReservationCreatedData; +use NexiCheckout\Model\Webhook\ReservationCreated; +use NexiCheckout\Model\Webhook\WebhookInterface; +use Psr\Log\LoggerInterface; class PaymentReservationCreated implements WebhookProcessorInterface { @@ -17,36 +26,52 @@ class PaymentReservationCreated implements WebhookProcessorInterface * @param OrderRepositoryInterface $orderRepository * @param WebhookDataLoader $webhookDataLoader * @param Builder $transactionBuilder + * @param Comment $comment + * @param SubscriptionManagement $subscriptionManagement + * @param LoggerInterface $logger */ public function __construct( private readonly OrderRepositoryInterface $orderRepository, private readonly WebhookDataLoader $webhookDataLoader, private readonly Builder $transactionBuilder, + private readonly Comment $comment, + private readonly SubscriptionManagement $subscriptionManagement, + private readonly LoggerInterface $logger ) { } /** * ProcessWebhook function for 'payment.reservation.created.v2' event. * - * @param array $webhookData + * @param WebhookInterface $webhook * * @return void + * @throws CouldNotSaveException * @throws NotFoundException */ - public function processWebhook(array $webhookData): void + public function processWebhook(WebhookInterface $webhook): void { - $paymentId = $webhookData['data']['paymentId']; + /* @var ReservationCreatedData $data */ + $data = $webhook->getData(); + $paymentId = $data->getPaymentId(); $paymentTransaction = $this->webhookDataLoader->getTransactionByPaymentId($paymentId); if (!$paymentTransaction) { throw new NotFoundException(__('Payment transaction not found for %1.', $paymentId)); } - /** @var \Magento\Sales\Model\Order $order */ + /** @var Order $order */ $order = $paymentTransaction->getOrder(); - $order->setState(Order::STATE_PENDING_PAYMENT)->setStatus(Order::STATE_PENDING_PAYMENT); + if ($this->authorizationAlreadyExists($webhook->getId())) { + return; + } + + $order->setState(Order::STATE_PENDING_PAYMENT) + ->setStatus(AddPaymentAuthorizedOrderStatus::STATUS_NEXI_AUTHORIZED); + $this->setSelectedPaymentMethodData($order, $data); + $reservationTransaction = $this->transactionBuilder->build( - $webhookData['id'], + $webhook->getId(), $order, ['payment_id' => $paymentId], TransactionInterface::TYPE_AUTH @@ -55,11 +80,82 @@ public function processWebhook(array $webhookData): void $reservationTransaction->setParentTxnId($paymentId); $reservationTransaction->setParentId($paymentTransaction->getTransactionId()); - $order->getPayment()->addTransactionCommentsToOrder( - $reservationTransaction, - __('Payment reservation created.') - ); + if ($data->getSubscriptionId() !== null) { + $order->getPayment()->setAdditionalInformation('subscription_id', $data->getSubscriptionId()); + $this->subscriptionManagement->processSubscription($order, $data->getSubscriptionId()); + } + + $payment = $order->getPayment(); + $amount = $data->getAmount()->getAmount() / 100; + $amount = $payment->formatAmount($amount, true); + $payment->setBaseAmountAuthorized($amount); $this->orderRepository->save($order); + + $this->saveComment($paymentId, $data, $order); + } + + /** + * Checks if an authorization already exists for the given ID. + * + * @param string $id + * + * @return bool + */ + private function authorizationAlreadyExists(string $id): bool + { + return $this->webhookDataLoader->getTransactionByPaymentId($id, TransactionInterface::TYPE_AUTH) !== null; + } + + /** + * Saves a comment in the order with details from the webhook data. + * + * @param mixed $paymentId + * @param ReservationCreatedData $webhookData + * @param Order $order + * + * @return void + */ + private function saveComment(mixed $paymentId, ReservationCreatedData $webhookData, Order $order): void + { + $this->comment->saveComment( + __( + 'Webhook Received. Payment reservation created for payment ID: %1' + . '
Reservation Id: %2' + . '
Amount: %3 %4.', + $paymentId, + $webhookData->getPaymentId(), + number_format($webhookData->getAmount()->getAmount() / 100, 2, '.', ''), + $webhookData->getAmount()->getCurrency() + ), + $order + ); + } + + /** + * Sets the selected payment method data in the order's payment information. + * + * @param Order $order + * @param ReservationCreatedData $webhookData + * + * @return void + */ + private function setSelectedPaymentMethodData(Order $order, ReservationCreatedData $webhookData): void + { + try { + $payment = $order->getPayment(); + if ($payment) { + $payment->setAdditionalInformation( + Nexi::SELECTED_PATMENT_METHOD, + $webhookData->getPaymentMethod() + ); + $payment->setAdditionalInformation( + Nexi::SELECTED_PATMENT_TYPE, + $webhookData->getPaymentType() + ); + } + } catch (\Exception $e) { + $this->logger->critical($e); + } } } diff --git a/Model/Webhook/WebhookProcessorInterface.php b/Model/Webhook/WebhookProcessorInterface.php index b751ccc6..6e8e138f 100644 --- a/Model/Webhook/WebhookProcessorInterface.php +++ b/Model/Webhook/WebhookProcessorInterface.php @@ -1,15 +1,19 @@ getEvent()->value; if (array_key_exists($event, $this->webhookProcessors)) { - $this->webhookProcessors[$event]->processWebhook($webhookData); + $this->webhookProcessors[$event]->processWebhook($webhook); } } diff --git a/Observer/CartRevokeObserver.php b/Observer/CartRevokeObserver.php new file mode 100644 index 00000000..e69de29b diff --git a/Observer/ReactivateQuoteObserver.php b/Observer/ReactivateQuoteObserver.php new file mode 100644 index 00000000..a6e9e87d --- /dev/null +++ b/Observer/ReactivateQuoteObserver.php @@ -0,0 +1,56 @@ +getEvent()->getOrder(); + $quote = $observer->getEvent()->getQuote(); + + if (!$order || !$quote) { + return; + } + + $payment = $order->getPayment(); + + if (!$payment instanceof OrderPaymentInterface + || $payment->getMethod() !== Config::CODE + || !$this->config->isEmbedded() + ) { + return; + } + + // Restore the quote in the session + $this->session->restoreQuote(); + + // Set the last real order ID to the session for the success page + $this->session->setData(self::NEXI_LAST_ORDER_ID, $order->getIncrementId()); + } +} diff --git a/Observer/ScheduledCartValidation.php b/Observer/ScheduledCartValidation.php new file mode 100644 index 00000000..4f83e106 --- /dev/null +++ b/Observer/ScheduledCartValidation.php @@ -0,0 +1,59 @@ +getEvent()->getOrder()->getQuoteId(); + $cart = $this->cartRepository->get($cartId); + + if ($cart->getItems() && $this->totalConfigProvider->isSubscriptionsEnabled()) { + 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/Observer/UpdateOrderReference.php b/Observer/UpdateOrderReference.php new file mode 100644 index 00000000..8598ba7b --- /dev/null +++ b/Observer/UpdateOrderReference.php @@ -0,0 +1,50 @@ +getEvent()->getOrder(); + $payment = $order->getPayment(); + if ($payment->getMethod() !== Config::CODE || !$this->config->isEmbedded()) { + return; + } + try { + $commandPool = $this->commandManagerPool->get(Config::CODE); + $commandPool->executeByCode( + commandCode: 'update_reference', + arguments: ['order' => $order] + ); + } catch (\Exception $e) { + $this->logger->error($e->getMessage(), ['stacktrace' => $e->getTrace()]); + } + } +} diff --git a/Plugin/CheckoutSession.php b/Plugin/CheckoutSession.php new file mode 100644 index 00000000..5cab8eb3 --- /dev/null +++ b/Plugin/CheckoutSession.php @@ -0,0 +1,46 @@ +getId()) { + return $result; + } + + $orderId = $subject->getData(ReactivateQuoteObserver::NEXI_LAST_ORDER_ID); + $order = $this->orderFactory->create(); + if ($orderId) { + $order->loadByIncrementId($orderId); + } + + return $order; + } +} diff --git a/Plugin/DisableVoidAfterCapturePlugin.php b/Plugin/DisableVoidAfterCapturePlugin.php new file mode 100644 index 00000000..8a05924f --- /dev/null +++ b/Plugin/DisableVoidAfterCapturePlugin.php @@ -0,0 +1,32 @@ +getOrder(); + $payment = $order->getPayment(); + + if ($payment->getMethod() === Config::CODE && + $order->getBaseGrandTotal() === $payment->getBaseAmountPaid() + ) { + return false; + } + + return $result; + } +} diff --git a/Plugin/Order/Data/PaymentMethodCustomerOrderInfo.php b/Plugin/Order/Data/PaymentMethodCustomerOrderInfo.php new file mode 100644 index 00000000..3c85d4ec --- /dev/null +++ b/Plugin/Order/Data/PaymentMethodCustomerOrderInfo.php @@ -0,0 +1,31 @@ +getOrder()->getPayment(); + if ($payment->getMethod() === Config::CODE) { + return "

" . $payment->getAdditionalInformation()['method_title'] . "

" + . "

" . $payment->getAdditionalInformation(Nexi::SELECTED_PATMENT_TYPE) . " - " + . $payment->getAdditionalInformation(Nexi::SELECTED_PATMENT_METHOD) . "

"; + } else { + return $subject->getChildHtml('payment_info'); + } + } +} diff --git a/Plugin/PaymentData.php b/Plugin/PaymentData.php new file mode 100644 index 00000000..fb1246b8 --- /dev/null +++ b/Plugin/PaymentData.php @@ -0,0 +1,34 @@ +getSubselection()) { + $result->setAdditionalInformation( + 'subselection', + $data['extension_attributes']->getSubselection() + ); + } + + return $result; + } +} diff --git a/Plugin/PaymentInformationManagementPlugin.php b/Plugin/PaymentInformationManagementPlugin.php index 20ff006e..415757b6 100644 --- a/Plugin/PaymentInformationManagementPlugin.php +++ b/Plugin/PaymentInformationManagementPlugin.php @@ -7,17 +7,17 @@ use Magento\Checkout\Model\GuestPaymentInformationManagement; use Magento\Checkout\Model\PaymentInformationManagement; use Magento\Checkout\Model\Session; -use Psr\Log\LoggerInterface; +use Nexi\Checkout\Model\Language; class PaymentInformationManagementPlugin { /** * @param Session $checkoutSession - * @param LoggerInterface $logger + * @param Language $language */ public function __construct( private readonly Session $checkoutSession, - private readonly LoggerInterface $logger + private readonly Language $language ) { } @@ -45,13 +45,20 @@ public function afterSavePaymentInformationAndPlaceOrder( /** * Get the redirect URL from the order payment information. * - * @return string[] + * @return string */ private function getRedirectUrl() { $order = $this->checkoutSession->getLastRealOrder(); $payment = $order->getPayment(); - return $payment->getAdditionalInformation('redirect_url'); + $redirectUrl = $payment?->getAdditionalInformation('redirect_url'); + + if ($redirectUrl && $this->language->getCode() !== Language::DEFAULT_LOCALE) { + $separator = strpos((string)$redirectUrl, '?') !== false ? '&' : '?'; + $redirectUrl .= $separator . 'language=' . $this->language->getCode(); + } + + return $redirectUrl; } } diff --git a/Plugin/PreventDifferentScheduledCart.php b/Plugin/PreventDifferentScheduledCart.php new file mode 100644 index 00000000..f53a46d7 --- /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/QouteToOrder/ProcessTransactionId.php b/Plugin/QouteToOrder/ProcessTransactionId.php new file mode 100644 index 00000000..53987a7f --- /dev/null +++ b/Plugin/QouteToOrder/ProcessTransactionId.php @@ -0,0 +1,31 @@ +getAdditionalInformation('payment_id')) { + $result->setLastTransId($result->getAdditionalInformation('payment_id')); + } + + return $result; + } +} diff --git a/Plugin/RecurringToOrderGrid.php b/Plugin/RecurringToOrderGrid.php new file mode 100644 index 00000000..4d19f655 --- /dev/null +++ b/Plugin/RecurringToOrderGrid.php @@ -0,0 +1,47 @@ +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/Plugin/SendEmailPlugin.php b/Plugin/SendEmailPlugin.php new file mode 100644 index 00000000..aa51b527 --- /dev/null +++ b/Plugin/SendEmailPlugin.php @@ -0,0 +1,37 @@ +config->isEmbedded()) { + $observer->getEvent()->getOrder()->setCanSendNewEmailFlag(false); + } + + return [$observer]; + } +} 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/Setup/Patch/Data/AddPaymentAuthorizedOrderStatus.php b/Setup/Patch/Data/AddPaymentAuthorizedOrderStatus.php new file mode 100644 index 00000000..e6aaa08c --- /dev/null +++ b/Setup/Patch/Data/AddPaymentAuthorizedOrderStatus.php @@ -0,0 +1,79 @@ +moduleDataSetup->startSetup(); + + $data = [ + 'status' => self::STATUS_NEXI_AUTHORIZED, + 'label' => __('Payment Authorized') + ]; + + $this->moduleDataSetup->getConnection()->insertOnDuplicate( + $this->moduleDataSetup->getTable('sales_order_status'), + $data, + ['status', 'label'] + ); + + $data = [ + 'status' => self::STATUS_NEXI_AUTHORIZED, + 'state' => Order::STATE_PENDING_PAYMENT, + 'is_default' => 0, + 'visible_on_front' => 1 + ]; + + $this->moduleDataSetup->getConnection()->insertOnDuplicate( + $this->moduleDataSetup->getTable('sales_order_status_state'), + $data, + ['status', 'state', 'is_default', 'visible_on_front'] + ); + + $this->moduleDataSetup->endSetup(); + + return $this; + } + + /** + * @inheritdoc + */ + public static function getDependencies() + { + return []; + } + + /** + * @inheritdoc + */ + public function getAliases() + { + return []; + } +} diff --git a/Setup/Patch/Data/AddSubscriptionScheduleAttribute.php b/Setup/Patch/Data/AddSubscriptionScheduleAttribute.php new file mode 100644 index 00000000..3854ff02 --- /dev/null +++ b/Setup/Patch/Data/AddSubscriptionScheduleAttribute.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, 'subscription_schedule', [ + 'type' => 'int', + 'backend' => '', + 'frontend' => '', + 'label' => 'Subscription Schedule', + 'input' => 'select', + 'class' => '', + 'source' => 'Nexi\Checkout\Model\Attribute\SelectData', + 'global' => ScopedAttributeInterface::SCOPE_GLOBAL, + 'visible' => true, + 'required' => false, + 'user_defined' => false, + 'default' => SelectData::NO_SUBSCRIPTION_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/GenerateWebhookSecret.php b/Setup/Patch/Data/GenerateWebhookSecret.php index 13a9e3d9..9a492284 100644 --- a/Setup/Patch/Data/GenerateWebhookSecret.php +++ b/Setup/Patch/Data/GenerateWebhookSecret.php @@ -24,10 +24,9 @@ public function __construct( } /** - * Generate secret key for webhook verification + * Generate a secret key for webhook verification * * @return void - * @throws Exception */ public function apply(): void { diff --git a/Setup/Patch/Data/InstallProfilesPatch.php b/Setup/Patch/Data/InstallProfilesPatch.php new file mode 100644 index 00000000..c123726e --- /dev/null +++ b/Setup/Patch/Data/InstallProfilesPatch.php @@ -0,0 +1,97 @@ + '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/Test/Unit/Controller/Adminhtml/System/Config/TestConnectionTest.php b/Test/Unit/Controller/Adminhtml/System/Config/TestConnectionTest.php new file mode 100644 index 00000000..4f2f7228 --- /dev/null +++ b/Test/Unit/Controller/Adminhtml/System/Config/TestConnectionTest.php @@ -0,0 +1,142 @@ +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); + $this->urlMock = $this->createMock(Url::class); + $this->scopeConfigMock = $this->createMock(ScopeConfigInterface::class); + $stripTagsMock = $this->createMock(StripTags::class); + + $contextMock->method('getRequest')->willReturn($this->requestMock); + $this->jsonFactoryMock->method('create')->willReturn($this->jsonMock); + + $this->scopeConfigMock->method('getValue')->with( + 'currency/options/default', + ScopeInterface::SCOPE_STORE + )->willReturn('USD'); + + $this->urlMock->method('getUrl')->willReturn('https://example.com'); + + $this->controller = $objectManager->getObject( + TestConnection::class, + [ + 'context' => $contextMock, + 'resultJsonFactory' => $this->jsonFactoryMock, + 'tagFilter' => $stripTagsMock, + 'paymentApiFactory' => $this->paymentApiFactoryMock, + 'config' => $this->configMock, + 'url' => $this->urlMock, + 'scopeConfig' => $this->scopeConfigMock + ] + ); + } + + public function testExecuteSuccess() + { + $this->requestMock->method('getParams')->willReturn([ + 'secret_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([ + 'secret_key' => 'invalid_api_key', + 'environment' => Environment::LIVE + ]); + + $apiMock = $this->createMock(PaymentApi::class); + $this->paymentApiFactoryMock->method('create')->willReturn($apiMock); + $apiMock->method('createEmbeddedPayment') + ->willThrowException(new PaymentApiException('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..66c5e2f6 --- /dev/null +++ b/Test/Unit/Controller/Hpp/CancelActionTest.php @@ -0,0 +1,111 @@ +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(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->orderManagementInterfaceMock = $this->createMock(OrderManagementInterface::class); + $this->orderManagementInterfaceMock->method('cancel'); + + $this->controller = new CancelAction( + $this->redirectFactoryMock, + $this->urlMock, + $this->checkoutSessionMock, + $this->orderManagementInterfaceMock, + $this->messageManagerMock + ); + } + + public function testExecuteSuccess() + { + $this->checkoutSessionMock->expects($this->once()) + ->method('restoreQuote'); + $this->orderManagementInterfaceMock->expects($this->once()) + ->method('cancel'); + $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); + } +} diff --git a/Test/Unit/EnvironmentTest.php b/Test/Unit/EnvironmentTest.php index e2e0987c..ffa2f3d5 100644 --- a/Test/Unit/EnvironmentTest.php +++ b/Test/Unit/EnvironmentTest.php @@ -1,5 +1,7 @@ amountConverter = new AmountConverter(); + } + + /** + * Test successful conversion of amount to Nexi format (cents) + * + * @dataProvider amountDataProvider + */ + public function testConvertToNexiAmount($amount, $expected): void + { + $result = $this->amountConverter->convertToNexiAmount($amount); + $this->assertEquals($expected, $result); + } + + /** + * Test exception when non-numeric amount is provided + */ + public function testConvertToNexiAmountWithInvalidInput(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Amount must be a numeric value.'); + $this->amountConverter->convertToNexiAmount('not-a-number'); + } + + /** + * Data provider for testConvertToNexiAmount + * + * @return array + */ + public function amountDataProvider(): array + { + return [ + 'integer amount' => [100, 10000], + 'float amount' => [10.55, 1055], + 'zero amount' => [0, 0], + 'small decimal' => [0.01, 1], + 'rounding up' => [10.995, 1100], + 'rounding down' => [10.994, 1099], + 'string numeric' => ['10.55', 1055], + ]; + } +} diff --git a/Test/Unit/Gateway/Command/InitializeTest.php b/Test/Unit/Gateway/Command/InitializeTest.php new file mode 100644 index 00000000..019c484b --- /dev/null +++ b/Test/Unit/Gateway/Command/InitializeTest.php @@ -0,0 +1,188 @@ +commandManagerPoolMock = $this->createMock(CommandManagerPoolInterface::class); + $this->loggerMock = $this->createMock(LoggerInterface::class); + $this->transactionBuilderMock = $this->createMock(Builder::class); + $this->subjectReaderMock = $this->getMockBuilder(SubjectReader::class) + ->disableOriginalConstructor() + ->getMock(); + $this->configMock = $this->createMock(Config::class); + + $this->initialize = new Initialize( + $this->subjectReaderMock, + $this->commandManagerPoolMock, + $this->loggerMock, + $this->transactionBuilderMock, + $this->configMock, + ); + } + + /** + * Test createPayment method with successful execution + */ + public function testcreatePaymentSuccess(): void + { + // Mock payment data object + $paymentDataMock = $this->createMock(PaymentDataObjectInterface::class); + + // Setup expectations for commandManagerPool + $commandManagerMock = $this->createMock(CommandManagerInterface::class); + $this->commandManagerPoolMock->expects($this->once()) + ->method('get') + ->with(Config::CODE) + ->willReturn($commandManagerMock); + $commandManagerMock->expects($this->once()) + ->method('executeByCode') + ->with( + 'create_payment', + null, + ['payment' => $paymentDataMock] + ); + + // Execute the method + $this->initialize->createPayment($paymentDataMock); + } + + /** + * Test createPayment method with exception + */ + public function testcreatePaymentException(): void + { + // Mock payment data object + $paymentDataMock = $this->createMock(PaymentDataObjectInterface::class); + + // Setup expectations for commandManagerPool to throw exception + $commandManagerMock = $this->createMock(CommandManagerInterface::class); + $this->commandManagerPoolMock->expects($this->once()) + ->method('get') + ->with(Config::CODE) + ->willReturn($commandManagerMock); + $commandManagerMock->expects($this->once()) + ->method('executeByCode') + ->with( + 'create_payment', + null, + ['payment' => $paymentDataMock] + ) + ->willThrowException(new Exception('Test exception')); + + // Setup expectations for logger + $this->loggerMock->expects($this->once()) + ->method('error') + ->with('Test exception', $this->anything()); + + // Execute the method and expect exception + $this->expectException(LocalizedException::class); + $this->expectExceptionMessage('An error occurred during the payment process. Please try again later.'); + $this->initialize->createPayment($paymentDataMock); + } + + /** + * Test isPaymentAlreadyCreated method when payment ID exists + */ + public function testIsPaymentAlreadyCreatedTrue(): void + { + // Use reflection to test private method + $reflectionClass = new \ReflectionClass(Initialize::class); + $method = $reflectionClass->getMethod('isPaymentAlreadyCreated'); + $method->setAccessible(true); + + // Mock payment data object + $paymentDataMock = $this->createMock(PaymentDataObjectInterface::class); + $paymentMock = $this->createMock(Payment::class); + + // Setup expectations + $paymentDataMock->expects($this->once()) + ->method('getPayment') + ->willReturn($paymentMock); + $paymentMock->expects($this->once()) + ->method('getAdditionalInformation') + ->with('payment_id') + ->willReturn('existing-payment-id'); + + // Execute the method + $result = $method->invoke($this->initialize, $paymentDataMock); + $this->assertTrue($result); + } + + /** + * Test isPaymentAlreadyCreated method when payment ID does not exist + */ + public function testIsPaymentAlreadyCreatedFalse(): void + { + // Use reflection to test private method + $reflectionClass = new \ReflectionClass(Initialize::class); + $method = $reflectionClass->getMethod('isPaymentAlreadyCreated'); + $method->setAccessible(true); + + // Mock payment data object + $paymentDataMock = $this->createMock(PaymentDataObjectInterface::class); + $paymentMock = $this->createMock(Payment::class); + + // Setup expectations + $paymentDataMock->expects($this->once()) + ->method('getPayment') + ->willReturn($paymentMock); + $paymentMock->expects($this->once()) + ->method('getAdditionalInformation') + ->with('payment_id') + ->willReturn(null); + + // Execute the method + $result = $method->invoke($this->initialize, $paymentDataMock); + $this->assertFalse($result); + } +} diff --git a/Test/Unit/Gateway/Config/ConfigTest.php b/Test/Unit/Gateway/Config/ConfigTest.php new file mode 100644 index 00000000..dcebbd49 --- /dev/null +++ b/Test/Unit/Gateway/Config/ConfigTest.php @@ -0,0 +1,197 @@ +scopeConfigMock = $this->createMock(ScopeConfigInterface::class); + $this->config = new Config( + $this->scopeConfigMock, + Config::CODE + ); + } + + /** + * Test getEnvironment method + */ + public function testGetEnvironment(): void + { + $this->scopeConfigMock->expects($this->once()) + ->method('getValue') + ->with('payment/nexi/environment') + ->willReturn(Environment::LIVE); + + $this->assertEquals(Environment::LIVE, $this->config->getEnvironment()); + } + + /** + * Test isLiveMode method when environment is live + */ + public function testIsLiveModeTrueWhenEnvironmentIsLive(): void + { + $this->scopeConfigMock->expects($this->once()) + ->method('getValue') + ->with('payment/nexi/environment') + ->willReturn(Environment::LIVE); + + $this->assertTrue($this->config->isLiveMode()); + } + + /** + * Test isLiveMode method when environment is not live + */ + public function testIsLiveModeFalseWhenEnvironmentIsNotLive(): void + { + $this->scopeConfigMock->expects($this->once()) + ->method('getValue') + ->with('payment/nexi/environment') + ->willReturn(Environment::TEST); + + $this->assertFalse($this->config->isLiveMode()); + } + + /** + * Test isActive method + */ + public function testIsActive(): void + { + $this->scopeConfigMock->expects($this->once()) + ->method('getValue') + ->with('payment/nexi/active') + ->willReturn('1'); + + $this->assertTrue($this->config->isActive()); + } + + /** + * Test getApiKey method when in live mode + */ + public function testGetApiKeyInLiveMode(): void + { + $this->scopeConfigMock->expects($this->exactly(2)) + ->method('getValue') + ->willReturnMap([ + ['payment/nexi/environment', ScopeInterface::SCOPE_STORE, null, Environment::LIVE], + ['payment/nexi/secret_key', ScopeInterface::SCOPE_STORE, null, 'live-api-key'] + ]); + + $this->assertEquals('live-api-key', $this->config->getApiKey()); + } + + /** + * Test getApiKey method when in test mode + */ + public function testGetApiKeyInTestMode(): void + { + $this->scopeConfigMock->expects($this->exactly(2)) + ->method('getValue') + ->willReturnMap([ + ['payment/nexi/environment', ScopeInterface::SCOPE_STORE, null, Environment::TEST], + ['payment/nexi/test_secret_key', ScopeInterface::SCOPE_STORE, null, 'test-api-key'] + ]); + + $this->assertEquals('test-api-key', $this->config->getApiKey()); + } + + /** + * Test getCheckoutKey method when in live mode + */ + public function testGetCheckoutKeyInLiveMode(): void + { + $this->scopeConfigMock->expects($this->exactly(2)) + ->method('getValue') + ->willReturnMap([ + ['payment/nexi/environment', ScopeInterface::SCOPE_STORE, null, Environment::LIVE], + ['payment/nexi/checkout_key', ScopeInterface::SCOPE_STORE, null, 'live-checkout-key'] + ]); + + $this->assertEquals('live-checkout-key', $this->config->getCheckoutKey()); + } + + /** + * Test getCheckoutKey method when in test mode + */ + public function testGetCheckoutKeyInTestMode(): void + { + $this->scopeConfigMock->expects($this->exactly(2)) + ->method('getValue') + ->willReturnMap([ + ['payment/nexi/environment', ScopeInterface::SCOPE_STORE, null, Environment::TEST], + ['payment/nexi/test_checkout_key', ScopeInterface::SCOPE_STORE, null, 'test-checkout-key'] + ]); + + $this->assertEquals('test-checkout-key', $this->config->getCheckoutKey()); + } + + /** + * Test isEmbedded method when integration type is embedded + */ + public function testIsEmbeddedTrue(): void + { + $this->scopeConfigMock->expects($this->once()) + ->method('getValue') + ->with('payment/nexi/integration_type') + ->willReturn(IntegrationTypeEnum::EmbeddedCheckout->name); + + $this->assertTrue($this->config->isEmbedded()); + } + + /** + * Test isEmbedded method when integration type is not embedded + */ + public function testIsEmbeddedFalse(): void + { + $this->scopeConfigMock->expects($this->once()) + ->method('getValue') + ->with('payment/nexi/integration_type') + ->willReturn(IntegrationTypeEnum::HostedPaymentPage->name); + + $this->assertFalse($this->config->isEmbedded()); + } + + /** + * Test getPaymentAction method when auto capture is enabled + */ + public function testGetPaymentActionWithAutoCapture(): void + { + $this->scopeConfigMock->expects($this->once()) + ->method('getValue') + ->with('payment/nexi/is_auto_capture') + ->willReturn('1'); + + $this->assertEquals(MethodInterface::ACTION_AUTHORIZE_CAPTURE, $this->config->getPaymentAction()); + } + + /** + * Test getPaymentAction method when auto capture is disabled + */ + public function testGetPaymentActionWithoutAutoCapture(): void + { + $this->scopeConfigMock->expects($this->once()) + ->method('getValue') + ->with('payment/nexi/is_auto_capture') + ->willReturn('0'); + + $this->assertEquals(MethodInterface::ACTION_AUTHORIZE, $this->config->getPaymentAction()); + } +} diff --git a/Test/Unit/Gateway/Http/ClientTest.php b/Test/Unit/Gateway/Http/ClientTest.php new file mode 100644 index 00000000..70561c22 --- /dev/null +++ b/Test/Unit/Gateway/Http/ClientTest.php @@ -0,0 +1,136 @@ +paymentApiFactoryMock = $this->createMock(PaymentApiFactory::class); + $this->configMock = $this->createMock(Config::class); + $this->loggerMock = $this->createMock(LoggerInterface::class); + + // Create a mock for PaymentApi with the testMethod method + $this->paymentApiMock = $this->getMockBuilder(PaymentApi::class) + ->disableOriginalConstructor() + ->getMock(); + + // We'll set up the testMethod in each test + + $this->client = new Client( + $this->paymentApiFactoryMock, + $this->configMock, + $this->loggerMock + ); + } + + /** + * Test getPaymentApi method + */ + public function testGetPaymentApi(): void + { + // Setup expectations for config + $this->configMock->expects($this->once()) + ->method('getApiKey') + ->willReturn('test-api-key'); + $this->configMock->expects($this->once()) + ->method('isLiveMode') + ->willReturn(false); + + // Setup expectations for payment API factory + $this->paymentApiFactoryMock->expects($this->once()) + ->method('create') + ->with('test-api-key', false) + ->willReturn($this->paymentApiMock); + + // Execute the method + $result = $this->client->getPaymentApi(); + $this->assertSame($this->paymentApiMock, $result); + } + + /** + * Test placeRequest method with exception + */ + public function testPlaceRequestWithException(): void + { + // Create mock for transfer object + $transferMock = $this->createMock(TransferInterface::class); + $transferMock->expects($this->once()) + ->method('getUri') + ->willReturn('retrievePayment'); + $transferMock->expects($this->exactly(4)) + ->method('getBody') + ->willReturn('test-body'); + + // Set up the PaymentApi mock + $this->paymentApiMock = $this->getMockBuilder(PaymentApi::class) + ->disableOriginalConstructor() + ->getMock(); + + // Set up the retrievePayment method to throw an exception + $this->paymentApiMock->expects($this->once()) + ->method('retrievePayment') + ->with('test-body') + ->willThrowException(new PaymentApiException('Test exception')); + + // Setup expectations for payment API factory + $this->paymentApiFactoryMock->expects($this->once()) + ->method('create') + ->willReturn($this->paymentApiMock); + + // Setup expectations for config + $this->configMock->expects($this->once()) + ->method('getApiKey') + ->willReturn('test-api-key'); + $this->configMock->expects($this->once()) + ->method('isLiveMode') + ->willReturn(false); + + // Setup expectations for logger + $this->loggerMock->expects($this->once()) + ->method('debug'); + $this->loggerMock->expects($this->once()) + ->method('error'); + + // Execute the method and expect exception + $this->expectException(LocalizedException::class); + $this->expectExceptionMessage('An error occurred during the payment process. Please try again later.'); + $this->client->placeRequest($transferMock); + } +} diff --git a/Test/Unit/Gateway/Http/TransferFactoryTest.php b/Test/Unit/Gateway/Http/TransferFactoryTest.php new file mode 100644 index 00000000..6c7229dd --- /dev/null +++ b/Test/Unit/Gateway/Http/TransferFactoryTest.php @@ -0,0 +1,65 @@ +transferBuilderMock = $this->createMock(TransferBuilder::class); + $this->transferMock = $this->createMock(TransferInterface::class); + + $this->transferFactory = new TransferFactory($this->transferBuilderMock); + } + + /** + * Test create method + */ + public function testCreate(): void + { + // Prepare test data + $request = [ + 'nexi_method' => 'testMethod', + 'body' => ['param1' => 'value1', 'param2' => 'value2'] + ]; + + // Setup expectations for transfer builder + $this->transferBuilderMock->expects($this->once()) + ->method('setBody') + ->with($request['body']) + ->willReturnSelf(); + $this->transferBuilderMock->expects($this->once()) + ->method('setUri') + ->with('testMethod') + ->willReturnSelf(); + $this->transferBuilderMock->expects($this->once()) + ->method('build') + ->willReturn($this->transferMock); + + // Execute the method + $result = $this->transferFactory->create($request); + + // Verify the result + $this->assertSame($this->transferMock, $result); + } +} diff --git a/Test/Unit/Gateway/StringSanitizerTest.php b/Test/Unit/Gateway/StringSanitizerTest.php new file mode 100644 index 00000000..3cd468cc --- /dev/null +++ b/Test/Unit/Gateway/StringSanitizerTest.php @@ -0,0 +1,99 @@ +stringSanitizer = new StringSanitizer(); + } + + /** + * Test sanitizing strings with special characters + * + * @dataProvider specialCharactersDataProvider + */ + public function testSanitizeSpecialCharacters(string $input, string $expected): void + { + $result = $this->stringSanitizer->sanitize($input); + $this->assertEquals($expected, $result); + } + + /** + * Test truncating strings that exceed the maximum length + * + * @dataProvider lengthDataProvider + */ + public function testSanitizeTruncatesLongStrings(string $input, int $maxLength, string $expected): void + { + $result = $this->stringSanitizer->sanitize($input, $maxLength); + $this->assertEquals($expected, $result); + $this->assertLessThanOrEqual($maxLength, strlen($result)); + } + + /** + * Test that strings shorter than the maximum length are not modified + */ + public function testSanitizeDoesNotModifyShortStrings(): void + { + $input = 'This is a normal string with no special characters'; + $result = $this->stringSanitizer->sanitize($input); + $this->assertEquals($input, $result); + } + + /** + * Data provider for testSanitizeSpecialCharacters + * + * @return array + */ + public function specialCharactersDataProvider(): array + { + return [ + 'angle brackets' => ['Test ', 'Test -tag-'], + 'quotes' => ['Test "quoted" and \'single\'', 'Test -quoted- and -single-'], + 'ampersand' => ['Test & symbol', 'Test - symbol'], + 'backslash' => ['Test \\ backslash', 'Test - backslash'], + 'multiple special chars' => ['', '-Test - -string- with -all- special - chars-'], + ]; + } + + /** + * Data provider for testSanitizeTruncatesLongStrings + * + * @return array + */ + public function lengthDataProvider(): array + { + return [ + 'default max length' => [ + str_repeat('a', 150), + 128, + str_repeat('a', 128) + ], + 'custom max length' => [ + str_repeat('b', 50), + 30, + str_repeat('b', 30) + ], + 'exact max length' => [ + str_repeat('c', 20), + 20, + str_repeat('c', 20) + ], + 'shorter than max length' => [ + str_repeat('d', 10), + 20, + str_repeat('d', 10) + ], + ]; + } +} diff --git a/Test/Unit/Model/Webhook/Data/WebhookDataLoaderTest.php b/Test/Unit/Model/Webhook/Data/WebhookDataLoaderTest.php new file mode 100644 index 00000000..0a43850a --- /dev/null +++ b/Test/Unit/Model/Webhook/Data/WebhookDataLoaderTest.php @@ -0,0 +1,241 @@ +searchCriteriaBuilderMock = $this->createMock(SearchCriteriaBuilder::class); + $this->transactionRepositoryMock = $this->createMock(TransactionRepositoryInterface::class); + + $this->webhookDataLoader = new WebhookDataLoader( + $this->searchCriteriaBuilderMock, + $this->transactionRepositoryMock + ); + } + + public function testGetTransactionByPaymentIdReturnsTransactionWhenFound(): void + { + $txnId = 'payment-123'; + $txnType = TransactionInterface::TYPE_PAYMENT; + + // Mock search criteria + $searchCriteriaMock = $this->createMock(SearchCriteria::class); + $searchResultMock = $this->createMock(TransactionSearchResultInterface::class); + $transactionMock = $this->createMock(TransactionInterface::class); + + // Setup expectations for search criteria builder + $this->searchCriteriaBuilderMock->expects($this->exactly(2)) + ->method('addFilter') + ->withConsecutive( + ['txn_id', $txnId, 'eq'], + ['txn_type', $txnType, 'eq'] + ) + ->willReturnSelf(); + $this->searchCriteriaBuilderMock->expects($this->once()) + ->method('create') + ->willReturn($searchCriteriaMock); + + // Setup expectations for transaction repository + $this->transactionRepositoryMock->expects($this->once()) + ->method('getList') + ->with($searchCriteriaMock) + ->willReturn($searchResultMock); + $searchResultMock->expects($this->once()) + ->method('getItems') + ->willReturn([$transactionMock]); + + // Execute the method + $result = $this->webhookDataLoader->getTransactionByPaymentId($txnId, $txnType); + + // Assert result + $this->assertSame($transactionMock, $result); + } + + public function testGetTransactionByPaymentIdReturnsNullWhenNotFound(): void + { + $txnId = 'payment-123'; + $txnType = TransactionInterface::TYPE_PAYMENT; + + // Mock search criteria + $searchCriteriaMock = $this->createMock(SearchCriteria::class); + $searchResultMock = $this->createMock(TransactionSearchResultInterface::class); + + // Setup expectations for search criteria builder + $this->searchCriteriaBuilderMock->expects($this->exactly(2)) + ->method('addFilter') + ->withConsecutive( + ['txn_id', $txnId, 'eq'], + ['txn_type', $txnType, 'eq'] + ) + ->willReturnSelf(); + $this->searchCriteriaBuilderMock->expects($this->once()) + ->method('create') + ->willReturn($searchCriteriaMock); + + // Setup expectations for transaction repository + $this->transactionRepositoryMock->expects($this->once()) + ->method('getList') + ->with($searchCriteriaMock) + ->willReturn($searchResultMock); + $searchResultMock->expects($this->once()) + ->method('getItems') + ->willReturn([]); + + // Execute the method + $result = $this->webhookDataLoader->getTransactionByPaymentId($txnId, $txnType); + + // Assert result + $this->assertNull($result); + } + + public function testGetTransactionByOrderIdReturnsTransactionWhenFound(): void + { + $orderId = 123; + $txnType = TransactionInterface::TYPE_PAYMENT; + + // Mock search criteria + $searchCriteriaMock = $this->createMock(SearchCriteria::class); + $searchResultMock = $this->createMock(TransactionSearchResultInterface::class); + $transactionMock = $this->createMock(TransactionInterface::class); + + // Setup expectations for search criteria builder + $this->searchCriteriaBuilderMock->expects($this->exactly(2)) + ->method('addFilter') + ->withConsecutive( + ['order_id', $orderId, 'eq'], + ['txn_type', $txnType, 'eq'] + ) + ->willReturnSelf(); + $this->searchCriteriaBuilderMock->expects($this->once()) + ->method('create') + ->willReturn($searchCriteriaMock); + + // Setup expectations for transaction repository + $this->transactionRepositoryMock->expects($this->once()) + ->method('getList') + ->with($searchCriteriaMock) + ->willReturn($searchResultMock); + $searchResultMock->expects($this->once()) + ->method('getItems') + ->willReturn([$transactionMock]); + + // Execute the method + $result = $this->webhookDataLoader->getTransactionByOrderId($orderId, $txnType); + + // Assert result + $this->assertSame($transactionMock, $result); + } + + public function testGetTransactionByOrderIdThrowsExceptionWhenNotFound(): void + { + $orderId = 123; + $txnType = TransactionInterface::TYPE_PAYMENT; + + // Mock search criteria + $searchCriteriaMock = $this->createMock(SearchCriteria::class); + $searchResultMock = $this->createMock(TransactionSearchResultInterface::class); + + // Setup expectations for search criteria builder + $this->searchCriteriaBuilderMock->expects($this->exactly(2)) + ->method('addFilter') + ->withConsecutive( + ['order_id', $orderId, 'eq'], + ['txn_type', $txnType, 'eq'] + ) + ->willReturnSelf(); + $this->searchCriteriaBuilderMock->expects($this->once()) + ->method('create') + ->willReturn($searchCriteriaMock); + + // Setup expectations for transaction repository + $this->transactionRepositoryMock->expects($this->once()) + ->method('getList') + ->with($searchCriteriaMock) + ->willReturn($searchResultMock); + $searchResultMock->expects($this->once()) + ->method('getItems') + ->willReturn([]); + + // Expect exception + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Transaction not found for payment ID.'); + + // Execute the method + $this->webhookDataLoader->getTransactionByOrderId($orderId, $txnType); + } + + public function testLoadOrderByPaymentId(): void + { + $paymentId = 'payment-123'; + + // Mock transaction and order + $transactionMock = $this->getMockBuilder(TransactionInterface::class) + ->disableOriginalConstructor() + ->addMethods(['getOrder']) + ->getMockForAbstractClass(); + $orderMock = $this->createMock(Order::class); + + // Mock search criteria + $searchCriteriaMock = $this->createMock(SearchCriteria::class); + $searchResultMock = $this->createMock(TransactionSearchResultInterface::class); + + // Setup expectations for search criteria builder + $this->searchCriteriaBuilderMock->expects($this->exactly(2)) + ->method('addFilter') + ->withConsecutive( + ['txn_id', $paymentId, 'eq'], + ['txn_type', TransactionInterface::TYPE_PAYMENT, 'eq'] + ) + ->willReturnSelf(); + $this->searchCriteriaBuilderMock->expects($this->once()) + ->method('create') + ->willReturn($searchCriteriaMock); + + // Setup expectations for transaction repository + $this->transactionRepositoryMock->expects($this->once()) + ->method('getList') + ->with($searchCriteriaMock) + ->willReturn($searchResultMock); + $searchResultMock->expects($this->once()) + ->method('getItems') + ->willReturn([$transactionMock]); + + // Setup expectations for transaction + $transactionMock->expects($this->once()) + ->method('getOrder') + ->willReturn($orderMock); + + // Execute the method + $result = $this->webhookDataLoader->loadOrderByPaymentId($paymentId); + + // Assert result + $this->assertSame($orderMock, $result); + } +} diff --git a/Test/Unit/Model/Webhook/PaymentCancelCreatedTest.php b/Test/Unit/Model/Webhook/PaymentCancelCreatedTest.php new file mode 100644 index 00000000..8df36c26 --- /dev/null +++ b/Test/Unit/Model/Webhook/PaymentCancelCreatedTest.php @@ -0,0 +1,86 @@ +webhookDataLoaderMock = $this->createMock(WebhookDataLoader::class); + $this->commentMock = $this->createMock(Comment::class); + $this->webhookMock = $this->createMock(WebhookInterface::class); + $this->cancelCreatedDataMock = $this->createMock(CancelCreatedData::class); + + $this->paymentCancelCreated = new PaymentCancelCreated( + $this->webhookDataLoaderMock, + $this->commentMock + ); + } + + public function testProcessWebhookSuccessfully(): void + { + $paymentId = 'payment-123'; + + // Mock webhook data + $this->cancelCreatedDataMock->expects($this->exactly(2)) + ->method('getPaymentId') + ->willReturn($paymentId); + + // Mock webhook + $this->webhookMock->expects($this->exactly(2)) + ->method('getData') + ->willReturn($this->cancelCreatedDataMock); + + // Mock order + $orderMock = $this->createMock(Order::class); + + // Setup expectations + $this->webhookDataLoaderMock->expects($this->once()) + ->method('loadOrderByPaymentId') + ->with($paymentId) + ->willReturn($orderMock); + + $this->commentMock->expects($this->once()) + ->method('saveComment') + ->with( + __('Webhook Received. Payment cancel created for payment ID: %1', $paymentId), + $orderMock + ); + + // Execute the method + $this->paymentCancelCreated->processWebhook($this->webhookMock); + } +} diff --git a/Test/Unit/Model/Webhook/PaymentCancelFailedTest.php b/Test/Unit/Model/Webhook/PaymentCancelFailedTest.php new file mode 100644 index 00000000..7de0ebec --- /dev/null +++ b/Test/Unit/Model/Webhook/PaymentCancelFailedTest.php @@ -0,0 +1,86 @@ +webhookDataLoaderMock = $this->createMock(WebhookDataLoader::class); + $this->commentMock = $this->createMock(Comment::class); + $this->webhookMock = $this->createMock(WebhookInterface::class); + $this->cancelFailedDataMock = $this->createMock(CancelFailedData::class); + + $this->paymentCancelFailed = new PaymentCancelFailed( + $this->webhookDataLoaderMock, + $this->commentMock + ); + } + + public function testProcessWebhookSuccessfully(): void + { + $paymentId = 'payment-123'; + + // Mock webhook data + $this->cancelFailedDataMock->expects($this->exactly(2)) + ->method('getPaymentId') + ->willReturn($paymentId); + + // Mock webhook + $this->webhookMock->expects($this->exactly(2)) + ->method('getData') + ->willReturn($this->cancelFailedDataMock); + + // Mock order + $orderMock = $this->createMock(Order::class); + + // Setup expectations + $this->webhookDataLoaderMock->expects($this->once()) + ->method('loadOrderByPaymentId') + ->with($paymentId) + ->willReturn($orderMock); + + $this->commentMock->expects($this->once()) + ->method('saveComment') + ->with( + __('Webhook Received. Payment cancel failed for payment ID: %1', $paymentId), + $orderMock + ); + + // Execute the method + $this->paymentCancelFailed->processWebhook($this->webhookMock); + } +} diff --git a/Test/Unit/Model/Webhook/PaymentChargeCreatedTest.php b/Test/Unit/Model/Webhook/PaymentChargeCreatedTest.php new file mode 100644 index 00000000..356ce683 --- /dev/null +++ b/Test/Unit/Model/Webhook/PaymentChargeCreatedTest.php @@ -0,0 +1,214 @@ +orderRepositoryMock = $this->createMock(OrderRepositoryInterface::class); + $this->webhookDataLoaderMock = $this->createMock(WebhookDataLoader::class); + $this->transactionBuilderMock = $this->createMock(Builder::class); + $this->commentMock = $this->createMock(Comment::class); + $this->amountConverterMock = $this->createMock(AmountConverter::class); + $this->webhookMock = $this->createMock(ChargeCreated::class); + $this->chargeCreatedDataMock = $this->createMock(ChargeCreatedData::class); + $this->amountMock = $this->createMock(Amount::class); + + $this->paymentChargeCreated = new PaymentChargeCreated( + $this->orderRepositoryMock, + $this->webhookDataLoaderMock, + $this->transactionBuilderMock, + $this->commentMock, + $this->amountConverterMock + ); + } + + public function testProcessWebhookWithFullCharge(): void + { + $paymentId = 'payment-123'; + $chargeId = 'charge-123'; + $amountValue = 10000; // 100.00 in cents + $currency = 'USD'; + + // Mock webhook data + $this->amountMock->expects($this->atLeastOnce()) + ->method('getAmount') + ->willReturn($amountValue); + $this->amountMock->method('getCurrency') + ->willReturn($currency); + + $this->chargeCreatedDataMock->expects($this->atLeastOnce()) + ->method('getPaymentId') + ->willReturn($paymentId); + $this->chargeCreatedDataMock->expects($this->atLeastOnce()) + ->method('getChargeId') + ->willReturn($chargeId); + $this->chargeCreatedDataMock->expects($this->atLeastOnce()) + ->method('getAmount') + ->willReturn($this->amountMock); + + // Mock webhook + $this->webhookMock->expects($this->atLeastOnce()) + ->method('getData') + ->willReturn($this->chargeCreatedDataMock); + + // Mock order and payment + $orderMock = $this->createMock(Order::class); + $paymentMock = $this->getMockBuilder(OrderPaymentInterface::class) + ->disableOriginalConstructor() + ->addMethods(['addTransactionCommentsToOrder']) + ->getMockForAbstractClass(); + $invoiceMock = $this->createMock(Invoice::class); + + // Mock transactions + $reservationTransactionMock = $this->createMock(TransactionInterface::class); + $chargeTransactionMock = $this->createMock(TransactionInterface::class); + + // Setup expectations for loadOrderByPaymentId + $this->webhookDataLoaderMock->expects($this->once()) + ->method('loadOrderByPaymentId') + ->with($paymentId) + ->willReturn($orderMock); + + // Setup expectations for saveComment + $this->commentMock->expects($this->once()) + ->method('saveComment') + ->with( + __( + 'Webhook Received. Payment charge created for payment ID: %1
Charge ID: %2
Amount: %3 %4.', + $paymentId, + $chargeId, + number_format($amountValue / 100, 2, '.', ''), + $currency + ), + $orderMock + ); + + // Setup expectations for getTransactionByOrderId + $this->webhookDataLoaderMock->expects($this->once()) + ->method('getTransactionByOrderId') + ->with($this->anything(), TransactionInterface::TYPE_AUTH) + ->willReturn($reservationTransactionMock); + + // Setup expectations for getTransactionByPaymentId + $this->webhookDataLoaderMock->expects($this->once()) + ->method('getTransactionByPaymentId') + ->with($chargeId, TransactionInterface::TYPE_CAPTURE) + ->willReturn(null); + + // Setup expectations for transaction builder + $this->transactionBuilderMock->expects($this->once()) + ->method('build') + ->with( + $chargeId, + $orderMock, + $this->anything(), + TransactionInterface::TYPE_CAPTURE + ) + ->willReturn($chargeTransactionMock); + + // Setup expectations for transaction + $reservationTransactionMock->expects($this->once()) + ->method('getTransactionId') + ->willReturn('txn-123'); + $reservationTransactionMock->expects($this->once()) + ->method('getTxnId') + ->willReturn('txn-123'); + $chargeTransactionMock->expects($this->once()) + ->method('setParentId') + ->with('txn-123') + ->willReturnSelf(); + $chargeTransactionMock->expects($this->once()) + ->method('setParentTxnId') + ->with('txn-123') + ->willReturnSelf(); + + // Setup expectations for isFullCharge + $orderMock->expects($this->once()) + ->method('getGrandTotal') + ->willReturn(100.00); + + // Setup expectations for order state update + $orderMock->expects($this->once()) + ->method('setState') + ->with(Order::STATE_PROCESSING) + ->willReturnSelf(); + $orderMock->expects($this->once()) + ->method('setStatus') + ->with(Order::STATE_PROCESSING) + ->willReturnSelf(); + + // Setup expectations for order save + $this->orderRepositoryMock->expects($this->once()) + ->method('save') + ->with($orderMock); + + // Execute the method + $this->paymentChargeCreated->processWebhook($this->webhookMock); + } +} diff --git a/Test/Unit/Model/Webhook/PaymentChargeFailedTest.php b/Test/Unit/Model/Webhook/PaymentChargeFailedTest.php new file mode 100644 index 00000000..596c4f81 --- /dev/null +++ b/Test/Unit/Model/Webhook/PaymentChargeFailedTest.php @@ -0,0 +1,86 @@ +webhookDataLoaderMock = $this->createMock(WebhookDataLoader::class); + $this->commentMock = $this->createMock(Comment::class); + $this->webhookMock = $this->createMock(WebhookInterface::class); + $this->chargeFailedDataMock = $this->createMock(ChargeFailedData::class); + + $this->paymentChargeFailed = new PaymentChargeFailed( + $this->webhookDataLoaderMock, + $this->commentMock + ); + } + + public function testProcessWebhookSuccessfully(): void + { + $paymentId = 'payment-123'; + + // Mock webhook data + $this->chargeFailedDataMock->expects($this->once()) + ->method('getPaymentId') + ->willReturn($paymentId); + + // Mock webhook + $this->webhookMock->expects($this->once()) + ->method('getData') + ->willReturn($this->chargeFailedDataMock); + + // Mock order + $orderMock = $this->createMock(Order::class); + + // Setup expectations + $this->webhookDataLoaderMock->expects($this->once()) + ->method('loadOrderByPaymentId') + ->with($paymentId) + ->willReturn($orderMock); + + $this->commentMock->expects($this->once()) + ->method('saveComment') + ->with( + __('Webhook Received. Payment charge failed for payment ID: %1', $paymentId), + $orderMock + ); + + // Execute the method + $this->paymentChargeFailed->processWebhook($this->webhookMock); + } +} diff --git a/Test/Unit/Model/Webhook/PaymentCreatedTest.php b/Test/Unit/Model/Webhook/PaymentCreatedTest.php new file mode 100644 index 00000000..232e2aaf --- /dev/null +++ b/Test/Unit/Model/Webhook/PaymentCreatedTest.php @@ -0,0 +1,489 @@ +transactionBuilderMock = $this->createMock(Builder::class); + $this->orderCollectionFactoryMock = $this->getMockForNonExistingClass( + 'Magento\Reports\Model\ResourceModel\Order\CollectionFactory', + ['create'] + ); + $this->webhookDataLoaderMock = $this->createMock(WebhookDataLoader::class); + $this->orderRepositoryMock = $this->createMock(OrderRepositoryInterface::class); + $this->paymentCollectionFactoryMock = $this->getMockForNonExistingClass( + 'Magento\Sales\Model\ResourceModel\Order\Payment\CollectionFactory', + ['create'] + ); + $this->commentMock = $this->createMock(Comment::class); + $this->webhookMock = $this->createMock(WebhookInterface::class); + $this->paymentCreatedDataMock = $this->createMock(PaymentCreatedData::class); + $this->nexiOrderMock = $this->createMock(NexiOrder::class); + $this->amountMock = $this->createMock(Amount::class); + + $this->paymentCreated = new PaymentCreated( + $this->transactionBuilderMock, + $this->orderCollectionFactoryMock, + $this->webhookDataLoaderMock, + $this->orderRepositoryMock, + $this->paymentCollectionFactoryMock, + $this->commentMock + ); + } + + /** + * Create a mock for a non-existing class + * + * @param string $className The name of the class to mock + * @param array|null $methods The methods to mock (optional) + * + * @return \PHPUnit\Framework\MockObject\MockObject The mock object + */ + private function getMockForNonExistingClass(string $className, array $methods = null) + { + $builder = $this->getMockBuilder($className) + ->disableOriginalConstructor() + ->disableOriginalClone() + ->disableArgumentCloning() + ->allowMockingUnknownTypes(); + + if ($methods !== null) { + $builder->setMethods($methods); + } + + return $builder->getMock(); + } + + public function testProcessWebhookWithOrderReferenceAndExistingTransaction(): void + { + $paymentId = 'payment-123'; + $orderReference = '000000123'; + $amountValue = 10000; + $currency = 'EUR'; + + // Mock amount + $this->amountMock->expects($this->any()) + ->method('getAmount') + ->willReturn($amountValue); + $this->amountMock->expects($this->any()) + ->method('getCurrency') + ->willReturn($currency); + + // Mock order + $this->nexiOrderMock->expects($this->any()) + ->method('getReference') + ->willReturn($orderReference); + $this->nexiOrderMock->expects($this->any()) + ->method('getAmount') + ->willReturn($this->amountMock); + $this->nexiOrderMock->expects($this->any()) + ->method('getOrderItems') + ->willReturn([]); + + // Mock payment created data + $this->paymentCreatedDataMock->expects($this->any()) + ->method('getPaymentId') + ->willReturn($paymentId); + $this->paymentCreatedDataMock->expects($this->any()) + ->method('getOrder') + ->willReturn($this->nexiOrderMock); + + // Mock webhook + $this->webhookMock->expects($this->any()) + ->method('getData') + ->willReturn($this->paymentCreatedDataMock); + + // Mock order and collection + $orderMock = $this->createMock(Order::class); + $orderCollectionMock = $this->createMock(OrderCollection::class); + + // Mock transaction + $transactionMock = $this->createMock(TransactionInterface::class); + + // Setup expectations for getTransactionByPaymentId + $this->webhookDataLoaderMock->expects($this->once()) + ->method('getTransactionByPaymentId') + ->with($paymentId) + ->willReturn($transactionMock); + + // Setup expectations for order collection + $this->orderCollectionFactoryMock->expects($this->once()) + ->method('create') + ->willReturn($orderCollectionMock); + $orderCollectionMock->expects($this->once()) + ->method('addFieldToFilter') + ->with('increment_id', $orderReference) + ->willReturnSelf(); + $orderCollectionMock->expects($this->once()) + ->method('getFirstItem') + ->willReturn($orderMock); + + // Execute the method + $this->paymentCreated->processWebhook($this->webhookMock); + } + + public function testProcessWebhookWithoutOrderReferenceAndExistingTransaction(): void + { + $paymentId = 'payment-123'; + $amountValue = 10000; + $currency = 'EUR'; + + // Mock amount + $this->amountMock->expects($this->any()) + ->method('getAmount') + ->willReturn($amountValue); + $this->amountMock->expects($this->any()) + ->method('getCurrency') + ->willReturn($currency); + + // Mock order + $this->nexiOrderMock->expects($this->any()) + ->method('getReference') + ->willReturn(null); + $this->nexiOrderMock->expects($this->any()) + ->method('getAmount') + ->willReturn($this->amountMock); + $this->nexiOrderMock->expects($this->any()) + ->method('getOrderItems') + ->willReturn([]); + + // Mock payment created data + $this->paymentCreatedDataMock->expects($this->any()) + ->method('getPaymentId') + ->willReturn($paymentId); + $this->paymentCreatedDataMock->expects($this->any()) + ->method('getOrder') + ->willReturn($this->nexiOrderMock); + + // Mock webhook + $this->webhookMock->expects($this->any()) + ->method('getData') + ->willReturn($this->paymentCreatedDataMock); + + // Mock order, payment, and collections + $orderMock = $this->createMock(Order::class); + $paymentMock = $this->createMock(Payment::class); + $paymentCollectionMock = $this->createMock(PaymentCollection::class); + $orderCollectionMock = $this->createMock(OrderCollection::class); + + // Mock transaction + $transactionMock = $this->createMock(TransactionInterface::class); + + // Setup expectations for getTransactionByPaymentId + $this->webhookDataLoaderMock->expects($this->once()) + ->method('getTransactionByPaymentId') + ->with($paymentId) + ->willReturn($transactionMock); + + // Setup expectations for payment collection + $this->paymentCollectionFactoryMock->expects($this->once()) + ->method('create') + ->willReturn($paymentCollectionMock); + $paymentCollectionMock->expects($this->once()) + ->method('addFieldToFilter') + ->with('last_trans_id', $paymentId) + ->willReturnSelf(); + $paymentCollectionMock->expects($this->once()) + ->method('getFirstItem') + ->willReturn($paymentMock); + $paymentMock->expects($this->once()) + ->method('getParentId') + ->willReturn(1); + + // Setup expectations for order collection + $this->orderCollectionFactoryMock->expects($this->once()) + ->method('create') + ->willReturn($orderCollectionMock); + $orderCollectionMock->expects($this->once()) + ->method('addFieldToFilter') + ->with('entity_id', 1) + ->willReturnSelf(); + $orderCollectionMock->expects($this->once()) + ->method('getFirstItem') + ->willReturn($orderMock); + + $orderMock->method('getId')->willReturn(1); + + // Execute the method + $this->paymentCreated->processWebhook($this->webhookMock); + } + + public function testProcessWebhookWithOrderReferenceAndNoTransaction(): void + { + $paymentId = 'payment-123'; + $orderReference = '000000123'; + $amountValue = 10000; + $currency = 'EUR'; + + // Mock amount + $this->amountMock->expects($this->any()) + ->method('getAmount') + ->willReturn($amountValue); + $this->amountMock->expects($this->any()) + ->method('getCurrency') + ->willReturn($currency); + + // Mock order + $this->nexiOrderMock->expects($this->any()) + ->method('getReference') + ->willReturn($orderReference); + $this->nexiOrderMock->expects($this->any()) + ->method('getAmount') + ->willReturn($this->amountMock); + $this->nexiOrderMock->expects($this->any()) + ->method('getOrderItems') + ->willReturn([]); + + // Mock payment created data + $this->paymentCreatedDataMock->expects($this->any()) + ->method('getPaymentId') + ->willReturn($paymentId); + $this->paymentCreatedDataMock->expects($this->any()) + ->method('getOrder') + ->willReturn($this->nexiOrderMock); + + // Mock webhook + $this->webhookMock->expects($this->any()) + ->method('getData') + ->willReturn($this->paymentCreatedDataMock); + + // Mock order, payment, and collections + $orderMock = $this->createMock(Order::class); + $paymentMock = $this->getMockBuilder(OrderPaymentInterface::class) + ->disableOriginalConstructor() + ->addMethods(['addTransactionCommentsToOrder']) + ->getMockForAbstractClass(); + $orderCollectionMock = $this->createMock(OrderCollection::class); + + // Mock transaction + $transactionMock = $this->createMock(TransactionInterface::class); + + // Setup expectations for getTransactionByPaymentId + $this->webhookDataLoaderMock->expects($this->once()) + ->method('getTransactionByPaymentId') + ->with($paymentId) + ->willReturn(null); + + // Setup expectations for order collection + $this->orderCollectionFactoryMock->expects($this->once()) + ->method('create') + ->willReturn($orderCollectionMock); + $orderCollectionMock->expects($this->once()) + ->method('addFieldToFilter') + ->with('increment_id', $orderReference) + ->willReturnSelf(); + $orderCollectionMock->expects($this->once()) + ->method('getFirstItem') + ->willReturn($orderMock); + + // Setup expectations for saveComment + $this->commentMock->expects($this->once()) + ->method('saveComment') + ->with( + __( + 'Webhook Received. Payment created for Payment ID: %1' + . '
Amount: %2 %3.', + $paymentId, + number_format($amountValue / 100, 2, '.', ''), + $currency + ), + $orderMock + ); + + // Setup expectations for order state + $orderMock->expects($this->once()) + ->method('getState') + ->willReturn(Order::STATE_NEW); + $orderMock->expects($this->once()) + ->method('setState') + ->with(Order::STATE_PENDING_PAYMENT) + ->willReturnSelf(); + $orderMock->expects($this->once()) + ->method('setStatus') + ->with(Order::STATE_PENDING_PAYMENT) + ->willReturnSelf(); + + // Setup expectations for transaction builder + $this->transactionBuilderMock->expects($this->once()) + ->method('build') + ->with( + $paymentId, + $orderMock, + ['payment_id' => $paymentId], + TransactionInterface::TYPE_PAYMENT + ) + ->willReturn($transactionMock); + + // Setup expectations for order save + $this->orderRepositoryMock->expects($this->once()) + ->method('save') + ->with($orderMock); + + // Execute the method + $this->paymentCreated->processWebhook($this->webhookMock); + } + + public function testProcessWebhookWithNoTransactionAndOrderNotInNewState(): void + { + $paymentId = 'payment-123'; + $orderReference = '000000123'; + $amountValue = 10000; + $currency = 'EUR'; + + // Mock amount + $this->amountMock->expects($this->any()) + ->method('getAmount') + ->willReturn($amountValue); + $this->amountMock->expects($this->any()) + ->method('getCurrency') + ->willReturn($currency); + + // Mock order + $this->nexiOrderMock->expects($this->any()) + ->method('getReference') + ->willReturn($orderReference); + $this->nexiOrderMock->expects($this->any()) + ->method('getAmount') + ->willReturn($this->amountMock); + $this->nexiOrderMock->expects($this->any()) + ->method('getOrderItems') + ->willReturn([]); + + // Mock payment created data + $this->paymentCreatedDataMock->expects($this->any()) + ->method('getPaymentId') + ->willReturn($paymentId); + $this->paymentCreatedDataMock->expects($this->any()) + ->method('getOrder') + ->willReturn($this->nexiOrderMock); + + // Mock webhook + $this->webhookMock->expects($this->any()) + ->method('getData') + ->willReturn($this->paymentCreatedDataMock); + + // Mock order and collection + $orderMock = $this->createMock(Order::class); + $orderCollectionMock = $this->createMock(OrderCollection::class); + + // Setup expectations for getTransactionByPaymentId + $this->webhookDataLoaderMock->expects($this->once()) + ->method('getTransactionByPaymentId') + ->with($paymentId) + ->willReturn(null); + + // Setup expectations for order collection + $this->orderCollectionFactoryMock->expects($this->once()) + ->method('create') + ->willReturn($orderCollectionMock); + $orderCollectionMock->expects($this->once()) + ->method('addFieldToFilter') + ->with('increment_id', $orderReference) + ->willReturnSelf(); + $orderCollectionMock->expects($this->once()) + ->method('getFirstItem') + ->willReturn($orderMock); + + // Setup expectations for saveComment + $this->commentMock->expects($this->once()) + ->method('saveComment') + ->with( + __( + 'Webhook Received. Payment created for Payment ID: %1' + . '
Amount: %2 %3.', + $paymentId, + number_format($amountValue / 100, 2, '.', ''), + $currency + ), + $orderMock + ); + + // Setup expectations for order state + $orderMock->expects($this->once()) + ->method('getState') + ->willReturn(Order::STATE_PROCESSING); + + // No transaction should be created + $this->transactionBuilderMock->expects($this->never()) + ->method('build'); + + // Execute the method + $this->paymentCreated->processWebhook($this->webhookMock); + } +} diff --git a/Test/Unit/Model/Webhook/PaymentRefundCompletedTest.php b/Test/Unit/Model/Webhook/PaymentRefundCompletedTest.php new file mode 100644 index 00000000..6a61e217 --- /dev/null +++ b/Test/Unit/Model/Webhook/PaymentRefundCompletedTest.php @@ -0,0 +1,213 @@ +webhookDataLoaderMock = $this->createMock(WebhookDataLoader::class); + $this->transactionBuilderMock = $this->createMock(Builder::class); + $this->orderRepositoryMock = $this->createMock(OrderRepositoryInterface::class); + $this->creditmemoFactoryMock = $this->createMock(CreditmemoFactory::class); + $this->creditmemoManagementMock = $this->createMock(CreditmemoManagementInterface::class); + $this->amountConverterMock = $this->createMock(AmountConverter::class); + $this->commentMock = $this->createMock(Comment::class); + $this->webhookMock = $this->createMock(RefundCompleted::class); + $this->refundCompletedDataMock = $this->createMock(RefundCompletedData::class); + $this->amountMock = $this->createMock(Amount::class); + + $this->paymentRefundCompleted = new PaymentRefundCompleted( + $this->webhookDataLoaderMock, + $this->transactionBuilderMock, + $this->orderRepositoryMock, + $this->creditmemoFactoryMock, + $this->creditmemoManagementMock, + $this->amountConverterMock, + $this->commentMock + ); + } + + public function testProcessWebhookWithFullRefund(): void + { + $paymentId = 'payment-123'; + $refundId = 'refund-123'; + $amountValue = 10000; // 100.00 in cents + $currency = 'USD'; + + // Mock amount + $this->amountMock->expects($this->any()) + ->method('getAmount') + ->willReturn($amountValue); + $this->amountMock->expects($this->any()) + ->method('getCurrency') + ->willReturn($currency); + + // Mock refund completed data + $this->refundCompletedDataMock->expects($this->any()) + ->method('getPaymentId') + ->willReturn($paymentId); + $this->refundCompletedDataMock->expects($this->any()) + ->method('getRefundId') + ->willReturn($refundId); + $this->refundCompletedDataMock->expects($this->any()) + ->method('getAmount') + ->willReturn($this->amountMock); + + // Mock webhook + $this->webhookMock->expects($this->any()) + ->method('getData') + ->willReturn($this->refundCompletedDataMock); + + // Mock order and payment + $orderMock = $this->createMock(Order::class); + $orderMock->method('canCreditmemo')->willReturn(true); + $paymentMock = $this->getMockBuilder(OrderPaymentInterface::class) + ->disableOriginalConstructor() + ->addMethods(['addTransactionCommentsToOrder', 'addTransaction']) + ->getMockForAbstractClass(); + $creditmemoMock = $this->createMock(Creditmemo::class); + + // Mock transaction + $refundTransactionMock = $this->createMock(TransactionInterface::class); + + // Setup expectations for loadOrderByPaymentId + $this->webhookDataLoaderMock->expects($this->once()) + ->method('loadOrderByPaymentId') + ->with($paymentId) + ->willReturn($orderMock); + + // Setup expectations for transaction builder + $this->transactionBuilderMock->expects($this->once()) + ->method('build') + ->with( + $refundId, + $orderMock, + ['payment_id' => $paymentId], + TransactionInterface::TYPE_REFUND + ) + ->willReturn($refundTransactionMock); + + // Setup expectations for transaction + $refundTransactionMock->expects($this->once()) + ->method('setParentTxnId') + ->with($paymentId) + ->willReturnSelf(); + $refundTransactionMock->expects($this->once()) + ->method('setAdditionalInformation') + ->with('details', $this->anything()) + ->willReturnSelf(); + + // Setup expectations for isFullRefund + $orderMock->expects($this->once()) + ->method('getGrandTotal') + ->willReturn(100.00); + $this->amountConverterMock->expects($this->once()) + ->method('convertToNexiAmount') + ->with(100.00) + ->willReturn(10000); + + // Setup expectations for processFullRefund + $this->creditmemoFactoryMock->expects($this->once()) + ->method('createByOrder') + ->with($orderMock) + ->willReturn($creditmemoMock); + $creditmemoMock->expects($this->once()) + ->method('setTransactionId') + ->with($refundId) + ->willReturnSelf(); + $this->creditmemoManagementMock->expects($this->once()) + ->method('refund') + ->with($creditmemoMock); + + // Setup expectations for payment + $orderMock->expects($this->atLeastOnce()) + ->method('getPayment') + ->willReturn($paymentMock); + + $orderMock->expects($this->atLeastOnce()) + ->method('getTotalRefunded') + ->willReturn(0.00); + + // Setup expectations for order save + $this->orderRepositoryMock->expects($this->once()) + ->method('save') + ->with($orderMock); + + // Execute the method + $this->paymentRefundCompleted->processWebhook($this->webhookMock); + } +} diff --git a/Test/Unit/Model/Webhook/PaymentRefundFailedTest.php b/Test/Unit/Model/Webhook/PaymentRefundFailedTest.php new file mode 100644 index 00000000..e50e5154 --- /dev/null +++ b/Test/Unit/Model/Webhook/PaymentRefundFailedTest.php @@ -0,0 +1,87 @@ +webhookDataLoaderMock = $this->createMock(WebhookDataLoader::class); + $this->commentMock = $this->createMock(Comment::class); + $this->webhookMock = $this->createMock(RefundFailed::class); + $this->refundFailedDataMock = $this->createMock(RefundFailedData::class); + + $this->paymentRefundFailed = new PaymentRefundFailed( + $this->webhookDataLoaderMock, + $this->commentMock + ); + } + + public function testProcessWebhookSuccessfully(): void + { + $paymentId = 'payment-123'; + + // Mock webhook data + $this->refundFailedDataMock->expects($this->once()) + ->method('getPaymentId') + ->willReturn($paymentId); + + // Mock webhook + $this->webhookMock->expects($this->once()) + ->method('getData') + ->willReturn($this->refundFailedDataMock); + + // Mock order + $orderMock = $this->createMock(Order::class); + + // Setup expectations + $this->webhookDataLoaderMock->expects($this->once()) + ->method('loadOrderByPaymentId') + ->with($paymentId) + ->willReturn($orderMock); + + $this->commentMock->expects($this->once()) + ->method('saveComment') + ->with( + __('Webhook Received. Payment refund failed for payment ID: %1', $paymentId), + $orderMock + ); + + // Execute the method + $this->paymentRefundFailed->processWebhook($this->webhookMock); + } +} diff --git a/Test/Unit/Model/Webhook/PaymentReservationCreatedTest.php b/Test/Unit/Model/Webhook/PaymentReservationCreatedTest.php new file mode 100644 index 00000000..fb6eae4f --- /dev/null +++ b/Test/Unit/Model/Webhook/PaymentReservationCreatedTest.php @@ -0,0 +1,242 @@ +orderRepositoryMock = $this->createMock(OrderRepositoryInterface::class); + $this->webhookDataLoaderMock = $this->createMock(WebhookDataLoader::class); + $this->transactionBuilderMock = $this->createMock(Builder::class); + $this->commentMock = $this->createMock(Comment::class); + $this->subscriptionManagement = $this->createMock(SubscriptionManagement::class); + $this->webhookMock = $this->createMock(ReservationCreated::class); + $this->reservationCreatedDataMock = $this->createMock(ReservationCreatedData::class); + $this->amountMock = $this->createMock(Amount::class); + $this->loggerMock = $this->createMock(LoggerInterface::class); + + $this->paymentReservationCreated = new PaymentReservationCreated( + $this->orderRepositoryMock, + $this->webhookDataLoaderMock, + $this->transactionBuilderMock, + $this->commentMock, + $this->subscriptionManagement, + $this->loggerMock + ); + } + + public function testProcessWebhookSuccessfully(): void + { + $webhookId = 'webhook-123'; + $paymentId = 'payment-123'; + $rawAmount = 1300; + $formattedAmount = 13.00; + $currency = 'EUR'; + + // Mock amount + $this->amountMock->expects($this->any()) + ->method('getAmount') + ->willReturn($rawAmount); + $this->amountMock->expects($this->any()) + ->method('getCurrency') + ->willReturn($currency); + + // Mock reservation created data + $this->reservationCreatedDataMock->expects($this->any()) + ->method('getPaymentId') + ->willReturn($paymentId); + $this->reservationCreatedDataMock->expects($this->any()) + ->method('getAmount') + ->willReturn($this->amountMock); + $this->reservationCreatedDataMock->expects($this->any()) + ->method('getPaymentMethod') + ->willReturn('card'); + $this->reservationCreatedDataMock->expects($this->any()) + ->method('getPaymentType') + ->willReturn('VISA'); + + // Mock webhook + $this->webhookMock->expects($this->any()) + ->method('getId') + ->willReturn($webhookId); + $this->webhookMock->expects($this->any()) + ->method('getData') + ->willReturn($this->reservationCreatedDataMock); + + // Mock order and payment + $orderMock = $this->createMock(Order::class); + $paymentMock = $this->createMock(Payment::class); + + // Mock reservation transaction + $reservationTransactionMock = $this->getMockBuilder(TransactionInterface::class) + ->disableOriginalConstructor() + ->addMethods(['getOrder']) + ->getMockForAbstractClass(); + + // Setup expectations + $this->webhookDataLoaderMock + ->method('getTransactionByPaymentId') + ->willReturnMap([ + [$webhookId, TransactionInterface::TYPE_AUTH, null], + [$paymentId, TransactionInterface::TYPE_PAYMENT, $reservationTransactionMock] + ]); + + $reservationTransactionMock->expects($this->once()) + ->method('getOrder') + ->willReturn($orderMock); + + $orderMock->expects($this->once()) + ->method('setState') + ->with(Order::STATE_PENDING_PAYMENT) + ->willReturnSelf(); + + $orderMock->expects($this->once()) + ->method('setStatus') + ->with(AddPaymentAuthorizedOrderStatus::STATUS_NEXI_AUTHORIZED) + ->willReturnSelf(); + + $this->transactionBuilderMock->expects($this->once()) + ->method('build') + ->with( + $webhookId, + $orderMock, + ['payment_id' => $paymentId], + TransactionInterface::TYPE_AUTH + ) + ->willReturn($reservationTransactionMock); + + $reservationTransactionMock->expects($this->once()) + ->method('setIsClosed') + ->with(0) + ->willReturnSelf(); + + $reservationTransactionMock->expects($this->once()) + ->method('setParentTxnId') + ->with($paymentId) + ->willReturnSelf(); + + $reservationTransactionMock->expects($this->once()) + ->method('setParentId') + ->willReturnSelf(); + + $orderMock->method('getPayment') + ->willReturn($paymentMock); + + $paymentMock->expects($this->once()) + ->method('formatAmount') + ->with($rawAmount / 100, true) + ->willReturn($formattedAmount); + + $paymentMock->expects($this->once()) + ->method('setBaseAmountAuthorized') + ->with($formattedAmount) + ->willReturnSelf(); + + $this->orderRepositoryMock->expects($this->once()) + ->method('save') + ->with($orderMock); + + // Execute the method + $this->paymentReservationCreated->processWebhook($this->webhookMock); + } + + public function testProcessWebhookThrowsExceptionWhenTransactionNotFound(): void + { + $webhookId = 'webhook-123'; + $paymentId = 'payment-123'; + + // Mock reservation created data + $this->reservationCreatedDataMock->expects($this->any()) + ->method('getPaymentId') + ->willReturn($paymentId); + + // Mock webhook + $this->webhookMock->expects($this->any()) + ->method('getId') + ->willReturn($webhookId); + $this->webhookMock->expects($this->any()) + ->method('getData') + ->willReturn($this->reservationCreatedDataMock); + + $this->webhookDataLoaderMock + ->method('getTransactionByPaymentId') + ->with($paymentId, TransactionInterface::TYPE_PAYMENT) + ->willReturn(null); + + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Payment transaction not found for payment-123.'); + + // Execute the method + $this->paymentReservationCreated->processWebhook($this->webhookMock); + } +} diff --git a/Test/Unit/TransactionBuilderTest.php b/Test/Unit/TransactionBuilderTest.php new file mode 100644 index 00000000..6606d63d --- /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 Builder($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/Ui/Component/Listing/Column/SubscriptionAction.php b/Ui/Component/Listing/Column/SubscriptionAction.php new file mode 100644 index 00000000..c44ef0fe --- /dev/null +++ b/Ui/Component/Listing/Column/SubscriptionAction.php @@ -0,0 +1,79 @@ +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/SubscriptionDataProvider.php b/Ui/DataProvider/SubscriptionDataProvider.php new file mode 100644 index 00000000..0e9bd1ce --- /dev/null +++ b/Ui/DataProvider/SubscriptionDataProvider.php @@ -0,0 +1,92 @@ +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/SubscriptionForm.php b/Ui/DataProvider/SubscriptionForm.php new file mode 100644 index 00000000..bca17576 --- /dev/null +++ b/Ui/DataProvider/SubscriptionForm.php @@ -0,0 +1,140 @@ +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( + 'subscriptions/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/SubscriptionProfile.php b/Ui/DataProvider/SubscriptionProfile.php new file mode 100644 index 00000000..5ef8f602 --- /dev/null +++ b/Ui/DataProvider/SubscriptionProfile.php @@ -0,0 +1,59 @@ +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/SubscriptionProfileForm.php b/Ui/DataProvider/SubscriptionProfileForm.php new file mode 100644 index 00000000..3405a134 --- /dev/null +++ b/Ui/DataProvider/SubscriptionProfileForm.php @@ -0,0 +1,104 @@ +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/composer.json b/composer.json index 1af20453..eaddb542 100644 --- a/composer.json +++ b/composer.json @@ -3,10 +3,11 @@ "description": "Nets Easy Checkout", "require": { "php": "^8.0", - "nexi-checkout/php-payment-sdk": "^0.4.1", + "nexi-checkout/php-payment-sdk": "^0.8.1", "magento/framework": ">=102.0.0", "ext-curl": "*", - "giggsey/libphonenumber-for-php-lite": "^9.0.5" + "giggsey/libphonenumber-for-php-lite": "^9.0.5", + "nesbot/carbon": "^2.57.0 || ^3.0" }, "require-dev": { "magento/magento-coding-standard": "*", 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..2a06a788 --- /dev/null +++ b/etc/adminhtml/di.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + 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..4d1d23b8 --- /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..cef2cb5d 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..7dc827bb 100644 --- a/etc/adminhtml/system.xml +++ b/etc/adminhtml/system.xml @@ -10,52 +10,101 @@ Magento\Config\Model\Config\Source\Yesno - + required-entry + + 1 + - -