diff --git a/src/Resources/config/services/services.xml b/src/Resources/config/services/services.xml index 935d2dfdc..6b460d9c7 100644 --- a/src/Resources/config/services/services.xml +++ b/src/Resources/config/services/services.xml @@ -115,6 +115,7 @@ + diff --git a/src/Resources/config/services/subscriber.xml b/src/Resources/config/services/subscriber.xml index 5c400c9dc..3efd493f7 100644 --- a/src/Resources/config/services/subscriber.xml +++ b/src/Resources/config/services/subscriber.xml @@ -66,5 +66,12 @@ + + + + + + + diff --git a/src/Service/Order/OrderStatusUpdater.php b/src/Service/Order/OrderStatusUpdater.php index bebcb6f86..c1ed0d2a1 100644 --- a/src/Service/Order/OrderStatusUpdater.php +++ b/src/Service/Order/OrderStatusUpdater.php @@ -16,6 +16,7 @@ class OrderStatusUpdater { + public const ORDER_STATE_FORCE_OPEN = 'order-state-force-open'; /** * @var OrderStateService */ @@ -101,7 +102,7 @@ public function updatePaymentStatus(OrderTransactionEntity $transaction, string { # if we are already in_progress...then don't switch to OPEN again # otherwise SEPA bank transfer would switch back to OPEN - if ($currentShopwareStatusKey !== OrderTransactionStates::STATE_IN_PROGRESS) { + if ($currentShopwareStatusKey !== OrderTransactionStates::STATE_IN_PROGRESS || $context->hasState(self::ORDER_STATE_FORCE_OPEN)) { $addLog = true; $this->transactionTransitionService->reOpenTransaction($transaction, $context); } diff --git a/src/Service/Order/OrderTimeService.php b/src/Service/Order/OrderTimeService.php new file mode 100644 index 000000000..0c9666ece --- /dev/null +++ b/src/Service/Order/OrderTimeService.php @@ -0,0 +1,56 @@ +now = $now ?? new DateTime(); + } + + /** + * Checks if the age of the last transaction of the order is greater than the specified number of hours. + * + * @param OrderEntity $order The order entity to check. + * @param int $hours The number of hours to compare against. + * + * @return bool Returns true if the order is older than the specified number of hours, false otherwise. + */ + public function isOrderAgeGreaterThan(OrderEntity $order, int $hours): bool + { + $transactions = $order->getTransactions(); + + if ($transactions === null || count($transactions) === 0) { + return false; + } + + /** @var ?OrderTransactionEntity $lastTransaction */ + $lastTransaction = $transactions->last(); + + if ($lastTransaction === null) { + return false; + } + + $transitionDate = $lastTransaction->getCreatedAt(); + + if ($transitionDate === null) { + return false; + } + + $interval = $this->now->diff($transitionDate); + $diffInHours = $interval->h + ($interval->days * 24); + + return $diffInHours > $hours; + } +} diff --git a/src/Service/SettingsService.php b/src/Service/SettingsService.php index 040972781..c1ca33cbb 100644 --- a/src/Service/SettingsService.php +++ b/src/Service/SettingsService.php @@ -12,8 +12,10 @@ class SettingsService implements PluginSettingsServiceInterface { public const SYSTEM_CONFIG_DOMAIN = 'MolliePayments.config'; private const SYSTEM_CORE_LOGIN_REGISTRATION_CONFIG_DOMAIN = 'core.loginRegistration'; + private const SYSTEM_CORE_CART_CONFIG_DOMAIN = 'core.cart'; private const PHONE_NUMBER_FIELD_REQUIRED = 'phoneNumberFieldRequired'; + private const PAYMENT_FINALIZE_TRANSACTION_TIME = 'paymentFinalizeTransactionTime'; const LIVE_API_KEY = 'liveApiKey'; const TEST_API_KEY = 'testApiKey'; const LIVE_PROFILE_ID = 'liveProfileId'; @@ -88,6 +90,10 @@ public function getSettings(?string $salesChannelId = null): MollieSettingStruct $structData[self::PHONE_NUMBER_FIELD_REQUIRED] = $coreSettings[self::PHONE_NUMBER_FIELD_REQUIRED] ?? false; + /** @var array $cartSettings */ + $cartSettings = $this->systemConfigService->get(self::SYSTEM_CORE_CART_CONFIG_DOMAIN, $salesChannelId); + $structData[self::PAYMENT_FINALIZE_TRANSACTION_TIME] = $cartSettings[self::PAYMENT_FINALIZE_TRANSACTION_TIME] ?? 1800; + return (new MollieSettingStruct())->assign($structData); } diff --git a/src/Setting/MollieSettingStruct.php b/src/Setting/MollieSettingStruct.php index 97c7078d7..48539b026 100644 --- a/src/Setting/MollieSettingStruct.php +++ b/src/Setting/MollieSettingStruct.php @@ -255,6 +255,11 @@ class MollieSettingStruct extends Struct */ protected $applePayDirectDomainAllowList = ''; + /** + * @var int + */ + protected $paymentFinalizeTransactionTime; + /** * @return string */ @@ -982,4 +987,14 @@ public function setApplePayDirectDomainAllowList(string $applePayDirectDomainAll { $this->applePayDirectDomainAllowList = $applePayDirectDomainAllowList; } + + public function getPaymentFinalizeTransactionTime(): int + { + return $this->paymentFinalizeTransactionTime; + } + + public function setPaymentFinalizeTransactionTime(int $paymentFinalizeTransactionTime): void + { + $this->paymentFinalizeTransactionTime = $paymentFinalizeTransactionTime; + } } diff --git a/src/Subscriber/OrderEditSubscriber.php b/src/Subscriber/OrderEditSubscriber.php new file mode 100644 index 000000000..931fbe20a --- /dev/null +++ b/src/Subscriber/OrderEditSubscriber.php @@ -0,0 +1,144 @@ +orderStatusUpdater = $orderStatusUpdater; + $this->orderTimeService = $orderTimeService; + $this->settingsService = $settingsService; + } + + public static function getSubscribedEvents(): array + { + return [ + AccountOrderPageLoadedEvent::class => 'accountOrderDetailPageLoaded' + ]; + } + + public function accountOrderDetailPageLoaded(AccountOrderPageLoadedEvent $event): void + { + $orders = $event->getPage()->getOrders(); + + foreach ($orders as $order) { + if (!$order instanceof OrderEntity || $this->isMolliePayment($order) === false) { + continue; + } + + $transactions = $order->getTransactions(); + + if ($transactions === null || $transactions->count() === 0) { + continue; + } + + $lastTransaction = $transactions->filter(Closure::fromCallable([$this, 'sortTransactionsByDate']))->last(); + + $lastStatus = $lastTransaction->getStateMachineState()->getTechnicalName(); + + // disregard any orders that are not in progress + if ($lastStatus !== OrderStates::STATE_IN_PROGRESS) { + continue; + } + + $settings = $this->settingsService->getSettings(); + $finalizeTransactionTimeInMinutes = $settings->getPaymentFinalizeTransactionTime(); + $finalizeTransactionTimeInHours = (int) ceil($finalizeTransactionTimeInMinutes / 60); + + if ($this->orderUsesSepaPayment($order)) { + $finalizeTransactionTimeInHours = (int) ceil($settings->getPaymentMethodBankTransferDueDateDays() / 24); + } + + if ($this->orderTimeService->isOrderAgeGreaterThan($order, $finalizeTransactionTimeInHours) === false) { + continue; + } + + // orderStatusUpdater needs the order to be set on the transaction + $lastTransaction->setOrder($order); + $context = $event->getContext(); + // this forces the order to be open again + $context->addState(OrderStatusUpdater::ORDER_STATE_FORCE_OPEN); + try { + $this->orderStatusUpdater->updatePaymentStatus($lastTransaction, MolliePaymentStatus::MOLLIE_PAYMENT_CANCELED, $context); + } catch (\Exception $exception) { + } + } + } + + /** + * @param OrderEntity $order + * @return bool + * @todo refactor once php8.0 is minimum version. Use Null-safe operator + */ + private function orderUsesSepaPayment(OrderEntity $order): bool + { + $transactions = $order->getTransactions(); + + if ($transactions === null || count($transactions) === 0) { + return false; + } + + $lastTransaction = $transactions->last(); + + if ($lastTransaction instanceof OrderTransactionEntity === false) { + return false; + } + + $paymentMethod = $lastTransaction->getPaymentMethod(); + + if ($paymentMethod === null) { + return false; + } + + return $paymentMethod->getHandlerIdentifier() === BankTransferPayment::class; + } + + private function isMolliePayment(OrderEntity $order): bool + { + $customFields = $order->getCustomFields(); + + return is_array($customFields) && count($customFields) && isset($customFields['mollie_payments']); + } + + /** + * @param OrderTransactionEntity $a + * @param OrderTransactionEntity $b + * @return int + */ + private function sortTransactionsByDate(OrderTransactionEntity $a, OrderTransactionEntity $b): int + { + return $a->getCreatedAt() <=> $b->getCreatedAt(); + } +} diff --git a/tests/PHPUnit/Service/Order/OrderTimeServiceTest.php b/tests/PHPUnit/Service/Order/OrderTimeServiceTest.php new file mode 100644 index 000000000..1221088bd --- /dev/null +++ b/tests/PHPUnit/Service/Order/OrderTimeServiceTest.php @@ -0,0 +1,75 @@ +orderMockWithLastTransactionTimestamp($orderDate); + + $result = (new OrderTimeService($now))->isOrderAgeGreaterThan($order, 1); + + $this->assertSame($expected, $result); + } + + private function orderMockWithLastTransactionTimestamp(\DateTime $time): OrderEntity + { + $entity = $this->createMock(OrderEntity::class); + $transaction = $this->createMock(OrderTransactionEntity::class); + $transactions = new OrderTransactionCollection([$transaction]); + + $entity->method('getTransactions')->willReturn($transactions); + + $transaction->method('getCreatedAt')->willReturn($time); + + return $entity; + } + + public function dateComparisonLogicProvider() + { + return [ + 'order is older than 1 hour' => [ + new \DateTime('2021-01-01 12:00:00'), + new \DateTime('2021-01-01 10:00:00'), + true + ], + 'order is not older than 1 hour' => [ + new \DateTime('2021-01-01 12:00:00'), + new \DateTime('2021-01-01 11:00:00'), + false + ], + 'order is not older than 1 hour, but 1 second' => [ + new \DateTime('2021-01-01 12:00:00'), + new \DateTime('2021-01-01 11:59:59'), + false + ], + 'order is older than a year' => [ + new \DateTime('2021-01-01 12:00:00'), + new \DateTime('2020-01-01 12:00:00'), + true + ], + 'order is 2 months old' => [ + new \DateTime('2021-01-01 12:00:00'), + new \DateTime('2020-11-01 12:00:00'), + true + ], + ]; + } +} \ No newline at end of file