diff --git a/mollie-payments-for-woocommerce.php b/mollie-payments-for-woocommerce.php
index 733e5d73e..948c54206 100644
--- a/mollie-payments-for-woocommerce.php
+++ b/mollie-payments-for-woocommerce.php
@@ -3,7 +3,7 @@
* Plugin Name: Mollie Payments for WooCommerce
* Plugin URI: https://www.mollie.com
* Description: Accept payments in WooCommerce with the official Mollie plugin
- * Version: 8.0.5
+ * Version: 8.0.6-beta1
* Author: Mollie
* Author URI: https://www.mollie.com
* Requires at least: 5.0
diff --git a/package.json b/package.json
index 81d6b8628..991e9d31d 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",
diff --git a/phpcs.xml.dist b/phpcs.xml.dist
index 33e29a680..890402816 100644
--- a/phpcs.xml.dist
+++ b/phpcs.xml.dist
@@ -6,6 +6,13 @@
./inc
./src
./tests/
+ *.js
+ *.jsx
+ *.ts
+ *.tsx
+ *.css
+ *.scss
+ *.json
diff --git a/readme.txt b/readme.txt
index e9f77c25f..7de652e65 100644
--- a/readme.txt
+++ b/readme.txt
@@ -218,6 +218,14 @@ Automatic updates should work like a charm; as always though, ensure you backup
== Changelog ==
+= 8.0.6-beta1 - 11-09-2025 =
+* Fixed - TypeError in OrderLines.php when processing vouchers with Germanized plugin
+* Fixed - Apple Pay not eligible for Express Checkout in editor with non-Apple Pay compatible device
+* Fixed - Block Editor error `This block encountered an error and cannot show a preview`
+* Fixed - The new `woocommerce_cancel_unpaid_order` filter causes orders not created via checkout to be cancelled
+* Improvement - Improve handling and validation of phone numbers
+* Improvement - Improve handling of webhooks that uses now the REST API and depending on transaction id
+
= 8.0.5 - 11-08-2025 =
* Added - PayPal Subscriptions support through Vaulting
* Fixed - Save Mandates from Payments API
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 000000000..fb4f71e69
--- /dev/null
+++ b/resources/js/src/checkout/blocks/components/Label.js
@@ -0,0 +1,9 @@
+export const Label = ({item}) => {
+ console.log(item.label.title, item.label.icon); // Debugging purposes
+ return (
+ <>
+ {item.label.title}
+ {item.label.icon &&
}
+ >
+ );
+};
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 000000000..b5381297e
--- /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 000000000..5e8e4222d
--- /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 000000000..c04bddf6b
--- /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: ,
+ content: (
+
+ ),
+ edit: { item.edit }
,
+ paymentMethodId: item.paymentMethodId,
+ canMakePayment: () => {
+ //only the methods that return is available on backend will be loaded here so we show them
+ return true;
+ },
+ ariaLabel: item.ariaLabel,
+ supports: {
+ features: item.supports,
+ },
+ };
+};
+export default molliePaymentMethod;
diff --git a/resources/js/src/checkout/blocks/components/paymentFields/BirthdateField.js b/resources/js/src/checkout/blocks/components/paymentFields/BirthdateField.js
new file mode 100644
index 000000000..6e1142f77
--- /dev/null
+++ b/resources/js/src/checkout/blocks/components/paymentFields/BirthdateField.js
@@ -0,0 +1,19 @@
+export const BirthdateField = ( { label, value, onChange } ) => {
+ const handleChange = ( e ) => onChange( e.target.value );
+ const className =
+ 'wc-block-components-text-input wc-block-components-address-form__billing-birthdate';
+
+ return (
+
+
+
+
+ );
+};
diff --git a/resources/js/src/checkout/blocks/components/paymentFields/CompanyField.js b/resources/js/src/checkout/blocks/components/paymentFields/CompanyField.js
new file mode 100644
index 000000000..5aaa51f7a
--- /dev/null
+++ b/resources/js/src/checkout/blocks/components/paymentFields/CompanyField.js
@@ -0,0 +1,19 @@
+export const CompanyField = ( { label, value, onChange } ) => {
+ const handleChange = ( e ) => onChange( e.target.value );
+ const className =
+ 'wc-block-components-text-input wc-block-components-address-form__billing_company_billie';
+
+ return (
+
+
+
+
+ );
+};
diff --git a/resources/js/src/checkout/blocks/components/paymentFields/CreditCardField.js b/resources/js/src/checkout/blocks/components/paymentFields/CreditCardField.js
new file mode 100644
index 000000000..785fdb7b0
--- /dev/null
+++ b/resources/js/src/checkout/blocks/components/paymentFields/CreditCardField.js
@@ -0,0 +1,3 @@
+export const CreditCardField = ( { content } ) => {
+ return ;
+};
diff --git a/resources/js/src/checkout/blocks/components/paymentFields/IssuerSelect.js b/resources/js/src/checkout/blocks/components/paymentFields/IssuerSelect.js
new file mode 100644
index 000000000..c7c675db2
--- /dev/null
+++ b/resources/js/src/checkout/blocks/components/paymentFields/IssuerSelect.js
@@ -0,0 +1,17 @@
+export const IssuerSelect = ( {
+ issuerKey,
+ issuers,
+ selectedIssuer,
+ updateIssuer,
+} ) => {
+ const handleChange = ( e ) => updateIssuer( e.target.value );
+
+ return (
+
+ );
+};
diff --git a/resources/js/src/checkout/blocks/components/paymentFields/PhoneField.js b/resources/js/src/checkout/blocks/components/paymentFields/PhoneField.js
new file mode 100644
index 000000000..b378e2d01
--- /dev/null
+++ b/resources/js/src/checkout/blocks/components/paymentFields/PhoneField.js
@@ -0,0 +1,19 @@
+export const PhoneField = ( { id, label, value, onChange, placeholder } ) => {
+ const handleChange = ( e ) => onChange( e.target.value );
+ const className = `wc-block-components-text-input wc-block-components-address-form__${ id }`;
+
+ return (
+
+
+
+
+ );
+};
diff --git a/resources/js/src/checkout/blocks/components/paymentMethods/CreditCardComponent.js b/resources/js/src/checkout/blocks/components/paymentMethods/CreditCardComponent.js
new file mode 100644
index 000000000..fa5d938a6
--- /dev/null
+++ b/resources/js/src/checkout/blocks/components/paymentMethods/CreditCardComponent.js
@@ -0,0 +1,50 @@
+import { CreditCardField } from '../paymentFields/CreditCardField';
+
+/**
+ * Credit Card Payment Component
+ * Handles Mollie credit card payment method with tokenization
+ * @param {Object} props - The component props
+ * @param {Object} props.item - Payment method item configuration
+ * @param {Function} props.useEffect - React useEffect hook
+ * @param {string} props.activePaymentMethod - Currently active payment method identifier
+ */
+const CreditCardComponent = ( { item, useEffect, activePaymentMethod } ) => {
+ useEffect( () => {
+ const creditCardSelected = new Event(
+ 'mollie_creditcard_component_selected',
+ { bubbles: true }
+ );
+
+ const handleComponentsReady = () => {
+ document.documentElement.dispatchEvent( creditCardSelected );
+ };
+
+ // Listen for Mollie components ready event
+ document.addEventListener(
+ 'mollie_components_ready_to_submit',
+ handleComponentsReady
+ );
+
+ return () => {
+ document.removeEventListener(
+ 'mollie_components_ready_to_submit',
+ handleComponentsReady
+ );
+ };
+ }, [] );
+
+ // Dispatch credit card selection event when component mounts
+ useEffect( () => {
+ if ( activePaymentMethod === 'mollie_wc_gateway_creditcard' ) {
+ const creditCardSelected = new Event(
+ 'mollie_creditcard_component_selected',
+ { bubbles: true }
+ );
+ document.documentElement.dispatchEvent( creditCardSelected );
+ }
+ }, [ activePaymentMethod ] );
+
+ return ;
+};
+
+export default CreditCardComponent;
diff --git a/resources/js/src/checkout/blocks/components/paymentMethods/DefaultComponent.js b/resources/js/src/checkout/blocks/components/paymentMethods/DefaultComponent.js
new file mode 100644
index 000000000..78c611002
--- /dev/null
+++ b/resources/js/src/checkout/blocks/components/paymentMethods/DefaultComponent.js
@@ -0,0 +1,44 @@
+import { IssuerSelect } from '../paymentFields/IssuerSelect';
+
+/**
+ * Default Payment Component
+ * Handles payment methods with issuer selection (banks) or simple content display
+ * @param {Object} props - The component props
+ * @param {Object} props.item - Payment method item configuration
+ * @param {string} props.activePaymentMethod - Currently active payment method identifier
+ * @param {string|null} props.selectedIssuer - Currently selected issuer ID (from store)
+ * @param {Function} props.setSelectedIssuer - Function to update the selected issuer in store
+ */
+const DefaultComponent = ( {
+ item,
+ activePaymentMethod,
+ selectedIssuer,
+ setSelectedIssuer,
+} ) => {
+ const issuerKey = `mollie-payments-for-woocommerce_issuer_${ activePaymentMethod }`;
+
+ let itemContent = null;
+ if ( item.content && item.content !== '' ) {
+ itemContent = { item.content }
;
+ }
+
+ // Show issuer selection for payment methods that have issuers (banks, etc.)
+ if ( item.issuers && item.issuers.length > 0 ) {
+ return (
+
+ { itemContent }
+
+
+ );
+ }
+
+ // Simple content display for payment methods without special requirements
+ return { itemContent }
;
+};
+
+export default DefaultComponent;
diff --git a/resources/js/src/checkout/blocks/components/paymentMethods/PaymentFieldsComponent.js b/resources/js/src/checkout/blocks/components/paymentMethods/PaymentFieldsComponent.js
new file mode 100644
index 000000000..a590fb2be
--- /dev/null
+++ b/resources/js/src/checkout/blocks/components/paymentMethods/PaymentFieldsComponent.js
@@ -0,0 +1,228 @@
+import { PhoneField } from '../paymentFields/PhoneField';
+import { BirthdateField } from '../paymentFields/BirthdateField';
+import { CompanyField } from '../paymentFields/CompanyField';
+
+/**
+ * Generic Payment Fields Component
+ * Handles field rendering and validation for different payment methods
+ * @param {Object} props - The component props
+ * @param {Object} props.item - Payment method item configuration
+ * @param {Function} props.jQuery - jQuery library function
+ * @param {Function} props.useEffect - React useEffect hook
+ * @param {Object} props.billing - Billing data object
+ * @param {Object} props.shippingData - Shipping data object
+ * @param {Object} props.eventRegistration - Event registration object
+ * @param {Object} props.requiredFields - Required field labels/strings
+ * @param {boolean} props.isPhoneFieldVisible - Whether phone field is visible elsewhere
+ * @param {string} props.inputPhone - Current phone input value
+ * @param {string} props.inputBirthdate - Current birthdate input value
+ * @param {string} props.inputCompany - Current company input value
+ * @param {string} props.phonePlaceholder - Phone field placeholder text
+ * @param {Function} props.setInputPhone - Function to update phone input value
+ * @param {Function} props.setInputBirthdate - Function to update birthdate input value
+ * @param {Function} props.setInputCompany - Function to update company input value
+ * @param {Function} props.updatePhonePlaceholderByCountry - Function to update phone placeholder based on country
+ * @param {Object} [props.fieldConfig] - Optional field configuration object
+ */
+const PaymentFieldsComponent = ( {
+ item,
+ jQuery,
+ useEffect,
+ billing,
+ shippingData,
+ eventRegistration,
+ requiredFields,
+ isPhoneFieldVisible,
+ inputPhone,
+ inputBirthdate,
+ inputCompany,
+ phonePlaceholder,
+ setInputPhone,
+ setInputBirthdate,
+ setInputCompany,
+ updatePhonePlaceholderByCountry,
+ fieldConfig = {},
+} ) => {
+ const { onCheckoutValidation } = eventRegistration;
+ const { companyNameString, phoneString } = requiredFields;
+
+ const {
+ showCompany = false,
+ showPhone = false,
+ showBirthdate = false,
+ companyRequired = false,
+ phoneRequired = false,
+ birthdateRequired = false,
+ companyLabel = 'Company name',
+ phoneLabel = 'Phone',
+ birthdateLabel = 'Birthdate',
+ } = fieldConfig;
+
+ function getPhoneField() {
+ const shippingPhone = document.getElementById( 'shipping-phone' );
+ const billingPhone = document.getElementById( 'billing-phone' );
+ return billingPhone || shippingPhone;
+ }
+
+ // Company field label update
+ useEffect( () => {
+ if ( ! showCompany ) {
+ return;
+ }
+
+ const companyLabelElement = jQuery(
+ 'div.wc-block-components-text-input.wc-block-components-address-form__company > label'
+ );
+ if (
+ companyLabelElement.length === 0 ||
+ item.hideCompanyField === true
+ ) {
+ return;
+ }
+
+ const labelText =
+ item.companyPlaceholder || companyNameString || 'Company name';
+ companyLabelElement.replaceWith(
+ ``
+ );
+ }, [
+ showCompany,
+ item.companyPlaceholder,
+ item.hideCompanyField,
+ companyNameString,
+ jQuery,
+ ] );
+
+ // Phone field label update
+ useEffect( () => {
+ if ( ! showPhone ) {
+ return;
+ }
+
+ const phoneLabelElement = getPhoneField()?.labels?.[ 0 ] ?? null;
+ if ( ! phoneLabelElement || phoneLabelElement.length === 0 ) {
+ return;
+ }
+
+ const labelText = item.phonePlaceholder || phoneString || 'Phone';
+ phoneLabelElement.innerText = labelText;
+ }, [ showPhone, item.phonePlaceholder, phoneString ] );
+
+ // Validation effect
+ useEffect( () => {
+ const unsubscribeProcessing = onCheckoutValidation( () => {
+ // Company validation
+ if ( companyRequired ) {
+ const isCompanyEmpty =
+ billing.billingData.company === '' &&
+ shippingData.shippingAddress.company === '' &&
+ inputCompany === '';
+
+ if ( isCompanyEmpty ) {
+ return {
+ errorMessage:
+ item.errorMessage || 'Company field is required',
+ };
+ }
+ }
+
+ // Phone validation
+ if ( phoneRequired ) {
+ const isPhoneEmpty =
+ billing.billingData.phone === '' &&
+ shippingData.shippingAddress.phone === '' &&
+ inputPhone === '';
+
+ if ( isPhoneEmpty ) {
+ return {
+ errorMessage:
+ item.errorMessage || 'Phone field is required',
+ };
+ }
+ }
+
+ // Birthdate validation
+ if ( birthdateRequired ) {
+ if ( inputBirthdate === '' ) {
+ return {
+ errorMessage:
+ item.errorMessage || 'Birthdate field is required',
+ };
+ }
+
+ const today = new Date();
+ const birthdate = new Date( inputBirthdate );
+ if ( birthdate > today ) {
+ return {
+ errorMessage: item.errorMessage || 'Invalid birthdate',
+ };
+ }
+ }
+ } );
+
+ return () => {
+ unsubscribeProcessing();
+ };
+ }, [
+ onCheckoutValidation,
+ companyRequired,
+ phoneRequired,
+ birthdateRequired,
+ billing.billingData,
+ shippingData.shippingAddress,
+ inputPhone,
+ inputCompany,
+ inputBirthdate,
+ item.errorMessage,
+ ] );
+
+ // Country-based phone placeholder update
+ useEffect( () => {
+ if ( ! showPhone ) {
+ return;
+ }
+
+ const country = billing.billingData.country;
+ if ( country ) {
+ updatePhonePlaceholderByCountry( country );
+ }
+ }, [
+ billing.billingData.country,
+ updatePhonePlaceholderByCountry,
+ showPhone,
+ ] );
+
+ return (
+ <>
+ { item.content &&
{ item.content }
}
+
+ { showCompany && (
+
+ ) }
+
+ { showBirthdate && (
+
+ ) }
+
+ { showPhone && ! isPhoneFieldVisible && (
+
+ ) }
+ >
+ );
+};
+
+export default PaymentFieldsComponent;
diff --git a/resources/js/src/checkout/blocks/hoc/withMollieStore.js b/resources/js/src/checkout/blocks/hoc/withMollieStore.js
new file mode 100644
index 000000000..248d59127
--- /dev/null
+++ b/resources/js/src/checkout/blocks/hoc/withMollieStore.js
@@ -0,0 +1,67 @@
+import { MOLLIE_STORE_KEY } from '../store';
+
+/**
+ * Higher-Order Component that connects components to the Mollie Redux store
+ * Provides store state and actions as props to wrapped components
+ * @param {Function} WrappedComponent
+ */
+const withMollieStore = ( WrappedComponent ) => {
+ const WithMollieStoreComponent = ( props ) => {
+ const { useSelect, useDispatch } = wp.data;
+
+ // Store selectors
+ const storeData = useSelect(
+ ( select ) => ( {
+ selectedIssuer: select( MOLLIE_STORE_KEY ).getSelectedIssuer(),
+ inputPhone: select( MOLLIE_STORE_KEY ).getInputPhone(),
+ inputBirthdate: select( MOLLIE_STORE_KEY ).getInputBirthdate(),
+ inputCompany: select( MOLLIE_STORE_KEY ).getInputCompany(),
+ phonePlaceholder:
+ select( MOLLIE_STORE_KEY ).getPhonePlaceholder(),
+ cardToken: select( MOLLIE_STORE_KEY ).getCardToken(),
+ } ),
+ []
+ );
+
+ // Store actions
+ const storeActions = useDispatch( MOLLIE_STORE_KEY );
+ const {
+ setSelectedIssuer,
+ setInputPhone,
+ setInputBirthdate,
+ setInputCompany,
+ setCardToken,
+ updatePhonePlaceholderByCountry,
+ } = storeActions;
+
+ return (
+
+ );
+ };
+
+ WithMollieStoreComponent.displayName = `withMollieStore(${
+ WrappedComponent.displayName || WrappedComponent.name || 'Component'
+ })`;
+
+ return WithMollieStoreComponent;
+};
+
+export default withMollieStore;
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 000000000..ff27a619b
--- /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 ) => ( { dispatch } ) => {
+ const countryCodes = {
+ BE: '+32xxxxxxxxx',
+ NL: '+316xxxxxxxx',
+ DE: '+49xxxxxxxxx',
+ AT: '+43xxxxxxxxx',
+ };
+ const placeholder = countryCodes[ country ] || countryCodes.NL;
+ 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 000000000..87dbc9333
--- /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 000000000..e5f032d99
--- /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 000000000..494222dd3
--- /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 000000000..98ce8227a
--- /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 000000000..4782b3383
--- /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 };
+};
diff --git a/src/Assets/MollieCheckoutBlocksSupport.php b/src/Assets/MollieCheckoutBlocksSupport.php
index af5a97f97..a9a646bbb 100644
--- a/src/Assets/MollieCheckoutBlocksSupport.php
+++ b/src/Assets/MollieCheckoutBlocksSupport.php
@@ -145,7 +145,10 @@ public static function gatewayDataForWCBlocks(Data $dataService, array $deprecat
$issuers = false;
}
$title = $method->title($container);
- $labelMarkup = "{$title}{$gateway->get_icon()}";
+ $labelContent = [
+ 'title' => $title,
+ 'icon' => $gateway->get_icon(),
+ ];
$hasSurcharge = $method->hasSurcharge();
$countryCodes = [
'BE' => '+32xxxxxxxxx',
@@ -158,7 +161,7 @@ public static function gatewayDataForWCBlocks(Data $dataService, array $deprecat
$phonePlaceholder = in_array($country, array_keys($countryCodes)) ? $countryCodes[$country] : $countryCodes['NL'];
$gatewayData[] = [
'name' => $gatewayKey,
- 'label' => $labelMarkup,
+ 'label' => $labelContent,
'content' => $content,
'issuers' => $issuers,
'hasSurcharge' => $hasSurcharge,
diff --git a/src/Gateway/GatewayModule.php b/src/Gateway/GatewayModule.php
index be1ba53ea..a71cdaa04 100644
--- a/src/Gateway/GatewayModule.php
+++ b/src/Gateway/GatewayModule.php
@@ -224,6 +224,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;
}
diff --git a/src/MerchantCapture/MerchantCaptureModule.php b/src/MerchantCapture/MerchantCaptureModule.php
index 6f55e35a2..835826470 100644
--- a/src/MerchantCapture/MerchantCaptureModule.php
+++ b/src/MerchantCapture/MerchantCaptureModule.php
@@ -172,7 +172,6 @@ static function ($payment, WC_Order $order) use ($container) {
self::ORDER_PAYMENT_STATUS_META_KEY,
ManualCaptureStatus::STATUS_AUTHORIZED
);
- $order->set_transaction_id($payment->id);
$order->save();
} elseif (
$payment->isPaid() && (
diff --git a/src/Payment/MollieOrder.php b/src/Payment/MollieOrder.php
index 7b424e20a..328dcb671 100644
--- a/src/Payment/MollieOrder.php
+++ b/src/Payment/MollieOrder.php
@@ -99,6 +99,7 @@ public function setActiveMolliePayment($orderId)
self::$customerId = $this->getMollieCustomerIdFromPaymentObject();
self::$order = wc_get_order($orderId);
+ self::$order->set_transaction_id($this->data->id);
self::$order->update_meta_data('_mollie_order_id', $this->data->id);
self::$order->save();
diff --git a/src/Payment/MollieOrderService.php b/src/Payment/MollieOrderService.php
index 41d5dca4f..55c6fdb03 100644
--- a/src/Payment/MollieOrderService.php
+++ b/src/Payment/MollieOrderService.php
@@ -89,157 +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));
-
- $data_helper = $this->data;
- $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' => substr($transactionID, 0, 4) === 'ord_' ? '_mollie_order_id' : '_mollie_payment_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)) {
+ $order = $orders[0];
+
+ 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;
}
- $this->setGateway($gateway);
- // 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 {
- $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) {
- $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
- $this->releaseOrderLock($lockKey);
- }
+ if (!$this->doPaymentForOrder($order)) {
+ $this->httpResponse->setHttpResponseCode(400);
+ };
+ // Status 200
}
protected function getPaymentIdFromRequest(): ?string
@@ -247,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.
*
@@ -297,6 +158,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 +180,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
@@ -328,19 +201,35 @@ public function checkPaymentForUnpaidOrder(\WC_Order $order): bool
$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 */
+ /* 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,
@@ -351,13 +240,6 @@ public function checkPaymentForUnpaidOrder(\WC_Order $order): bool
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/MolliePayment.php b/src/Payment/MolliePayment.php
index 3f192d8cf..9c2babea4 100644
--- a/src/Payment/MolliePayment.php
+++ b/src/Payment/MolliePayment.php
@@ -82,7 +82,7 @@ public function setActiveMolliePayment($orderId)
self::$paymentId = $this->getMolliePaymentIdFromPaymentObject();
self::$customerId = $this->getMollieCustomerIdFromPaymentObject();
self::$order = wc_get_order($orderId);
-
+ self::$order->set_transaction_id($this->data->id);
self::$order->update_meta_data('_mollie_payment_id', $this->data->id);
self::$order->save();
diff --git a/src/Payment/OrderLines.php b/src/Payment/OrderLines.php
index 9f80261ae..c97e5f1e6 100644
--- a/src/Payment/OrderLines.php
+++ b/src/Payment/OrderLines.php
@@ -268,7 +268,7 @@ private function process_fees()
$cart_fee_total = $cart_fee['total'];
}
- if (empty(round($cart_fee_total, 2))) {
+ if (empty(round(floatval($cart_fee_total), 2))) {
continue;
}
diff --git a/src/Payment/PaymentLines.php b/src/Payment/PaymentLines.php
index 074c796a3..51f2febfe 100644
--- a/src/Payment/PaymentLines.php
+++ b/src/Payment/PaymentLines.php
@@ -259,7 +259,7 @@ private function process_fees()
$cart_fee_total = $cart_fee['total'];
}
- if (empty(round($cart_fee_total, 2))) {
+ if (empty(round(floatval($cart_fee_total), 2))) {
continue;
}
diff --git a/src/Payment/PaymentModule.php b/src/Payment/PaymentModule.php
index a7857c89d..e53f00d39 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);
@@ -149,6 +155,9 @@ public function cancelOrderOnExpiryDate()
{
$classNames = $this->gatewayClassnames;
foreach ($classNames as $gateway) {
+ if (empty($gateway)) {
+ continue;
+ }
$gatewayName = strtolower($gateway) . '_settings';
$gatewaySettings = get_option($gatewayName);
@@ -179,10 +188,14 @@ public function cancelOrderOnExpiryDate()
if ($unpaid_orders) {
foreach ($unpaid_orders as $unpaid_order) {
$order = wc_get_order($unpaid_order);
+ $mollieOrderService = $this->container->get(MollieOrderService::class);
+ if ($mollieOrderService->checkPaymentForUnpaidOrder($order)) {
+ continue;
+ }
add_filter('mollie-payments-for-woocommerce_order_status_cancelled', static function ($newOrderStatus) {
return SharedDataDictionary::STATUS_CANCELLED;
});
- $order->update_status('cancelled', __('Unpaid order cancelled - time limit reached.', 'woocommerce'), true);
+ $order->update_status('cancelled', __('Unpaid order cancelled - time limit reached.', 'woocommerce'));
$this->cancelOrderAtMollie($order->get_id());
}
}
diff --git a/src/Payment/Request/Middleware/UrlMiddleware.php b/src/Payment/Request/Middleware/UrlMiddleware.php
index 464b92859..ed1ab3392 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,19 +100,22 @@ 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 (! $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);
+ $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]);
+ $this->logger->debug(" Order {$order->get_id()} webhookUrl: {$webhookUrl}", [true]);
return apply_filters($this->pluginId . '_webhook_url', $webhookUrl, $order);
}
diff --git a/src/Payment/Webhooks/RestApi.php b/src/Payment/Webhooks/RestApi.php
new file mode 100644
index 000000000..f151b3a94
--- /dev/null
+++ b/src/Payment/Webhooks/RestApi.php
@@ -0,0 +1,102 @@
+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 . ' fall back to search in meta data');
+ //Fallback search order in order mollie oder meta
+ $orders = wc_get_orders([
+ 'limit' => 2,
+ 'meta_key' => substr($transactionID, 0, 4) === 'ord_' ? '_mollie_order_id' : '_mollie_payment_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) {
+ $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 446c36579..9e7131a96 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)
+ );
+ },
];
};
diff --git a/src/PaymentMethods/Voucher.php b/src/PaymentMethods/Voucher.php
index 69b49d26c..27a3e191b 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,65 @@ 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 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
+ */
+ 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) {
diff --git a/src/Subscription/MollieSubscriptionGatewayHandler.php b/src/Subscription/MollieSubscriptionGatewayHandler.php
index 243544f45..333056db9 100644
--- a/src/Subscription/MollieSubscriptionGatewayHandler.php
+++ b/src/Subscription/MollieSubscriptionGatewayHandler.php
@@ -312,7 +312,6 @@ public function scheduled_subscription_payment($renewal_total, WC_Order $renewal
try {
if ($validMandate) {
$payment = $this->apiHelper->getApiClient($apiKey)->payments->create($data);
- $renewal_order->set_transaction_id($payment->id);
//check the payment method is the one in the order, if not we want this payment method in the order MOL-596
$paymentMethodUsed = 'mollie_wc_gateway_' . $payment->method;
if ($paymentMethodUsed !== $renewalOrderMethod) {
diff --git a/tests/Integration/Common/Factories/OrderFactory.php b/tests/Integration/Common/Factories/OrderFactory.php
index 464c0a820..9c4d6f7c8 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 d60982d9a..8d5e337c2 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/phpunit.xml.dist b/tests/Integration/phpunit.xml.dist
new file mode 100644
index 000000000..144e61988
--- /dev/null
+++ b/tests/Integration/phpunit.xml.dist
@@ -0,0 +1,12 @@
+
+
+
+
+ spec
+
+
+
diff --git a/tests/Integration/spec/webhooks/WebhooksIntegrationTest.php b/tests/Integration/spec/webhooks/WebhooksIntegrationTest.php
index a78e23fae..4abb71ddb 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