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 ACH Direct Debit payment processing for non-saved bank accounts #3761

Merged
merged 25 commits into from
Jan 31, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
761ad35
proof of concept
rafaelzaleski Jan 16, 2025
cb681c0
Fix icon reference in payment methods map
rafaelzaleski Jan 22, 2025
167cec8
Load ACH conditionally in the payment methods map
rafaelzaleski Jan 22, 2025
5961260
Load Stripe ID from WC_Stripe_Payment_Methods
rafaelzaleski Jan 22, 2025
007f3e3
Add feature flag check when registering PMs
rafaelzaleski Jan 22, 2025
89eead6
Add placeholder icon
rafaelzaleski Jan 22, 2025
a80133d
Update unit tests
rafaelzaleski Jan 24, 2025
918613f
Add test for ACH availability in the US
rafaelzaleski Jan 24, 2025
cb2ea5d
Merge branch 'develop' into add/3747-ach-payment-processing
rafaelzaleski Jan 28, 2025
d56bb5b
Fix lint errors
rafaelzaleski Jan 28, 2025
fa6d818
Remove unused vars in unit tests
rafaelzaleski Jan 28, 2025
9cbc3b8
Remove unused var
rafaelzaleski Jan 28, 2025
973e13d
Revert "Remove unused vars in unit tests"
rafaelzaleski Jan 28, 2025
c4899c2
Remove unused vars in tests
rafaelzaleski Jan 28, 2025
c5d5796
Fix error in blocks checkout
rafaelzaleski Jan 28, 2025
1856210
Use Generic Bank Icon
rafaelzaleski Jan 28, 2025
a5617d3
Remove css from bank debit icon
rafaelzaleski Jan 28, 2025
a85682e
Remove unused svg
rafaelzaleski Jan 28, 2025
850cc46
Merge branch 'develop' into add/3747-ach-payment-processing
bborman22 Jan 30, 2025
ee74a0c
Add icon for shortcode checkout
rafaelzaleski Jan 30, 2025
697182f
Refactor how the payment method class is loaded.
rafaelzaleski Jan 30, 2025
e3d34c8
Remove additional space
rafaelzaleski Jan 30, 2025
58598fc
Revert "Refactor how the payment method class is loaded."
rafaelzaleski Jan 30, 2025
0d7ab32
fix indentation
rafaelzaleski Jan 30, 2025
ee4a5ad
Merge branch 'develop' into add/3747-ach-payment-processing
rafaelzaleski Jan 30, 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
1 change: 1 addition & 0 deletions assets/images/bank-debit.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions client/payment-method-icons/bank-debit/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions client/payment-method-icons/bank-debit/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import React from 'react';
import IconWithShell from '../styles/icon-with-shell';
import icon from './icon.svg';

const BankDebitIcon = ( props ) => <IconWithShell { ...props } src={ icon } />;

export default BankDebitIcon;
4 changes: 3 additions & 1 deletion client/payment-method-icons/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import BoletoIcon from './boleto';
import OxxoIcon from './oxxo';
import WechatPayIcon from './wechat-pay';
import CashAppIcon from './cashapp';
import BankDebitIcon from './bank-debit';

