Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ACSS support for WC Subscriptions #4051

Merged
merged 24 commits into from
Mar 27, 2025
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
9b4208b
Add ACSS payment tokenization
ricardo Mar 5, 2025
7a4bf67
Merge branch 'develop' into fix/3813-saved-payment-methods
ricardo Mar 7, 2025
593ee39
Add support for ACSS payment tokenization
ricardo Mar 7, 2025
9f8f8ef
Merge branch 'develop' into fix/3813-saved-payment-methods
ricardo Mar 10, 2025
f4f0718
Fix mandate using saved payment method at checkout
ricardo Mar 11, 2025
449e065
Add support for payment method type in setup intent initialization
ricardo Mar 12, 2025
367ad3a
Add unit tests for WC_Payment_Token_ACSS class
ricardo Mar 12, 2025
4ec297b
Fix unit tests
ricardo Mar 12, 2025
b2e74fc
Fix "init_setup_intent"
ricardo Mar 12, 2025
6ea7cdf
Merge branch 'develop' into fix/3813-saved-payment-methods
ricardo Mar 12, 2025
99caaf3
Add ACSS support for WC Subscriptions
ricardo Mar 12, 2025
54440ad
Handle mandate ID for renewal orders
ricardo Mar 12, 2025
389e82d
Refactor update_payment_intent method to support setup intents
ricardo Mar 12, 2025
1936888
Add mandate ID support for additional payment methods in renewal orders
ricardo Mar 12, 2025
325cf2a
Add support for free trial subscriptions in blocks checkout
ricardo Mar 13, 2025
f38039b
Update ACSS payment method options to support combined payment schedules
ricardo Mar 13, 2025
28b5871
Add changelog
ricardo Mar 13, 2025
fdef680
Merge branch 'develop' into add/3831-acss-subscriptions-support
ricardo Mar 14, 2025
5ccf8f8
Fix changing payment method for subscription
ricardo Mar 14, 2025
c6d25a6
Remove duplicate switch case
ricardo Mar 14, 2025
d5bfa7d
Merge branch 'develop' into add/3831-acss-subscriptions-support
ricardo Mar 25, 2025
8432941
Refactor update_intent method to use consistent parameter naming for …
ricardo Mar 25, 2025
0352277
Merge branch 'develop' into add/3831-acss-subscriptions-support
ricardo Mar 26, 2025
0870c81
Merge branch 'develop' into add/3831-acss-subscriptions-support
ricardo Mar 27, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
* Add - Add WooCommerce Pre-Orders support to Bacs.
* Tweak - Fix background in express checkout settings.
* Update - Update Amazon Pay icon to use image from WooCommerce Design Library.
* Add - Add ACSS payment tokenization.
* Add - Add ACSS support for WC Subscriptions.

