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 @@
Enabled
Magento\Config\Model\Config\Source\Yesno
-
+
Title
required-entry
+
+ 1
+
-
-
- Test Connection
- Nexi\Checkout\Block\Adminhtml\System\Config\TestConnection
+
+ Environment
+ Nexi\Checkout\Model\Config\Source\Environment
+
+ 1
+
Secret Key
required-entry
Magento\Config\Model\Config\Backend\Encrypted
+
+ 1
+ live
+
Checkout Key
required-entry
Magento\Config\Model\Config\Backend\Encrypted
+
+ 1
+ live
+
Test Secret Key
+ required-entry
Magento\Config\Model\Config\Backend\Encrypted
+
+ 1
+ test
+
Test Checkout Key
+ required-entry
Magento\Config\Model\Config\Backend\Encrypted
+
+ 1
+ test
+
+
+
+ payType Splitting
+ Magento\Config\Model\Config\Source\Yesno
+
+ 1
+
+
+
+ Payment Methods
+ Nexi\Checkout\Model\Config\Source\PayTypeOptions
+ Select the payment methods you want to enable.
+
+ 1
+
+ showInDefault="1" showInWebsite="1" showInStore="1">
Webshop Terms and Conditions URL
required-entry
add URL to your Webshop Terms and Conditions site.
+
+ 1
+
+ showInDefault="1" showInWebsite="1" showInStore="1">
Payment Terms and Conditions URL
required-entry
add URL to your payment Terms and Conditions site.
+
+ 1
+
Integration type
Nexi\Checkout\Model\Config\Source\IntegrationType
+
+ 1
+
@@ -64,17 +113,68 @@
If set to "Yes", the transaction will be charged automatically after the
reservation has been accepted.
+
+ 1
+
+ showInWebsite="1" showInStore="1">
New Order Status
- Magento\Sales\Model\Config\Source\Order\Status\NewStatus
+ Nexi\Checkout\Model\Config\Source\Order\NewStatus
+
+ 1
+
-
- Environment
- Nexi\Checkout\Model\Config\Source\Environment
+
+
+ Test Connection
+ Nexi\Checkout\Block\Adminhtml\System\Config\TestConnection
+
+ 1
+
+
+ Nexi: Subscriptions
+
+ nexi/subscriptions/active_subscriptions
+ Enabled Subscriptions
+ Activating subscriptions feature
+ Magento\Config\Model\Config\Source\Yesno
+
+
+ Subscriptions email notification schedule
+
+ 1
+
+
+
+ Subscriptions email billing schedule
+
+ 1
+
+
+
+ Alert email advance period
+ Controls the amount of days between notifying customer about upcoming payment and billing the payment
+ validate-digit validate-range range-0-30
+
+ 1
+
+
+
+ Force billing date to be on weekday (monday-friday)
+ Magento\Config\Model\Config\Source\Yesno
+
+ 1
+
+
+
diff --git a/etc/catalog_attributes.xml b/etc/catalog_attributes.xml
new file mode 100644
index 00000000..945392b7
--- /dev/null
+++ b/etc/catalog_attributes.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
diff --git a/etc/config.xml b/etc/config.xml
index 7f3953c8..2e8634c2 100644
--- a/etc/config.xml
+++ b/etc/config.xml
@@ -4,6 +4,7 @@
NexiFacade
Nexi Payments
+ https://www.nexi.de/content/dam/nexide/img/nexi-de-test/logos/nexi-logos/NEXI_RGB_Colore.png
authorize_capture
0
0
@@ -13,9 +14,7 @@
1
1
1
- 1
- 1
- 1
+ 0
1
1
1
@@ -40,6 +39,15 @@
HostedPaymentPage
+ 0
+
+ 0
+ 0 1 * * *
+ 15 3 * * *
+ subscription_email_template
+ 7
+ 0
+
diff --git a/etc/crontab.xml b/etc/crontab.xml
new file mode 100644
index 00000000..5ad6a979
--- /dev/null
+++ b/etc/crontab.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+ sales/subscriptions/bill_cron_schedule
+
+
+ sales/subscriptions/notify_cron_schedule
+
+
+
diff --git a/etc/csp_whitelist.xml b/etc/csp_whitelist.xml
new file mode 100644
index 00000000..44df0335
--- /dev/null
+++ b/etc/csp_whitelist.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+ *.dibspayment.eu
+
+
+
+
+ *.dibspayment.eu
+
+
+
+
+ *.dibspayment.eu
+
+
+
+
+ *.dibspayment.eu
+
+
+
+
+ *.dibspayment.eu
+
+
+
+
diff --git a/etc/db_schema.xml b/etc/db_schema.xml
new file mode 100644
index 00000000..a9a10d87
--- /dev/null
+++ b/etc/db_schema.xml
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/etc/db_schema_whitelist.json b/etc/db_schema_whitelist.json
new file mode 100644
index 00000000..ed6cd226
--- /dev/null
+++ b/etc/db_schema_whitelist.json
@@ -0,0 +1,49 @@
+{
+ "nexi_subscriptions": {
+ "column": {
+ "entity_id": true,
+ "status": true,
+ "next_order_date": true,
+ "recurring_profile_id": true,
+ "updated_at": true,
+ "end_date": true,
+ "repeat_count_left": true,
+ "retry_count": true,
+ "customer_id": true
+ },
+ "index": {
+ "NEXI_SUBSCRIPTIONS_STATUS": true
+ },
+ "constraint": {
+ "PRIMARY": true,
+ "FK_8D8B31C8FA705FDDF495326B9267594D": true,
+ "FK_C52FB2CF53BB599FD4BAF601615C36C6": true,
+ "NEXI_SUBSCRIPTIONS_CUSTOMER_ID_CUSTOMER_ENTITY_ENTITY_ID": true,
+ "FK_A3A41A8A2917DB96EB7845EE6B4A46B6": true
+ }
+ },
+ "recurring_payment_profiles": {
+ "column": {
+ "profile_id": true,
+ "name": true,
+ "description": true,
+ "schedule": true
+ },
+ "constraint": {
+ "PRIMARY": true
+ }
+ },
+ "nexi_subscription_link": {
+ "column": {
+ "link_id": true,
+ "order_id": true,
+ "subscription_id": true
+ },
+ "constraint": {
+ "PRIMARY": true,
+ "NEXI_SUBSCRIPTION_LINK_ORDER_ID_SALES_ORDER_ENTITY_ID": true,
+ "FK_86F57A98A67516BAAF65D6F8B80F2C0D": true,
+ "NEXI_SUBSCRIPTION_LINK_ORDER_ID_SUBSCRIPTION_ID": true
+ }
+ }
+}
diff --git a/etc/di.xml b/etc/di.xml
index efc36cba..aea6e163 100644
--- a/etc/di.xml
+++ b/etc/di.xml
@@ -6,7 +6,7 @@
Nexi\Checkout\Gateway\Config\Config::CODE
Magento\Payment\Block\Form
- Magento\Payment\Block\Form
+ Nexi\Checkout\Block\Info\Nexi
NexiValueHandlerPool
Magento\Payment\Gateway\Validator\ValidatorPool
NexiCommandPool
@@ -65,8 +65,14 @@
- NexiCommandCreatePayment
- Nexi\Checkout\Gateway\Command\Initialize
+ - NexiCommandRetrieve
- NexiCommandCapture
- NexiCommandRefund
+ - NexiCommandUpdateOrder
+ - NexiCommandUpdateReference
+ - NexiCommandSubscriptionCharge
+ - NexiCommandVoid
+ - NexiCommandVoid
@@ -77,6 +83,27 @@
Nexi\Checkout\Gateway\Http\TransferFactory
Nexi\Checkout\Gateway\Http\Client
Nexi\Checkout\Gateway\Handler\CreatePayment
+ NexiVirtualLogger
+
+
+
+
+
+ Nexi\Checkout\Gateway\Request\RetrieveRequestBuilder
+ Nexi\Checkout\Gateway\Http\TransferFactory
+ Nexi\Checkout\Gateway\Http\Client
+ Nexi\Checkout\Gateway\Handler\Retrieve
+
+ NexiVirtualLogger
+
+
+
+
+
+ Nexi\Checkout\Gateway\Request\UpdateOrderRequestBuilder
+ Nexi\Checkout\Gateway\Http\TransferFactory
+ Nexi\Checkout\Gateway\Http\Client
+ NexiVirtualLogger
@@ -86,6 +113,7 @@
Nexi\Checkout\Gateway\Http\TransferFactory
Nexi\Checkout\Gateway\Http\Client
Nexi\Checkout\Gateway\Handler\Capture
+ NexiVirtualLogger
@@ -95,6 +123,35 @@
Nexi\Checkout\Gateway\Http\TransferFactory
Nexi\Checkout\Gateway\Http\Client
Nexi\Checkout\Gateway\Handler\RefundCharge
+ NexiVirtualLogger
+
+
+
+
+
+ Nexi\Checkout\Gateway\Request\UpdateReferenceRequestBuilder
+ Nexi\Checkout\Gateway\Http\TransferFactory
+ Nexi\Checkout\Gateway\Http\Client
+ NexiVirtualLogger
+
+
+
+
+
+ Nexi\Checkout\Gateway\Request\SubscriptionChargeRequestBuilder
+ Nexi\Checkout\Gateway\Http\TransferFactory
+ Nexi\Checkout\Gateway\Http\Client
+ Nexi\Checkout\Gateway\Handler\SubscriptionCharge
+ NexiVirtualLogger
+
+
+
+
+
+ Nexi\Checkout\Gateway\Request\VoidRequestBuilder
+ Nexi\Checkout\Gateway\Http\TransferFactory
+ Nexi\Checkout\Gateway\Http\Client
+ NexiVirtualLogger
@@ -146,7 +203,7 @@
-
+
NexiVirtualLogger
Magento\Checkout\Model\Session\Proxy
@@ -181,6 +238,9 @@
+
+
+
@@ -202,4 +262,101 @@
NexiVirtualLogger
+
+
+ NexiVirtualLogger
+
+
+
+
+ NexiVirtualLogger
+
+
+
+
+ NexiVirtualLogger
+
+
+
+
+ Magento\Checkout\Model\Session\Proxy
+
+
+
+
+
+
+
+
+
+
+
+
+
+ - Nexi\Checkout\Console\Command\Bill
+
+ - Nexi\Checkout\Console\Command\Notify
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Magento\Backend\Model\Session\Quote\Proxy
+
+
+
+
+
+
+
+ Magento\Checkout\Model\Session\Proxy
+
+
+
+
+ Magento\Checkout\Model\Session\Proxy
+
+
+
+
+
+
+
+
+
+
+
diff --git a/etc/events.xml b/etc/events.xml
new file mode 100644
index 00000000..e6ea4b1b
--- /dev/null
+++ b/etc/events.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/etc/extension_attributes.xml b/etc/extension_attributes.xml
new file mode 100644
index 00000000..80cf8c6f
--- /dev/null
+++ b/etc/extension_attributes.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/etc/frontend/di.xml b/etc/frontend/di.xml
index c1d044c3..8a4d3477 100644
--- a/etc/frontend/di.xml
+++ b/etc/frontend/di.xml
@@ -5,10 +5,15 @@
- - Nexi\Checkout\Model\Ui\ConfigProvider
+ - Nexi\Checkout\Model\Ui\ConfigProvider
+ - Nexi\Checkout\Model\Subscription\TotalConfigProvider
+
+
+
+
+
diff --git a/etc/payment.xml b/etc/payment.xml
old mode 100755
new mode 100644
diff --git a/etc/webapi.xml b/etc/webapi.xml
index 3fa88589..07c834ac 100644
--- a/etc/webapi.xml
+++ b/etc/webapi.xml
@@ -2,4 +2,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/i18n/en_US.csv b/i18n/en_US.csv
new file mode 100755
index 00000000..eef3ad19
--- /dev/null
+++ b/i18n/en_US.csv
@@ -0,0 +1,4 @@
+
+"Card for subscription changed successfully","Card for subscription changed successfully"
+"The subscription doesn't belong to the customer", "The subscription doesn't belong to the customer"
+"The card has active subscriptions","The card has an active subscriptions"
diff --git a/i18n/fi_FI.csv b/i18n/fi_FI.csv
new file mode 100644
index 00000000..d241fcde
--- /dev/null
+++ b/i18n/fi_FI.csv
@@ -0,0 +1,16 @@
+"You have subscribed for recurring order from %store_name.","Olet tehnyt toistuvan tilauksen verkkokaupassamme."
+"If you want to cancel your recurring order log into your account .", "Jos haluat peruutta toistuvan tilauksen kirjaudu käyttäjätilillesi ."
+"Should the recurring payment fail, we will add %warning_period days to your order, so that you can update your payment details.", "Jos automaattinen uusinta epäonnistuu, tilaukseesi lisätään x päivää lisäaikaa, jotta ehdit päivittää maksutietosi."
+"Recurring payment stopped successfully","Tilaus pysäytettiin onnistuneesti"
+"Can't add product with different payment schedule","Tuotetta ei voi lisätä eri maksuaikataululla"
+"Recurring Payment","Toistuvat maksut"
+"Recurring total","Toistuvat maksut yhteensä"
+"Recurring payment purchases require using a saved card.","Toistuvat tilaukset vaativat tallennetun luottokortin maksuja varten"
+"Subscription couldn't be canceled","Tilausta ei voitu peruuttaa"
+"Subscription has been canceled correctly","Tilaus on peruutettu"
+"Subscription is closed","Tilaus on suljettu"
+"Subscription orders can't be shown","Tilaustilauksia ei voida näyttää"
+"The subscription doesn't belong to the customer", "Tilaus ei kuulu asiakkaalle"
+"The card has active subscriptions","Kortilla on aktiivisia tilauksia"
+"My Subscriptions","Toistuvat tilaukseni"
+"Subscription Total","Toistuvan tilauksen yhteensä"
diff --git a/view/adminhtml/layout/subscription_view_index.xml b/view/adminhtml/layout/subscription_view_index.xml
new file mode 100644
index 00000000..67037c56
--- /dev/null
+++ b/view/adminhtml/layout/subscription_view_index.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/view/adminhtml/layout/subscriptions_profile_edit.xml b/view/adminhtml/layout/subscriptions_profile_edit.xml
new file mode 100755
index 00000000..a01f23a2
--- /dev/null
+++ b/view/adminhtml/layout/subscriptions_profile_edit.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/view/adminhtml/layout/subscriptions_profile_index.xml b/view/adminhtml/layout/subscriptions_profile_index.xml
new file mode 100644
index 00000000..35c6092d
--- /dev/null
+++ b/view/adminhtml/layout/subscriptions_profile_index.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/view/adminhtml/layout/subscriptions_subscription_index.xml b/view/adminhtml/layout/subscriptions_subscription_index.xml
new file mode 100644
index 00000000..cd7df27d
--- /dev/null
+++ b/view/adminhtml/layout/subscriptions_subscription_index.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/view/adminhtml/layout/subscriptions_subscription_view.xml b/view/adminhtml/layout/subscriptions_subscription_view.xml
new file mode 100644
index 00000000..1d77b782
--- /dev/null
+++ b/view/adminhtml/layout/subscriptions_subscription_view.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/view/adminhtml/templates/info/checkout.phtml b/view/adminhtml/templates/info/checkout.phtml
new file mode 100644
index 00000000..3030ad6f
--- /dev/null
+++ b/view/adminhtml/templates/info/checkout.phtml
@@ -0,0 +1,30 @@
+
+
+= $block->getChildHtml() ?>
+
diff --git a/view/adminhtml/ui_component/sales_order_grid.xml b/view/adminhtml/ui_component/sales_order_grid.xml
new file mode 100644
index 00000000..28973de4
--- /dev/null
+++ b/view/adminhtml/ui_component/sales_order_grid.xml
@@ -0,0 +1,49 @@
+
+
+
+
+
+ -
+
- text
+ - Recurring Payment Status
+
+
+
+ false
+
+
+
+
+ -
+
- text
+ - Recurring Customer Id
+
+
+
+ false
+
+
+
+
+ -
+
- text
+ - Recurring Payment ID
+
+
+
+ false
+
+
+
+
+ -
+
- text
+ - Recurring Payment Profile
+
+
+
+ false
+
+
+
+
diff --git a/view/adminhtml/ui_component/subscription_profile_edit.xml b/view/adminhtml/ui_component/subscription_profile_edit.xml
new file mode 100644
index 00000000..50e03462
--- /dev/null
+++ b/view/adminhtml/ui_component/subscription_profile_edit.xml
@@ -0,0 +1,127 @@
+
+
diff --git a/view/adminhtml/ui_component/subscription_profile_listing.xml b/view/adminhtml/ui_component/subscription_profile_listing.xml
new file mode 100644
index 00000000..1b89e3bc
--- /dev/null
+++ b/view/adminhtml/ui_component/subscription_profile_listing.xml
@@ -0,0 +1,151 @@
+
+
+
+ -
+
- subscription_profile_listing.subscription_profile_listing_data_source
+
+ - subscription_profile_listing.subscription_profile_listing_data_source
+
+ - subscription_profile_columns
+
+
+
+
+
+ primary
+ Add New Profile
+
+
+
+
+
+ Nexi\Checkout\Ui\DataProvider\SubscriptionProfile
+ subscription_profile_listing_data_source
+ profile_id
+ profile_id
+
+ -
+
+ -
+
- profile_id
+
+
+
+
+
+ -
+
- Magento_Ui/js/grid/provider
+
+
+
+
+
+
+
+
+ -
+
-
+ subscription_profile_listing.subscription_profile_listing.subscription_profile_columns.ids
+
+ - bottom
+ - Magento_Ui/js/grid/tree-massactions
+ - profile_id
+
+
+
+
+ -
+
- delete
+ - Delete
+
+ -
+
- Delete items
+ - Are you sure you want to delete
+ selected items?
+
+
+
+
+
+
+
+
+ -
+
-
+
-
+
-
+
- Magento_Ui/js/form/element/ui-select
+ - ui/grid/filters/elements/ui-select
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+ subscription_profile_listing.subscription_profile_listing.subscription_profile_columns.actions
+
+ - applyAction
+ -
+
- edit
+ - ${ $.$data.rowIndex }
+
+
+
+
+
+
+ -
+
- profile_id
+
+
+
+
+
+ textRange
+ ID
+ 25
+
+
+
+
+ text
+ ui/grid/cells/text
+ Name
+
+
+
+
+ text
+ ui/grid/cells/text
+ Description
+
+
+
+
+ text
+ ui/grid/cells/text
+ Schedule
+
+
+
+
+ -
+
- subscriptions/profile/edit
+ - id
+
+
+
+ profile_id
+
+
+
+
diff --git a/view/adminhtml/ui_component/subscriptions_listing.xml b/view/adminhtml/ui_component/subscriptions_listing.xml
new file mode 100644
index 00000000..ea86911a
--- /dev/null
+++ b/view/adminhtml/ui_component/subscriptions_listing.xml
@@ -0,0 +1,187 @@
+
+
+
+ -
+
- subscriptions_listing.subscriptions_listing_data_source
+ - subscriptions_listing.subscriptions_listing_data_source
+
+ - subscriptions_columns
+
+
+
+ Nexi\Checkout\Ui\DataProvider\SubscriptionDataProvider
+ subscriptions_listing_data_source
+ entity_id
+ entity_id
+
+ -
+
+ -
+
- entity_id
+
+
+
+
+
+ -
+
- Magento_Ui/js/grid/provider
+
+
+
+
+
+
+
+
+ -
+
- subscriptions_listing.subscriptions_listing.subscriptions_columns.ids
+ - bottom
+ - Magento_Ui/js/grid/tree-massactions
+ - entity_id
+
+
+
+
+ -
+
- delete
+ - Delete
+
+ -
+
- Delete items
+ - Are you sure you want to delete selected items?
+
+
+
+
+
+
+
+ -
+
-
+
-
+
-
+
- Magento_Ui/js/form/element/ui-select
+ - ui/grid/filters/elements/ui-select
+
+
+
+
+
+
+
+
+
+
+
+
+ - subscriptions_listing.subscriptions_listing.subscriptions_columns.actions
+ - applyAction
+ -
+
- view
+ - ${ $.$data.rowIndex }
+
+
+
+
+
+
+ -
+
- entity_id
+
+
+
+
+
+ textRange
+ ID
+ 25
+
+
+
+
+ text
+ ui/grid/cells/text
+ Status
+
+
+
+
+ dateRange
+ date
+ Next order date
+
+
+
+
+ text
+ ui/grid/cells/text
+ Profile id
+ false
+
+
+
+
+ text
+ ui/grid/cells/text
+ Profile Name
+
+
+
+
+ text
+ ui/grid/cells/text
+ Last Order id
+
+
+
+
+ dateRange
+ date
+ Updated at
+ false
+
+
+
+
+ dateRange
+ date
+ End date
+ false
+
+
+
+
+ text
+ ui/grid/cells/text
+ Repeats left
+ false
+
+
+
+
+ text
+ ui/grid/cells/text
+ Retries left
+ false
+
+
+
+
+ text
+ ui/grid/cells/text
+ Customer Email
+
+
+
+
+ -
+
- subscriptions/subscription/view
+ - id
+
+
+
+ entity_id
+
+
+
+
diff --git a/view/adminhtml/ui_component/subscriptions_view.xml b/view/adminhtml/ui_component/subscriptions_view.xml
new file mode 100644
index 00000000..0993374e
--- /dev/null
+++ b/view/adminhtml/ui_component/subscriptions_view.xml
@@ -0,0 +1,157 @@
+
+
diff --git a/view/adminhtml/web/css/subscription.css b/view/adminhtml/web/css/subscription.css
new file mode 100644
index 00000000..9b07c726
--- /dev/null
+++ b/view/adminhtml/web/css/subscription.css
@@ -0,0 +1,8 @@
+.recurring-order-link {
+ display: inline-block;
+ padding-top: 8px;
+}
+
+.form-inline .admin__fieldset > .admin__field {
+ margin-bottom: 22px;
+}
diff --git a/view/adminhtml/web/template/form/element/link.html b/view/adminhtml/web/template/form/element/link.html
new file mode 100644
index 00000000..7355f386
--- /dev/null
+++ b/view/adminhtml/web/template/form/element/link.html
@@ -0,0 +1 @@
+View
diff --git a/view/frontend/email/recurring_new.html b/view/frontend/email/recurring_new.html
new file mode 100644
index 00000000..35c7583b
--- /dev/null
+++ b/view/frontend/email/recurring_new.html
@@ -0,0 +1,95 @@
+
+
+
+{{template config_path="design/email/header_template"}}
+
+
+
+
+ {{trans "%customer_name," customer_name=$order_data.customer_name}}
+
+ {{trans "You have subscribed for recurring order from %store_name." store_name=$store.frontend_name}}
+ {{trans "Please note that your order will be automatically renewed in %warning_period days." warning_period=$warning_period}}
+
+
+ {{trans 'If you have questions about your order, you can email us at %store_email ' store_email=$store_email |raw}}{{depend store_phone}} {{trans 'or call us at %store_phone ' store_phone=$store_phone |raw}}{{/depend}}.
+ {{trans 'If you want to cancel your recurring order log into your account .' account_url=$this.getUrl($store,'customer/account/',[_nosid:1]) |raw}}
+ {{trans 'Should the recurring payment fail, we will add %warning_period days to your order, so that you can update your payment details.' warning_period=$warning_period}}
+ {{depend store_hours}}
+ {{trans 'Our hours are %store_hours .' store_hours=$store_hours |raw}}
+ {{/depend}}
+
+
+
+
+
+ {{trans 'Your Order #%increment_id ' increment_id=$order.increment_id |raw}}
+ {{trans 'Placed on %created_at ' created_at=$created_at_formatted |raw}}
+
+
+
+
+ {{depend order_data.email_customer_note}}
+
+
+
+ {{var order_data.email_customer_note|escape|nl2br}}
+
+
+
+ {{/depend}}
+
+
+
+ {{trans "Billing Info"}}
+ {{var formattedBillingAddress|raw}}
+
+ {{depend order_data.is_not_virtual}}
+
+ {{trans "Shipping Info"}}
+ {{var formattedShippingAddress|raw}}
+
+ {{/depend}}
+
+
+
+ {{trans "Payment Method"}}
+ {{var payment_html|raw}}
+
+ {{depend order_data.is_not_virtual}}
+
+ {{trans "Shipping Method"}}
+ {{var order.shipping_description}}
+ {{if shipping_msg}}
+ {{var shipping_msg}}
+ {{/if}}
+
+ {{/depend}}
+
+
+ {{layout handle="sales_email_order_items" order_id=$order_id area="frontend"}}
+
+
+
+
+{{template config_path="design/email/footer_template"}}
diff --git a/view/frontend/email/restore_order_notification.html b/view/frontend/email/restore_order_notification.html
new file mode 100644
index 00000000..155235aa
--- /dev/null
+++ b/view/frontend/email/restore_order_notification.html
@@ -0,0 +1,27 @@
+
+
+
+{{template config_path="design/email/header_template"}}
+
+{{trans "Hello"}}
+
+ {{trans
+ 'Restore order %order_increment '
+
+ order_url=$order.url
+ order_increment=$order.increment
+ |raw}}
+
+
+ {{trans
+ "The payment for the order %order_increment has been completed while the order was in canceled state.
+ Please navigate to your Magento orders, select the order in question, and click restore order. Note the stock."
+
+ order_increment=$order.increment
+ |raw}}
+
+
+{{template config_path="design/email/footer_template"}}
diff --git a/view/frontend/layout/checkout_index_index.xml b/view/frontend/layout/checkout_index_index.xml
index 023f2cf0..69bde7e6 100644
--- a/view/frontend/layout/checkout_index_index.xml
+++ b/view/frontend/layout/checkout_index_index.xml
@@ -8,6 +8,27 @@
-
-
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- uiComponent
+ - 35
+ -
+
-
+
- Nexi_Checkout/js/view/checkout/summary/recurring_total
+
+
+
+
+
+
+
+
+
-
-
-
@@ -17,11 +38,11 @@
-
-
-
-
+
-
- Nexi_Checkout/js/view/payment/nexi-payment
-
-
-
- false
+ - true
diff --git a/view/frontend/layout/checkout_onepage_success.xml b/view/frontend/layout/checkout_onepage_success.xml
new file mode 100644
index 00000000..6872092a
--- /dev/null
+++ b/view/frontend/layout/checkout_onepage_success.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/view/frontend/layout/customer_account.xml b/view/frontend/layout/customer_account.xml
new file mode 100644
index 00000000..50fa262e
--- /dev/null
+++ b/view/frontend/layout/customer_account.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+ nexi/order/payments
+ My Subscriptions
+ 229
+
+
+
+
+
diff --git a/view/frontend/layout/nexi_order_payments.xml b/view/frontend/layout/nexi_order_payments.xml
new file mode 100644
index 00000000..99204301
--- /dev/null
+++ b/view/frontend/layout/nexi_order_payments.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/view/frontend/layout/sales_order_printinvoice.xml b/view/frontend/layout/sales_order_printinvoice.xml
new file mode 100644
index 00000000..cefe2d28
--- /dev/null
+++ b/view/frontend/layout/sales_order_printinvoice.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/view/frontend/requirejs-config.js b/view/frontend/requirejs-config.js
new file mode 100644
index 00000000..d8aa54d5
--- /dev/null
+++ b/view/frontend/requirejs-config.js
@@ -0,0 +1,18 @@
+/**
+ * Copyright © Nexi. All rights reserved.
+ */
+var config = {
+ map: {
+ '*': {
+ 'Magento_Checkout/js/model/shipping-save-processor/default': 'Nexi_Checkout/js/model/shipping-save-processor/default',
+ 'nexi-success-page': 'Nexi_Checkout/js/success-page'
+ },
+ },
+ config: {
+ mixins: {
+ 'Magento_Checkout/js/checkout-data': {
+ 'Nexi_Checkout/js/model/checkout-data-ext': true
+ }
+ }
+ }
+};
diff --git a/view/frontend/templates/order/payments.phtml b/view/frontend/templates/order/payments.phtml
new file mode 100644
index 00000000..a6de1f4d
--- /dev/null
+++ b/view/frontend/templates/order/payments.phtml
@@ -0,0 +1,117 @@
+
+getCustomerSubscriptions() ?>
+getCustomerClosedSubscriptions() ?>
+isSubscriptionsEnabled()): ?>
+
+
+
+ = $escaper->escapeHtml(__('Subscriptions')) ?>
+
+
+ = $escaper->escapeHtml(__('Next order date')) ?>
+ = $escaper->escapeHtml(__('Status')) ?>
+ = $escaper->escapeHtml(__('Recurring payment profile')) ?>
+ = $escaper->escapeHtml(__('Grand total')) ?>
+ = $escaper->escapeHtml(__('Action')) ?>
+
+
+
+
+
+ = $escaper->escapeHtml($block->validateDate($recurringPayment->getNextOrderDate())) ?>
+ = /* @noEscape */
+ $escaper->escapeHtml($block->getRecurringPaymentStatusName($recurringPayment->getStatus())) ?>
+ = /* @noEscape */
+ $escaper->escapeHtml($recurringPayment->getName()) ?>
+ = $escaper->escapeHtml($block->getCurrentCurrency()) . $escaper->escapeHtml($recurringPayment->getBaseGrandTotal()) ?>
+
+
+
+ = $escaper->escapeHtml(__('Action')) ?> ∨
+
+
+
+
+
+
+
+
+
+ getPagerHtml()) : ?>
+ = $block->getPagerHtml() ?>
+
+
+ = $escaper->escapeHtml($block->getEmptyRecurringPaymentsMessage()) ?>
+
+
+
+
+ = $escaper->escapeHtml(__('Canceled subscriptions')) ?>
+
+
+ = $escaper->escapeHtml(__('Canceled subscriptions')) ?>
+
+
+ = $escaper->escapeHtml(__('Next order date')) ?>
+ = $escaper->escapeHtml(__('Status')) ?>
+ = $escaper->escapeHtml(__('Recurring payment profile')) ?>
+ = $escaper->escapeHtml(__('Grand total')) ?>
+
+
+
+
+
+ = $escaper->escapeHtml($block->validateDate($closedSubscription->getNextOrderDate())) ?>
+ = /* @noEscape */
+ $escaper->escapeHtml($block->getRecurringPaymentStatusName($closedSubscription->getStatus())) ?>
+ = /* @noEscape */
+ $escaper->escapeHtml($closedSubscription->getName()) ?>
+ = $escaper->escapeHtml($closedSubscription->getBaseGrandTotal()) ?>
+
+
+ = $escaper->escapeHtml(__('View Order')) ?>
+
+
+
+
+
+
+
+
+
+
+
diff --git a/view/frontend/templates/success/minicart-update.phtml b/view/frontend/templates/success/minicart-update.phtml
new file mode 100644
index 00000000..610530a3
--- /dev/null
+++ b/view/frontend/templates/success/minicart-update.phtml
@@ -0,0 +1,7 @@
+
diff --git a/view/frontend/web/css/source/_module.less b/view/frontend/web/css/source/_module.less
new file mode 100644
index 00000000..6e5a4501
--- /dev/null
+++ b/view/frontend/web/css/source/_module.less
@@ -0,0 +1,21 @@
+.checkout-payment-method {
+ .payment-method-subselection {
+
+ .subselection-label-container {
+ padding: 6px 0;
+
+ .with-icon {
+ display: inline-flex;
+
+ .subselection-label {
+ align-content: center;
+ }
+
+ .subselection-icon {
+ height: 48px;
+ padding: 0 10px;
+ }
+ }
+ }
+ }
+}
diff --git a/view/frontend/web/css/subscription.css b/view/frontend/web/css/subscription.css
new file mode 100644
index 00000000..79463e58
--- /dev/null
+++ b/view/frontend/web/css/subscription.css
@@ -0,0 +1,100 @@
+.customer-subscription-action {
+ display: inline-block;
+ position: relative;
+}
+
+.customer-subscription-action.active {
+ overflow: visible;
+}
+
+.customer-subscription-action::before,
+.customer-subscription-action::after {
+ content: '';
+ display: table;
+}
+
+.customer-subscription-action ul::after {
+ right: 9px;
+ top: -14px;
+}
+
+.customer-subscription-action ul::after {
+ border: 7px solid;
+ border-top-color: currentcolor;
+ border-right-color: currentcolor;
+ border-bottom-color: currentcolor;
+ border-left-color: currentcolor;
+ border-color: transparent transparent #bbbbbb transparent;
+ z-index: 98;
+}
+
+.customer-subscription-action ul::before, .customer-subscription-action ul::after {
+ border-bottom-style: solid;
+ content: '';
+ display: block;
+ height: 0;
+ position: absolute;
+ width: 0;
+}
+
+.customer-subscription-action ul::before {
+ right: 10px;
+ top: -12px;
+}
+
+.customer-subscription-action ul::before {
+ border: 6px solid;
+ border-top-color: currentcolor;
+ border-right-color: currentcolor;
+ border-bottom-color: currentcolor;
+ border-left-color: currentcolor;
+ border-color: transparent transparent #ffffff transparent;
+ z-index: 99;
+}
+
+.customer-subscription-action.active ul {
+ display: block;
+}
+
+.customer-subscription-action ul {
+ margin: 0;
+ padding: 0;
+ list-style: none none;
+ background: #ffffff;
+ border: 1px solid #bbbbbb;
+ margin-top: 4px;
+ min-width: 100%;
+ z-index: 101;
+ box-sizing: border-box;
+ display: none;
+ position: absolute;
+ top: 100%;
+ right: 0;
+ box-shadow: 0 3px 3px rgba(0, 0, 0, 0.15);
+}
+
+.customer-subscription-action ul li {
+ margin: 0;
+ padding: 0;
+}
+
+.customer-subscription-action ul {
+ list-style: none none;
+}
+
+.customer-subscription-action li a:visited {
+ color: #333333;
+}
+
+.customer-subscription-action li a {
+ color: #333333;
+ text-decoration: none;
+ display: block;
+ line-height: 1.4;
+ padding: 8px;
+}
+
+.customer-subscription-action ul li:hover {
+ background: #e8e8e8;
+ cursor: pointer;
+}
diff --git a/view/frontend/web/images/applepay.png b/view/frontend/web/images/applepay.png
new file mode 100644
index 00000000..bb7e857d
Binary files /dev/null and b/view/frontend/web/images/applepay.png differ
diff --git a/view/frontend/web/images/googlepay.png b/view/frontend/web/images/googlepay.png
new file mode 100644
index 00000000..54f2ef26
Binary files /dev/null and b/view/frontend/web/images/googlepay.png differ
diff --git a/view/frontend/web/images/klarna.png b/view/frontend/web/images/klarna.png
new file mode 100644
index 00000000..c2d7131b
Binary files /dev/null and b/view/frontend/web/images/klarna.png differ
diff --git a/view/frontend/web/images/mobilepay.png b/view/frontend/web/images/mobilepay.png
new file mode 100644
index 00000000..4aaf02c8
Binary files /dev/null and b/view/frontend/web/images/mobilepay.png differ
diff --git a/view/frontend/web/images/nexi-cards.png b/view/frontend/web/images/nexi-cards.png
new file mode 100644
index 00000000..b07081bc
Binary files /dev/null and b/view/frontend/web/images/nexi-cards.png differ
diff --git a/view/frontend/web/images/paypal.png b/view/frontend/web/images/paypal.png
new file mode 100644
index 00000000..c88ced59
Binary files /dev/null and b/view/frontend/web/images/paypal.png differ
diff --git a/view/frontend/web/images/ratepay.png b/view/frontend/web/images/ratepay.png
new file mode 100644
index 00000000..270ee3ae
Binary files /dev/null and b/view/frontend/web/images/ratepay.png differ
diff --git a/view/frontend/web/images/sofort.png b/view/frontend/web/images/sofort.png
new file mode 100644
index 00000000..e0c807e4
Binary files /dev/null and b/view/frontend/web/images/sofort.png differ
diff --git a/view/frontend/web/images/swish.png b/view/frontend/web/images/swish.png
new file mode 100644
index 00000000..e02875a7
Binary files /dev/null and b/view/frontend/web/images/swish.png differ
diff --git a/view/frontend/web/images/trustly.png b/view/frontend/web/images/trustly.png
new file mode 100644
index 00000000..bd356653
Binary files /dev/null and b/view/frontend/web/images/trustly.png differ
diff --git a/view/frontend/web/images/vipps.png b/view/frontend/web/images/vipps.png
new file mode 100644
index 00000000..8e3d375e
Binary files /dev/null and b/view/frontend/web/images/vipps.png differ
diff --git a/view/frontend/web/js/model/checkout-data-ext.js b/view/frontend/web/js/model/checkout-data-ext.js
new file mode 100644
index 00000000..2db2e9da
--- /dev/null
+++ b/view/frontend/web/js/model/checkout-data-ext.js
@@ -0,0 +1,52 @@
+define([
+ 'Magento_Customer/js/customer-data'
+], function (
+ storage
+) {
+ 'use strict';
+
+ return function (checkoutData) {
+
+ var cacheKey = 'checkout-data',
+
+ /**
+ * @param {Object} data
+ */
+ saveData = function (data) {
+ storage.set(cacheKey, data);
+ },
+
+ /**
+ * @return {Object}
+ */
+ getData = function () {
+ //Makes sure that checkout storage is initiated (any method can be used)
+ checkoutData.getSelectedShippingAddress();
+
+ return storage.get(cacheKey)();
+ };
+
+ /**
+ * Save the pickup address in persistence storage
+ *
+ * @param {Object} data
+ */
+ checkoutData.setNexiSubselection = function (data) {
+ var obj = getData();
+
+ obj.nexiSubselection = data;
+ saveData(obj);
+ };
+
+ /**
+ * Get the pickup address from persistence storage
+ *
+ * @return {*}
+ */
+ checkoutData.getNexiSubselection = function () {
+ return getData().nexiSubselection || null;
+ };
+
+ return checkoutData;
+ };
+});
diff --git a/view/frontend/web/js/model/shipping-save-processor/default.js b/view/frontend/web/js/model/shipping-save-processor/default.js
new file mode 100644
index 00000000..c5251564
--- /dev/null
+++ b/view/frontend/web/js/model/shipping-save-processor/default.js
@@ -0,0 +1,75 @@
+/**
+ * Copyright © Nexi. All rights reserved.
+ */
+define([
+ 'ko',
+ 'Magento_Checkout/js/model/quote',
+ 'Magento_Checkout/js/model/resource-url-manager',
+ 'mage/storage',
+ 'Magento_Checkout/js/model/payment-service',
+ 'Magento_Checkout/js/model/payment/method-converter',
+ 'Magento_Checkout/js/model/error-processor',
+ 'Magento_Checkout/js/model/full-screen-loader',
+ 'Magento_Checkout/js/action/select-billing-address',
+ 'Magento_Checkout/js/model/shipping-save-processor/payload-extender'
+], function (
+ ko,
+ quote,
+ resourceUrlManager,
+ storage,
+ paymentService,
+ methodConverter,
+ errorProcessor,
+ fullScreenLoader,
+ selectBillingAddressAction,
+ payloadExtender
+) {
+ 'use strict';
+
+ return {
+ /**
+ * @return {jQuery.Deferred}
+ */
+ saveShippingInformation: function () {
+ var payload;
+
+ if (!quote.billingAddress() && quote.shippingAddress().canUseForBilling()) {
+ selectBillingAddressAction(quote.shippingAddress());
+ }
+
+ payload = {
+ addressInformation: {
+ 'shipping_address': quote.shippingAddress(),
+ 'billing_address': quote.billingAddress(),
+ 'shipping_method_code': quote.shippingMethod()['method_code'],
+ 'shipping_carrier_code': quote.shippingMethod()['carrier_code']
+ }
+ };
+
+ // Add email to shipping address if available
+ if (quote.guestEmail) {
+ payload.addressInformation.shipping_address.email = quote.guestEmail;
+ }
+
+ payloadExtender(payload);
+
+ fullScreenLoader.startLoader();
+
+ return storage.post(
+ resourceUrlManager.getUrlForSetShippingInformation(quote),
+ JSON.stringify(payload)
+ ).done(
+ function (response) {
+ quote.setTotals(response.totals);
+ paymentService.setPaymentMethods(methodConverter(response['payment_methods']));
+ fullScreenLoader.stopLoader();
+ }
+ ).fail(
+ function (response) {
+ errorProcessor.process(response);
+ fullScreenLoader.stopLoader();
+ }
+ );
+ }
+ };
+});
diff --git a/view/frontend/web/js/sdk/loader.js b/view/frontend/web/js/sdk/loader.js
new file mode 100644
index 00000000..9c5aa56a
--- /dev/null
+++ b/view/frontend/web/js/sdk/loader.js
@@ -0,0 +1,37 @@
+define([
+ 'jquery',
+ 'Magento_Checkout/js/model/full-screen-loader'
+], function ($, fullScreenLoader) {
+ 'use strict';
+
+ return {
+ loadSdk: function (isTestMode) {
+ return new Promise((resolve, reject) => {
+ if (window.Dibs?.Checkout) {
+ resolve();
+ return;
+ }
+
+ const sdkUrl = isTestMode
+ ? 'https://test.checkout.dibspayment.eu/v1/checkout.js?v=1'
+ : 'https://checkout.dibspayment.eu/v1/checkout.js?v=1';
+
+ fullScreenLoader.startLoader();
+
+ const script = document.createElement('script');
+ script.src = sdkUrl;
+ script.async = true;
+ script.onload = () => {
+ fullScreenLoader.stopLoader();
+ resolve();
+ };
+ script.onerror = () => {
+ fullScreenLoader.stopLoader();
+ reject(new Error('Failed to load Nexi Checkout SDK'));
+ };
+
+ document.head.appendChild(script);
+ });
+ }
+ };
+});
diff --git a/view/frontend/web/js/success-page.js b/view/frontend/web/js/success-page.js
new file mode 100644
index 00000000..997ed9e3
--- /dev/null
+++ b/view/frontend/web/js/success-page.js
@@ -0,0 +1,12 @@
+define([
+ 'jquery',
+ 'Magento_Customer/js/customer-data'
+], function ($, customerData) {
+ 'use strict';
+
+ return function () {
+ var sections = ['cart'];
+ customerData.invalidate(sections);
+ customerData.reload(sections, true);
+ };
+});
diff --git a/view/frontend/web/js/view/checkout/summary/recurring_total.js b/view/frontend/web/js/view/checkout/summary/recurring_total.js
new file mode 100644
index 00000000..a7d73b1a
--- /dev/null
+++ b/view/frontend/web/js/view/checkout/summary/recurring_total.js
@@ -0,0 +1,42 @@
+define([
+ 'jquery',
+ 'ko',
+ 'uiComponent',
+ 'Magento_Checkout/js/model/quote',
+ 'Magento_Checkout/js/checkout-data',
+ 'Magento_Customer/js/customer-data',
+ 'Magento_Checkout/js/model/totals',
+ 'Magento_Checkout/js/view/summary/abstract-total',
+ 'Magento_Catalog/js/price-utils',
+ 'mage/translate'],
+ function($, ko, Component, quote, checkoutData, customerData, totals, abstractTotal, priceUtils, $t){
+
+ return Component.extend({
+ defaults: {
+ displayMode: window.checkoutConfig.reviewShippingDisplayMode,
+ template: 'Nexi_Checkout/checkout/summary/recurring_total'
+ },
+
+ getRecurringTotalsText: function (){
+ return $t('Recurring Payment');
+ },
+
+ isRecurringScheduled: function (){
+ return window.checkoutConfig.isRecurringScheduled;
+ },
+
+ getRecurringSubtotal: function (){
+ return priceUtils.formatPrice(window.checkoutConfig.recurringSubtotal);
+ },
+
+ getRecurringShipping: function (){
+ return priceUtils.formatPrice(totals.totals()['shipping_amount']);
+ },
+
+ getRecurringTotal: function (){
+ return priceUtils.formatPrice(
+ window.checkoutConfig.recurringSubtotal + totals.totals()['shipping_amount']
+ );
+ }
+ });
+ });
diff --git a/view/frontend/web/js/view/payment/initialize-payment.js b/view/frontend/web/js/view/payment/initialize-payment.js
new file mode 100644
index 00000000..68d9148f
--- /dev/null
+++ b/view/frontend/web/js/view/payment/initialize-payment.js
@@ -0,0 +1,65 @@
+define([
+ "mage/storage",
+ "Magento_Checkout/js/model/url-builder",
+ "Magento_Checkout/js/model/quote",
+ "Magento_Checkout/js/model/full-screen-loader",
+ "Magento_Checkout/js/model/error-processor",
+ "Magento_Customer/js/model/customer",
+], function (
+ storage,
+ urlBuilder,
+ quote,
+ fullScreenLoader,
+ errorProcessor,
+ customer
+) {
+ "use strict";
+
+ return function () {
+ const payload = {
+ cartId: quote.getQuoteId(),
+ paymentMethod: {
+ method: this.getCode(),
+ },
+ integrationType: this.config.integrationType,
+ };
+
+ if (this.config.payTypeSplitting) {
+ payload.paymentMethod.additionalData = {
+ subselection: this.subselection(),
+ };
+ }
+
+ const serviceUrl = customer.isLoggedIn()
+ ? urlBuilder.createUrl("/nexi/carts/mine/payment-initialize", {})
+ : urlBuilder.createUrl("/nexi/guest-carts/:quoteId/payment-initialize", {
+ quoteId: quote.getQuoteId(),
+ });
+
+ fullScreenLoader.startLoader();
+
+ return new Promise((resolve, reject) => {
+ storage
+ .post(serviceUrl, JSON.stringify(payload))
+ .done(function (response) {
+ resolve(JSON.parse(response));
+ })
+ .fail(
+ function (response) {
+ errorProcessor.process(response, this.messageContainer);
+ let redirectURL = response.getResponseHeader("errorRedirectAction");
+
+ if (redirectURL) {
+ setTimeout(function () {
+ errorProcessor.redirectTo(redirectURL);
+ }, 3000);
+ }
+ reject(response);
+ }.bind(this)
+ )
+ .always(function () {
+ fullScreenLoader.stopLoader();
+ });
+ });
+ };
+});
diff --git a/view/frontend/web/js/view/payment/method-renderer/nexi-method.js b/view/frontend/web/js/view/payment/method-renderer/nexi-method.js
old mode 100755
new mode 100644
index 93ff865d..e31615ba
--- a/view/frontend/web/js/view/payment/method-renderer/nexi-method.js
+++ b/view/frontend/web/js/view/payment/method-renderer/nexi-method.js
@@ -13,46 +13,134 @@ define(
'Magento_Checkout/js/model/url-builder',
'mage/url',
'Magento_Checkout/js/model/full-screen-loader',
+ 'Magento_Checkout/js/model/error-processor',
'Magento_Customer/js/model/customer',
'Magento_Checkout/js/checkout-data',
'Magento_Checkout/js/model/totals',
'Magento_Ui/js/model/messageList',
'mage/translate',
- 'Magento_Ui/js/modal/modal'
+ 'Magento_Ui/js/modal/modal',
+ 'Nexi_Checkout/js/sdk/loader',
+ 'Nexi_Checkout/js/view/payment/initialize-payment',
+ 'Nexi_Checkout/js/view/payment/render-embedded',
+ 'Nexi_Checkout/js/view/payment/validate',
+ 'Magento_Checkout/js/model/payment/place-order-hooks',
],
- function (ko,
- $,
- _,
- storage,
- Component,
- placeOrderAction,
- selectPaymentMethodAction,
- additionalValidators,
- quote,
- getTotalsAction,
- urlBuilder,
- url,
- fullScreenLoader,
- customer,
- checkoutData,
- totals,
- messageList,
- $t,
- modal
+ function (
+ ko,
+ $,
+ _,
+ storage,
+ Component,
+ placeOrderAction,
+ selectPaymentMethodAction,
+ additionalValidators,
+ quote,
+ getTotalsAction,
+ urlBuilder,
+ url,
+ fullScreenLoader,
+ errorProcessor,
+ customer,
+ checkoutData,
+ totals,
+ messageList,
+ $t,
+ modal,
+ sdkLoader,
+ initializeCartPayment,
+ renderEmbeddedCheckout,
+ validatePayment,
+ placeOrderHooks,
) {
'use strict';
return Component.extend({
defaults: {
- template: window.checkoutConfig.payment.nexi.integrationType ? 'Nexi_Checkout/payment/nexi-hosted' : 'Nexi_Checkout/payment/nexi-embedded.html',
- config: window.checkoutConfig.payment.nexi
+ template: window.checkoutConfig.payment.nexi.integrationType == 'HostedPaymentPage'
+ ? (window.checkoutConfig.payment.nexi.payTypeSplitting
+ ? 'Nexi_Checkout/payment/nexi-hosted-type-split.html'
+ : 'Nexi_Checkout/payment/nexi-hosted.html')
+ : (window.checkoutConfig.payment.nexi.payTypeSplitting
+ ? 'Nexi_Checkout/payment/nexi-embedded-type-split.html'
+ : 'Nexi_Checkout/payment/nexi-embedded.html'),
+ config: window.checkoutConfig.payment.nexi,
+ creditCardType: '',
+ creditCardExpYear: '',
+ creditCardExpMonth: '',
+ creditCardNumber: '',
+ creditCardVerificationNumber: '',
+ selectedCardType: null
},
- placeOrder: function (data, event) {
- let placeOrder = placeOrderAction(this.getData(), false, this.messageContainer);
+ payTypeSplitting: ko.observable(window.checkoutConfig.payment.nexi.payTypeSplitting),
+ isEmbedded: ko.observable(false),
+ dibsCheckout: ko.observable(false),
+ isRendering: ko.observable(false),
+ eventsSubscribed: ko.observable(false),
+ subselection: ko.observable(false),
+ initialize: function () {
+ this._super();
+ if (this.config.integrationType === 'EmbeddedCheckout') {
+ this.isEmbedded(true);
+ }
+ if (this.payTypeSplitting()) {
+ if (this.isActive()) {
+ this.subselection(checkoutData.getNexiSubselection() || false);
+ this.moveContentToSubselection(this.subselection());
+ }
+ this.subselection.subscribe(function (newSubselection) {
+ if (newSubselection) {
+ this.moveContentToSubselection(newSubselection);
+ this.selectPaymentMethod();
+ }
+ checkoutData.setNexiSubselection(newSubselection);
+ }, this);
+
+ }
+ if (this.isActive() && this.isEmbedded()) {
+ this.renderCheckout();
+ }
+
+ quote.paymentMethod.subscribe(function (method) {
+ this.hideIframeIfNeeded();
+ this.clearSubselection(method);
+ }, this);
- $.when(placeOrder).done(function (response) {
- this.afterPlaceOrder(response);
- }.bind(this));
+ placeOrderHooks.requestModifiers.push(
+ function (headers, payload) {
+
+ if (payload.paymentMethod.extension_attributes === undefined) {
+ payload.paymentMethod.extension_attributes = {};
+ }
+ payload.paymentMethod.extension_attributes.subselection = this.subselection();
+ }.bind(this)
+ );
+ },
+ afterRender: function () {
+ if (this.isActive()) {
+ this.moveContentToSubselection(this.subselection());
+ }
+ },
+ moveContentToSubselection: function (subselection) {
+ const contentElement = document.getElementById("nexi-content");
+ const placeholderElement = document.getElementById("nexi-content-" + subselection);
+ if (contentElement && placeholderElement) {
+ if (subselection) {
+ placeholderElement.appendChild(contentElement);
+ } else {
+ const originalContainer = document.getElementById("nexi-original-container");
+ if (originalContainer) {
+ originalContainer.appendChild(contentElement);
+ }
+ }
+ }
+ },
+ isActive: function () {
+ return this.getCode() === this.isChecked();
+ },
+ async placeOrder(data, event) {
+ const response = await placeOrderAction(this.getData(), false, this.messageContainer);
+ this.afterPlaceOrder(response);
},
afterPlaceOrder: function (response) {
if (this.isHosted()) {
@@ -62,9 +150,125 @@ define(
}
}
},
+ async renderCheckout() {
+ this.subscribeToEvents();
+ quote.totals.subscribe(async function (quote) {
+ if (!this.isActive()) {
+ return;
+ }
+ await renderEmbeddedCheckout.call(this);
+ this.subscribeToEvents();
+ }, this);
+ },
+ clearNexiCheckout() {
+ if (this.dibsCheckout()) {
+ this.dibsCheckout().cleanup();
+ }
+ if (document.getElementById("nexi-checkout-container")) {
+ document.getElementById("nexi-checkout-container").innerHTML = "";
+ }
+ },
+ selectPaymentMethod: function () {
+ this.clearNexiCheckout();
+ this._super();
+ checkoutData.setNexiSubselection(this.subselection());
+ if (this.isEmbedded()) {
+ this.renderCheckout();
+ }
+ return true;
+ },
+
+ subscribeToEvents: function () {
+ if (this.dibsCheckout()) {
+ // If events are already subscribed to this instance, don't subscribe again
+ if (this.eventsSubscribed() === true) {
+ return;
+ }
+
+ // Store a reference to the current dibsCheckout instance to ensure we're subscribing to the right one
+ const currentDibsCheckout = this.dibsCheckout();
+
+ currentDibsCheckout.on(
+ "payment-completed",
+ async function () {
+ window.location.href = url.build("checkout/onepage/success");
+ }.bind(this)
+ );
+
+ currentDibsCheckout.on(
+ "pay-initialized",
+ async function (paymentId) {
+ try {
+ const validationResult = await validatePayment.call(this);
+ if (!validationResult.success) {
+ console.error("Payment validation failed. paymentId:", paymentId);
+ window.location.reload();
+ }
+
+ await this.placeOrder(); // Ensure the order is placed before proceeding
+
+ // Trigger Dibs processing only after the order is placed
+ // Use the same instance reference to send the event
+ currentDibsCheckout.send("payment-order-finalized", true);
+ } catch (error) {
+ console.error("Error during payment initialization:", error);
+ window.location.reload();
+ }
+ }.bind(this)
+ );
+
+ currentDibsCheckout.on(
+ "payment-cancelled",
+ async function (paymentId) {
+ fullScreenLoader.stopLoader();
+ }.bind(this)
+ );
+
+ this.eventsSubscribed(true);
+ }
+ },
isHosted: function () {
return this.config.integrationType === 'HostedPaymentPage';
},
+ hideIframeIfNeeded: function () {
+ const method = quote.paymentMethod();
+ const container = document.getElementById("nexi-checkout-container");
+ if (!container) {
+ return;
+ }
+
+ if (!method || method.method !== this.getCode()) {
+ container.style.display = "none";
+ } else {
+ container.style.display = "block";
+ }
+ },
+ getData: function () {
+ return {
+ method: this.getCode(),
+ additional_data: {
+ integrationType: this.config.integrationType,
+ paymentMethod: this.config.paymentMethod,
+ subselection: this.subselection(),
+ }
+ };
+ },
+ clearSubselection(method) {
+ if (method && method.method !== this.getCode()) {
+ this.subselection(false);
+ }
+ },
+ getSubselections: function () {
+ if (this.config.payTypeSplitting && this.config.subselections) {
+ return this.config.subselections.map(function (subselection) {
+ return {
+ value: subselection.value,
+ label: subselection.label,
+ };
+ }, this);
+ }
+ return [];
+ }
});
}
);
diff --git a/view/frontend/web/js/view/payment/nexi-payment.js b/view/frontend/web/js/view/payment/nexi-payment.js
old mode 100755
new mode 100644
index a462d1b0..f8073aa1
--- a/view/frontend/web/js/view/payment/nexi-payment.js
+++ b/view/frontend/web/js/view/payment/nexi-payment.js
@@ -16,6 +16,7 @@ define(
component: 'Nexi_Checkout/js/view/payment/method-renderer/nexi-method'
}
);
+
return Component.extend({});
}
);
diff --git a/view/frontend/web/js/view/payment/render-embedded.js b/view/frontend/web/js/view/payment/render-embedded.js
new file mode 100644
index 00000000..fce2bea8
--- /dev/null
+++ b/view/frontend/web/js/view/payment/render-embedded.js
@@ -0,0 +1,59 @@
+define([
+ "Nexi_Checkout/js/sdk/loader",
+ "Nexi_Checkout/js/view/payment/initialize-payment",
+ "Nexi_Checkout/js/view/payment/validate",
+ "mage/url",
+ 'Magento_Checkout/js/model/quote',
+], function (sdkLoader, initializePayment, validatePayment, url, quote) {
+ "use strict";
+
+ // Define the rendering function
+ return async function () {
+ if (this.isRendering()) {
+ console.log("Rendering already in progress. Skipping this call.");
+ return;
+ }
+
+ let selectedPaymentMethod = quote.paymentMethod();
+ if (!selectedPaymentMethod || selectedPaymentMethod.method !== "nexi") {
+ console.log("Selected payment method is not Nexi. Skipping rendering.");
+ return;
+ }
+
+ this.isRendering(true);
+ try {
+ await sdkLoader.loadSdk(this.config.environment === "test");
+
+ if (this.dibsCheckout()) {
+ this.dibsCheckout().cleanup();
+ }
+
+ // Clear the container before rendering
+ if (document.getElementById("nexi-checkout-container")) {
+ document.getElementById("nexi-checkout-container").innerHTML = "";
+ }
+
+ const response = await initializePayment.call(this)
+
+ if (response.paymentId) {
+ let checkoutOptions = {
+ checkoutKey: response.checkoutKey,
+ paymentId: response.paymentId,
+ containerId: "nexi-checkout-container",
+ language: response.locale || "en-GB"
+ };
+ const newDibsCheckout = new Dibs.Checkout(checkoutOptions);
+ this.dibsCheckout(newDibsCheckout);
+
+ // Reset eventsSubscribed flag to ensure events are subscribed to the new instance
+ this.eventsSubscribed(false);
+
+ console.log("Nexi Checkout SDK loaded successfully. paymentId: ", response.paymentId);
+ }
+ } catch (error) {
+ console.error("Error loading Nexi SDK or initializing payment:", error);
+ } finally {
+ this.isRendering(false);
+ }
+ }
+});
diff --git a/view/frontend/web/js/view/payment/validate.js b/view/frontend/web/js/view/payment/validate.js
new file mode 100644
index 00000000..9b3e7dcc
--- /dev/null
+++ b/view/frontend/web/js/view/payment/validate.js
@@ -0,0 +1,56 @@
+define([
+ "mage/storage",
+ "Magento_Checkout/js/model/url-builder",
+ "Magento_Checkout/js/model/quote",
+ "Magento_Checkout/js/model/full-screen-loader",
+ "Magento_Checkout/js/model/error-processor",
+ "Magento_Customer/js/model/customer",
+], function (
+ storage,
+ urlBuilder,
+ quote,
+ fullScreenLoader,
+ errorProcessor,
+ customer
+) {
+ "use strict";
+
+ return function () {
+ const payload = {
+ cartId: quote.getQuoteId(),
+ paymentId: this.dibsCheckout().options.paymentId
+ };
+
+ const serviceUrl = customer.isLoggedIn()
+ ? urlBuilder.createUrl("/nexi/carts/mine/payment-validate", {})
+ : urlBuilder.createUrl("/nexi/guest-carts/:quoteId/payment-validate", {
+ quoteId: quote.getQuoteId()
+ });
+
+ fullScreenLoader.startLoader();
+
+ return new Promise((resolve, reject) => {
+ storage
+ .post(serviceUrl, JSON.stringify(payload))
+ .done(function (response) {
+ resolve(JSON.parse(response));
+ })
+ .fail(
+ function (response) {
+ errorProcessor.process(response, this.messageContainer);
+ let redirectURL = response.getResponseHeader("errorRedirectAction");
+
+ if (redirectURL) {
+ setTimeout(function () {
+ errorProcessor.redirectTo(redirectURL);
+ }, 3000);
+ }
+ reject(response);
+ }.bind(this)
+ )
+ .always(function () {
+ fullScreenLoader.stopLoader();
+ });
+ });
+ };
+});
diff --git a/view/frontend/web/template/checkout/summary/recurring_total.html b/view/frontend/web/template/checkout/summary/recurring_total.html
new file mode 100644
index 00000000..166bbb89
--- /dev/null
+++ b/view/frontend/web/template/checkout/summary/recurring_total.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/view/frontend/web/template/payment/nexi-embedded-type-split.html b/view/frontend/web/template/payment/nexi-embedded-type-split.html
new file mode 100644
index 00000000..f19b323a
--- /dev/null
+++ b/view/frontend/web/template/payment/nexi-embedded-type-split.html
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/view/frontend/web/template/payment/nexi-embedded.html b/view/frontend/web/template/payment/nexi-embedded.html
index 21e4ae90..fbf4eaab 100644
--- a/view/frontend/web/template/payment/nexi-embedded.html
+++ b/view/frontend/web/template/payment/nexi-embedded.html
@@ -1,5 +1,3 @@
-
-
-
diff --git a/view/frontend/web/template/payment/nexi-hosted-type-split.html b/view/frontend/web/template/payment/nexi-hosted-type-split.html
new file mode 100644
index 00000000..6eaf23e0
--- /dev/null
+++ b/view/frontend/web/template/payment/nexi-hosted-type-split.html
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/view/frontend/web/template/payment/nexi-hosted.html b/view/frontend/web/template/payment/nexi-hosted.html
index b20743a3..a6a7a383 100644
--- a/view/frontend/web/template/payment/nexi-hosted.html
+++ b/view/frontend/web/template/payment/nexi-hosted.html
@@ -4,15 +4,24 @@
name="payment[method]"
class="radio"
data-bind="attr: {'id': getCode()}, value: getCode(), checked: isChecked, click: selectPaymentMethod, visible: isRadioButtonVisible()"/>
+
-
+
+
+
+
+
+
+
+
+
-
+
@@ -20,11 +29,11 @@