export default {
alipay: AlipayIcon,
Expand All @@ -36,5 +37,6 @@ export default {
oxxo: OxxoIcon,
wechat_pay: WechatPayIcon,
cashapp: CashAppIcon,
bacs_debit: CreditCardIcon,
us_bank_account: BankDebitIcon,
bacs_debit: BankDebitIcon,
};
14 changes: 14 additions & 0 deletions client/payment-methods-map.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import icons from './payment-method-icons';

const accountCountry =
window.wc_stripe_settings_params?.account_country || 'US';
const isAchEnabled = window.wc_stripe_settings_params?.is_ach_enabled === '1';

const paymentMethodsMap = {
card: {
Expand Down Expand Up @@ -241,6 +242,19 @@ const paymentMethodsMap = {
},
};

if ( isAchEnabled ) {
paymentMethodsMap.us_bank_account = {
id: 'us_bank_account',
label: __( 'ACH Direct Debit', 'woocommerce-gateway-stripe' ),
description: __(
'ACH lets you accept payments from customers with a US bank account.',
'woocommerce-gateway-stripe'
),
Icon: icons.us_bank_account,
currencies: [ 'USD' ],
};
}

// Enable Bacs according to feature flag value
if ( window.wc_stripe_settings_params?.is_bacs_enabled ) {
paymentMethodsMap.bacs_debit = {
Expand Down
2 changes: 2 additions & 0 deletions client/stripe-utils/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const PAYMENT_METHOD_LINK = 'link';
* Payment method names constants with the `stripe` prefix
*/
export const PAYMENT_METHOD_STRIPE_CARD = 'stripe';
export const PAYMENT_METHOD_STRIPE_ACH = 'stripe_us_bank_account';
export const PAYMENT_METHOD_STRIPE_GIROPAY = 'stripe_giropay';
export const PAYMENT_METHOD_STRIPE_EPS = 'stripe_eps';
export const PAYMENT_METHOD_STRIPE_IDEAL = 'stripe_ideal';
Expand All @@ -48,6 +49,7 @@ export const PAYMENT_METHOD_STRIPE_BACS = 'stripe_bacs_debit';
export function getPaymentMethodsConstants() {
return {
card: PAYMENT_METHOD_STRIPE_CARD,
us_bank_account: PAYMENT_METHOD_STRIPE_ACH,
giropay: PAYMENT_METHOD_STRIPE_GIROPAY,
eps: PAYMENT_METHOD_STRIPE_EPS,
ideal: PAYMENT_METHOD_STRIPE_IDEAL,
Expand Down
1 change: 1 addition & 0 deletions includes/abstracts/abstract-wc-stripe-payment-gateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,7 @@ public function payment_icons() {
return apply_filters(
'wc_stripe_payment_icons',
[
WC_Stripe_Payment_Methods::ACH => '<img src="' . WC_STRIPE_PLUGIN_URL . '/assets/images/bank-debit.svg" class="stripe-ach-icon stripe-icon" alt="ACH" />',
WC_Stripe_Payment_Methods::ALIPAY => '<img src="' . WC_STRIPE_PLUGIN_URL . '/assets/images/alipay.svg" class="stripe-alipay-icon stripe-icon" alt="Alipay" />',
WC_Stripe_Payment_Methods::WECHAT_PAY => '<img src="' . WC_STRIPE_PLUGIN_URL . '/assets/images/wechat.svg" class="stripe-wechat-icon stripe-icon" alt="Wechat Pay" />',
WC_Stripe_Payment_Methods::BANCONTACT => '<img src="' . WC_STRIPE_PLUGIN_URL . '/assets/images/bancontact.svg" class="stripe-bancontact-icon stripe-icon" alt="Bancontact" />',
Expand Down
1 change: 1 addition & 0 deletions includes/class-wc-stripe-intent-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -967,6 +967,7 @@ private function build_base_payment_intent_request_params( $payment_information
*/
public function is_mandate_data_required( $selected_payment_type, $is_using_saved_payment_method = false ) {
$payment_methods_with_mandates = [
WC_Stripe_Payment_Methods::ACH,
WC_Stripe_Payment_Methods::BACS_DEBIT,
WC_Stripe_Payment_Methods::SEPA_DEBIT,
WC_Stripe_Payment_Methods::BANCONTACT,
Expand Down
2 changes: 2 additions & 0 deletions includes/constants/class-wc-stripe-payment-methods.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
* Class WC_Stripe_Payment_Methods
*/
class WC_Stripe_Payment_Methods {

const ACH = 'us_bank_account';
const AFFIRM = 'affirm';
const AFTERPAY_CLEARPAY = 'afterpay_clearpay';
const ALIPAY = 'alipay';
Expand Down
12 changes: 11 additions & 1 deletion includes/payment-methods/class-wc-stripe-upe-payment-gateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class WC_Stripe_UPE_Payment_Gateway extends WC_Gateway_Stripe {
*/
const UPE_AVAILABLE_METHODS = [
WC_Stripe_UPE_Payment_Method_CC::class,
WC_Stripe_UPE_Payment_Method_ACH::class,
WC_Stripe_UPE_Payment_Method_Alipay::class,
WC_Stripe_UPE_Payment_Method_Giropay::class,
WC_Stripe_UPE_Payment_Method_Klarna::class,
Expand Down Expand Up @@ -155,6 +156,11 @@ public function __construct() {

foreach ( self::UPE_AVAILABLE_METHODS as $payment_method_class ) {

// Show ACH only if feature is enabled.
if ( WC_Stripe_UPE_Payment_Method_ACH::class === $payment_method_class && ! WC_Stripe_Feature_Flags::is_ach_lpm_enabled() ) {
Copy link
Contributor

Choose a reason for hiding this comment

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

I saw that Bacs above uses a different approach to checking if the method should be enabled / added to the list. I'd like if we could keep the approach standardized (as much as possible). I'd prefer the approach in this PR just because it matches what's been used for Sofort and Giropay, but I'd be curious which is the preferred approach between @asumaran and @rafaelzaleski. Depending on which is preferred, this PR may not need changes and instead a change for Bacs would be required.

Copy link
Contributor

Choose a reason for hiding this comment

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

Additionally, I was wondering if it's possible / preferrable to add an "enabled" property to the payment method class that would check against the feature flag? This could eliminate the need to check which class we're working on in the loop and abstract things a bit. I don't think it's necessary for this PR, but wondering if this would be desirable as a future enhancement?

Copy link
Contributor

Choose a reason for hiding this comment

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

Copy link
Contributor

Choose a reason for hiding this comment

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

Sorry I just discussed with @asumaran and the first comment is already addressed here. We can disregard the first comment then.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Additionally, I was wondering if it's possible / preferrable to add an "enabled" property to the payment method class that would check against the feature flag?

We could override the method is_enabled_at_checkout (link) and add a check for the feature flag. However, I’d prefer not to load the payment method class at all while it's behind feature flags.

continue;
}

/** Show Sofort if it's already enabled. Hide from the new merchants and keep it for the old ones who are already using this gateway, until we remove it completely.
* Stripe is deprecating Sofort https://support.stripe.com/questions/sofort-is-being-deprecated-as-a-standalone-payment-method.
*/
Expand Down Expand Up @@ -2156,6 +2162,10 @@ protected function prepare_payment_information_from_request( WC_Order $order ) {
'has_subscription' => $this->has_subscription( $order->get_id() ),
];

if ( 'us_bank_account' === $selected_payment_type ) {
WC_Stripe_API::attach_payment_method_to_customer( $payment_information['customer'], $payment_method_id );
}

if ( ! empty( $payment_method_id ) ) {
$payment_method_details = WC_Stripe_API::get_payment_method( $payment_method_id );
$payment_information['payment_method'] = $payment_method_id;
Expand All @@ -2167,7 +2177,7 @@ protected function prepare_payment_information_from_request( WC_Order $order ) {
$order,
$payment_method_details
);
$payment_information['capture_method'] = $capture_method;
$payment_information['capture_method'] = $capture_method;
} else {
$confirmation_token_id = sanitize_text_field( wp_unslash( $_POST['wc-stripe-confirmation-token'] ?? '' ) );
$payment_information['confirmation_token'] = $confirmation_token_id;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}

/**
* Class that handles ACH Direct Debit as a UPE Payment Method.
*
* @extends WC_Stripe_UPE_Payment_Method
*/
class WC_Stripe_UPE_Payment_Method_ACH extends WC_Stripe_UPE_Payment_Method {

/**
* Stripe's internal identifier for ACH Direct Debit.
*/
const STRIPE_ID = WC_Stripe_Payment_Methods::ACH;

/**
* Constructor for ACH Direct Debit payment method.
*/
public function __construct() {
parent::__construct();

$this->stripe_id = self::STRIPE_ID;
$this->title = __( 'ACH Direct Debit', 'woocommerce-gateway-stripe' );
$this->is_reusable = false; // Usually ACH requires verification per transaction.
$this->supported_currencies = [ 'USD' ];
$this->supported_countries = [ 'US' ];
$this->label = __( 'ACH Direct Debit', 'woocommerce-gateway-stripe' );
$this->description = __( 'Pay directly from your US bank account via ACH.', 'woocommerce-gateway-stripe' );
}

/**
* Checks if ACH is available for the Stripe account's country.
*
* @return bool True if US-based account; false otherwise.
*/
public function is_available_for_account_country() {
return in_array( WC_Stripe::get_instance()->account->get_account_country(), $this->supported_countries, true );
}

/**
* Returns string representing payment method type
* to query to retrieve saved payment methods from Stripe.
*/
public function get_retrievable_type() {
return $this->get_id();
}
}
8 changes: 8 additions & 0 deletions tests/phpunit/test-class-wc-stripe-upe-payment-gateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ class WC_Stripe_UPE_Payment_Gateway_Test extends WP_UnitTestCase {
public function set_up() {
parent::set_up();

update_option( WC_Stripe_Feature_Flags::LPM_ACH_FEATURE_FLAG_NAME, 'yes' );
Copy link
Contributor

Choose a reason for hiding this comment

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

Did you double-check that this is working? For Bacs, I had to place it in set_up_before_class.

Copy link
Contributor

@asumaran asumaran Jan 31, 2025

Choose a reason for hiding this comment

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

I had to do that in tests/phpunit/admin/test-class-wc-rest-stripe-settings-controller.php BTW

Copy link
Contributor

Choose a reason for hiding this comment

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

This seemed to work for me. All the unit tests passed and when I remove this line the test_get_upe_available_payment_methods test fails as it's missing ACH. So it seems this is properly setting the option value and tear down properly removing it.


$mock_account = $this->getMockBuilder( 'WC_Stripe_Account' )
->disableOriginalConstructor()
->getMock();
Expand Down Expand Up @@ -181,6 +183,11 @@ public function set_up() {
);
}

public function tear_down() {
parent::tear_down();
delete_option( WC_Stripe_Feature_Flags::LPM_ACH_FEATURE_FLAG_NAME );
}

/**
* Helper function to set $_POST vars for saved payment method.
*/
Expand Down Expand Up @@ -258,6 +265,7 @@ public function get_upe_available_payment_methods_provider() {
'US',
[
WC_Stripe_UPE_Payment_Method_CC::STRIPE_ID,
WC_Stripe_UPE_Payment_Method_ACH::STRIPE_ID,
WC_Stripe_UPE_Payment_Method_Alipay::STRIPE_ID,
WC_Stripe_UPE_Payment_Method_Klarna::STRIPE_ID,
WC_Stripe_UPE_Payment_Method_Affirm::STRIPE_ID,
Expand Down
87 changes: 50 additions & 37 deletions tests/phpunit/test-class-wc-stripe-upe-payment-method.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,49 +73,51 @@ class WC_Stripe_UPE_Payment_Method_Test extends WP_UnitTestCase {
* Mock capabilities object from Stripe response--all inactive.
*/
const MOCK_INACTIVE_CAPABILITIES_RESPONSE = [
'alipay_payments' => 'inactive',
'bancontact_payments' => 'inactive',
'card_payments' => 'inactive',
'eps_payments' => 'inactive',
'giropay_payments' => 'inactive',
'klarna_payments' => 'inactive',
'affirm_payments' => 'inactive',
'clearpay_afterpay_payments' => 'inactive',
'ideal_payments' => 'inactive',
'p24_payments' => 'inactive',
'sepa_debit_payments' => 'inactive',
'sofort_payments' => 'inactive',
'transfers' => 'inactive',
'multibanco_payments' => 'inactive',
'boleto_payments' => 'inactive',
'oxxo_payments' => 'inactive',
'link_payments' => 'inactive',
'wechat_pay_payments' => 'inactive',
'alipay_payments' => 'inactive',
'bancontact_payments' => 'inactive',
'card_payments' => 'inactive',
'eps_payments' => 'inactive',
'giropay_payments' => 'inactive',
'klarna_payments' => 'inactive',
'affirm_payments' => 'inactive',
'clearpay_afterpay_payments' => 'inactive',
'ideal_payments' => 'inactive',
'p24_payments' => 'inactive',
'sepa_debit_payments' => 'inactive',
'sofort_payments' => 'inactive',
'transfers' => 'inactive',
'multibanco_payments' => 'inactive',
'boleto_payments' => 'inactive',
'oxxo_payments' => 'inactive',
'link_payments' => 'inactive',
'wechat_pay_payments' => 'inactive',
'us_bank_account_ach_payments' => 'inactive',
];

/**
* Mock capabilities object from Stripe response--all active.
*/
const MOCK_ACTIVE_CAPABILITIES_RESPONSE = [
'alipay_payments' => 'active',
'bancontact_payments' => 'active',
'card_payments' => 'active',
'eps_payments' => 'active',
'giropay_payments' => 'active',
'klarna_payments' => 'active',
'affirm_payments' => 'active',
'clearpay_afterpay_payments' => 'active',
'ideal_payments' => 'active',
'p24_payments' => 'active',
'sepa_debit_payments' => 'active',
'sofort_payments' => 'active',
'transfers' => 'active',
'multibanco_payments' => 'active',
'boleto_payments' => 'active',
'oxxo_payments' => 'active',
'link_payments' => 'active',
'cashapp_payments' => 'active',
'wechat_pay_payments' => 'active',
'alipay_payments' => 'active',
'bancontact_payments' => 'active',
'card_payments' => 'active',
'eps_payments' => 'active',
'giropay_payments' => 'active',
'klarna_payments' => 'active',
'affirm_payments' => 'active',
'clearpay_afterpay_payments' => 'active',
'ideal_payments' => 'active',
'p24_payments' => 'active',
'sepa_debit_payments' => 'active',
'sofort_payments' => 'active',
'transfers' => 'active',
'multibanco_payments' => 'active',
'boleto_payments' => 'active',
'oxxo_payments' => 'active',
'link_payments' => 'active',
'cashapp_payments' => 'active',
'wechat_pay_payments' => 'active',
'us_bank_account_ach_payments' => 'active',
];

/**
Expand Down Expand Up @@ -245,6 +247,7 @@ public function test_payment_methods_show_correct_default_outputs() {
$boleto_method = $this->mock_payment_methods['boleto'];
$oxxo_method = $this->mock_payment_methods['oxxo'];
$wechat_pay_method = $this->mock_payment_methods['wechat_pay'];
$ach_method = $this->mock_payment_methods['us_bank_account'];

$this->assertEquals( WC_Stripe_Payment_Methods::CARD, $card_method->get_id() );
$this->assertEquals( 'Credit / Debit Card', $card_method->get_label() );
Expand Down Expand Up @@ -345,6 +348,13 @@ public function test_payment_methods_show_correct_default_outputs() {
$this->assertFalse( $wechat_pay_method->is_reusable() );
$this->assertEquals( WC_Stripe_Payment_Methods::WECHAT_PAY, $wechat_pay_method->get_retrievable_type() );
$this->assertEquals( '', $wechat_pay_method->get_testing_instructions() );

$this->assertEquals( WC_Stripe_Payment_Methods::ACH, $ach_method->get_id() );
$this->assertEquals( 'ACH Direct Debit', $ach_method->get_label() );
$this->assertEquals( 'ACH Direct Debit', $ach_method->get_title() );
$this->assertFalse( $ach_method->is_reusable() ); // Currently non-reusable; future improvement may change this.
$this->assertEquals( WC_Stripe_Payment_Methods::ACH, $ach_method->get_retrievable_type() );
$this->assertEquals( '', $ach_method->get_testing_instructions() );
}

/**
Expand Down Expand Up @@ -375,6 +385,7 @@ public function test_card_payment_method_capability_is_always_enabled() {
$multibanco_method = $this->mock_payment_methods['multibanco'];
$oxxo_method = $this->mock_payment_methods['oxxo'];
$wechat_pay_method = $this->mock_payment_methods['wechat_pay'];
$ach_method = $this->mock_payment_methods['us_bank_account'];

$this->assertTrue( $card_method->is_enabled_at_checkout() );
$this->assertFalse( $klarna_method->is_enabled_at_checkout() );
Expand All @@ -390,6 +401,7 @@ public function test_card_payment_method_capability_is_always_enabled() {
$this->assertFalse( $multibanco_method->is_enabled_at_checkout() );
$this->assertFalse( $oxxo_method->is_enabled_at_checkout() );
$this->assertFalse( $wechat_pay_method->is_enabled_at_checkout() );
$this->assertFalse( $ach_method->is_enabled_at_checkout() );
}

/**
Expand Down Expand Up @@ -605,6 +617,7 @@ public function test_payment_methods_are_reusable_if_cart_contains_subscription(

public function test_payment_methods_support_custom_name_and_description() {
$payment_method_ids = [
WC_Stripe_Payment_Methods::ACH,
WC_Stripe_Payment_Methods::CARD,
WC_Stripe_Payment_Methods::KLARNA,
WC_Stripe_Payment_Methods::AFTERPAY_CLEARPAY,
Expand Down
1 change: 1 addition & 0 deletions woocommerce-gateway-stripe.php
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ public function init() {
require_once __DIR__ . '/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php';
require_once __DIR__ . '/includes/payment-methods/class-wc-stripe-upe-payment-method.php';
require_once __DIR__ . '/includes/payment-methods/class-wc-stripe-upe-payment-method-cc.php';
require_once __DIR__ . '/includes/payment-methods/class-wc-stripe-upe-payment-method-ach.php';
require_once __DIR__ . '/includes/payment-methods/class-wc-stripe-upe-payment-method-alipay.php';
require_once __DIR__ . '/includes/payment-methods/class-wc-stripe-upe-payment-method-bacs.php';
require_once __DIR__ . '/includes/payment-methods/class-wc-stripe-upe-payment-method-giropay.php';
Expand Down
Loading