= 9.2.0 - 2025-02-13 =
* Fix - Fix missing product_id parameter for the express checkout add-to-cart operation.
Expand Down
5 changes: 4 additions & 1 deletion client/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,13 @@ export default class WCStripeAPI {
/**
* Creates a setup intent without confirming it.
*
* @param {string} paymentMethodType The type of payment method.
*
* @return {Promise} The final promise for the request to the server.
*/
initSetupIntent() {
initSetupIntent( paymentMethodType ) {
return this.request( this.getAjaxUrl( 'init_setup_intent' ), {
payment_method_type: paymentMethodType,
_ajax_nonce: this.options?.createSetupIntentNonce,
} )
.then( ( response ) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,13 @@ const PaymentElements = ( {

async function createIntent() {
try {
const response = await api.createIntent(
getBlocksConfiguration()?.orderId,
paymentMethodId
);
const paymentNeeded = getBlocksConfiguration()?.isPaymentNeeded;
const response = paymentNeeded
? await api.createIntent(
getBlocksConfiguration()?.orderId,
paymentMethodId
)
: await api.initSetupIntent( paymentMethodId );

setClientSecret( response.client_secret );
setPaymentIntentId( response.id );
Expand Down
7 changes: 6 additions & 1 deletion client/classic/upe/deferred-intent.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,14 @@ jQuery( function ( $ ) {
$( 'form#order_review' ).length
) {
maybeMountStripePaymentElement();

// For payment methods that don't support deferred intents, we mount the Payment Element only when the PM is selected.
$( 'input[name="payment_method"]' ).on( 'change', () => {
maybeMountStripePaymentElement();
} );
}

// For payment methods that don't support deferred intents, we mount the Payment Element only when it's selected.
// For payment methods that don't support deferred intents, we mount the Payment Element only when the PM is selected.
$( 'form.checkout' ).on( 'change', 'input[name="payment_method"]', () => {
maybeMountStripePaymentElement();
} );
Expand Down
11 changes: 10 additions & 1 deletion client/classic/upe/payment-processing.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,16 @@ async function createStripePaymentElement( api, paymentMethodType ) {
// If the payment method doesn't support deferred intent, the intent must be created here.
if ( ! supportsDeferredIntent ) {
try {
intent = await api.createIntent( null, paymentMethodType );
const isSetupIntent =
document.getElementById( 'add_payment_method' ) ||
! getStripeServerData()?.isPaymentNeeded ||
getStripeServerData()?.isChangingPayment;

if ( isSetupIntent ) {
intent = await api.initSetupIntent( paymentMethodType );
} else {
intent = await api.createIntent( null, paymentMethodType );
}
} catch ( error ) {
showErrorPaymentMethod(
error?.message ??
Expand Down
16 changes: 15 additions & 1 deletion includes/abstracts/abstract-wc-stripe-payment-gateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -550,8 +550,13 @@ public function process_response( $response, $order ) {
$this->update_fees( $order, is_string( $response->balance_transaction ) ? $response->balance_transaction : $response->balance_transaction->id );
}

// TODO: Refactor and add mandate ID support for other payment methods, if necessary.
// The mandate ID is not available for the intent object, so we need to fetch the charge.
// Mandate ID is necessary for renewal payments for certain payment methods and Indian cards.
if ( isset( $response->payment_method_details->card->mandate ) ) {
$order->update_meta_data( '_stripe_mandate_id', $response->payment_method_details->card->mandate );
} elseif ( isset( $response->payment_method_details->acss_debit->mandate ) ) {
$order->update_meta_data( '_stripe_mandate_id', $response->payment_method_details->acss_debit->mandate );
}

if ( isset( $response->payment_method, $response->payment_method_details ) ) {
Expand Down Expand Up @@ -1618,14 +1623,23 @@ public function save_intent_to_order( $order, $intent ) {
if ( 'payment_intent' === $intent->object ) {
WC_Stripe_Helper::add_payment_intent_to_order( $intent->id, $order );

// Add the mandate id necessary for renewal payments with Indian cards if it's present.
// TODO: Refactor and add mandate ID support for other payment methods, if necessary.
// The mandate ID is not available for the intent object, so we need to fetch the charge.
// Mandate ID is necessary for renewal payments for certain payment methods and Indian cards.
Comment on lines +1639 to +1641
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mandate ID here is necessary for renewal orders. Without it, renewal orders will fail.

I think it's best to keep this as a TODO just to indicate that the approach should be refactored if adding new payment methods, and also to make sure we test other payment methods because I figured this is necessary for at least card and ACSS.

@asumaran I couldn't test BACS subscriptions, but this doesn't seem necessary for ACH as far as I have tested. Can you try processing a renewal order for BACS and make sure this is not needed for BACS? There's a similar check in line 1631.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ricardo, for Bacs, automatic subscription renewal worked fine, and the ‘Renew now’ option also worked without any issues. Thanks for the heads up!

$charge = $this->get_latest_charge_from_intent( $intent );

if ( isset( $charge->payment_method_details->card->mandate ) ) {
$order->update_meta_data( '_stripe_mandate_id', $charge->payment_method_details->card->mandate );
} elseif ( isset( $charge->payment_method_details->acss_debit->mandate ) ) {
$order->update_meta_data( '_stripe_mandate_id', $charge->payment_method_details->acss_debit->mandate );
}
} elseif ( 'setup_intent' === $intent->object ) {
$order->update_meta_data( '_stripe_setup_intent', $intent->id );

// Add mandate for free trial subscriptions.
if ( isset( $intent->mandate ) ) {
$order->update_meta_data( '_stripe_mandate_id', $intent->mandate );
}
Comment on lines +1653 to +1655
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For payment intents, the mandate ID is only available from the charge object, however for setup intents, the mandate ID can be accessed directly from the intent object.

}

if ( is_callable( [ $order, 'save' ] ) ) {
Expand Down
1 change: 1 addition & 0 deletions includes/class-wc-stripe-customer.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class WC_Stripe_Customer {
WC_Stripe_UPE_Payment_Method_Sepa::STRIPE_ID,
WC_Stripe_UPE_Payment_Method_Cash_App_Pay::STRIPE_ID,
WC_Stripe_UPE_Payment_Method_ACH::STRIPE_ID,
WC_Stripe_UPE_Payment_Method_ACSS::STRIPE_ID,
WC_Stripe_UPE_Payment_Method_Bacs_Debit::STRIPE_ID,
];

Expand Down
73 changes: 47 additions & 26 deletions includes/class-wc-stripe-intent-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,7 @@ public function update_payment_intent_ajax() {
throw new Exception( __( 'Unable to verify your request. Please reload the page and try again.', 'woocommerce-gateway-stripe' ) );
}

wp_send_json_success( $this->update_payment_intent( $payment_intent_id, $order_id, $save_payment_method, $selected_upe_payment_type ), 200 );
wp_send_json_success( $this->update_intent( $payment_intent_id, $order_id, $save_payment_method, $selected_upe_payment_type ), 200 );
} catch ( Exception $e ) {
// Send back error so it can be displayed to the customer.
wp_send_json_error(
Expand All @@ -424,9 +424,10 @@ public function update_payment_intent_ajax() {
}

/**
* Updates payment intent to be able to save payment method.
* Updates payment intent or setup intent to be able to save payment method.
*
* @since 5.6.0
* @version x.x.x
*
* @param {string} $payment_intent_id The id of the payment intent to update.
* @param {int} $order_id The id of the order if intent created from Order.
Expand All @@ -436,7 +437,7 @@ public function update_payment_intent_ajax() {
* @throws Exception If the update intent call returns with an error.
* @return array|null An array with result of the update, or nothing
*/
public function update_payment_intent( $payment_intent_id = '', $order_id = null, $save_payment_method = false, $selected_upe_payment_type = '' ) {
public function update_intent( $payment_intent_id = '', $order_id = null, $save_payment_method = false, $selected_upe_payment_type = '' ) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method now handles setup intents as well. Looking at the Stripe logs, there was an error where it was incorrectly calling payment_intents/seti_... instead of setup_intents/seti....

Given the similarity of a potential new method for handling only setup intents, I decided to unify both needs into the same function.

I'd appreciate the reviewer double-checking this approach, but I believe it shouldn't affect existing flows, given how minimal the change is.

$order = wc_get_order( $order_id );

if ( ! is_a( $order, 'WC_Order' ) ) {
Expand All @@ -449,15 +450,19 @@ public function update_payment_intent( $payment_intent_id = '', $order_id = null
$customer = new WC_Stripe_Customer( wp_get_current_user()->ID );

if ( $payment_intent_id ) {

$request = [
'amount' => WC_Stripe_Helper::get_stripe_amount( $amount, strtolower( $currency ) ),
'currency' => strtolower( $currency ),
'metadata' => $gateway->get_metadata_from_order( $order ),
/* translators: 1) blog name 2) order number */
'description' => sprintf( __( '%1$s - Order %2$s', 'woocommerce-gateway-stripe' ), wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ), $order->get_order_number() ),
];

$is_setup_intent = substr( $payment_intent_id, 0, 4 ) === 'seti';
if ( ! $is_setup_intent ) {
// These parameters are only supported for payment intents.
$request['amount'] = WC_Stripe_Helper::get_stripe_amount( $amount, strtolower( $currency ) );
$request['currency'] = strtolower( $currency );
}

if ( '' !== $selected_upe_payment_type ) {
// Only update the payment_method_types if we have a reference to the payment type the customer selected.
$request['payment_method_types'] = [ $selected_upe_payment_type ];
Expand Down Expand Up @@ -485,9 +490,11 @@ public function update_payment_intent( $payment_intent_id = '', $order_id = null

$level3_data = $gateway->get_level3_data_from_order( $order );

// Use "setup_intents" endpoint if `$payment_intent_id` starts with `seti_`.
$endpoint = $is_setup_intent ? 'setup_intents' : 'payment_intents';
WC_Stripe_API::request_with_level3_data(
$request,
"payment_intents/{$payment_intent_id}",
"{$endpoint}/{$payment_intent_id}",
$level3_data,
$order
);
Expand All @@ -506,7 +513,7 @@ public function update_payment_intent( $payment_intent_id = '', $order_id = null
* Handle AJAX requests for creating a setup intent without confirmation for Stripe UPE.
*
* @since 5.6.0
* @version 5.6.0
* @version x.x.x
*/
public function init_setup_intent_ajax() {
try {
Expand All @@ -515,7 +522,9 @@ public function init_setup_intent_ajax() {
throw new Exception( __( "We're not able to add this payment method. Please refresh the page and try again.", 'woocommerce-gateway-stripe' ) );
}

wp_send_json_success( $this->init_setup_intent(), 200 );
$payment_method_type = isset( $_POST['payment_method_type'] ) ? wc_clean( wp_unslash( $_POST['payment_method_type'] ) ) : '';

wp_send_json_success( $this->init_setup_intent( $payment_method_type ), 200 );
} catch ( Exception $e ) {
// Send back error, so it can be displayed to the customer.
wp_send_json_error(
Expand All @@ -532,11 +541,13 @@ public function init_setup_intent_ajax() {
* Creates a setup intent without confirmation.
*
* @since 5.6.0
* @version 5.6.0
* @version x.x.x
*
* @param string|null $payment_method_type The type of payment method to use for the intent.
* @return array
* @throws Exception If customer for the current user cannot be read/found.
*/
public function init_setup_intent() {
public function init_setup_intent( $payment_method_type = null ) {
// Determine the customer managing the payment methods, create one if we don't have one already.
$user = wp_get_current_user();
$customer = new WC_Stripe_Customer( $user->ID );
Expand All @@ -547,17 +558,18 @@ public function init_setup_intent() {
$customer_id = $customer->update_customer();
}

$gateway = $this->get_upe_gateway();
$payment_method_types = array_filter( $gateway->get_upe_enabled_payment_method_ids(), [ $gateway, 'is_enabled_for_saved_payments' ] );
$gateway = $this->get_upe_gateway();
$enabled_payment_methods = $payment_method_type ? [ $payment_method_type ] : array_values( array_filter( $gateway->get_upe_enabled_payment_method_ids(), [ $gateway, 'is_enabled_for_saved_payments' ] ) );

$setup_intent = WC_Stripe_API::request(
[
'customer' => $customer_id,
'confirm' => 'false',
'payment_method_types' => array_values( $payment_method_types ),
],
'setup_intents'
);
$request = [
'customer' => $customer_id,
'confirm' => 'false',
'payment_method_types' => $enabled_payment_methods,
];

$request = $this->maybe_add_mandate_options( $request, $payment_method_type, true );

$setup_intent = WC_Stripe_API::request( $request, 'setup_intents' );

if ( ! empty( $setup_intent->error ) ) {
throw new Exception( $setup_intent->error->message );
Expand Down Expand Up @@ -763,7 +775,7 @@ public function create_and_confirm_payment_intent( $payment_information ) {
$request['statement_descriptor_suffix'] = $payment_information['statement_descriptor_suffix'];
}

if ( isset( $payment_information['payment_method_options'] ) ) {
if ( ! empty( $payment_information['payment_method_options'] ) ) {
$request['payment_method_options'] = $payment_information['payment_method_options'];
}

Expand Down Expand Up @@ -802,21 +814,26 @@ public function create_and_confirm_payment_intent( $payment_information ) {
*
* @param array $request The request array to add the mandate options to.
* @param string|null $payment_method_type The type of payment method to use for the intent.
* @param bool $is_setup_intent Whether the request is for a setup intent.
*
* @return array
*/
private function maybe_add_mandate_options( $request, $payment_method_type ) {
// Add required mandate options for ACSS.
private function maybe_add_mandate_options( $request, $payment_method_type, $is_setup_intent = false ) {
if ( WC_Stripe_UPE_Payment_Method_ACSS::STRIPE_ID === $payment_method_type ) {
$request['payment_method_options'] = [
WC_Stripe_Payment_Methods::ACSS_DEBIT => [
'mandate_options' => [
'payment_schedule' => 'interval',
'interval_description' => __( 'One-time payment', 'woocommerce-gateway-stripe' ), // TODO: Change to cadence if purchasing a subscription.
'payment_schedule' => 'combined',
'interval_description' => __( 'Payments as per agreement', 'woocommerce-gateway-stripe' ),
Comment on lines +835 to +836
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I figured it's best to set the payment schedule to combined and the interval description to something that can fit both one-time payments and subscriptions, because the same payment method (and consequently the mandate) can be used for both recurring and non-recurring payments, depending on how the payment method is saved in WooCommerce.

Reference: https://docs.stripe.com/payments/acss-debit#payment-schedule

'transaction_type' => 'personal',
],
],
];

// If it's a setup intent, add the CAD currency parameter.
if ( $is_setup_intent ) {
$request['payment_method_options'][ WC_Stripe_Payment_Methods::ACSS_DEBIT ]['currency'] = strtolower( WC_Stripe_Currency_Code::CANADIAN_DOLLAR );
}
}

return $request;
Expand Down Expand Up @@ -961,6 +978,8 @@ private function build_base_payment_intent_request_params( $payment_information
$request = WC_Stripe_Helper::add_mandate_data( $request );
}

$request = $this->maybe_add_mandate_options( $request, $payment_information['selected_payment_type'] );

// Does not set the return URL if Single Payment Element is enabled or if the request needs redirection.
if ( $this->get_upe_gateway()->is_spe_enabled() || $this->request_needs_redirection( $payment_method_types ) ) {
$request['return_url'] = $payment_information['return_url'];
Expand Down Expand Up @@ -1033,6 +1052,8 @@ public function create_and_confirm_setup_intent( $payment_information ) {
$request = WC_Stripe_Helper::add_mandate_data( $request );
}

$request = $this->maybe_add_mandate_options( $request, $payment_information['selected_payment_type'], true );

// For voucher payment methods type like Boleto, Oxxo, Multibanco, and Cash App, we shouldn't confirm the intent immediately as this is done on the front-end when displaying the voucher to the customer.
// When the intent is confirmed, Stripe sends a webhook to the store which puts the order on-hold, which we only want to happen after successfully displaying the voucher.
if ( $this->is_delayed_confirmation_required( $request['payment_method_types'] ) ) {
Expand Down
15 changes: 12 additions & 3 deletions includes/compat/trait-wc-stripe-subscriptions.php
Original file line number Diff line number Diff line change
Expand Up @@ -730,9 +730,9 @@ public function validate_subscription_payment_meta( $payment_method_id, $payment
* mandates for 3DS payments in India. It's ok to apply this across the board; Stripe will
* take care of handling any authorizations.
*
* @param Array $request The HTTP request that will be sent to Stripe to create the payment intent.
* @param array $request The HTTP request that will be sent to Stripe to create the payment intent.
* @param WC_Order $order The renewal order.
* @param Array $prepared_source The source object.
* @param object $prepared_source The source object.
*/
public function add_subscription_information_to_intent( $request, $order, $prepared_source ) {
// Just in case the order doesn't contain a subscription we return the base request.
Expand Down Expand Up @@ -772,6 +772,7 @@ public function add_subscription_information_to_intent( $request, $order, $prepa
}

// Add mandate options to request to create new mandate if mandate id does not already exist in a previous renewal or parent order.
// Note: This is for backwards compatibility if `_stripe_mandate_id` is not set.
$mandate_options = $this->create_mandate_options_for_order( $order, $subscriptions_for_renewal_order );
if ( ! empty( $mandate_options ) ) {
$request['payment_method_options']['card']['mandate_options'] = $mandate_options;
Expand Down Expand Up @@ -989,12 +990,20 @@ public function maybe_render_subscription_payment_method( $payment_method_to_dis
break 3;
case WC_Stripe_Payment_Methods::ACH:
$payment_method_to_display = sprintf(
/* translators: account type (checking, savings), last 4 digits of account. */
/* translators: 1) account type (checking, savings), 2) last 4 digits of account. */
__( 'Via %1$s Account ending in %2$s', 'woocommerce-gateway-stripe' ),
ucfirst( $source->us_bank_account->account_type ),
$source->us_bank_account->last4
);
break 3;
case WC_Stripe_Payment_Methods::ACSS_DEBIT:
$payment_method_to_display = sprintf(
/* translators: 1) bank name, 2) last 4 digits of account. */
__( 'Via %1$s ending in %2$s', 'woocommerce-gateway-stripe' ),
$source->acss_debit->bank_name,
$source->acss_debit->last4
);
break 3;
case WC_Stripe_Payment_Methods::BACS_DEBIT:
/* translators: 1) the Bacs Direct Debit payment method's last 4 numbers */
$payment_method_to_display = sprintf( __( 'Via Bacs Direct Debit ending in (%1$s)', 'woocommerce-gateway-stripe' ), $source->bacs_debit->last4 );
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -704,7 +704,7 @@ public function process_payment( $order_id, $retry = true, $force_save_source =
if ( $payment_intent_id && ! $this->payment_methods[ $selected_payment_type ]->supports_deferred_intent() ) {
// Adds customer and metadata to PaymentIntent.
// These parameters cannot be added upon updating the intent via the `/confirm` API.
$this->intent_controller->update_payment_intent( $payment_intent_id, $order_id );
$this->intent_controller->update_intent( $payment_intent_id, $order_id );
}

// Flag for using a deferred intent. To be removed.
Expand Down
Loading
Loading