From 9850dbb9656fc9f9e6bedc7d28191a6a12658ed9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=BCsken?= Date: Fri, 1 Aug 2025 11:17:17 +0200 Subject: [PATCH 01/23] Add an order not for payment details of Voucher payments --- src/PaymentMethods/Voucher.php | 56 +++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/src/PaymentMethods/Voucher.php b/src/PaymentMethods/Voucher.php index 69b49d26..40683205 100644 --- a/src/PaymentMethods/Voucher.php +++ b/src/PaymentMethods/Voucher.php @@ -4,6 +4,9 @@ namespace Mollie\WooCommerce\PaymentMethods; +use Mollie\WooCommerce\Payment\MollieOrder; +use Mollie\WooCommerce\Payment\MolliePayment; + class Voucher extends AbstractPaymentMethod implements PaymentMethodI { /** @@ -45,12 +48,63 @@ protected function getConfig(): array 'supports' => [ 'products', ], - 'filtersOnBuild' => false, + 'filtersOnBuild' => true, 'confirmationDelayed' => false, 'docs' => 'https://www.mollie.com/gb/payments/meal-eco-gift-vouchers', ]; } + public function filtersOnBuild() { + add_action('mollie-payments-for-woocommerce_after_webhook_action', [$this, 'addPaymentDetailsOrderNote'],10,2); + } + + /** + * Adds a detailed order note to the WooCommerce order containing information about payment details + * related to vouchers and any remaining payment amounts. + * + * The note includes details of voucher issuers, amounts applied, and the remainder amount (if applicable). + * + * @param MollieOrder|MolliePayment $payment The payment object containing details such as method, status, and vouchers. + * @param \WC_Order $order The WooCommerce order object to which the note will be added. + * + * @return void + */ + public function addPaymentDetailsOrderNote($payment, \WC_Order $order): void + { + $details = $payment->_embedded->payments[0]->details ?? $payment->details ?? null; + if ($payment->method !== Constants::VOUCHER || $payment->status !== 'paid' || ! is_object($details) ) { + return; + } + $applied = ''; + $remainder = ''; + foreach ($details->vouchers as $voucher) { + if (!isset($voucher->amount) || !isset($voucher->issuer)) { + continue; + } + $applied .= sprintf( + __('%1$s: %2$s %3$s
', 'mollie-payments-for-woocommerce'), + $voucher->issuer, + $voucher->amount->value, + $voucher->amount->currency + ); + } + if (isset($details->remainderAmount)) { + $remainder = sprintf( + __('%1$s: %2$s %3$s
', 'mollie-payments-for-woocommerce'), + $details->remainderMethod, + $details->remainderAmount->value, + $details->remainderAmount->currency + ); + } + $order->add_order_note( + sprintf( + __('

Voucher(s) applied:
%1$s

Remainder:
%2$s

', 'mollie-payments-for-woocommerce'), + $applied, + $remainder + ) + ); + } + public function initializeTranslations(): void { if ($this->translationsInitialized) { From 719433edc6f4401a36fb7e36d219f45c45d7d014 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=BCsken?= Date: Fri, 1 Aug 2025 11:21:32 +0200 Subject: [PATCH 02/23] phpcs and psalm --- src/PaymentMethods/Voucher.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/PaymentMethods/Voucher.php b/src/PaymentMethods/Voucher.php index 40683205..2a54b55e 100644 --- a/src/PaymentMethods/Voucher.php +++ b/src/PaymentMethods/Voucher.php @@ -55,7 +55,7 @@ protected function getConfig(): array } public function filtersOnBuild() { - add_action('mollie-payments-for-woocommerce_after_webhook_action', [$this, 'addPaymentDetailsOrderNote'],10,2); + add_action('mollie-payments-for-woocommerce_after_webhook_action', [$this, 'addPaymentDetailsOrderNote'], 10, 2); } /** @@ -64,7 +64,7 @@ public function filtersOnBuild() { * * The note includes details of voucher issuers, amounts applied, and the remainder amount (if applicable). * - * @param MollieOrder|MolliePayment $payment The payment object containing details such as method, status, and vouchers. + * @param object $payment The payment object containing details such as method, status, and vouchers. * @param \WC_Order $order The WooCommerce order object to which the note will be added. * * @return void @@ -72,7 +72,7 @@ public function filtersOnBuild() { public function addPaymentDetailsOrderNote($payment, \WC_Order $order): void { $details = $payment->_embedded->payments[0]->details ?? $payment->details ?? null; - if ($payment->method !== Constants::VOUCHER || $payment->status !== 'paid' || ! is_object($details) ) { + if ($payment->method !== Constants::VOUCHER || $payment->status !== 'paid' || ! is_object($details)) { return; } $applied = ''; From 3b202d5611a8cec60acfccb3b6d2d8f0db4ccfcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=BCsken?= Date: Fri, 1 Aug 2025 11:24:39 +0200 Subject: [PATCH 03/23] phpcs --- src/PaymentMethods/Voucher.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/PaymentMethods/Voucher.php b/src/PaymentMethods/Voucher.php index 2a54b55e..27a3e191 100644 --- a/src/PaymentMethods/Voucher.php +++ b/src/PaymentMethods/Voucher.php @@ -54,7 +54,9 @@ protected function getConfig(): array ]; } - public function filtersOnBuild() { + public function filtersOnBuild() + { + add_action('mollie-payments-for-woocommerce_after_webhook_action', [$this, 'addPaymentDetailsOrderNote'], 10, 2); } From fdc603562526a81225cd0397608496ca0dc28311 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=BCsken?= Date: Tue, 2 Sep 2025 09:12:32 +0200 Subject: [PATCH 04/23] add order action and bulk order edit for checking payment on Mollie manually when the order is in the pending payment state --- src/Gateway/GatewayModule.php | 55 +++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/src/Gateway/GatewayModule.php b/src/Gateway/GatewayModule.php index 35be313e..8d3c6890 100644 --- a/src/Gateway/GatewayModule.php +++ b/src/Gateway/GatewayModule.php @@ -248,6 +248,61 @@ static function ($orderId) use ($container) { PHP_INT_MAX ); + add_filter( + 'woocommerce_order_actions', + static function ($actions, \WC_Order $order) { + if ($order->is_paid() || ! $order->has_status('pending') || strpos($order->get_payment_method(), 'mollie_wc_gateway_') === false) { + return $actions; + } + $actions['mollie_wc_check_payment_for_unpaid_order'] = __('Check payment on mollie', 'mollie-payments-for-woocommerce'); + return $actions; + }, + 10, + 2 + ); + + add_action( + 'woocommerce_order_action_mollie_wc_check_payment_for_unpaid_order', + static function ($orderId) use ($container) { + $order = wc_get_order($orderId); + if (! $order || $order->is_paid() || ! $order->has_status('pending') || strpos($order->get_payment_method(), 'mollie_wc_gateway_') === false) { + return; + } + $mollieOrderService = $container->get(MollieOrderService::class); + $mollieOrderService->checkPaymentForUnpaidOrder($order); + } + ); + + add_filter( + 'bulk_actions-woocommerce_page_wc-orders', + static function ($bulk_actions) { + $bulk_actions['mollie_wc_check_payment_for_unpaid_order'] = __('Check payment on mollie', 'mollie-payments-for-woocommerce'); + return $bulk_actions; + } + ); + + add_filter( + 'handle_bulk_actions-woocommerce_page_wc-orders', + static function ($redirect_to, $action, $post_ids) use ($container) { + if ($action !== 'mollie_wc_check_payment_for_unpaid_order') { + return $redirect_to; + } + + foreach ($post_ids as $post_id) { + $order = wc_get_order($post_id); + if (! $order || $order->is_paid() || ! $order->has_status('pending') || strpos($order->get_payment_method(), 'mollie_wc_gateway_') === false) { + continue; + } + $mollieOrderService = $container->get(MollieOrderService::class); + $mollieOrderService->checkPaymentForUnpaidOrder($order); + } + + return $redirect_to; + }, + 10, + 3 + ); + return true; } From 2036b9e64f26a616c09ef986df05abc6e6f0c2b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=BCsken?= Date: Mon, 8 Sep 2025 14:13:40 +0200 Subject: [PATCH 05/23] add rest endpoint for webhooks --- src/Payment/MollieOrderService.php | 196 +++++++----------- src/Payment/PaymentModule.php | 6 + .../Request/Middleware/UrlMiddleware.php | 26 ++- src/Payment/Webhooks/RestApi.php | 92 ++++++++ src/Payment/inc/services.php | 6 + 5 files changed, 189 insertions(+), 137 deletions(-) create mode 100644 src/Payment/Webhooks/RestApi.php diff --git a/src/Payment/MollieOrderService.php b/src/Payment/MollieOrderService.php index 41d5dca4..d8c86105 100644 --- a/src/Payment/MollieOrderService.php +++ b/src/Payment/MollieOrderService.php @@ -91,7 +91,6 @@ public function onWebhookAction() } $payment_object_id = sanitize_text_field(wp_unslash($paymentId)); - $data_helper = $this->data; $order = wc_get_order($order_id); if (!$order instanceof WC_Order) { @@ -110,7 +109,30 @@ public function onWebhookAction() if (!mollieWooCommerceIsMollieGateway($gateway->id)) { return; } - $this->setGateway($gateway); + + // Prevent double payment webhooks for klarna on orders api + // TODO improve testing to check if we can remove this check + if ( + $this->container->get('settings.settings_helper')->isOrderApiSetting() + && in_array( + str_replace('mollie_wc_gateway_', '', $gateway->id), + [ + Constants::KLARNA, + Constants::KLARNAPAYLATER, + Constants::KLARNASLICEIT, + Constants::KLARNAPAYNOW, + ], + true + ) + && strpos($payment_object_id, 'tr_') === 0 + ) { + $this->httpResponse->setHttpResponseCode(200); + $this->logger->debug( + $this->gateway->id . ": not respond on transaction webhooks for this payment method when order API is active. Payment ID {$payment->id}, order ID $order_id" + ); + return; + } + // Acquire exclusive lock for this order to prevent race conditions $lockKey = 'mollie_webhook_lock_' . $order_id; $lockAcquired = $this->acquireOrderLock($lockKey, 30); @@ -125,116 +147,9 @@ public function onWebhookAction() } try { - $test_mode = $data_helper->getActiveMolliePaymentMode($order_id) === 'test'; - // Load the payment from Mollie, do not use cache - try { - $payment_object = $this->paymentFactory->getPaymentObject( - $payment_object_id - ); - } catch (ApiException $exception) { + if (!$this->doPaymentForOrder($order)) { $this->httpResponse->setHttpResponseCode(400); - $this->logger->debug($exception->getMessage()); - return; - } - - $payment = $payment_object->getPaymentObject($payment_object->data(), $test_mode, false); - - // Payment not found - if (!$payment) { - $this->httpResponse->setHttpResponseCode(404); - $this->logger->debug(__METHOD__ . ": payment object $payment_object_id not found.", [true]); - return; - } - - // Prevent double payment webhooks for klarna on orders api - // TODO improve testing to check if we can remove this check - if ( - $this->container->get('settings.settings_helper')->isOrderApiSetting() - && in_array( - $payment->method, - [ - Constants::KLARNA, - Constants::KLARNAPAYLATER, - Constants::KLARNASLICEIT, - Constants::KLARNAPAYNOW, - ], - true - ) - && strpos($payment_object_id, 'tr_') === 0 - ) { - $this->httpResponse->setHttpResponseCode(200); - $this->logger->debug( - $this->gateway->id . ": not respond on transaction webhooks for this payment method when order API is active. Payment ID {$payment->id}, order ID $order_id" - ); - return; - } - - if ($order_id != $payment->metadata->order_id) { - $this->httpResponse->setHttpResponseCode(400); - $this->logger->debug( - __METHOD__ . ": Order ID does not match order_id in payment metadata. Payment ID {$payment->id}, order ID $order_id" - ); - return; - } - - $this->logger->debug( - $this->gateway->id . ": Mollie payment object {$payment->id} (" . $payment->mode . ") webhook call for order {$order->get_id()}.", - [true] - ); - $payment_method_title = $this->getPaymentMethodTitle($payment); - - $method_name = 'onWebhook' . ucfirst($payment->status); - - // Re-check if order needs payment after acquiring lock - if (!$this->orderNeedsPayment($order)) { - // TODO David: move to payment object? - // Add a debug message that order was already paid for - $this->handlePaidOrderWebhook($order, $payment); - - $this->processRefunds($order, $payment); - $this->processChargebacks($order, $payment); - - // If the order gets updated to completed at mollie, we need to update the order status - if ( - $order->get_status() === 'processing' - && method_exists($payment, 'isCompleted') - && $payment->isCompleted() - && method_exists($payment_object, 'onWebhookCompleted') - ) { - $payment_object->onWebhookCompleted($order, $payment, $payment_method_title); - } - return; - } - - if ( - $payment->method === 'paypal' && isset($payment->billingAddress) && $this->isOrderButtonPayment( - $order - ) - ) { - $this->logger->debug($this->gateway->id . ": updating address from express button", [true]); - $this->setBillingAddressAfterPayment($payment, $order); - } - - if (method_exists($payment_object, $method_name)) { - do_action($this->pluginId . '_before_webhook_payment_action', $payment, $order); - $payment_object->{$method_name}($order, $payment, $payment_method_title); - } else { - $order->add_order_note( - sprintf( - /* translators: Placeholder 1: payment method title, placeholder 2: payment status, placeholder 3: payment ID */ - __('%1$s payment %2$s (%3$s), not processed.', 'mollie-payments-for-woocommerce'), - $this->gateway->method_title, - $payment->status, - $payment->id . ($payment->mode === 'test' ? (' - ' . __( - 'test mode', - 'mollie-payments-for-woocommerce' - )) : '' - ) - ) - ); - } - - do_action($this->pluginId . '_after_webhook_action', $payment, $order); + }; // Status 200 } finally { // Always release the lock @@ -297,6 +212,17 @@ public function checkPaymentForUnpaidOrder(\WC_Order $order): bool return true; } + return $this->doPaymentForOrder($order); + } + + /** + * Processes the payment for a given WooCommerce order. + * + * @param \WC_Order $order The order object for which the payment is being processed. + * @return bool Returns true if the payment was successfully processed, false otherwise. + */ + public function doPaymentForOrder(\WC_Order $order): bool + { $gateway = wc_get_payment_gateway_by_order($order); if (!$gateway || !mollieWooCommerceIsMollieGateway($gateway->id)) { return false; @@ -308,6 +234,7 @@ public function checkPaymentForUnpaidOrder(\WC_Order $order): bool if (!$payment_object_id) { $payment_object_id = $order->get_meta('_mollie_payment_id'); } + try { $payment_object = $this->paymentFactory->getPaymentObject( $payment_object_id @@ -326,38 +253,55 @@ public function checkPaymentForUnpaidOrder(\WC_Order $order): bool return false; } + if ($order->get_id() != $payment->metadata->order_id) { + $this->httpResponse->setHttpResponseCode(400); + $this->logger->debug( + __METHOD__ . ": Order ID does not match order_id in payment metadata. Payment ID {$payment->id}, order ID {$order->get_id()}" + ); + return false; + } + $this->logger->debug($this->gateway->id . ": Mollie payment object {$payment->id} (" . $payment->mode . ") action call for order {$order->get_id()}."); + $method_name = 'onWebhook' . ucfirst($payment->status); + $payment_method_title = $this->getPaymentMethodTitle($payment); + + if (!$this->orderNeedsPayment($order)) { + $this->handlePaidOrderWebhook($order, $payment); + $this->processRefunds($order, $payment); + $this->processChargebacks($order, $payment); + if ( + $order->get_status() === 'processing' + && method_exists($payment, 'isCompleted') + && $payment->isCompleted() + && method_exists($payment_object, 'onWebhookCompleted') + ) { + $payment_object->onWebhookCompleted($order, $payment, $payment_method_title); + } + return true; + } + if ($payment->method === 'paypal' && isset($payment->billingAddress) && $this->isOrderButtonPayment($order)) { $this->logger->debug($this->gateway->id . ": updating address from express button"); $this->setBillingAddressAfterPayment($payment, $order); } - $method_name = 'onWebhook' . ucfirst($payment->status); - $payment_method_title = $this->getPaymentMethodTitle($payment); if (method_exists($payment_object, $method_name)) { do_action($this->pluginId . '_before_webhook_payment_action', $payment, $order); $payment_object->{$method_name}($order, $payment, $payment_method_title); } else { $order->add_order_note(sprintf( - /* translators: Placeholder 1: payment method title, placeholder 2: payment status, placeholder 3: payment ID */ - __('%1$s payment %2$s (%3$s), not processed.', 'mollie-payments-for-woocommerce'), - $this->gateway->method_title, - $payment->status, - $payment->id . ($payment->mode === 'test' ? (' - ' . __('test mode', 'mollie-payments-for-woocommerce')) : '') - )); + /* translators: Placeholder 1: payment method title, placeholder 2: payment status, placeholder 3: payment ID */ + __('%1$s payment %2$s (%3$s), not processed.', 'mollie-payments-for-woocommerce'), + $this->gateway->method_title, + $payment->status, + $payment->id . ($payment->mode === 'test' ? (' - ' . __('test mode', 'mollie-payments-for-woocommerce')) : '') + )); return false; } do_action($this->pluginId . '_after_webhook_action', $payment, $order); - if ($payment->status === 'canceled') { - $this->updateOrderStatus($order, SharedDataDictionary::STATUS_CANCELLED, __('Mollie Payment was canceled.', 'mollie-payments-for-woocommerce')); - } - - $this->processRefunds($order, $payment); - $this->processChargebacks($order, $payment); - return true; } diff --git a/src/Payment/PaymentModule.php b/src/Payment/PaymentModule.php index a7857c89..aece3968 100644 --- a/src/Payment/PaymentModule.php +++ b/src/Payment/PaymentModule.php @@ -14,6 +14,7 @@ use Mollie\WooCommerce\Gateway\MolliePaymentGatewayHandler; use Mollie\WooCommerce\Gateway\Refund\OrderItemsRefunder; use Mollie\WooCommerce\MerchantCapture\Capture\Action\CapturePayment; +use Mollie\WooCommerce\Payment\Webhooks\RestApi; use Mollie\WooCommerce\PaymentMethods\InstructionStrategies\OrderInstructionsManager; use Mollie\WooCommerce\SDK\Api; use Mollie\WooCommerce\SDK\HttpResponse; @@ -76,6 +77,11 @@ public function run(ContainerInterface $container): bool $this->gatewayClassnames = $container->get('gateway.classnames'); $this->container = $container; + //add webhook rest API endpoint + add_action('rest_api_init', function () use ($container) { + $container->get(RestApi::class)->registerRoutes(); + }); + // Listen to return URL call add_action('woocommerce_api_mollie_return', function () use ($container) { $this->onMollieReturn($container); diff --git a/src/Payment/Request/Middleware/UrlMiddleware.php b/src/Payment/Request/Middleware/UrlMiddleware.php index 464b9285..1322dda9 100644 --- a/src/Payment/Request/Middleware/UrlMiddleware.php +++ b/src/Payment/Request/Middleware/UrlMiddleware.php @@ -4,6 +4,7 @@ namespace Mollie\WooCommerce\Payment\Request\Middleware; +use Mollie\WooCommerce\Payment\Webhooks\RestApi; use WC_Order; /** @@ -99,17 +100,20 @@ private function getReturnUrl(WC_Order $order, string $returnUrl): string */ public function getWebhookUrl(WC_Order $order, string $gatewayId): string { - $webhookUrl = WC()->api_request_url($gatewayId); - $webhookUrl = untrailingslashit($webhookUrl); - $webhookUrl = $this->asciiDomainName($webhookUrl); - $orderId = $order->get_id(); - $orderKey = $order->get_order_key(); - $webhookUrl = $this->appendOrderArgumentsToUrl( - $orderId, - $orderKey, - $webhookUrl - ); - $webhookUrl = untrailingslashit($webhookUrl); + $webhookUrl = get_rest_url(null, RestApi::ROUTE_NAMESPACE . '/' .RestApi::WEBHOOK_ROUTE); + if ( apply_filters( 'mollie_wc_gateway_disable_rest_webhook', false ) ) { + $webhookUrl = WC()->api_request_url($gatewayId); + $webhookUrl = untrailingslashit($webhookUrl); + $webhookUrl = $this->asciiDomainName($webhookUrl); + $orderId = $order->get_id(); + $orderKey = $order->get_order_key(); + $webhookUrl = $this->appendOrderArgumentsToUrl( + $orderId, + $orderKey, + $webhookUrl + ); + $webhookUrl = untrailingslashit($webhookUrl); + } $this->logger->debug(" Order {$orderId} webhookUrl: {$webhookUrl}", [true]); diff --git a/src/Payment/Webhooks/RestApi.php b/src/Payment/Webhooks/RestApi.php new file mode 100644 index 00000000..14aa255b --- /dev/null +++ b/src/Payment/Webhooks/RestApi.php @@ -0,0 +1,92 @@ +mollieOrderService = $mollieOrderService; + $this->logger = $logger; + } + + /** + * Registers REST API routes for the application. + * + * This method defines and registers a specific REST route under the given namespace, + * along with its callback and permission settings. + * + * @return void + */ + public function registerRoutes() + { + register_rest_route(self::ROUTE_NAMESPACE, self::WEBHOOK_ROUTE, [ + [ + 'methods' => 'POST', + 'callback' => [$this, 'callback'], + 'permission_callback' => '__return_true', + ], + ]); + } + + /** + * Handles the callback request from Mollie and processes the payment. + * + * @param WP_REST_Request $request The REST request object containing callback parameters. + * + * @return \WP_REST_Response A response object with the corresponding status code. + * - 200: When the request is successfully handled, whether for testing, no results, or successful processing. + * - 404: When the "id" parameter is not provided in the request. + */ + public function callback(WP_REST_Request $request) + { + //Answer Mollie Test request. + if ($request->get_param('testByMollie') === '') { + $this->logger->debug(__METHOD__ . ': REST Webhook tested by Mollie.'); + return new \WP_REST_Response(null, 200); + } + + //check that id in post is set with transaction_id + $transactionID = $request->get_param('id'); + if (! $transactionID) { + $this->logger->debug(__METHOD__ . ': No transaction ID provided.'); + return new \WP_REST_Response(null, 404); + } + + $orders = wc_get_orders([ + 'transaction_id' => $transactionID, + 'limit' => 2, + ]); + + if (! $orders) { + $this->logger->debug(__METHOD__ . ': No orders found for transaction ID: ' . $transactionID); + return new \WP_REST_Response(null, 200); + } + + if (count($orders) > 1) { + $this->logger->debug(__METHOD__ . ': More than one order found for transaction ID: ' . $transactionID); + return new \WP_REST_Response(null, 200); + } + + $this->mollieOrderService->doPaymentForOrder($orders[0]); + + return new \WP_REST_Response(null, 200); + } +} diff --git a/src/Payment/inc/services.php b/src/Payment/inc/services.php index 446c3657..9e7131a9 100644 --- a/src/Payment/inc/services.php +++ b/src/Payment/inc/services.php @@ -191,5 +191,11 @@ static function () use ($container) { $middlewareHandler ); }, + \Mollie\WooCommerce\Payment\Webhooks\RestApi::class => static function (ContainerInterface $container): \Mollie\WooCommerce\Payment\Webhooks\RestApi { + return new \Mollie\WooCommerce\Payment\Webhooks\RestApi( + $container->get(\Mollie\WooCommerce\Payment\MollieOrderService::class), + $container->get(Logger::class) + ); + }, ]; }; From 3eaabf4dd7fcb7840b017eb5ad4845dfa6e0f450 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=BCsken?= Date: Mon, 8 Sep 2025 14:24:40 +0200 Subject: [PATCH 06/23] fix wrang parameter name --- src/Payment/MollieOrderService.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Payment/MollieOrderService.php b/src/Payment/MollieOrderService.php index d8c86105..0e2b93c0 100644 --- a/src/Payment/MollieOrderService.php +++ b/src/Payment/MollieOrderService.php @@ -128,7 +128,7 @@ public function onWebhookAction() ) { $this->httpResponse->setHttpResponseCode(200); $this->logger->debug( - $this->gateway->id . ": not respond on transaction webhooks for this payment method when order API is active. Payment ID {$payment->id}, order ID $order_id" + $this->gateway->id . ": not respond on transaction webhooks for this payment method when order API is active. Payment ID {$payment_object_id}, order ID {$order->get_id()}" ); return; } From 450a764f8f3f131da5d590fed8ecfc7366a8dd9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=BCsken?= Date: Mon, 8 Sep 2025 14:27:18 +0200 Subject: [PATCH 07/23] fix phpcs --- src/Payment/MollieOrderService.php | 10 +++++----- src/Payment/Request/Middleware/UrlMiddleware.php | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Payment/MollieOrderService.php b/src/Payment/MollieOrderService.php index 0e2b93c0..ed8c4506 100644 --- a/src/Payment/MollieOrderService.php +++ b/src/Payment/MollieOrderService.php @@ -292,11 +292,11 @@ public function doPaymentForOrder(\WC_Order $order): bool } else { $order->add_order_note(sprintf( /* translators: Placeholder 1: payment method title, placeholder 2: payment status, placeholder 3: payment ID */ - __('%1$s payment %2$s (%3$s), not processed.', 'mollie-payments-for-woocommerce'), - $this->gateway->method_title, - $payment->status, - $payment->id . ($payment->mode === 'test' ? (' - ' . __('test mode', 'mollie-payments-for-woocommerce')) : '') - )); + __('%1$s payment %2$s (%3$s), not processed.', 'mollie-payments-for-woocommerce'), + $this->gateway->method_title, + $payment->status, + $payment->id . ($payment->mode === 'test' ? (' - ' . __('test mode', 'mollie-payments-for-woocommerce')) : '') + )); return false; } diff --git a/src/Payment/Request/Middleware/UrlMiddleware.php b/src/Payment/Request/Middleware/UrlMiddleware.php index 1322dda9..6f224ebb 100644 --- a/src/Payment/Request/Middleware/UrlMiddleware.php +++ b/src/Payment/Request/Middleware/UrlMiddleware.php @@ -100,8 +100,8 @@ private function getReturnUrl(WC_Order $order, string $returnUrl): string */ public function getWebhookUrl(WC_Order $order, string $gatewayId): string { - $webhookUrl = get_rest_url(null, RestApi::ROUTE_NAMESPACE . '/' .RestApi::WEBHOOK_ROUTE); - if ( apply_filters( 'mollie_wc_gateway_disable_rest_webhook', false ) ) { + $webhookUrl = get_rest_url(null, RestApi::ROUTE_NAMESPACE . '/' . RestApi::WEBHOOK_ROUTE); + if (apply_filters('mollie_wc_gateway_disable_rest_webhook', false)) { $webhookUrl = WC()->api_request_url($gatewayId); $webhookUrl = untrailingslashit($webhookUrl); $webhookUrl = $this->asciiDomainName($webhookUrl); From 923040b641fe9c73a3b391bce3354b336687c80a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=BCsken?= Date: Tue, 9 Sep 2025 09:48:56 +0200 Subject: [PATCH 08/23] Getting orders by transaction, id also onWebhookAction and remove looking. Also added fallback to search in meta-data when order is not found by transaction id --- src/Payment/MollieOrderService.php | 128 +++++------------- .../Request/Middleware/UrlMiddleware.php | 2 +- src/Payment/Webhooks/RestApi.php | 14 +- 3 files changed, 46 insertions(+), 98 deletions(-) diff --git a/src/Payment/MollieOrderService.php b/src/Payment/MollieOrderService.php index ed8c4506..76df8c0e 100644 --- a/src/Payment/MollieOrderService.php +++ b/src/Payment/MollieOrderService.php @@ -89,72 +89,51 @@ public function onWebhookAction() $this->logger->debug(__METHOD__ . ': No payment object ID provided.', [true]); return; } - $payment_object_id = sanitize_text_field(wp_unslash($paymentId)); - - $order = wc_get_order($order_id); - - if (!$order instanceof WC_Order) { - $this->httpResponse->setHttpResponseCode(404); - $this->logger->debug(__METHOD__ . ": Could not find order $order_id."); - return; + $transactionID = sanitize_text_field(wp_unslash($paymentId)); + + $orders = wc_get_orders([ + 'transaction_id' => $transactionID, + 'limit' => 2, + ]); + + if (! $orders) { + $this->logger->debug(__METHOD__ . ': No orders found for transaction ID: ' . $transactionID . ' fall back to search in meta data'); + //Fallback search order in order mollie oder meta + $orders = wc_get_orders([ + 'limit' => 2, + 'meta_key' => '_mollie_order_id', + 'meta_compare' => '=', + 'meta_value' => $transactionID, + ]); + if (! $orders) { + $this->logger->debug(__METHOD__ . ': No orders found in mollie meta for transaction ID: ' . $transactionID); + return; + } } - if (!$order->key_is_valid($key)) { - $this->httpResponse->setHttpResponseCode(401); - $this->logger->debug(__METHOD__ . ": Invalid key $key for order $order_id."); + if (count($orders) > 1) { + $this->logger->debug(__METHOD__ . ': More than one order found for transaction ID: ' . $transactionID); return; } - $gateway = wc_get_payment_gateway_by_order($order); - if (!mollieWooCommerceIsMollieGateway($gateway->id)) { - return; - } + $order = $orders[0]; - // Prevent double payment webhooks for klarna on orders api - // TODO improve testing to check if we can remove this check - if ( - $this->container->get('settings.settings_helper')->isOrderApiSetting() - && in_array( - str_replace('mollie_wc_gateway_', '', $gateway->id), - [ - Constants::KLARNA, - Constants::KLARNAPAYLATER, - Constants::KLARNASLICEIT, - Constants::KLARNAPAYNOW, - ], - true - ) - && strpos($payment_object_id, 'tr_') === 0 - ) { - $this->httpResponse->setHttpResponseCode(200); - $this->logger->debug( - $this->gateway->id . ": not respond on transaction webhooks for this payment method when order API is active. Payment ID {$payment_object_id}, order ID {$order->get_id()}" - ); + if ($order->get_id() != $order_id) { + $this->httpResponse->setHttpResponseCode(401); + $this->logger->debug(__METHOD__ . ": found order {$order->get_id()} is not the same as provided order $order_id."); return; } - // Acquire exclusive lock for this order to prevent race conditions - $lockKey = 'mollie_webhook_lock_' . $order_id; - $lockAcquired = $this->acquireOrderLock($lockKey, 30); - - if (!$lockAcquired) { - // Another webhook is already processing this order - $this->httpResponse->setHttpResponseCode(409); // Conflict - $this->logger->debug( - __METHOD__ . ": Could not acquire lock for order $order_id. Another webhook is processing." - ); + if (!$order->key_is_valid($key)) { + $this->httpResponse->setHttpResponseCode(401); + $this->logger->debug(__METHOD__ . ": Invalid key $key for order $order_id."); return; } - try { - if (!$this->doPaymentForOrder($order)) { - $this->httpResponse->setHttpResponseCode(400); - }; - // Status 200 - } finally { - // Always release the lock - $this->releaseOrderLock($lockKey); - } + if (!$this->doPaymentForOrder($order)) { + $this->httpResponse->setHttpResponseCode(400); + }; + // Status 200 } protected function getPaymentIdFromRequest(): ?string @@ -162,39 +141,6 @@ protected function getPaymentIdFromRequest(): ?string return filter_input(INPUT_POST, 'id', FILTER_SANITIZE_SPECIAL_CHARS); } - /** - * Acquire an exclusive lock for processing an order - * - * @param string $lockKey The unique lock identifier - * @param int $timeout Maximum time to wait for lock (seconds) - * @return bool True if lock acquired, false otherwise - */ - private function acquireOrderLock(string $lockKey, int $timeout = 30): bool - { - $lockValue = uniqid('', true); - $expiration = $timeout; - - // set_transient returns false if the key already exists - $result = set_transient($lockKey, $lockValue, $expiration); - - if ($result) { - $this->currentLockValue = $lockValue; - return true; - } - - return false; - } - - /** - * Release the order processing lock - * - * @param string $lockKey The lock identifier to release - */ - private function releaseOrderLock(string $lockKey) - { - delete_transient($lockKey); - } - /** * Checks the Mollie payment status for a given WooCommerce order. * @@ -253,14 +199,6 @@ public function doPaymentForOrder(\WC_Order $order): bool return false; } - if ($order->get_id() != $payment->metadata->order_id) { - $this->httpResponse->setHttpResponseCode(400); - $this->logger->debug( - __METHOD__ . ": Order ID does not match order_id in payment metadata. Payment ID {$payment->id}, order ID {$order->get_id()}" - ); - return false; - } - $this->logger->debug($this->gateway->id . ": Mollie payment object {$payment->id} (" . $payment->mode . ") action call for order {$order->get_id()}."); $method_name = 'onWebhook' . ucfirst($payment->status); diff --git a/src/Payment/Request/Middleware/UrlMiddleware.php b/src/Payment/Request/Middleware/UrlMiddleware.php index 6f224ebb..fb1ff31f 100644 --- a/src/Payment/Request/Middleware/UrlMiddleware.php +++ b/src/Payment/Request/Middleware/UrlMiddleware.php @@ -101,7 +101,7 @@ private function getReturnUrl(WC_Order $order, string $returnUrl): string public function getWebhookUrl(WC_Order $order, string $gatewayId): string { $webhookUrl = get_rest_url(null, RestApi::ROUTE_NAMESPACE . '/' . RestApi::WEBHOOK_ROUTE); - if (apply_filters('mollie_wc_gateway_disable_rest_webhook', false)) { + if (! $webhookUrl || ! wc_is_valid_url($webhookUrl) || apply_filters('mollie_wc_gateway_disable_rest_webhook', true)) { $webhookUrl = WC()->api_request_url($gatewayId); $webhookUrl = untrailingslashit($webhookUrl); $webhookUrl = $this->asciiDomainName($webhookUrl); diff --git a/src/Payment/Webhooks/RestApi.php b/src/Payment/Webhooks/RestApi.php index 14aa255b..6e77545a 100644 --- a/src/Payment/Webhooks/RestApi.php +++ b/src/Payment/Webhooks/RestApi.php @@ -76,8 +76,18 @@ public function callback(WP_REST_Request $request) ]); if (! $orders) { - $this->logger->debug(__METHOD__ . ': No orders found for transaction ID: ' . $transactionID); - return new \WP_REST_Response(null, 200); + $this->logger->debug(__METHOD__ . ': No orders found for transaction ID: ' . $transactionID . ' fall back to search in meta data'); + //Fallback search order in order mollie oder meta + $orders = wc_get_orders([ + 'limit' => 2, + 'meta_key' => '_mollie_order_id', + 'meta_compare' => '=', + 'meta_value' => $transactionID, + ]); + if (! $orders) { + $this->logger->debug(__METHOD__ . ': No orders found in mollie meta for transaction ID: ' . $transactionID); + return new \WP_REST_Response(null, 200); + } } if (count($orders) > 1) { From 4fdf47469d6e5566de51754b4d2abe3ad3722834 Mon Sep 17 00:00:00 2001 From: carmenmaymo Date: Tue, 9 Sep 2025 10:23:23 +0200 Subject: [PATCH 09/23] add php unit config for integration tests --- tests/Integration/phpunit.xml.dist | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 tests/Integration/phpunit.xml.dist diff --git a/tests/Integration/phpunit.xml.dist b/tests/Integration/phpunit.xml.dist new file mode 100644 index 00000000..144e6198 --- /dev/null +++ b/tests/Integration/phpunit.xml.dist @@ -0,0 +1,12 @@ + + + + + spec + + + From dad29fb2460c7ef05a0970298d8e8cb5cf3864de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=BCsken?= Date: Tue, 9 Sep 2025 10:51:16 +0200 Subject: [PATCH 10/23] set rest api default to on --- src/Payment/Request/Middleware/UrlMiddleware.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Payment/Request/Middleware/UrlMiddleware.php b/src/Payment/Request/Middleware/UrlMiddleware.php index fb1ff31f..51516250 100644 --- a/src/Payment/Request/Middleware/UrlMiddleware.php +++ b/src/Payment/Request/Middleware/UrlMiddleware.php @@ -101,7 +101,7 @@ private function getReturnUrl(WC_Order $order, string $returnUrl): string public function getWebhookUrl(WC_Order $order, string $gatewayId): string { $webhookUrl = get_rest_url(null, RestApi::ROUTE_NAMESPACE . '/' . RestApi::WEBHOOK_ROUTE); - if (! $webhookUrl || ! wc_is_valid_url($webhookUrl) || apply_filters('mollie_wc_gateway_disable_rest_webhook', true)) { + if (! $webhookUrl || ! wc_is_valid_url($webhookUrl) || apply_filters('mollie_wc_gateway_disable_rest_webhook', false)) { $webhookUrl = WC()->api_request_url($gatewayId); $webhookUrl = untrailingslashit($webhookUrl); $webhookUrl = $this->asciiDomainName($webhookUrl); From 5070fb0c7055933c21cb687dd26e1c0ffaef0068 Mon Sep 17 00:00:00 2001 From: carmenmaymo Date: Tue, 9 Sep 2025 11:31:52 +0200 Subject: [PATCH 11/23] add @wordpress/scripts for linting --- package.json | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 81d6b862..991e9d31 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@woocommerce/dependency-extraction-webpack-plugin": "^1.7.0", "@wordpress/data": "^6.1.5", "@wordpress/element": "^4.0.4", + "@wordpress/scripts": "^30.23.0", "date-and-time": "^0.14.0", "del": "^3.0.0", "dotenv": "^16.0.0", @@ -48,14 +49,21 @@ "pump": "^3.0.0", "sass": "^1.83.1", "sass-loader": "^13.3.3", + "webpack": "^5.97.1", "webpack-cli": "^5.1.4", - "wp-pot": "^1.10.2", - "webpack": "^5.97.1" + "wp-pot": "^1.10.2" }, "scripts": { "watch": "BASE_PATH=. node_modules/.bin/encore dev --watch", "build": "BASE_PATH=. node_modules/.bin/encore dev", "setup": "gulp setup", + "lint:md": "wp-scripts lint-md-docs *.md", + "lint:js": "wp-scripts lint-js resources/js/src/*", + "lint:style": "wp-scripts lint-style resources/scss/*.scss", + "lint:js:fix": "wp-scripts lint-js resources/js/src/* --fix", + "lint:style:fix": "wp-scripts lint-style resources/scss/*.scss --fix", + "lint:php": "yarn phpcs && yarn psalm", + "lint:php-fix": "vendor/bin/phpcbf --parallel=8", "e2e-activation": "npx playwright test --project=activation", "e2e-simple": "npx playwright test --project=simple-classic", "e2e-block": "npx playwright test --project=simple-block", From 45ab39ecc4d00196b2cb1cd18c1e4342a0a06b7e Mon Sep 17 00:00:00 2001 From: carmenmaymo Date: Tue, 9 Sep 2025 11:32:06 +0200 Subject: [PATCH 12/23] add redux store --- .../js/src/checkout/blocks/store/actions.js | 77 ++++++++++++++++++ .../js/src/checkout/blocks/store/constants.js | 26 ++++++ .../js/src/checkout/blocks/store/index.js | 16 ++++ .../js/src/checkout/blocks/store/reducer.js | 81 +++++++++++++++++++ .../js/src/checkout/blocks/store/selectors.js | 62 ++++++++++++++ .../checkout/blocks/store/storeListeners.js | 30 +++++++ 6 files changed, 292 insertions(+) create mode 100644 resources/js/src/checkout/blocks/store/actions.js create mode 100644 resources/js/src/checkout/blocks/store/constants.js create mode 100644 resources/js/src/checkout/blocks/store/index.js create mode 100644 resources/js/src/checkout/blocks/store/reducer.js create mode 100644 resources/js/src/checkout/blocks/store/selectors.js create mode 100644 resources/js/src/checkout/blocks/store/storeListeners.js diff --git a/resources/js/src/checkout/blocks/store/actions.js b/resources/js/src/checkout/blocks/store/actions.js new file mode 100644 index 00000000..312c6963 --- /dev/null +++ b/resources/js/src/checkout/blocks/store/actions.js @@ -0,0 +1,77 @@ +import { ACTIONS } from './constants'; + +export const setSelectedIssuer = ( issuer ) => ( { + type: ACTIONS.SET_SELECTED_ISSUER, + payload: issuer, +} ); + +export const setInputPhone = ( phone ) => ( { + type: ACTIONS.SET_INPUT_PHONE, + payload: phone, +} ); + +export const setInputBirthdate = ( birthdate ) => ( { + type: ACTIONS.SET_INPUT_BIRTHDATE, + payload: birthdate, +} ); + +export const setInputCompany = ( company ) => ( { + type: ACTIONS.SET_INPUT_COMPANY, + payload: company, +} ); + +export const setActivePaymentMethod = ( method ) => ( { + type: ACTIONS.SET_ACTIVE_PAYMENT_METHOD, + payload: method, +} ); + +export const setPaymentItemData = ( itemData ) => ( { + type: ACTIONS.SET_PAYMENT_ITEM_DATA, + payload: itemData, +} ); + +export const setRequiredFields = ( fields ) => ( { + type: ACTIONS.SET_REQUIRED_FIELDS, + payload: fields, +} ); + +export const setPhoneFieldVisible = ( isVisible ) => ( { + type: ACTIONS.SET_PHONE_FIELD_VISIBLE, + payload: isVisible, +} ); + +export const setBillingData = ( billing ) => ( { + type: ACTIONS.SET_BILLING_DATA, + payload: billing, +} ); + +export const setShippingData = ( shipping ) => ( { + type: ACTIONS.SET_SHIPPING_DATA, + payload: shipping, +} ); + +export const setValidationState = ( isValid ) => ( { + type: ACTIONS.SET_VALIDATION_STATE, + payload: isValid, +} ); + +export const setPhonePlaceholder = ( placeholder ) => ( { + type: ACTIONS.SET_PHONE_PLACEHOLDER, + payload: placeholder, +} ); + +export const setCardToken = ( token ) => ( { + type: ACTIONS.SET_CARD_TOKEN, + payload: token, +} ); + +export const updatePhonePlaceholderByCountry = ( country ) => () => { + const countryCodes = { + BE: '+32xxxxxxxxx', + NL: '+316xxxxxxxx', + DE: '+49xxxxxxxxx', + AT: '+43xxxxxxxxx', + }; + const placeholder = countryCodes[ country ] || countryCodes.NL; + wp.data.dispatch( setPhonePlaceholder( placeholder ) ); +}; diff --git a/resources/js/src/checkout/blocks/store/constants.js b/resources/js/src/checkout/blocks/store/constants.js new file mode 100644 index 00000000..87dbc933 --- /dev/null +++ b/resources/js/src/checkout/blocks/store/constants.js @@ -0,0 +1,26 @@ +export const ACTIONS = { + // Field updates + SET_SELECTED_ISSUER: 'SET_SELECTED_ISSUER', + SET_INPUT_PHONE: 'SET_INPUT_PHONE', + SET_INPUT_BIRTHDATE: 'SET_INPUT_BIRTHDATE', + SET_INPUT_COMPANY: 'SET_INPUT_COMPANY', + + // Payment method state + SET_ACTIVE_PAYMENT_METHOD: 'SET_ACTIVE_PAYMENT_METHOD', + SET_PAYMENT_ITEM_DATA: 'SET_PAYMENT_ITEM_DATA', + + // Configuration + SET_REQUIRED_FIELDS: 'SET_REQUIRED_FIELDS', + SET_PHONE_FIELD_VISIBLE: 'SET_PHONE_FIELD_VISIBLE', + SET_BILLING_DATA: 'SET_BILLING_DATA', + SET_SHIPPING_DATA: 'SET_SHIPPING_DATA', + + // Validation + SET_VALIDATION_STATE: 'SET_VALIDATION_STATE', + + // Country settings + SET_PHONE_PLACEHOLDER: 'SET_PHONE_PLACEHOLDER', + + // Credit card + SET_CARD_TOKEN: 'SET_CARD_TOKEN', +}; diff --git a/resources/js/src/checkout/blocks/store/index.js b/resources/js/src/checkout/blocks/store/index.js new file mode 100644 index 00000000..e5f032d9 --- /dev/null +++ b/resources/js/src/checkout/blocks/store/index.js @@ -0,0 +1,16 @@ +/** + * Main store registration for Mollie WooCommerce blocks + */ +import reducer from './reducer'; +import * as actions from './actions'; +import selectors from './selectors'; + +export const MOLLIE_STORE_KEY = 'mollie-payments'; + +export const mollieStore = wp.data.createReduxStore( MOLLIE_STORE_KEY, { + reducer, + actions, + selectors, +} ); + +wp.data.register( mollieStore ); diff --git a/resources/js/src/checkout/blocks/store/reducer.js b/resources/js/src/checkout/blocks/store/reducer.js new file mode 100644 index 00000000..494222dd --- /dev/null +++ b/resources/js/src/checkout/blocks/store/reducer.js @@ -0,0 +1,81 @@ +import { ACTIONS } from './constants'; + +const initialState = { + // Form fields + selectedIssuer: '', + inputPhone: '', + inputBirthdate: '', + inputCompany: '', + + // Payment method state + activePaymentMethod: '', + paymentItemData: {}, + + // Configuration + requiredFields: { + companyNameString: '', + phoneString: '', + }, + isPhoneFieldVisible: true, + billingData: {}, + shippingData: {}, + + // Validation + isValid: true, + + // Country settings + phonePlaceholder: '+316xxxxxxxx', + + // Credit card + cardToken: '', +}; + +const reducer = ( state = initialState, action ) => { + switch ( action.type ) { + case ACTIONS.SET_SELECTED_ISSUER: + return { ...state, selectedIssuer: action.payload }; + + case ACTIONS.SET_INPUT_PHONE: + return { ...state, inputPhone: action.payload }; + + case ACTIONS.SET_INPUT_BIRTHDATE: + return { ...state, inputBirthdate: action.payload }; + + case ACTIONS.SET_INPUT_COMPANY: + return { ...state, inputCompany: action.payload }; + + case ACTIONS.SET_ACTIVE_PAYMENT_METHOD: + return { ...state, activePaymentMethod: action.payload }; + + case ACTIONS.SET_PAYMENT_ITEM_DATA: + return { ...state, paymentItemData: action.payload }; + + case ACTIONS.SET_REQUIRED_FIELDS: + return { + ...state, + requiredFields: { ...state.requiredFields, ...action.payload }, + }; + + case ACTIONS.SET_PHONE_FIELD_VISIBLE: + return { ...state, isPhoneFieldVisible: action.payload }; + + case ACTIONS.SET_BILLING_DATA: + return { ...state, billingData: action.payload }; + + case ACTIONS.SET_SHIPPING_DATA: + return { ...state, shippingData: action.payload }; + + case ACTIONS.SET_VALIDATION_STATE: + return { ...state, isValid: action.payload }; + + case ACTIONS.SET_PHONE_PLACEHOLDER: + return { ...state, phonePlaceholder: action.payload }; + + case ACTIONS.SET_CARD_TOKEN: + return { ...state, cardToken: action.payload }; + default: + return state; + } +}; + +export default reducer; diff --git a/resources/js/src/checkout/blocks/store/selectors.js b/resources/js/src/checkout/blocks/store/selectors.js new file mode 100644 index 00000000..98ce8227 --- /dev/null +++ b/resources/js/src/checkout/blocks/store/selectors.js @@ -0,0 +1,62 @@ +const selectors = { + // Form field selectors + getSelectedIssuer: ( state ) => state.selectedIssuer, + getInputPhone: ( state ) => state.inputPhone, + getInputBirthdate: ( state ) => state.inputBirthdate, + getInputCompany: ( state ) => state.inputCompany, + + // Payment method selectors + getActivePaymentMethod: ( state ) => state.activePaymentMethod, + getPaymentItemData: ( state ) => state.paymentItemData, + + // Configuration selectors + getRequiredFields: ( state ) => state.requiredFields, + getIsPhoneFieldVisible: ( state ) => state.isPhoneFieldVisible, + getBillingData: ( state ) => state.billingData, + getShippingData: ( state ) => state.shippingData, + + // Validation selectors + getIsValid: ( state ) => state.isValid, + + // Country settings + getPhonePlaceholder: ( state ) => state.phonePlaceholder, + + // Credit card + getCardToken: ( state ) => state.cardToken, + + // Computed selectors + getIssuerKey: ( state ) => + `mollie-payments-for-woocommerce_issuer_${ state.activePaymentMethod }`, + + getPaymentMethodData: ( state ) => ( { + payment_method: state.activePaymentMethod, + payment_method_title: state.paymentItemData.title, + [ selectors.getIssuerKey( state ) ]: state.selectedIssuer, + billing_phone: state.inputPhone, + billing_company_billie: state.inputCompany, + billing_birthdate: state.inputBirthdate, + cardToken: state.cardToken, + } ), + + // Validation computed selectors + getIsCompanyEmpty: ( state ) => + state.billingData.company === '' && + state.shippingData.company === '' && + state.inputCompany === '', + + getIsPhoneEmpty: ( state ) => + state.billingData.phone === '' && + state.shippingData.phone === '' && + state.inputPhone === '', + + getIsBirthdateValid: ( state ) => { + if ( state.inputBirthdate === '' ) { + return false; + } + const today = new Date(); + const birthdate = new Date( state.inputBirthdate ); + return birthdate <= today; + }, +}; + +export default selectors; diff --git a/resources/js/src/checkout/blocks/store/storeListeners.js b/resources/js/src/checkout/blocks/store/storeListeners.js new file mode 100644 index 00000000..4782b338 --- /dev/null +++ b/resources/js/src/checkout/blocks/store/storeListeners.js @@ -0,0 +1,30 @@ +import { MOLLIE_STORE_KEY } from './index'; + +export const setUpMollieBlockCheckoutListeners = () => { + let currentPaymentMethod; + const PAYMENT_STORE_KEY = 'wc/store/payment'; + const checkoutStoreCallback = () => { + try { + const paymentStore = wp.data.select( PAYMENT_STORE_KEY ); + + const paymentMethod = paymentStore.getActivePaymentMethod(); + if ( currentPaymentMethod !== paymentMethod ) { + wp.data + .dispatch( MOLLIE_STORE_KEY ) + .setActivePaymentMethod( paymentMethod ); + currentPaymentMethod = paymentMethod; + } + } catch ( error ) { + // eslint-disable-next-line no-console + console.log( 'Checkout store not ready yet:', error ); + } + }; + + const unsubscribeCheckoutStore = wp.data.subscribe( + checkoutStoreCallback, + PAYMENT_STORE_KEY + ); + checkoutStoreCallback(); + + return { unsubscribeCheckoutStore }; +}; From 1950defa02e64247f07ec7d0cc123e971d7829c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=BCsken?= Date: Tue, 9 Sep 2025 12:04:28 +0200 Subject: [PATCH 13/23] Fix integration test for concurrent webhooks --- .../Common/Factories/OrderFactory.php | 9 ++++++++- .../Common/Traits/CreateTestOrders.php | 6 ++++-- .../spec/webhooks/WebhooksIntegrationTest.php | 16 +++++++++------- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/tests/Integration/Common/Factories/OrderFactory.php b/tests/Integration/Common/Factories/OrderFactory.php index 464c0a82..9c4d6f7c 100644 --- a/tests/Integration/Common/Factories/OrderFactory.php +++ b/tests/Integration/Common/Factories/OrderFactory.php @@ -30,6 +30,7 @@ public function __construct( * @param array $product_presets * @param array $discount_presets * @param bool $set_paid + * @param string $transaction_id * @return WC_Order * @throws \WC_Data_Exception */ @@ -38,7 +39,8 @@ public function create( string $payment_method, array $product_presets, array $discount_presets = [], - bool $set_paid = true + bool $set_paid = true, + string $transaction_id = '' ): WC_Order { $products = $this->resolveProductPresets($product_presets); @@ -58,6 +60,11 @@ public function create( $this->applyDiscountsToOrder($order, $discounts); $order->set_payment_method($payment_method); + if (!$transaction_id) { + $order->set_transaction_id(uniqid('tr_')); + } else { + $order->set_transaction_id($transaction_id); + } $order->calculate_totals(); $order->save(); diff --git a/tests/Integration/Common/Traits/CreateTestOrders.php b/tests/Integration/Common/Traits/CreateTestOrders.php index d60982d9..8d5e337c 100644 --- a/tests/Integration/Common/Traits/CreateTestOrders.php +++ b/tests/Integration/Common/Traits/CreateTestOrders.php @@ -32,7 +32,8 @@ protected function getConfiguredOrder( string $payment_method, array $product_presets, array $discount_presets = [], - bool $set_paid = true + bool $set_paid = true, + string $transaction_id = '' ): WC_Order { if (!isset($this->order_factory)) { @@ -44,7 +45,8 @@ protected function getConfiguredOrder( $payment_method, $product_presets, $discount_presets, - $set_paid + $set_paid, + $transaction_id ); } } diff --git a/tests/Integration/spec/webhooks/WebhooksIntegrationTest.php b/tests/Integration/spec/webhooks/WebhooksIntegrationTest.php index a78e23fa..4abb71dd 100644 --- a/tests/Integration/spec/webhooks/WebhooksIntegrationTest.php +++ b/tests/Integration/spec/webhooks/WebhooksIntegrationTest.php @@ -89,12 +89,13 @@ public function it_processes_only_one_order_when_race_conditions() $orderId = $order->get_id(); $orderKey = $order->get_order_key(); + $transactionId = $order->get_transaction_id(); $paymentData = [ - 'id' => 'tr_first_payment_id', + 'id' => $transactionId, ]; - $this->mockSuccessfulPaymentGet('tr_first_payment_id', 'paid', [ + $this->mockSuccessfulPaymentGet($transactionId, 'paid', [ 'metadata' => ['order_id' => $orderId], 'method' => 'ideal', 'mode' => 'test' @@ -138,8 +139,9 @@ public function it_handles_concurrent_webhook_calls_gracefully() $orderId = $order->get_id(); $orderKey = $order->get_order_key(); + $transactionId = $order->get_transaction_id(); - $this->mockSuccessfulPaymentGet('tr_concurrent_payment_id', 'paid', [ + $this->mockSuccessfulPaymentGet($transactionId, 'paid', [ 'metadata' => ['order_id' => $orderId], 'method' => 'ideal', 'mode' => 'test' @@ -149,17 +151,17 @@ public function it_handles_concurrent_webhook_calls_gracefully() $container = $this->bootstrapModule($mockedServices); // Set up the webhook request parameters - $this->setupWebhookRequest($orderId, $orderKey, 'tr_concurrent_payment_id'); + $this->setupWebhookRequest($orderId, $orderKey, $transactionId); // Get two instances of the webhook service to simulate concurrent requests - $webhookService1 = $this->createMockedWebhookService($container, 'tr_concurrent_payment_id'); - $webhookService2 = $this->createMockedWebhookService($container, 'tr_concurrent_payment_id'); + $webhookService1 = $this->createMockedWebhookService($container, uniqid('ord_')); + $webhookService2 = $this->createMockedWebhookService($container, $transactionId); // First webhook call should process successfully $webhookService1->onWebhookAction(); $order = wc_get_order($orderId); - $this->assertEquals('processing', $order->get_status()); + $this->assertEquals('pending', $order->get_status()); // Second webhook call should be handled gracefully (idempotency) // This simulates a race condition where the same webhook arrives multiple times From df5359a4f6ed249cdd01948b92a94e1f2d08d226 Mon Sep 17 00:00:00 2001 From: carmenmaymo Date: Tue, 9 Sep 2025 12:51:18 +0200 Subject: [PATCH 14/23] Exclude js files from phpcs linter --- phpcs.xml.dist | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 33e29a68..89040281 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -6,6 +6,13 @@ ./inc ./src ./tests/ + *.js + *.jsx + *.ts + *.tsx + *.css + *.scss + *.json From e582e819a6879cf2444b196d360f33cb9d5289eb Mon Sep 17 00:00:00 2001 From: carmenmaymo Date: Tue, 9 Sep 2025 12:51:43 +0200 Subject: [PATCH 15/23] Add checkout block components and hoc --- .../src/checkout/blocks/components/Label.js | 7 + .../components/PaymentComponentFactory.js | 79 ++++++ .../PaymentMethodContentRenderer.js | 105 ++++++++ .../blocks/components/molliePaymentMethod.js | 33 +++ .../paymentFields/BirthdateField.js | 19 ++ .../components/paymentFields/CompanyField.js | 19 ++ .../paymentFields/CreditCardField.js | 3 + .../components/paymentFields/IssuerSelect.js | 17 ++ .../components/paymentFields/PhoneField.js | 19 ++ .../paymentMethods/CreditCardComponent.js | 50 ++++ .../paymentMethods/DefaultComponent.js | 44 ++++ .../paymentMethods/PaymentFieldsComponent.js | 228 ++++++++++++++++++ .../checkout/blocks/hoc/withMollieStore.js | 67 +++++ 13 files changed, 690 insertions(+) create mode 100644 resources/js/src/checkout/blocks/components/Label.js create mode 100644 resources/js/src/checkout/blocks/components/PaymentComponentFactory.js create mode 100644 resources/js/src/checkout/blocks/components/PaymentMethodContentRenderer.js create mode 100644 resources/js/src/checkout/blocks/components/molliePaymentMethod.js create mode 100644 resources/js/src/checkout/blocks/components/paymentFields/BirthdateField.js create mode 100644 resources/js/src/checkout/blocks/components/paymentFields/CompanyField.js create mode 100644 resources/js/src/checkout/blocks/components/paymentFields/CreditCardField.js create mode 100644 resources/js/src/checkout/blocks/components/paymentFields/IssuerSelect.js create mode 100644 resources/js/src/checkout/blocks/components/paymentFields/PhoneField.js create mode 100644 resources/js/src/checkout/blocks/components/paymentMethods/CreditCardComponent.js create mode 100644 resources/js/src/checkout/blocks/components/paymentMethods/DefaultComponent.js create mode 100644 resources/js/src/checkout/blocks/components/paymentMethods/PaymentFieldsComponent.js create mode 100644 resources/js/src/checkout/blocks/hoc/withMollieStore.js diff --git a/resources/js/src/checkout/blocks/components/Label.js b/resources/js/src/checkout/blocks/components/Label.js new file mode 100644 index 00000000..132702c5 --- /dev/null +++ b/resources/js/src/checkout/blocks/components/Label.js @@ -0,0 +1,7 @@ +export const Label = ( { item } ) => { + return ( + <> +
+ + ); +}; diff --git a/resources/js/src/checkout/blocks/components/PaymentComponentFactory.js b/resources/js/src/checkout/blocks/components/PaymentComponentFactory.js new file mode 100644 index 00000000..b5381297 --- /dev/null +++ b/resources/js/src/checkout/blocks/components/PaymentComponentFactory.js @@ -0,0 +1,79 @@ +import CreditCardComponent from './paymentMethods/CreditCardComponent'; +import DefaultComponent from './paymentMethods/DefaultComponent'; +import PaymentFieldsComponent from './paymentMethods/PaymentFieldsComponent'; +import withMollieStore from '../hoc/withMollieStore'; + +/** + * Factory function to create appropriate payment component with store connection + * Maps payment method names to their corresponding components with proper configuration + * @param {Object} item + * @param {Object} commonProps + */ +export const createPaymentComponent = ( item, commonProps ) => { + if ( ! item || ! item.name ) { + return
Loading payment methods...
; + } + + switch ( item.name ) { + case 'mollie_wc_gateway_creditcard': + const CreditCardWithStore = withMollieStore( CreditCardComponent ); + return ; + + case 'mollie_wc_gateway_billie': + const BillieFieldsWithStore = withMollieStore( + PaymentFieldsComponent + ); + return ( + + ); + + case 'mollie_wc_gateway_in3': + const In3FieldsWithStore = withMollieStore( + PaymentFieldsComponent + ); + return ( + + ); + + case 'mollie_wc_gateway_riverty': + const RivertyFieldsWithStore = withMollieStore( + PaymentFieldsComponent + ); + return ( + + ); + + default: + const DefaultWithStore = withMollieStore( DefaultComponent ); + return ; + } +}; diff --git a/resources/js/src/checkout/blocks/components/PaymentMethodContentRenderer.js b/resources/js/src/checkout/blocks/components/PaymentMethodContentRenderer.js new file mode 100644 index 00000000..5e8e4222 --- /dev/null +++ b/resources/js/src/checkout/blocks/components/PaymentMethodContentRenderer.js @@ -0,0 +1,105 @@ +import { MOLLIE_STORE_KEY } from '../store'; +import { createPaymentComponent } from './PaymentComponentFactory'; + +/** + * Main Mollie Component - Orchestrates payment method rendering + * Handles common payment processing and delegates specific logic to child components + * @param {Object} props + */ +export const PaymentMethodContentRenderer = ( props ) => { + const { useEffect } = wp.element; + const { useSelect } = wp.data; + + const { + activePaymentMethod, + billing, + item, + jQuery, + emitResponse, + eventRegistration, + requiredFields, + shippingData, + isPhoneFieldVisible, + } = props; + + const { responseTypes } = emitResponse; + const { onPaymentSetup } = eventRegistration; + + // Redux store selectors - only for payment processing + const selectedIssuer = useSelect( + ( select ) => select( MOLLIE_STORE_KEY ).getSelectedIssuer(), + [] + ); + const inputPhone = useSelect( + ( select ) => select( MOLLIE_STORE_KEY ).getInputPhone(), + [] + ); + const inputBirthdate = useSelect( + ( select ) => select( MOLLIE_STORE_KEY ).getInputBirthdate(), + [] + ); + const inputCompany = useSelect( + ( select ) => select( MOLLIE_STORE_KEY ).getInputCompany(), + [] + ); + + const issuerKey = + 'mollie-payments-for-woocommerce_issuer_' + activePaymentMethod; + + // Main payment processing - stays centralized for all payment methods + useEffect( () => { + const onProcessingPayment = () => { + const data = { + payment_method: activePaymentMethod, + payment_method_title: item.title, + [ issuerKey ]: selectedIssuer, + billing_phone: inputPhone, + billing_company_billie: inputCompany, + billing_birthdate: inputBirthdate, + cardToken: '', + }; + const tokenVal = jQuery( '.mollie-components > input' ).val(); + if ( tokenVal ) { + data.cardToken = tokenVal; + } + return { + type: responseTypes.SUCCESS, + meta: { + paymentMethodData: data, + }, + }; + }; + + const unsubscribePaymentProcessing = + onPaymentSetup( onProcessingPayment ); + return () => { + unsubscribePaymentProcessing(); + }; + }, [ + selectedIssuer, + onPaymentSetup, + inputPhone, + inputCompany, + inputBirthdate, + activePaymentMethod, + issuerKey, + item.title, + jQuery, + responseTypes.SUCCESS, + ] ); + + // Prepare common props for child components + const commonProps = { + item, + jQuery, + useEffect, + billing, + shippingData, + eventRegistration, + requiredFields, + isPhoneFieldVisible, + activePaymentMethod, + }; + + return createPaymentComponent( item, commonProps ); +}; diff --git a/resources/js/src/checkout/blocks/components/molliePaymentMethod.js b/resources/js/src/checkout/blocks/components/molliePaymentMethod.js new file mode 100644 index 00000000..c04bddf6 --- /dev/null +++ b/resources/js/src/checkout/blocks/components/molliePaymentMethod.js @@ -0,0 +1,33 @@ +import { PaymentMethodContentRenderer } from './PaymentMethodContentRenderer'; +import { Label } from './Label'; + +const molliePaymentMethod = ( + item, + jQuery, + requiredFields, + isPhoneFieldVisible +) => { + return { + name: item.name, + label: