diff --git a/inc/settings/mollie_advanced_settings.php b/inc/settings/mollie_advanced_settings.php index 31750d6b..e4026f49 100644 --- a/inc/settings/mollie_advanced_settings.php +++ b/inc/settings/mollie_advanced_settings.php @@ -199,6 +199,36 @@ class="mollie-settings-advanced-payment-desc-label button button-secondary butto __('Clear now', 'mollie-payments-for-woocommerce') ) . ')', ], + [ + 'id' => $pluginName . '_place_payment_onhold', + 'title' => __('Placing payments on Hold', 'mollie-payments-for-woocommerce'), + 'type' => 'select', + 'desc_tip' => true, + 'options' => [ + 'immediate_capture' => __('Capture payments immediately', 'mollie-payments-for-woocommerce'), + 'later_capture' => __('Authorize payments for a later capture', 'mollie-payments-for-woocommerce'), + ], + 'default' => 'immediate_capture', + 'desc' => sprintf( + __( + 'Authorized payment can be captured or voided by changing the order status instead of doing it manually.', + 'mollie-payments-for-woocommerce' + ) + ), + ], + [ + 'id' => $pluginName . '_capture_or_void', + 'title' => __( + 'Capture or void on status change', + 'mollie-payments-for-woocommerce' + ), + 'type' => 'checkbox', + 'default' => 'no', + 'desc' => __( + 'Capture authorized payments automatically when setting the order status to Processing or Completed. Void the payment by setting the order status Canceled.', + 'mollie-payments-for-woocommerce' + ), + ], [ 'id' => $pluginName . '_sectionend', 'type' => 'sectionend', diff --git a/mollie-payments-for-woocommerce.php b/mollie-payments-for-woocommerce.php index fcadd0a9..bd3dd27c 100644 --- a/mollie-payments-for-woocommerce.php +++ b/mollie-payments-for-woocommerce.php @@ -19,6 +19,7 @@ namespace Mollie\WooCommerce; +use Mollie\WooCommerce\MerchantCapture\MerchantCaptureModule; use Mollie\WooCommerce\Vendor\Inpsyde\Modularity\Package; use Mollie\WooCommerce\Vendor\Inpsyde\Modularity\Properties\PluginProperties; use Mollie\WooCommerce\Activation\ActivationModule; @@ -161,6 +162,7 @@ function initialize() new GatewayModule(), new VoucherModule(), new PaymentModule(), + new MerchantCaptureModule(), new UninstallModule(), ]; $modules = apply_filters('mollie_wc_plugin_modules', $modules); diff --git a/resources/js/advancedSettings.js b/resources/js/advancedSettings.js index d78b22aa..3aa2bc0d 100644 --- a/resources/js/advancedSettings.js +++ b/resources/js/advancedSettings.js @@ -1,9 +1,9 @@ ( - function ({_, jQuery }) { + function ({_, jQuery}) { function mollie_settings__insertTextAtCursor(target, text, dontIgnoreSelection) { if (target.setRangeText) { - if ( !dontIgnoreSelection ) { + if (!dontIgnoreSelection) { // insert at end target.setRangeText(text, target.value.length, target.value.length, "end"); } else { @@ -16,13 +16,14 @@ } target.focus(); } - jQuery(document).ready(function($) { + + jQuery(document).ready(function ($) { $(".mollie-settings-advanced-payment-desc-label") .data("ignore-click", "false") - .on("mousedown", function(e) { + .on("mousedown", function (e) { const input = document.getElementById("mollie-payments-for-woocommerce_api_payment_description"); - if ( document.activeElement && input === document.activeElement ) { - $(this).on("mouseup.molliesettings", function(e) { + if (document.activeElement && input === document.activeElement) { + $(this).on("mouseup.molliesettings", function (e) { $(this).data("ignore-click", "true"); $(this).off(".molliesettings"); const tag = $(this).data("tag"); @@ -31,15 +32,15 @@ }); } let $this = $(this); - $(window).on("mouseup.molliesettings drag.molliesettings blur.molliesettings", function(e) { + $(window).on("mouseup.molliesettings drag.molliesettings blur.molliesettings", function (e) { $this.off(".molliesettings"); $(window).off(".molliesettings"); }); }) - .on("click", function(e) { + .on("click", function (e) { e.preventDefault(); e.stopImmediatePropagation(); - if ( $(this).data("ignore-click") === "false" ) { + if ($(this).data("ignore-click") === "false") { const tag = $(this).data("tag"); const input = document.getElementById("mollie-payments-for-woocommerce_api_payment_description"); mollie_settings__insertTextAtCursor(input, tag, false); @@ -48,9 +49,44 @@ } }) ; + registerManualCaptureFields(); }); } ) ( window ) + +function registerManualCaptureFields() { + const onHoldSelect = jQuery('[name="mollie-payments-for-woocommerce_place_payment_onhold"]'); + if (onHoldSelect.length === 0) { + return; + } + toggleManualCaptureFields(onHoldSelect); + onHoldSelect.on('change', function(){ + toggleManualCaptureFields(onHoldSelect); + }) +} + +function toggleManualCaptureFields(onHoldSelect) { + const currentValue = onHoldSelect.find('option:selected'); + if (currentValue.length === 0) { + return; + } + + const captureStatusChangeField = jQuery('[name="mollie-payments-for-woocommerce_capture_or_void"]'); + if (captureStatusChangeField.length === 0) { + return; + } + + const captureStatusChangeFieldParent = captureStatusChangeField.closest('tr'); + if (captureStatusChangeFieldParent.length === 0) { + return; + } + + if (currentValue.val() === 'later_capture') { + captureStatusChangeFieldParent.show(); + } else { + captureStatusChangeFieldParent.hide(); + } +} diff --git a/src/MerchantCapture/Capture/Action/AbstractPaymentCaptureAction.php b/src/MerchantCapture/Capture/Action/AbstractPaymentCaptureAction.php new file mode 100644 index 00000000..70d3ef8b --- /dev/null +++ b/src/MerchantCapture/Capture/Action/AbstractPaymentCaptureAction.php @@ -0,0 +1,40 @@ +apiHelper = $apiHelper; + $this->settingsHelper = $settingsHelper; + $this->order = wc_get_order($orderId); + $this->logger = $logger; + $this->pluginId = $pluginId; + $this->setApiKey(); + } + + protected function setApiKey() + { + $this->apiKey = $this->settingsHelper->getApiKey(); + } +} diff --git a/src/MerchantCapture/Capture/Action/CapturePayment.php b/src/MerchantCapture/Capture/Action/CapturePayment.php new file mode 100644 index 00000000..41fd5ab7 --- /dev/null +++ b/src/MerchantCapture/Capture/Action/CapturePayment.php @@ -0,0 +1,65 @@ +order->get_meta('_mollie_payment_id'); + + if (!$paymentId) { + $this->logger->error('Missing Mollie payment ID in order ' . $this->order->get_id()); + $this->order->add_order_note( + __( + 'The Mollie payment ID is missing, and we are unable to capture the funds.', + 'mollie-payments-for-woocommerce' + ) + ); + return; + } + + $paymentCapturesApi = $this->apiHelper->getApiClient($this->apiKey)->paymentCaptures; + $captureData = [ + 'amount' => [ + 'currency' => $this->order->get_currency(), + 'value' => $this->order->get_total(), + ], + ]; + $this->logger->debug( + 'SEND AN ORDER CAPTURE, orderId: ' . $this->order->get_id( + ) . ' transactionId: ' . $paymentId . 'Capture data: ' . json_encode($captureData) + ); + $paymentCapturesApi->createForId($paymentId, $captureData); + $this->order->update_meta_data( + MerchantCaptureModule::ORDER_PAYMENT_STATUS_META_KEY, + ManualCaptureStatus::STATUS_WAITING + ); + $this->order->add_order_note( + sprintf( + __( + 'The payment capture of %s has been sent successfully, and we are currently awaiting confirmation.', + 'mollie-payments-for-woocommerce' + ), + wc_price($this->order->get_total()) + ) + ); + $this->order->save(); + } catch (ApiException $exception) { + $this->logger->error($exception->getMessage()); + $this->order->add_order_note( + __( + 'Payment Capture Failed. We encountered an issue while processing the payment capture.', + 'mollie-payments-for-woocommerce' + ) + ); + } + } +} diff --git a/src/MerchantCapture/Capture/Action/VoidPayment.php b/src/MerchantCapture/Capture/Action/VoidPayment.php new file mode 100644 index 00000000..0ef1d152 --- /dev/null +++ b/src/MerchantCapture/Capture/Action/VoidPayment.php @@ -0,0 +1,34 @@ +order->get_meta('_mollie_payment_id'); + $paymentCapturesApi = $this->apiHelper->getApiClient($this->apiKey)->payments; + try { + $paymentCapturesApi->cancel($paymentId); + $this->order->update_meta_data( + MerchantCaptureModule::ORDER_PAYMENT_STATUS_META_KEY, + ManualCaptureStatus::STATUS_VOIDED + ); + $this->order->save(); + } catch (ApiException $exception) { + $this->logger->error($exception->getMessage()); + $this->order->add_order_note( + __( + 'Payment Void Failed. We encountered an issue while canceling the pre-authorized payment.', + 'mollie-payments-for-woocommerce' + ) + ); + } + } +} diff --git a/src/MerchantCapture/Capture/Type/ManualCapture.php b/src/MerchantCapture/Capture/Type/ManualCapture.php new file mode 100644 index 00000000..70c890c2 --- /dev/null +++ b/src/MerchantCapture/Capture/Type/ManualCapture.php @@ -0,0 +1,51 @@ +container = $container; + add_action('woocommerce_order_actions', [$this, 'enableOrderCaptureButton'], 10, 2); + add_action('woocommerce_order_action_' . self::MOLLIE_MANUAL_CAPTURE_ACTION, [$this, 'manualCapture']); + add_filter('woocommerce_mollie_wc_gateway_creditcard_args', [$this, 'sendManualCaptureMode']); + } + + public function enableOrderCaptureButton(array $actions, \WC_Order $order): array + { + if (!$this->container->get('merchant.manual_capture.can_capture_the_order')($order)) { + return $actions; + } + $actions[self::MOLLIE_MANUAL_CAPTURE_ACTION] = __( + 'Capture authorized Mollie payment', + 'mollie-payments-for-woocommerce' + ); + return $actions; + } + + public function sendManualCaptureMode(array $paymentData): array + { + if ($this->container->get('merchant.manual_capture.enabled')) { + $paymentData['captureMode'] = 'manual'; + } + return $paymentData; + } + + public function manualCapture(\WC_Order $order) + { + + ($this->container->get(CapturePayment::class))($order->get_id()); + } +} diff --git a/src/MerchantCapture/Capture/Type/StateChangeCapture.php b/src/MerchantCapture/Capture/Type/StateChangeCapture.php new file mode 100644 index 00000000..9e9ec3e8 --- /dev/null +++ b/src/MerchantCapture/Capture/Type/StateChangeCapture.php @@ -0,0 +1,55 @@ +container = $container; + add_action('woocommerce_order_status_changed', [$this, "orderStatusChange"], 10, 3); + } + + public function orderStatusChange(int $orderId, string $oldStatus, string $newStatus) + { + $stateChangeCaptureEnabled = $this->container->get('merchant.manual_capture.on_status_change_enabled'); + if (empty($stateChangeCaptureEnabled) || $stateChangeCaptureEnabled === 'no') { + return; + } + + if (!in_array($oldStatus, $this->container->get('merchant.manual_capture.void_statuses'))) { + return; + } + + if (in_array($newStatus, [SharedDataDictionary::STATUS_PROCESSING, SharedDataDictionary::STATUS_COMPLETED])) { + $this->capturePayment($orderId); + return; + } + + if ($newStatus === SharedDataDictionary::STATUS_CANCELLED) { + $this->voidPayment($orderId); + } + } + + protected function capturePayment(int $orderId) + { + ($this->container->get(CapturePayment::class))($orderId); + } + + protected function voidPayment(int $orderId) + { + ($this->container->get(VoidPayment::class))($orderId); + } +} diff --git a/src/MerchantCapture/ManualCaptureStatus.php b/src/MerchantCapture/ManualCaptureStatus.php new file mode 100644 index 00000000..06c34985 --- /dev/null +++ b/src/MerchantCapture/ManualCaptureStatus.php @@ -0,0 +1,14 @@ + static function () { + $captureType = get_option('mollie-payments-for-woocommerce_place_payment_onhold'); + return $captureType === 'later_capture'; + }, + 'merchant.manual_capture.supported_methods' => static function () { + return ['mollie_wc_gateway_creditcard']; + }, + 'merchant.manual_capture.void_statuses' => static function () { + return apply_filters('mollie_wc_gateway_void_order_state', [SharedDataDictionary::STATUS_ON_HOLD]); + }, + 'merchant.manual_capture.capture_statuses' => static function () { + return apply_filters('mollie_wc_gateway_capture_order_state', [SharedDataDictionary::STATUS_ON_HOLD]); + }, + 'merchant.manual_capture.is_authorized' => static function ($container) { + return static function (WC_Order $order) use ($container) { + $orderIsAuthorized = $order->get_meta( + self::ORDER_PAYMENT_STATUS_META_KEY + ) === ManualCaptureStatus::STATUS_AUTHORIZED; + $isManualCaptureMethod = in_array( + $order->get_payment_method(), + $container->get('merchant.manual_capture.supported_methods') + ); + + return $isManualCaptureMethod && $orderIsAuthorized; + }; + }, + 'merchant.manual_capture.is_waiting' => static function ($container) { + return static function (WC_Order $order) use ($container) { + $orderIsWaiting = $order->get_meta( + self::ORDER_PAYMENT_STATUS_META_KEY + ) === ManualCaptureStatus::STATUS_WAITING; + $isManualCaptureMethod = in_array( + $order->get_payment_method(), + $container->get('merchant.manual_capture.supported_methods') + ); + + return $isManualCaptureMethod && $orderIsWaiting; + }; + }, + 'merchant.manual_capture.can_capture_the_order' => static function ($container) { + return static function (WC_Order $order) use ($container) { + $orderIsAuthorized = $order->get_meta( + self::ORDER_PAYMENT_STATUS_META_KEY + ) === ManualCaptureStatus::STATUS_AUTHORIZED; + $isManualCaptureMethod = in_array( + $order->get_payment_method(), + $container->get('merchant.manual_capture.supported_methods') + ); + $isCorrectState = in_array( + $order->get_status(), + $container->get('merchant.manual_capture.capture_statuses') + ); + return $isManualCaptureMethod && $orderIsAuthorized && $isCorrectState; + }; + }, + 'merchant.manual_capture.on_status_change_enabled' => static function () { + return get_option('mollie-payments-for-woocommerce_capture_or_void', false); + }, + CapturePayment::class => static function ($container) { + return static function (int $orderId) use ($container) { + /** @var Api $api */ + $api = $container->get('SDK.api_helper'); + + /** @var Settings $settings */ + $settings = $container->get('settings.settings_helper'); + + /** @var Logger $logger */ + $logger = $container->get(Logger::class); + + $pluginId = $container->get('shared.plugin_id'); + + return (new CapturePayment($orderId, $api, $settings, $logger, $pluginId))(); + }; + }, + VoidPayment::class => static function ($container) { + return static function (int $orderId) use ($container) { + /** @var Api $api */ + $api = $container->get('SDK.api_helper'); + + /** @var Settings $settings */ + $settings = $container->get('settings.settings_helper'); + + /** @var Logger $logger */ + $logger = $container->get(Logger::class); + + $pluginId = $container->get('shared.plugin_id'); + + return (new VoidPayment($orderId, $api, $settings, $logger, $pluginId))(); + }; + }, + ]; + } + + public function run(ContainerInterface $container): bool + { + $pluginId = $container->get('shared.plugin_id'); + add_action($pluginId . '_after_webhook_action', static function (Payment $payment, WC_Order $order) use ($container) { + if ($payment->isAuthorized()) { + if (!$payment->getAmountCaptured() == 0.0) { + return; + } + $order->set_status(SharedDataDictionary::STATUS_ON_HOLD); + $order->update_meta_data(self::ORDER_PAYMENT_STATUS_META_KEY, ManualCaptureStatus::STATUS_AUTHORIZED); + $order->save(); + } elseif ($payment->isPaid() && ($container->get('merchant.manual_capture.is_waiting'))($order)) { + $order->update_meta_data(self::ORDER_PAYMENT_STATUS_META_KEY, ManualCaptureStatus::STATUS_CAPTURED); + $order->save(); + } + }, 10, 2); + + add_action('woocommerce_order_refunded', static function (int $orderId) use ($container) { + $order = wc_get_order($orderId); + if (!is_a($order, WC_Order::class)) { + return; + } + $merchantCanCapture = ($container->get('merchant.manual_capture.is_authorized'))($order); + if ($merchantCanCapture) { + ($container->get(VoidPayment::class))($order->get_id()); + } + }); + add_action('woocommerce_order_actions_start', static function (int $orderId) use ($container) { + $order = wc_get_order($orderId); + if (!is_a($order, WC_Order::class)) { + return; + } + $paymentStatus = $order->get_meta(MerchantCaptureModule::ORDER_PAYMENT_STATUS_META_KEY, true); + $actionBlockParagraphs = []; + + ob_start(); + (new StatusRenderer())($paymentStatus); + + $actionBlockParagraphs[] = ob_get_clean(); + if (($container->get('merchant.manual_capture.can_capture_the_order'))($order)) { + $actionBlockParagraphs[] = __( + 'To capture the authorized payment, select capture action from the list below.', + 'mollie-payments-for-woocommerce' + ); + } elseif (($container->get('merchant.manual_capture.is_authorized'))($order)) { + $actionBlockParagraphs[] = __( + 'Before capturing the authorized payment, ensure to set the order status to On Hold.', + 'mollie-payments-for-woocommerce' + ); + } + (new OrderActionBlock())($actionBlockParagraphs); + }); + add_filter( + 'mollie_wc_gateway_disable_ship_and_capture', + static function ($disableShipAndCapture, WC_Order $order) use ($container) { + if ($disableShipAndCapture) { + return true; + } + return $container->get('merchant.manual_capture.is_waiting')($order); + }, + 10, + 2 + ); + new OrderListPaymentColumn(); + new ManualCapture($container); + new StateChangeCapture($container); + return true; + } +} diff --git a/src/MerchantCapture/OrderListPaymentColumn.php b/src/MerchantCapture/OrderListPaymentColumn.php new file mode 100644 index 00000000..6447336b --- /dev/null +++ b/src/MerchantCapture/OrderListPaymentColumn.php @@ -0,0 +1,56 @@ + $column) { + $newColumns[$columnId] = $column; + if ($columnId === 'order_number') { + $newColumns['mollie_capture_payment_status'] = __( + 'Payment Status', + 'mollie-payments-for-woocommerce' + ); + $mollieColumnAdded = true; + } + } + + if (!$mollieColumnAdded) { + $newColumns['mollie_capture_payment_status'] = __('Payment Status', 'mollie-payments-for-woocommerce'); + } + + return $newColumns; + } + + public function renderColumnValue(string $column, int $orderId) + { + if ($column !== 'mollie_capture_payment_status') { + return; + } + /** @var \WC_Order $order */ + $order = wc_get_order($orderId); + + if (!is_a($order, \WC_Order::class)) { + return; + } + + $molliePaymentStatus = $order->get_meta(MerchantCaptureModule::ORDER_PAYMENT_STATUS_META_KEY); + + (new StatusRenderer())($molliePaymentStatus); + } +} diff --git a/src/MerchantCapture/UI/OrderActionBlock.php b/src/MerchantCapture/UI/OrderActionBlock.php new file mode 100644 index 00000000..d0fbd656 --- /dev/null +++ b/src/MerchantCapture/UI/OrderActionBlock.php @@ -0,0 +1,19 @@ +"; + foreach ($paragraphs as $paragraph) { + ?> +
= wp_kses($paragraph, ['mark' => ['class' => []], 'span' => []]); ?>
+ '; + } +} diff --git a/src/MerchantCapture/UI/StatusButton.php b/src/MerchantCapture/UI/StatusButton.php new file mode 100644 index 00000000..063217a5 --- /dev/null +++ b/src/MerchantCapture/UI/StatusButton.php @@ -0,0 +1,15 @@ + + = esc_html($text); ?> + pluginId . '_after_webhook_action', $payment, $order); // Status 200 } /** diff --git a/src/Payment/MolliePayment.php b/src/Payment/MolliePayment.php index 3d955c79..82636f65 100644 --- a/src/Payment/MolliePayment.php +++ b/src/Payment/MolliePayment.php @@ -453,6 +453,10 @@ public function refund(\WC_Order $order, $orderId, $paymentObject, $amount = nul return new WP_Error('1', $errorMessage); } + if ($paymentObject->isAuthorized()) { + return true; + } + if (! $paymentObject->isPaid()) { $errorMessage = "Can not refund payment $paymentObject->id for WooCommerce order $orderId as it is not paid."; diff --git a/src/Payment/PaymentModule.php b/src/Payment/PaymentModule.php index 884a3d4b..440968aa 100644 --- a/src/Payment/PaymentModule.php +++ b/src/Payment/PaymentModule.php @@ -339,7 +339,7 @@ public function shipAndCaptureOrderAtMollie($order_id) // To disable automatic shipping and capturing of the Mollie order when a WooCommerce order status is updated to completed, // store an option 'mollie-payments-for-woocommerce_disableShipOrderAtMollie' with value 1 - if (get_option($this->pluginId . '_' . 'disableShipOrderAtMollie', '0') === '1') { + if (apply_filters('mollie_wc_gateway_disable_ship_and_capture', get_option($this->pluginId . '_' . 'disableShipOrderAtMollie', '0') === '1', $order)) { return; } diff --git a/src/Payment/PaymentService.php b/src/Payment/PaymentService.php index 1e56d636..ede631b4 100644 --- a/src/Payment/PaymentService.php +++ b/src/Payment/PaymentService.php @@ -24,6 +24,7 @@ class PaymentService { public const PAYMENT_METHOD_TYPE_ORDER = 'order'; public const PAYMENT_METHOD_TYPE_PAYMENT = 'payment'; + /** * @var MolliePaymentGatewayI */ @@ -369,8 +370,7 @@ protected function processAsMollieOrder( : '', 'orderNumber' => isset($data['orderNumber']) ? $data['orderNumber'] : '', - 'lines' => isset($data['lines']) ? $data['lines'] : '', - ]; + 'lines' => isset($data['lines']) ? $data['lines'] : '', ]; $this->logger->debug(json_encode($apiCallLog)); $paymentOrder = $paymentObject; @@ -552,6 +552,7 @@ protected function processPaymentForMollie( $apiKey ); } + return $paymentObject; } diff --git a/src/SDK/WordPressHttpAdapter.php b/src/SDK/WordPressHttpAdapter.php index 1bffd185..5337cd0c 100644 --- a/src/SDK/WordPressHttpAdapter.php +++ b/src/SDK/WordPressHttpAdapter.php @@ -18,6 +18,7 @@ class WordPressHttpAdapter implements MollieHttpAdapterInterface * HTTP status code for an empty ok response. */ const HTTP_NO_CONTENT = 204; + const PAYMENT_HTTP_NO_CONTENT = 202; /** * @param string $httpMethod @@ -65,7 +66,7 @@ protected function parseResponse($response) $statusCode = wp_remote_retrieve_response_code($response); $httpBody = wp_remote_retrieve_body($response); if (empty($httpBody)) { - if ($statusCode === self::HTTP_NO_CONTENT) { + if ($statusCode === self::HTTP_NO_CONTENT || $statusCode === self::PAYMENT_HTTP_NO_CONTENT) { return null; }