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 payment processing with saved bank accounts #3811

Open
wants to merge 22 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ee3d13b
Add ACH Payment Token class
rafaelzaleski Jan 30, 2025
db4dec9
Merge branch 'develop' into add/3787-ach-save-payment-tokens
rafaelzaleski Jan 31, 2025
e8593d9
Add ACH Token to list of reusable gateways
rafaelzaleski Jan 31, 2025
a86364b
Load payment token class
rafaelzaleski Jan 31, 2025
a624b41
Declare the PM as reusable
rafaelzaleski Jan 31, 2025
f298939
Add ACH to queryable PM types
rafaelzaleski Feb 3, 2025
c23ed23
Add method to create PM token for user.
rafaelzaleski Feb 3, 2025
3636e97
Add PM title for the my account page
rafaelzaleski Feb 3, 2025
382d6bf
Adjust display name for ach PM in my account
rafaelzaleski Feb 3, 2025
28b45ad
Capitalize the display name
rafaelzaleski Feb 5, 2025
d2021be
Fix gateway ID when creating ACH token via checkout
rafaelzaleski Feb 5, 2025
8fc2907
Map the controller class for ACH tokens
rafaelzaleski Feb 5, 2025
da82fec
Merge branch 'develop' into add/3787-ach-save-payment-tokens
rafaelzaleski Feb 5, 2025
cb07546
Add tests for token creation
rafaelzaleski Feb 6, 2025
0a5d196
Fix failing tests
rafaelzaleski Feb 6, 2025
55a7fd0
Add tests for token class
rafaelzaleski Feb 6, 2025
2feac4a
Add ACH to tokens class tests
rafaelzaleski Feb 7, 2025
8d38ab7
Merge branch 'develop' into add/3787-ach-save-payment-tokens
rafaelzaleski Feb 7, 2025
0a80cf3
Remove exception that can cause fatal errors.
rafaelzaleski Feb 7, 2025
71ed58d
Fix incorrect version tags
rafaelzaleski Feb 11, 2025
0ee8af7
add test for token missing required props
rafaelzaleski Feb 11, 2025
61d1826
Merge branch 'develop' into add/3787-ach-save-payment-tokens
rafaelzaleski Feb 12, 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 includes/class-wc-stripe-customer.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class WC_Stripe_Customer {
WC_Stripe_UPE_Payment_Method_LINK::STRIPE_ID,
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,
];

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,12 @@ public function __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->is_reusable = true;
$this->label = __( 'ACH Direct Debit', 'woocommerce-gateway-stripe' );
$this->description = __( 'Pay directly from your US bank account via ACH.', 'woocommerce-gateway-stripe' );
$this->supported_currencies = [ WC_Stripe_Currency_Code::UNITED_STATES_DOLLAR ];
$this->supported_countries = [ 'US' ];
$this->supports[] = 'tokenization';
rafaelzaleski marked this conversation as resolved.
Show resolved Hide resolved
}

/**
Expand All @@ -46,4 +47,31 @@ public function is_available_for_account_country() {
public function get_retrievable_type() {
return $this->get_id();
}

/**
* Creates an ACH payment token for the customer.
*
* @param int $user_id The customer ID the payment token is associated with.
* @param stdClass $payment_method The payment method object.
*
* @return WC_Payment_Token_ACH|null The payment token created.
*/
public function create_payment_token_for_user( $user_id, $payment_method ) {
if ( ! isset( $payment_method->id ) || ! isset( $payment_method->us_bank_account ) ) {
return null;
}

$payment_token = new WC_Payment_Token_ACH();
$payment_token->set_gateway_id( WC_Stripe_Payment_Tokens::UPE_REUSABLE_GATEWAYS_BY_PAYMENT_METHOD[ self::STRIPE_ID ] );
$payment_token->set_user_id( $user_id );
$payment_token->set_token( $payment_method->id );
$payment_token->set_last4( $payment_method->us_bank_account->last4 );
$payment_token->set_bank_name( $payment_method->us_bank_account->bank_name );
$payment_token->set_account_type( $payment_method->us_bank_account->account_type );
$payment_token->set_fingerprint( $payment_method->us_bank_account->fingerprint );
$payment_token->save();

return $payment_token;
}

}
172 changes: 172 additions & 0 deletions includes/payment-tokens/class-wc-stripe-ach-payment-token.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
<?php

