diff --git a/controllers/admin/AdminMollieAdvancedSettingsController.php b/controllers/admin/AdminMollieAdvancedSettingsController.php index 11fb23dcc..5456e7613 100644 --- a/controllers/admin/AdminMollieAdvancedSettingsController.php +++ b/controllers/admin/AdminMollieAdvancedSettingsController.php @@ -363,6 +363,7 @@ private function getStatusMappings(): array $statusKeys = [ Config::MOLLIE_STATUS_AWAITING => $this->module->l('Awaiting', self::FILE_NAME), Config::MOLLIE_STATUS_OPEN => $this->module->l('Open', self::FILE_NAME), + Config::MOLLIE_AUTHORIZABLE_PAYMENT_STATUS_AUTHORIZED => $this->module->l('Authorized', self::FILE_NAME), Config::MOLLIE_STATUS_PAID => $this->module->l('Paid', self::FILE_NAME), Config::MOLLIE_STATUS_COMPLETED => $this->module->l('Completed', self::FILE_NAME), Config::MOLLIE_STATUS_CANCELED => $this->module->l('Canceled', self::FILE_NAME), diff --git a/controllers/admin/AdminMollieAjaxController.php b/controllers/admin/AdminMollieAjaxController.php index 52a2265c9..7e646881a 100644 --- a/controllers/admin/AdminMollieAjaxController.php +++ b/controllers/admin/AdminMollieAjaxController.php @@ -345,7 +345,7 @@ private function processCapture(): void $captureService = $this->module->getService(CaptureService::class); $amount = $captureAmount ? (float) $captureAmount : $order->total_paid; - $status = $captureService->handleCapture($transactionId, $amount); + $status = $captureService->handleCapture($transactionId, $amount, $orderId); $this->ajaxRender(json_encode($status)); } catch (\Throwable $e) { diff --git a/controllers/admin/AdminMolliePaymentMethodsController.php b/controllers/admin/AdminMolliePaymentMethodsController.php index 4e865f071..f0e203706 100644 --- a/controllers/admin/AdminMolliePaymentMethodsController.php +++ b/controllers/admin/AdminMolliePaymentMethodsController.php @@ -252,6 +252,16 @@ public function init(): void 'apiNotConfiguredMessage' => $this->module->l('Please configure your Mollie API keys in the API Configuration tab before managing payment methods.', self::FILE_NAME), 'infoBannerText' => $this->module->l('Here you can see all of the %s payment options. To include new payment methods go to', self::FILE_NAME), 'mollieDashboard' => $this->module->l('Mollie dashboard', self::FILE_NAME), + + 'captureMode' => $this->module->l('Capture mode', self::FILE_NAME), + 'automatic' => $this->module->l('Automatic', self::FILE_NAME), + 'manual' => $this->module->l('Manual', self::FILE_NAME), + 'captureModeAutomatic' => $this->module->l('Payment is captured immediately at checkout.', self::FILE_NAME), + 'captureModeManual' => $this->module->l('Payment stays authorized until you capture from the order page or via auto-capture below.', self::FILE_NAME), + 'autoCaptureOnStatus' => $this->module->l('Auto-capture on status change', self::FILE_NAME), + 'autoCaptureStatuses' => $this->module->l('Trigger on statuses', self::FILE_NAME), + 'autoCaptureInfo' => $this->module->l('When the order reaches one of these statuses, the authorized payment will be captured automatically. You can always capture manually from the order page.', self::FILE_NAME), + 'selectStatuses' => $this->module->l('Select statuses', self::FILE_NAME), ], ]); @@ -262,6 +272,8 @@ public function init(): void 'customerGroups' => $this->getCustomerGroups(), 'onlyOrderMethods' => Config::ORDER_API_ONLY_METHODS, 'onlyPaymentsMethods' => Config::PAYMENT_API_ONLY_METHODS, + 'orderStatuses' => $this->getOrderStatuses(), + 'manualCaptureEligibleMethods' => Config::MOLLIE_MANUAL_CAPTURE_ELIGIBLE_METHODS, ], ]); @@ -423,6 +435,12 @@ private function ajaxGetPaymentMethods(): void 'directCart' => (bool) ($this->configuration->get(Config::MOLLIE_APPLE_PAY_DIRECT_CART) ?: 0), 'buttonStyle' => (int) ($this->configuration->get(Config::MOLLIE_APPLE_PAY_DIRECT_STYLE) ?: 0), ] : null, + 'captureMode' => !empty($methodObj->is_manual_capture) ? 'manual' : 'automatic', + 'isManualCaptureEligible' => in_array($methodId, Config::MOLLIE_MANUAL_CAPTURE_ELIGIBLE_METHODS), + 'autoCapture' => in_array($methodId, Config::MOLLIE_MANUAL_CAPTURE_ELIGIBLE_METHODS) ? [ + 'enabled' => (bool) $this->configuration->get(Config::MOLLIE_METHOD_AUTO_CAPTURE_ENABLED . $methodId), + 'statuses' => json_decode($this->configuration->get(Config::MOLLIE_METHOD_AUTO_CAPTURE_STATUSES . $methodId) ?: '[]', true), + ] : null, ], ]; } catch (Exception $e) { @@ -759,6 +777,21 @@ private function getTaxRulesGroups(): array return $taxRulesGroups; } + private function getOrderStatuses(): array + { + $orderStatuses = OrderState::getOrderStates($this->context->language->id); + $result = []; + + foreach ($orderStatuses as $status) { + $result[] = [ + 'id' => (string) $status['id_order_state'], + 'name' => $status['name'], + ]; + } + + return $result; + } + private function getCustomerGroups(): array { $customerGroups = []; diff --git a/mollie.php b/mollie.php index 2c25b805e..f3f5f6b5f 100755 --- a/mollie.php +++ b/mollie.php @@ -610,7 +610,8 @@ public function hookDisplayAdminOrder($params) $order = new Order($params['id_order']); - if (!$order->hasBeenPaid()) { + $isAuthorized = isset($transaction['bank_status']) && $transaction['bank_status'] === 'authorized'; + if (!$order->hasBeenPaid() && !$isAuthorized) { return false; } @@ -628,6 +629,48 @@ public function hookDisplayAdminOrder($params) $isShipped = $shipService->isShipped($mollieTransactionId); $isCanceled = $cancelService->isCanceled($mollieTransactionId); + if ($mollieApiType === 'payments' && $products) { + $hasDiscount = false; + foreach ($products as $line) { + if ((isset($line->description) && $line->description === 'Discount') + || (float) $line->totalAmount->value < 0 + ) { + $hasDiscount = true; + break; + } + } + + $captureAmounts = $captureService->getCapturedAmounts($mollieTransactionId); + $refundAmounts = $refundService->getRefundedAmounts($mollieTransactionId); + foreach ($products as $line) { + $line->mollieLineCaptured = false; + $line->mollieLineRefunded = false; + $lineTotal = (float) $line->totalAmount->value; + foreach ($captureAmounts as $capIdx => $capAmount) { + if (abs($capAmount - $lineTotal) < 0.005) { + $line->mollieLineCaptured = true; + unset($captureAmounts[$capIdx]); + break; + } + } + foreach ($refundAmounts as $refIdx => $refAmount) { + if (abs($refAmount - $lineTotal) < 0.005) { + $line->mollieLineRefunded = true; + unset($refundAmounts[$refIdx]); + break; + } + } + $line->mollieCanCapture = !$isCaptured + && !$hasDiscount + && !$line->mollieLineCaptured + && $lineTotal <= ((float) $capturableAmount + 0.005); + $line->mollieCanRefund = !$hasDiscount + && $line->mollieLineCaptured + && !$line->mollieLineRefunded + && $lineTotal <= ((float) $refundableAmount + 0.005); + } + } + $lineActions = []; if ($mollieApiType === 'orders' && $products) { foreach ($products as $line) { @@ -875,6 +918,19 @@ public function hookActionOrderStatusUpdate(array $params): void return; } + + try { + /** @var \Mollie\Service\AutoCaptureService $autoCaptureService */ + $autoCaptureService = $this->getService(\Mollie\Service\AutoCaptureService::class); + $autoCaptureService->handleAutoCaptureOnStatusChange( + (int) $order->id, + (int) $orderStatus->id + ); + } catch (\Throwable $exception) { + $logger->error(sprintf('%s - Auto-capture failed', self::FILE_NAME), [ + 'exceptions' => ExceptionUtility::getExceptions($exception), + ]); + } } /** diff --git a/src/Config/Config.php b/src/Config/Config.php index 16fa8659d..7ed28f5f8 100644 --- a/src/Config/Config.php +++ b/src/Config/Config.php @@ -356,6 +356,14 @@ class Config const PAYMENT_API_ONLY_METHODS = []; + const MOLLIE_MANUAL_CAPTURE_ELIGIBLE_METHODS = [ + 'creditcard', + 'klarna', + ]; + + const MOLLIE_METHOD_AUTO_CAPTURE_ENABLED = 'MOLLIE_METHOD_AUTO_CAPTURE_ENABLED_'; + const MOLLIE_METHOD_AUTO_CAPTURE_STATUSES = 'MOLLIE_METHOD_AUTO_CAPTURE_STATUSES_'; + const ROUTE_RESEND_SECOND_CHANCE_PAYMENT_MESSAGE = 'mollie_module_admin_resend_payment_message'; const PAYMENT_FEE_SKU = 'payment-fee-sku'; diff --git a/src/Entity/MolPaymentMethod.php b/src/Entity/MolPaymentMethod.php index 379741ab8..f449b9fc9 100644 --- a/src/Entity/MolPaymentMethod.php +++ b/src/Entity/MolPaymentMethod.php @@ -107,6 +107,12 @@ class MolPaymentMethod extends ObjectModel * @var int */ public $id_shop; + + /** + * @var bool + */ + public $is_manual_capture; + /** * @var array */ @@ -140,6 +146,7 @@ class MolPaymentMethod extends ObjectModel 'live_environment' => ['type' => self::TYPE_BOOL, 'validate' => 'isBool'], 'position' => ['type' => self::TYPE_INT, 'validate' => 'isInt'], 'id_shop' => ['type' => self::TYPE_INT, 'validate' => 'isUnsignedInt'], + 'is_manual_capture' => ['type' => self::TYPE_BOOL, 'validate' => 'isBool'], ], ]; diff --git a/src/Handler/Order/OrderCreationHandler.php b/src/Handler/Order/OrderCreationHandler.php index 65436555f..d6e5c4cac 100644 --- a/src/Handler/Order/OrderCreationHandler.php +++ b/src/Handler/Order/OrderCreationHandler.php @@ -142,9 +142,12 @@ public function __construct( */ public function createOrder($apiPayment, int $cartId, bool $isAuthorizablePayment = false): int { - $orderStatus = $isAuthorizablePayment ? - (int) Config::getStatuses()[PaymentStatus::STATUS_AUTHORIZED] : - (int) Config::getStatuses()[PaymentStatus::STATUS_PAID]; + if ($isAuthorizablePayment) { + $orderStatus = (int) \Configuration::get(Config::MOLLIE_AUTHORIZABLE_PAYMENT_STATUS_AUTHORIZED) + ?: (int) Config::getStatuses()[PaymentStatus::STATUS_AUTHORIZED]; + } else { + $orderStatus = (int) Config::getStatuses()[PaymentStatus::STATUS_PAID]; + } $cart = new Cart($cartId); diff --git a/src/Handler/PaymentMethod/PaymentMethodSettingsHandler.php b/src/Handler/PaymentMethod/PaymentMethodSettingsHandler.php index c762b9742..8405df725 100644 --- a/src/Handler/PaymentMethod/PaymentMethodSettingsHandler.php +++ b/src/Handler/PaymentMethod/PaymentMethodSettingsHandler.php @@ -141,6 +141,8 @@ public function handlePaymentMethodSave(string $methodId, array $settings, int $ if ($methodId === Config::MOLLIE_VOUCHER_METHOD_ID && isset($settings['voucherCategory'])) { $this->handleVoucherSettings($settings); } + + $this->handleCaptureSettings($methodId, $settings); } /** @@ -178,6 +180,10 @@ private function handleBasicSettings( $paymentMethod->live_environment = $environment ? true : false; $paymentMethod->id_shop = $shopId; + + if (in_array($methodId, Config::MOLLIE_MANUAL_CAPTURE_ELIGIBLE_METHODS)) { + $paymentMethod->is_manual_capture = ($settings['captureMode'] ?? 'automatic') === 'manual'; + } } /** @@ -499,4 +505,27 @@ private function fetchMethodFromApi(string $methodId): ?array return null; } } + + private function handleCaptureSettings(string $methodId, array $settings): void + { + if (!in_array($methodId, Config::MOLLIE_MANUAL_CAPTURE_ELIGIBLE_METHODS)) { + return; + } + + if (!isset($settings['autoCapture'])) { + return; + } + + $autoCapture = $settings['autoCapture']; + + $this->configuration->updateValue( + Config::MOLLIE_METHOD_AUTO_CAPTURE_ENABLED . $methodId, + (int) ($autoCapture['enabled'] ?? false) + ); + + $this->configuration->updateValue( + Config::MOLLIE_METHOD_AUTO_CAPTURE_STATUSES . $methodId, + json_encode($autoCapture['statuses'] ?? []) + ); + } } diff --git a/src/Install/DatabaseTableInstaller.php b/src/Install/DatabaseTableInstaller.php index 6c3b29684..a8752b0fb 100644 --- a/src/Install/DatabaseTableInstaller.php +++ b/src/Install/DatabaseTableInstaller.php @@ -81,7 +81,8 @@ private function getCommands() `max_amount` decimal(20,6), `live_environment` TINYINT(1), `position` INT(10), - `id_shop` INT(64) DEFAULT 1 + `id_shop` INT(64) DEFAULT 1, + `is_manual_capture` TINYINT(1) DEFAULT 0 ) ENGINE=' . _MYSQL_ENGINE_ . ' DEFAULT CHARSET=utf8;'; $sql[] = 'CREATE TABLE IF NOT EXISTS `' . _DB_PREFIX_ . 'mol_order_payment_fee` ( diff --git a/src/Repository/PaymentMethodRepository.php b/src/Repository/PaymentMethodRepository.php index 9a104f6ae..1f0188f8e 100644 --- a/src/Repository/PaymentMethodRepository.php +++ b/src/Repository/PaymentMethodRepository.php @@ -73,6 +73,19 @@ public function getPaymentMethodIdByMethodId($paymentMethodId, $environment, $sh return Db::getInstance()->getValue($sql); } + public function isManualCapture(string $methodId, int $environment, ?int $shopId = null): bool + { + if (!$shopId) { + $shopId = Context::getContext()->shop->id; + } + + $sql = 'SELECT is_manual_capture FROM `' . _DB_PREFIX_ . 'mol_payment_method` + WHERE id_method = "' . pSQL($methodId) . '" AND live_environment = "' . (int) $environment . '" + AND id_shop = ' . (int) $shopId; + + return (bool) Db::getInstance()->getValue($sql); + } + /** * @todo create const for table keys * diff --git a/src/Repository/PaymentMethodRepositoryInterface.php b/src/Repository/PaymentMethodRepositoryInterface.php index 3a1d2443c..092980fd7 100644 --- a/src/Repository/PaymentMethodRepositoryInterface.php +++ b/src/Repository/PaymentMethodRepositoryInterface.php @@ -39,4 +39,6 @@ public function updatePaymentReason($transactionId, $reason); public function getCustomerGroupsForPaymentMethod(int $paymentMethodId): array; public function getLatestPaymentByCustomerAndMethod($customerId, $method, array $statuses); + + public function isManualCapture(string $methodId, int $environment, ?int $shopId = null): bool; } diff --git a/src/Service/AutoCaptureService.php b/src/Service/AutoCaptureService.php new file mode 100644 index 000000000..c002660e2 --- /dev/null +++ b/src/Service/AutoCaptureService.php @@ -0,0 +1,120 @@ + + * @copyright Mollie B.V. + * @license https://github.com/mollie/PrestaShop/blob/master/LICENSE.md + * + * @see https://github.com/mollie/PrestaShop + * @codingStandardsIgnoreStart + */ + +namespace Mollie\Service; + +use Mollie\Adapter\ConfigurationAdapter; +use Mollie\Config\Config; +use Mollie\Logger\LoggerInterface; +use Mollie\Repository\PaymentMethodRepositoryInterface; + +if (!defined('_PS_VERSION_')) { + exit; +} + +class AutoCaptureService +{ + const FILE_NAME = 'AutoCaptureService'; + + /** @var CaptureService */ + private $captureService; + + /** @var PaymentMethodRepositoryInterface */ + private $paymentMethodRepository; + + /** @var ConfigurationAdapter */ + private $configurationAdapter; + + /** @var LoggerInterface */ + private $logger; + + public function __construct( + CaptureService $captureService, + PaymentMethodRepositoryInterface $paymentMethodRepository, + ConfigurationAdapter $configurationAdapter, + LoggerInterface $logger + ) { + $this->captureService = $captureService; + $this->paymentMethodRepository = $paymentMethodRepository; + $this->configurationAdapter = $configurationAdapter; + $this->logger = $logger; + } + + public function handleAutoCaptureOnStatusChange(int $orderId, int $newStatusId): void + { + $payment = $this->paymentMethodRepository->getPaymentBy('order_id', $orderId); + + if (!$payment) { + return; + } + + $transactionId = $payment['transaction_id']; + $methodId = $payment['method'] ?? ''; + + if (!in_array($methodId, Config::MOLLIE_MANUAL_CAPTURE_ELIGIBLE_METHODS)) { + return; + } + + $environment = (int) $this->configurationAdapter->get(Config::MOLLIE_ENVIRONMENT); + + if (!$this->paymentMethodRepository->isManualCapture($methodId, $environment)) { + return; + } + + $autoCaptureEnabled = (bool) $this->configurationAdapter->get( + Config::MOLLIE_METHOD_AUTO_CAPTURE_ENABLED . $methodId + ); + + if (!$autoCaptureEnabled) { + return; + } + + $configuredStatuses = json_decode( + $this->configurationAdapter->get(Config::MOLLIE_METHOD_AUTO_CAPTURE_STATUSES . $methodId) ?: '[]', + true + ) ?? []; + + if (!in_array((string) $newStatusId, $configuredStatuses)) { + return; + } + + if ($this->captureService->isCaptured($transactionId)) { + $this->logger->debug(sprintf( + '%s - Skipping auto-capture for payment %s on order %d: already captured', + self::FILE_NAME, + $transactionId, + $orderId + )); + + return; + } + + $this->logger->debug(sprintf( + '%s - Auto-capturing payment %s for order %d on status change to %d', + self::FILE_NAME, + $transactionId, + $orderId, + $newStatusId + )); + + $result = $this->captureService->handleCapture($transactionId, null, $orderId); + + if (!$result['success']) { + $this->logger->error(sprintf( + '%s - Auto-capture failed for payment %s: %s', + self::FILE_NAME, + $transactionId, + $result['detailed'] ?? $result['message'] + )); + } + } +} diff --git a/src/Service/CaptureService.php b/src/Service/CaptureService.php index 1ff67152f..64e89e560 100644 --- a/src/Service/CaptureService.php +++ b/src/Service/CaptureService.php @@ -41,12 +41,16 @@ public function __construct(Mollie $module) * * @return array */ - public function handleCapture($transactionId, $amount = null) + public function handleCapture($transactionId, $amount = null, $orderId = null) { try { $payment = $this->getPayment($transactionId); $this->performCapture($transactionId, $payment, $amount); + if ($orderId) { + $this->updateOrderStatusToPaid((int) $orderId); + } + return $this->createSuccessResponse(); } catch (\Throwable $e) { return $this->createErrorResponse($e); @@ -133,13 +137,54 @@ private function createSuccessResponse(): array */ private function createErrorResponse(\Throwable $exception): array { + $message = $this->module->l('The payment could not be captured!', self::FILE_NAME); + + if (strpos($exception->getMessage(), 'expired') !== false + || strpos($exception->getMessage(), 'not authorized') !== false + ) { + $message = $this->module->l( + 'Payment authorization has expired. This payment can no longer be captured.', + self::FILE_NAME + ); + } + return [ 'success' => false, - 'message' => $this->module->l('The payment could not be captured!', self::FILE_NAME), + 'message' => $message, 'detailed' => $exception->getMessage(), ]; } + private function updateOrderStatusToPaid(int $orderId): void + { + try { + $paidStatusId = (int) \Configuration::get(Mollie\Config\Config::MOLLIE_STATUS_PAID); + + if (!$paidStatusId) { + return; + } + + $order = new \Order($orderId); + + if (!\Validate::isLoadedObject($order)) { + return; + } + + if ((int) $order->current_state === $paidStatusId) { + return; + } + + $history = new \OrderHistory(); + $history->id_order = $orderId; + $history->changeIdOrderState($paidStatusId, $orderId, true); + $history->add(); + } catch (\Throwable $e) { + /** @var \Mollie\Logger\LoggerInterface $logger */ + $logger = $this->module->getService(\Mollie\Logger\LoggerInterface::class); + $logger->error(sprintf('%s - Failed to update order status to paid for order %d: %s', self::FILE_NAME, $orderId, $e->getMessage())); + } + } + /** * Check if a payment is captured. Only applicable for payments API. * @@ -191,4 +236,28 @@ public function getCapturableAmount(string $transactionId): float return $capturable > 0.0 ? $capturable : 0.0; } + + /** + * Return the list of captured amounts for a Payments-API transaction. + * Used to match captures back to order lines so per-line Capture buttons + * can be disabled once their matching amount has already been captured. + * + * @return float[] + */ + public function getCapturedAmounts(string $transactionId): array + { + if (TransactionUtility::isOrderTransaction($transactionId)) { + return []; + } + + /** @var Payment $payment */ + $payment = $this->module->getApiClient()->payments->get($transactionId); + + $amounts = []; + foreach ($payment->captures() as $capture) { + $amounts[] = (float) $capture->amount->value; + } + + return $amounts; + } } diff --git a/src/Service/PaymentMethodService.php b/src/Service/PaymentMethodService.php index b499c5d69..e5fd1bb7b 100644 --- a/src/Service/PaymentMethodService.php +++ b/src/Service/PaymentMethodService.php @@ -337,6 +337,11 @@ public function getPaymentData( if (in_array($molPaymentMethod->id_method, Mollie\Config\Config::MOLLIE_MANUAL_CAPTURE_METHODS)) { $paymentData->setCaptureMode('manual'); + } elseif (in_array($molPaymentMethod->id_method, Mollie\Config\Config::MOLLIE_MANUAL_CAPTURE_ELIGIBLE_METHODS)) { + $environment = (int) Configuration::get(Mollie\Config\Config::MOLLIE_ENVIRONMENT); + if ($this->methodRepository->isManualCapture($molPaymentMethod->id_method, $environment)) { + $paymentData->setCaptureMode('manual'); + } } $paymentData->setMetadata($metaData); diff --git a/src/Service/RefundService.php b/src/Service/RefundService.php index 0e8c1b748..a2502d11d 100644 --- a/src/Service/RefundService.php +++ b/src/Service/RefundService.php @@ -180,4 +180,27 @@ public function isRefunded(string $transactionId, float $amount): bool return $refundedAmount >= $amount; } + + /** + * Return the list of refunded amounts for a Payments-API transaction. + * Used to match refunds back to order lines so per-line Refund buttons + * can be disabled once their matching amount has already been refunded. + * + * @return float[] + */ + public function getRefundedAmounts(string $transactionId): array + { + if (TransactionUtility::isOrderTransaction($transactionId)) { + return []; + } + + $payment = $this->module->getApiClient()->payments->get($transactionId, ['embed' => 'refunds']); + + $amounts = []; + foreach ($payment->refunds() as $refund) { + $amounts[] = (float) $refund->amount->value; + } + + return $amounts; + } } diff --git a/src/Service/TransactionService.php b/src/Service/TransactionService.php index ff598720f..5145fad6d 100644 --- a/src/Service/TransactionService.php +++ b/src/Service/TransactionService.php @@ -208,7 +208,8 @@ public function processTransaction($apiPayment) $this->orderStatusService->setOrderStatus($orderId, Config::MOLLIE_CHARGEBACK); } else { if (!$orderId && $isPaymentFinished) { - $orderId = $this->orderCreationHandler->createOrder($apiPayment, $cart->id); + $isManualCapturePayment = $this->isManualCapturePayment($apiPayment); + $orderId = $this->orderCreationHandler->createOrder($apiPayment, $cart->id, $isManualCapturePayment); if (!$orderId) { throw new TransactionException('Order is already created', HttpStatusCode::HTTP_METHOD_NOT_ALLOWED); @@ -583,4 +584,19 @@ private function paymentHasChargedBacks(Payment $apiPayment): bool { return $apiPayment->hasChargebacks(); } + + private function isManualCapturePayment($apiPayment): bool + { + if ($apiPayment->status !== \Mollie\Api\Types\PaymentStatus::STATUS_AUTHORIZED) { + return false; + } + + if (!in_array($apiPayment->method, Config::MOLLIE_MANUAL_CAPTURE_ELIGIBLE_METHODS)) { + return false; + } + + $environment = (int) $this->configurationAdapter->get(Config::MOLLIE_ENVIRONMENT); + + return $this->paymentMethodRepository->isManualCapture($apiPayment->method, $environment); + } } diff --git a/src/ServiceProvider/BaseServiceProvider.php b/src/ServiceProvider/BaseServiceProvider.php index 38b302924..39229af6d 100644 --- a/src/ServiceProvider/BaseServiceProvider.php +++ b/src/ServiceProvider/BaseServiceProvider.php @@ -111,6 +111,8 @@ use Mollie\Service\ApiKeyService; use Mollie\Service\ApiService; use Mollie\Service\ApiServiceInterface; +use Mollie\Service\AutoCaptureService; +use Mollie\Service\CaptureService; use Mollie\Service\Content\SmartyTemplateParser; use Mollie\Service\Content\TemplateParserInterface; use Mollie\Service\CountryService; @@ -300,6 +302,12 @@ public function register(Container $container) $this->addServiceArgument($service, Mollie::class); $this->addServiceArgument($service, ApplePayDirectCertificateHandler::class); + $service = $this->addService($container, AutoCaptureService::class, AutoCaptureService::class); + $this->addServiceArgument($service, CaptureService::class); + $this->addServiceArgument($service, PaymentMethodRepositoryInterface::class); + $this->addServiceArgument($service, ConfigurationAdapter::class); + $this->addServiceArgument($service, LoggerInterface::class); + $service = $this->addService($container, PaymentMethodLogoHandler::class, PaymentMethodLogoHandler::class); $this->addServiceArgument($service, CreditCardLogoProvider::class); $this->addServiceArgument($service, LoggerInterface::class); diff --git a/upgrade/Upgrade-6.4.3.php b/upgrade/Upgrade-6.4.3.php new file mode 100644 index 000000000..cac8b70e0 --- /dev/null +++ b/upgrade/Upgrade-6.4.3.php @@ -0,0 +1,48 @@ + + * @copyright Mollie B.V. + * @license https://github.com/mollie/PrestaShop/blob/master/LICENSE.md + * + * @see https://github.com/mollie/PrestaShop + * @codingStandardsIgnoreStart + */ + +if (!defined('_PS_VERSION_')) { + exit; +} + +/** + * @param Mollie $module + * + * @return bool + */ +function upgrade_module_6_4_3($module) +{ + try { + $columnExists = Db::getInstance()->executeS( + 'SHOW COLUMNS FROM `' . _DB_PREFIX_ . 'mol_payment_method` LIKE \'is_manual_capture\'' + ); + + if (empty($columnExists)) { + Db::getInstance()->execute( + 'ALTER TABLE `' . _DB_PREFIX_ . 'mol_payment_method` ADD COLUMN `is_manual_capture` TINYINT(1) DEFAULT 0' + ); + } + + return true; + } catch (Exception $e) { + PrestaShopLogger::addLog( + 'Mollie module upgrade to 6.4.3 failed: ' . $e->getMessage(), + 3, + $e->getCode(), + 'Module', + $module->id, + true + ); + + return false; + } +} diff --git a/views/js/admin/library/src/pages/advanced-settings/advanced-settings.css b/views/js/admin/library/src/pages/advanced-settings/advanced-settings.css index 979d37bd8..77b510f88 100644 --- a/views/js/admin/library/src/pages/advanced-settings/advanced-settings.css +++ b/views/js/admin/library/src/pages/advanced-settings/advanced-settings.css @@ -234,6 +234,21 @@ h2.section-title { font-size: 14px; } +.radio-select-search { + width: 100%; + padding: 8px 12px; + font-size: 14px; + border: 1px solid #e5e7eb; + border-radius: 6px; + background-color: #ffffff; + outline: none; +} + +.radio-select-search:focus { + border-color: #3b82f6; + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2); +} + .relative { position: relative; z-index: 100; diff --git a/views/js/admin/library/src/pages/advanced-settings/index.tsx b/views/js/admin/library/src/pages/advanced-settings/index.tsx index 64a15ca32..f2e719a4c 100644 --- a/views/js/admin/library/src/pages/advanced-settings/index.tsx +++ b/views/js/admin/library/src/pages/advanced-settings/index.tsx @@ -38,20 +38,28 @@ interface RadioSelectProps { function RadioSelect({ value, onValueChange, options, placeholder, className }: RadioSelectProps) { const [isOpen, setIsOpen] = useState(false) + const [search, setSearch] = useState("") const [dropdownPosition, setDropdownPosition] = useState<'bottom' | 'top'>('bottom') const selectedOption = options.find((opt) => opt.value === value) const dropdownRef = useRef(null) const buttonRef = useRef(null) + const searchInputRef = useRef(null) + + const filteredOptions = search + ? options.filter((opt) => opt.label.toLowerCase().includes(search.toLowerCase())) + : options useEffect(() => { function handleClickOutside(event: MouseEvent) { if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { setIsOpen(false) + setSearch("") } } if (isOpen) { document.addEventListener('mousedown', handleClickOutside) + setTimeout(() => searchInputRef.current?.focus(), 50) } return () => { @@ -63,7 +71,7 @@ function RadioSelect({ value, onValueChange, options, placeholder, className }: if (isOpen && buttonRef.current) { const rect = buttonRef.current.getBoundingClientRect() const spaceBelow = window.innerHeight - rect.bottom - const dropdownHeight = 300 // approximate max height + const dropdownHeight = 300 if (spaceBelow < dropdownHeight && rect.top > dropdownHeight) { setDropdownPosition('top') @@ -89,25 +97,42 @@ function RadioSelect({ value, onValueChange, options, placeholder, className }: {isOpen && (
-
- {options.map((option) => ( - - ))} + {option.label} + + )) + )}
)} @@ -125,10 +150,16 @@ interface MultiSelectProps { function MultiSelect({ value, onValueChange, options, placeholder, className }: MultiSelectProps) { const [isOpen, setIsOpen] = useState(false) + const [search, setSearch] = useState("") const [dropdownPosition, setDropdownPosition] = useState<'bottom' | 'top'>('bottom') const selectedOptions = options.filter((opt) => value.includes(opt.value)) const dropdownRef = useRef(null) const buttonRef = useRef(null) + const searchInputRef = useRef(null) + + const filteredOptions = search + ? options.filter((opt) => opt.label.toLowerCase().includes(search.toLowerCase())) + : options const toggleOption = (optionValue: string) => { if (value.includes(optionValue)) { @@ -147,11 +178,13 @@ function MultiSelect({ value, onValueChange, options, placeholder, className }: function handleClickOutside(event: MouseEvent) { if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { setIsOpen(false) + setSearch("") } } if (isOpen) { document.addEventListener('mousedown', handleClickOutside) + setTimeout(() => searchInputRef.current?.focus(), 50) } return () => { @@ -163,7 +196,7 @@ function MultiSelect({ value, onValueChange, options, placeholder, className }: if (isOpen && buttonRef.current) { const rect = buttonRef.current.getBoundingClientRect() const spaceBelow = window.innerHeight - rect.bottom - const dropdownHeight = 300 // approximate max height + const dropdownHeight = 300 if (spaceBelow < dropdownHeight && rect.top > dropdownHeight) { setDropdownPosition('top') @@ -206,13 +239,28 @@ function MultiSelect({ value, onValueChange, options, placeholder, className }: {isOpen && (
-
- {options.map((option) => ( - - ))} + )) + )}
)} diff --git a/views/js/admin/library/src/pages/payment-methods/components/payment-method-settings.tsx b/views/js/admin/library/src/pages/payment-methods/components/payment-method-settings.tsx index 5692641a4..c19f1e95a 100644 --- a/views/js/admin/library/src/pages/payment-methods/components/payment-method-settings.tsx +++ b/views/js/admin/library/src/pages/payment-methods/components/payment-method-settings.tsx @@ -108,8 +108,14 @@ function RadioSelect({ value, onValueChange, options, placeholder, className }: function MultiSelect({ value, onValueChange, options, placeholder, className }: MultiSelectProps) { const [isOpen, setIsOpen] = useState(false) + const [search, setSearch] = useState("") const selectedOptions = options.filter((opt) => value.includes(opt.value)) const dropdownRef = useRef(null) + const searchInputRef = useRef(null) + + const filteredOptions = search + ? options.filter((opt) => opt.label.toLowerCase().includes(search.toLowerCase())) + : options const toggleOption = (optionValue: string) => { if (value.includes(optionValue)) { @@ -128,11 +134,13 @@ function MultiSelect({ value, onValueChange, options, placeholder, className }: function handleClickOutside(event: MouseEvent) { if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { setIsOpen(false) + setSearch("") } } if (isOpen) { document.addEventListener('mousedown', handleClickOutside) + setTimeout(() => searchInputRef.current?.focus(), 50) } return () => { @@ -172,35 +180,49 @@ function MultiSelect({ value, onValueChange, options, placeholder, className }: {isOpen && (
-
- {options.map((option) => ( -
- {option.label} - - ))} + {option.label} + + )) + )}
)} @@ -514,9 +536,91 @@ export function PaymentMethodSettings({ method, countries, customerGroups, langu + {/* Capture Mode - only for eligible methods using Payments API */} + {method.settings.isManualCaptureEligible && method.settings.apiSelection === 'payments' && ( +
+
+
{t('captureMode')}
+
+ + +
+

+ {method.settings.captureMode === 'manual' ? t('captureModeManual') : t('captureModeAutomatic')} +

+
+ + {/* Auto-capture settings - only when manual mode */} + {method.settings.captureMode === 'manual' && ( +
+
+
+ +
+ onUpdateSettings({ + autoCapture: { + ...method.settings.autoCapture, + enabled, + statuses: method.settings.autoCapture?.statuses ?? [], + } + })} + /> +
+ + {method.settings.autoCapture?.enabled && ( +
+ + onUpdateSettings({ + autoCapture: { + ...method.settings.autoCapture, + enabled: method.settings.autoCapture?.enabled ?? false, + statuses, + } + })} + options={(window.molliePaymentMethodsConfig?.orderStatuses ?? []).map((status) => ({ + value: status.id, + label: status.name, + }))} + placeholder={t('selectStatuses')} + className="mt-1" + /> +

+ {t('autoCaptureInfo')} +

+
+ )} +
+ )} +
+ )} + {/* Collapsible Sections */} - {/* Klarna informational notice */} - {method.id === 'klarna' && ( + {/* Klarna informational notice - only shown for non-capture-eligible Klarna or when using Orders API */} + {method.id === 'klarna' && !method.settings.isManualCaptureEligible && (

{t('klarnaNotice')} diff --git a/views/js/admin/library/src/services/PaymentMethodsApiService.ts b/views/js/admin/library/src/services/PaymentMethodsApiService.ts index 0662b863c..1193a64cc 100644 --- a/views/js/admin/library/src/services/PaymentMethodsApiService.ts +++ b/views/js/admin/library/src/services/PaymentMethodsApiService.ts @@ -10,6 +10,8 @@ declare global { languages: Language[]; onlyOrderMethods: string[]; onlyPaymentsMethods: string[]; + orderStatuses: { id: string; name: string }[]; + manualCaptureEligibleMethods: string[]; }; } } @@ -72,6 +74,12 @@ export interface PaymentMethod { directCart?: boolean buttonStyle?: 0 | 1 | 2 // 0: black, 1: outline, 2: white } + captureMode?: 'automatic' | 'manual' + isManualCaptureEligible?: boolean + autoCapture?: { + enabled: boolean + statuses: string[] + } | null } } diff --git a/views/js/admin/library/src/shared/types/index.ts b/views/js/admin/library/src/shared/types/index.ts index 43e4876d1..efe0bf267 100644 --- a/views/js/admin/library/src/shared/types/index.ts +++ b/views/js/admin/library/src/shared/types/index.ts @@ -204,6 +204,17 @@ export interface MolliePaymentMethodsTranslations { apiNotConfiguredMessage: string; infoBannerText: string; mollieDashboard: string; + + // Capture Mode + captureMode: string; + automatic: string; + manual: string; + captureModeAutomatic: string; + captureModeManual: string; + autoCaptureOnStatus: string; + autoCaptureStatuses: string; + autoCaptureInfo: string; + selectStatuses: string; } export interface MollieAdvancedSettingsTranslations { diff --git a/views/templates/hook/order_info.tpl b/views/templates/hook/order_info.tpl index 4da22cccd..1e8b88236 100644 --- a/views/templates/hook/order_info.tpl +++ b/views/templates/hook/order_info.tpl @@ -90,12 +90,12 @@ {$product->totalAmount->value|escape:'html':'UTF-8'} {if $mollie_api_type == 'payments' && $product->description != 'Discount'} - {/if} {if $product->description != 'Discount'} - {/if}