if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly
}

// phpcs:disable WordPress.Files.FileName

/**
* WooCommerce Stripe ACH Direct Debit Payment Token.
*
* Representation of a payment token for ACH.
*
* @class WC_Payment_Token_ACH
* @since 9.1.1
*/
class WC_Payment_Token_ACH extends WC_Payment_Token implements WC_Stripe_Payment_Method_Comparison_Interface {

use WC_Stripe_Fingerprint_Trait;

/**
* Stores payment type.
*
* @var string
*/
protected $type = WC_Stripe_Payment_Methods::ACH;

/**
* Stores ACH payment token data.
*
* @var array
*/
protected $extra_data = [
'bank_name' => '',
'account_type' => '',
'last4' => '',
'payment_method_type' => WC_Stripe_Payment_Methods::ACH,
'fingerprint' => '',
];

/**
* Get type to display to user.
*
* @param string $deprecated Deprecated since WooCommerce 3.0
* @return string
*/
public function get_display_name( $deprecated = '' ) {
$display = sprintf(
/* translators: bank name, account type (checking, savings), last 4 digits of account. */
__( '%1$s account ending in %2$s (%3$s)', 'woocommerce-gateway-stripe' ),
ucfirst( $this->get_account_type() ),
$this->get_last4(),
$this->get_bank_name()
);

return $display;
}

/**
* Hook prefix
*/
protected function get_hook_prefix() {
return 'woocommerce_payment_token_ach_get_';
}

/**
* Validate ACH payment tokens.
*
* These fields are required by all ACH payment tokens:
* last4 - string Last 4 digits of the Account Number
* bank_name - string Name of the bank
* account_type - string Type of account (checking, savings)
* fingerprint - string Unique identifier for the bank account
*
* @return boolean True if the passed data is valid
*/
public function validate() {
if ( false === parent::validate() ) {
return false;
}

if ( ! $this->get_last4( 'edit' ) ) {
return false;
}

if ( ! $this->get_bank_name( 'edit' ) ) {
return false;
}

if ( ! $this->get_account_type( 'edit' ) ) {
return false;
}

if ( ! $this->get_fingerprint( 'edit' ) ) {
return false;
}

return true;
}

/**
* Get the bank name.
*
* @param string $context What the value is for. Valid values are view and edit.
* @return string
*/
public function get_bank_name( $context = 'view' ) {
return $this->get_prop( 'bank_name', $context );
}

/**
* Set the bank name.
*
* @param string $bank_name
*/
public function set_bank_name( $bank_name ) {
$this->set_prop( 'bank_name', $bank_name );
}

/**
* Get the account type.
*
* @param string $context What the value is for. Valid values are view and edit.
* @return string
*/
public function get_account_type( $context = 'view' ) {
return $this->get_prop( 'account_type', $context );
}

/**
* Set the account type.
*
* @param string $account_type
*/
public function set_account_type( $account_type ) {
$this->set_prop( 'account_type', $account_type );
}

/**
* Returns the last four digits.
*
* @param string $context What the value is for. Valid values are view and edit.
* @return string Last 4 digits
*/
public function get_last4( $context = 'view' ) {
return $this->get_prop( 'last4', $context );
}

/**
* Set the last four digits.
*
* @param string $last4
*/
public function set_last4( $last4 ) {
$this->set_prop( 'last4', $last4 );
}

/**
* Checks if the payment method token is equal a provided payment method.
*
* @inheritDoc
*/
public function is_equal_payment_method( $payment_method ): bool {
if (
WC_Stripe_Payment_Methods::ACH === $payment_method->type
&& ( $payment_method->{WC_Stripe_Payment_Methods::ACH}->fingerprint ?? null ) === $this->get_fingerprint() ) {
return true;
}

return false;
}
}
27 changes: 25 additions & 2 deletions includes/payment-tokens/class-wc-stripe-payment-tokens.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class WC_Stripe_Payment_Tokens {
const UPE_REUSABLE_GATEWAYS_BY_PAYMENT_METHOD = [
WC_Stripe_UPE_Payment_Method_CC::STRIPE_ID => WC_Stripe_UPE_Payment_Gateway::ID,
WC_Stripe_UPE_Payment_Method_Link::STRIPE_ID => WC_Stripe_UPE_Payment_Gateway::ID,
WC_Stripe_UPE_Payment_Method_ACH::STRIPE_ID => WC_Stripe_UPE_Payment_Gateway::ID . '_' . WC_Stripe_UPE_Payment_Method_ACH::STRIPE_ID,
WC_Stripe_UPE_Payment_Method_Bancontact::STRIPE_ID => WC_Stripe_UPE_Payment_Gateway::ID . '_' . WC_Stripe_UPE_Payment_Method_Bancontact::STRIPE_ID,
WC_Stripe_UPE_Payment_Method_Ideal::STRIPE_ID => WC_Stripe_UPE_Payment_Gateway::ID . '_' . WC_Stripe_UPE_Payment_Method_Ideal::STRIPE_ID,
WC_Stripe_UPE_Payment_Method_Sepa::STRIPE_ID => WC_Stripe_UPE_Payment_Gateway::ID . '_' . WC_Stripe_UPE_Payment_Method_Sepa::STRIPE_ID,
Expand Down Expand Up @@ -391,7 +392,7 @@ private function get_payment_method_type_from_token( $payment_token ) {
}

/**
* Controls the output for SEPA and Cash App on the my account page.
* Controls the output for some payment methods on the my account page.
*
* @since 4.8.0
* @param array $item Individual list item from woocommerce_saved_payment_methods_list.
Expand All @@ -408,6 +409,9 @@ public function get_account_saved_payment_methods_list_item( $item, $payment_tok
case WC_Stripe_Payment_Methods::CASHAPP_PAY:
$item['method']['brand'] = esc_html__( 'Cash App Pay', 'woocommerce-gateway-stripe' );
break;
case WC_Stripe_Payment_Methods::ACH:
$item['method']['brand'] = $payment_token->get_display_name();
break;
case WC_Stripe_Payment_Methods::LINK:
$item['method']['brand'] = sprintf(
/* translators: customer email */
Expand Down Expand Up @@ -534,6 +538,21 @@ private function add_token_to_user( $payment_method, WC_Stripe_Customer $custome
$token->set_email( $payment_method->link->email );
$token->set_payment_method_type( $payment_method_type );
break;
case WC_Stripe_UPE_Payment_Method_ACH::STRIPE_ID:
$token = new WC_Payment_Token_ACH();
if ( isset( $payment_method->us_bank_account->last4 ) ) {
$token->set_last4( $payment_method->us_bank_account->last4 );
}
if ( isset( $payment_method->us_bank_account->fingerprint ) ) {
$token->set_fingerprint( $payment_method->us_bank_account->fingerprint );
}
if ( isset( $payment_method->us_bank_account->account_type ) ) {
$token->set_account_type( $payment_method->us_bank_account->account_type );
}
if ( isset( $payment_method->us_bank_account->bank_name ) ) {
$token->set_bank_name( $payment_method->us_bank_account->bank_name );
}
break;
case WC_Stripe_UPE_Payment_Method_Cash_App_Pay::STRIPE_ID:
$token = new WC_Payment_Token_CashApp();

Expand Down Expand Up @@ -590,6 +609,7 @@ private function get_original_payment_method_type( $payment_method ) {
public static function get_token_label_overrides_for_checkout() {
$label_overrides = [];
$payment_method_types = [
WC_Stripe_UPE_Payment_Method_ACH::STRIPE_ID,
WC_Stripe_UPE_Payment_Method_Cash_App_Pay::STRIPE_ID,
WC_Stripe_UPE_Payment_Method_Link::STRIPE_ID,
];
Expand Down Expand Up @@ -686,7 +706,7 @@ public static function get_duplicate_token( $payment_method, $user_id, $gateway_
/**
* Token object.
*
* @var WC_Payment_Token_CashApp|WC_Stripe_Payment_Token_CC|WC_Payment_Token_Link|WC_Payment_Token_SEPA $token
* @var WC_Payment_Token_CashApp|WC_Stripe_Payment_Token_CC|WC_Payment_Token_Link|WC_Payment_Token_SEPA|WC_Payment_Token_ACH $token
*/
if ( $token->is_equal_payment_method( $payment_method ) ) {
return $token;
Expand All @@ -706,6 +726,9 @@ public function woocommerce_payment_token_class( $class, $type ) {
if ( WC_Payment_Token_CC::class === $class ) {
return WC_Stripe_Payment_Token_CC::class;
}
if ( WC_Stripe_UPE_Payment_Method_ACH::STRIPE_ID === $type ) {
return WC_Payment_Token_ACH::class;
}
return $class;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

/**
* These tests make assertions against class WC_Stripe_UPE_Payment_Method_ACH.
*/
class WC_Stripe_UPE_Payment_Method_ACH_Test extends WP_UnitTestCase {
/**
* Tests for create_payment_token_for_user.
*/
public function test_create_payment_token_for_user() {
$payment_method = (object) [
'id' => 'pm_test_ach_123',
WC_Stripe_Payment_Methods::ACH => (object) [
'last4' => '6789',
'bank_name' => 'Test Bank',
'account_type' => 'checking',
'fingerprint' => 'fp_test_123',
],
];

$ach_payment_method = new WC_Stripe_UPE_Payment_Method_ACH();
$user_id = 1234;

$token = $ach_payment_method->create_payment_token_for_user( $user_id, $payment_method );
rafaelzaleski marked this conversation as resolved.
Show resolved Hide resolved

$this->assertInstanceOf( 'WC_Payment_Token_ACH', $token );
$this->assertEquals( $user_id, $token->get_user_id() );
$this->assertEquals( $payment_method->id, $token->get_token() );
$this->assertEquals( $payment_method->{WC_Stripe_Payment_Methods::ACH}->last4, $token->get_last4() );
$this->assertEquals( $payment_method->{WC_Stripe_Payment_Methods::ACH}->bank_name, $token->get_bank_name() );
$this->assertEquals( $payment_method->{WC_Stripe_Payment_Methods::ACH}->account_type, $token->get_account_type() );
$this->assertEquals( $payment_method->{WC_Stripe_Payment_Methods::ACH}->fingerprint, $token->get_fingerprint() );
}

/**
* Tests that create_payment_token_for_user returns null when $payment_method is missing
* the `id` or `us_bank_account` properties.
*/
public function test_create_payment_token_for_user_returns_null_when_missing_required_properties() {
$ach_payment_method = new WC_Stripe_UPE_Payment_Method_ACH();
$user_id = 1234;

// Case 1: Missing 'id'.
$payment_method_missing_id = (object) [
WC_Stripe_Payment_Methods::ACH => (object) [
'last4' => '6789',
'bank_name' => 'Test Bank',
'account_type' => 'checking',
'fingerprint' => 'fp_test_123',
],
];

$token = $ach_payment_method->create_payment_token_for_user( $user_id, $payment_method_missing_id );
$this->assertNull( $token, 'Token should be null when the "id" property is missing.' );

// Case 2: Missing 'us_bank_account'.
$payment_method_missing_us_bank_account = (object) [
'id' => 'pm_test_ach_123',
];

$token = $ach_payment_method->create_payment_token_for_user( $user_id, $payment_method_missing_us_bank_account );
$this->assertNull( $token, 'Token should be null when the "us_bank_account" property is missing.' );
}
}
Loading
Loading