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

Fix recurring payments on Indian CCs #3011

Merged
merged 25 commits into from
Dec 13, 2022
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
6bade12
Initial add intent mandate implementaiton
ismaeldcom Sep 29, 2021
2dc7168
Include mandate id if exists
ismaeldcom Oct 1, 2021
3ddb5ff
Handle multiple subscriptions on a single order
ismaeldcom Oct 5, 2021
37dc468
Add order note about payment authorisation by customer
ismaeldcom Oct 5, 2021
a70792d
Add a comma to the order note text
ismaeldcom Oct 6, 2021
c1986cf
Fix current tests
ismaeldcom Oct 7, 2021
d16af9c
Fix param should be optional
ismaeldcom Oct 7, 2021
4a3f1b1
Add new tests
ismaeldcom Oct 8, 2021
171a964
Update renew note about customer authorization
ismaeldcom Oct 11, 2021
53bd7ea
Handle order with other products and signup fee
ismaeldcom Oct 11, 2021
5aca7ff
Handle UPE new card subscription too
ismaeldcom Oct 18, 2021
3291b88
Rebase conflicts
ismaeldcom Oct 25, 2022
b2229ac
Update tests
ismaeldcom Nov 2, 2022
6e1a634
Minor refactor and tests update
ismaeldcom Nov 3, 2022
7977d1e
Fix tests fro WP 6.1
ismaeldcom Nov 4, 2022
023e6b7
Add changelog entry
ismaeldcom Nov 4, 2022
7edbf44
Update notification note to meet new Stripe requriements
ismaeldcom Nov 17, 2022
155efb3
Update `process_webhook_payment_intent_failed` to support intent with…
ismaeldcom Nov 17, 2022
823fb03
Fix psalm errors
ismaeldcom Nov 18, 2022
d26264a
Minor comment update
ismaeldcom Nov 21, 2022
d220c6d
Use early return and align docblock params
ismaeldcom Nov 23, 2022
b29723c
Merge branch 'develop' into fix/2935-recurring-transactions-indian-ccs
shendy-a8c Nov 28, 2022
2777b24
Revert unneeded change
ismaeldcom Nov 29, 2022
21fbe37
Merge branch 'develop' into fix/2935-recurring-transactions-indian-ccs
shendy-a8c Dec 4, 2022
f2b219c
Merge branch 'develop' into fix/2935-recurring-transactions-indian-ccs
shendy-a8c Dec 12, 2022
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
4 changes: 4 additions & 0 deletions changelog/fix-2935-recurring-transactions-indian-ccs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: fix

Recurring payments for cards issued by Indian banks.
10 changes: 10 additions & 0 deletions includes/class-wc-payment-gateway-wcpay.php
Original file line number Diff line number Diff line change
Expand Up @@ -961,6 +961,12 @@ public function process_payment_for_order( $cart, $payment_information, $additio
];
}

// Add card mandate options parameters to the order payment intent if needed.
$additional_api_parameters = array_merge(
$additional_api_parameters,
$this->get_mandate_params_for_order( $order )
);

if ( $payment_needed ) {
$converted_amount = WC_Payments_Utils::prepare_amount( $amount, $order->get_currency() );
$currency = strtolower( $order->get_currency() );
Expand Down Expand Up @@ -1032,6 +1038,7 @@ public function process_payment_for_order( $cart, $payment_information, $additio
$client_secret = $intent->get_client_secret();
$currency = $intent->get_currency();
$next_action = $intent->get_next_action();
$processing = $intent->get_processing();
// We update the payment method ID server side when it's necessary to clone payment methods,
// for example when saving a payment method to a platform customer account. When this happens
// we need to make sure the payment method on the order matches the one on the merchant account
Expand Down Expand Up @@ -1095,6 +1102,7 @@ public function process_payment_for_order( $cart, $payment_information, $additio
$client_secret = $intent['client_secret'];
$currency = $order->get_currency();
$next_action = $intent['next_action'];
$processing = [];
}

if ( ! empty( $intent ) ) {
Expand Down Expand Up @@ -1176,6 +1184,8 @@ public function process_payment_for_order( $cart, $payment_information, $additio
$this->attach_exchange_info_to_order( $order, $charge_id );
$this->update_order_status_from_intent( $order, $intent_id, $status, $charge_id );

$this->maybe_add_customer_notification_note( $order, $processing );

if ( isset( $response ) ) {
return $response;
}
Expand Down
68 changes: 36 additions & 32 deletions includes/class-wc-payments-webhook-processing-service.php
Original file line number Diff line number Diff line change
Expand Up @@ -357,10 +357,13 @@ private function process_webhook_payment_intent_amount_capturable_updated( $even
*/
private function process_webhook_payment_intent_failed( $event_body ) {
// Check to make sure we should process this according to the payment method.
$charges_data = $event_body['data']['object']['charges']['data'][0] ?? null;
$payment_method_type = $charges_data['payment_method_details']['type'] ?? null;
$charge_id = $event_body['data']['object']['charges']['data'][0]['id'] ?? '';
$last_payment_error = $event_body['data']['object']['last_payment_error'] ?? null;
$payment_method = $last_payment_error['payment_method'] ?? null;
$payment_method_type = $payment_method['type'] ?? null;
ismaeldcom marked this conversation as resolved.
Show resolved Hide resolved

$actionable_methods = [
Payment_Method::CARD,
Payment_Method::US_BANK_ACCOUNT,
Payment_Method::BECS,
];
Expand All @@ -371,7 +374,7 @@ private function process_webhook_payment_intent_failed( $event_body ) {

// Get the order and make sure it is an order and the payment methods match.
$order = $this->get_order_from_event_body_intent_id( $event_body );
$payment_method_id = $charges_data['payment_method'] ?? null;
$payment_method_id = $payment_method['id'] ?? null;
ismaeldcom marked this conversation as resolved.
Show resolved Hide resolved

if ( ! $order
|| empty( $payment_method_id )
Expand All @@ -383,9 +386,8 @@ private function process_webhook_payment_intent_failed( $event_body ) {
$event_object = $this->read_webhook_property( $event_data, 'object' );
$intent_id = $this->read_webhook_property( $event_object, 'id' );
$intent_status = $this->read_webhook_property( $event_object, 'status' );
$charge_id = $this->read_webhook_property( $charges_data, 'id' );

$this->order_service->mark_payment_failed( $order, $intent_id, $intent_status, $charge_id, $this->get_failure_message_from_event( $event_body ) ); }
$this->order_service->mark_payment_failed( $order, $intent_id, $intent_status, $charge_id, $this->get_failure_message_from_error( $last_payment_error ) ); }

/**
* Process webhook for a successful payment intent.
Expand Down Expand Up @@ -418,6 +420,12 @@ private function process_webhook_payment_intent_succeeded( $event_body ) {
WC_Payments_Utils::ORDER_INTENT_CURRENCY_META_KEY => $currency,
];

// Save mandate id, necessary for some subscription renewals.
$mandate_id = $event_data['object']['charges']['data'][0]['payment_method_details']['card']['mandate'] ?? null;
if ( $mandate_id ) {
$meta_data_to_update['_stripe_mandate_id'] = $mandate_id;
}

foreach ( $meta_data_to_update as $key => $value ) {
// Override existing meta data with incoming values, if present.
if ( $value ) {
Expand Down Expand Up @@ -657,45 +665,41 @@ private function get_order_from_event_body_intent_id( $event_body ) {
}

/**
* Gets the proper failure message from the code in the event.
* Gets the proper failure message from the code in the error.
* Error codes from https://stripe.com/docs/error-codes.
*
* @param array $event_body The event that triggered the webhook.
* @param array $error The last payment error from the payment failed event.
*
* @return string The failure message.
*/
private function get_failure_message_from_event( $event_body ):string {
// Get the failure code from the event body.
$event_data = $this->read_webhook_property( $event_body, 'data' );
$event_object = $this->read_webhook_property( $event_data, 'object' );
$event_charges = $this->read_webhook_property( $event_object, 'charges' );
$charges_data = $this->read_webhook_property( $event_charges, 'data' );
$failure_code = $charges_data[0]['failure_code'] ?? '';
private function get_failure_message_from_error( $error ):string {
$code = $error['code'] ?? '';
$decline_code = $error['decline_code'] ?? '';
$message = $error['message'] ?? '';

switch ( $failure_code ) {
switch ( $code ) {
case 'account_closed':
$failure_message = __( "The customer's bank account has been closed.", 'woocommerce-payments' );
break;
return __( "The customer's bank account has been closed.", 'woocommerce-payments' );
case 'debit_not_authorized':
$failure_message = __( 'The customer has notified their bank that this payment was unauthorized.', 'woocommerce-payments' );
break;
return __( 'The customer has notified their bank that this payment was unauthorized.', 'woocommerce-payments' );
case 'insufficient_funds':
$failure_message = __( "The customer's account has insufficient funds to cover this payment.", 'woocommerce-payments' );
break;
return __( "The customer's account has insufficient funds to cover this payment.", 'woocommerce-payments' );
case 'no_account':
$failure_message = __( "The customer's bank account could not be located.", 'woocommerce-payments' );
break;
return __( "The customer's bank account could not be located.", 'woocommerce-payments' );
case 'payment_method_microdeposit_failed':
$failure_message = __( 'Microdeposit transfers failed. Please check the account, institution and transit numbers.', 'woocommerce-payments' );
break;
return __( 'Microdeposit transfers failed. Please check the account, institution and transit numbers.', 'woocommerce-payments' );
case 'payment_method_microdeposit_verification_attempts_exceeded':
$failure_message = __( 'You have exceeded the number of allowed verification attempts.', 'woocommerce-payments' );
break;

default:
oaratovskyi marked this conversation as resolved.
Show resolved Hide resolved
$failure_message = __( 'The payment was not able to be processed.', 'woocommerce-payments' );
break;
return __( 'You have exceeded the number of allowed verification attempts.', 'woocommerce-payments' );
case 'card_declined':
switch ( $decline_code ) {
case 'debit_notification_undelivered':
return __( "The customer's bank could not send pre-debit notification for the payment.", 'woocommerce-payments' );
case 'transaction_not_approved':
return __( 'For recurring payment greater than mandate amount or INR 15000, payment was not approved by the card holder.', 'woocommerce-payments' );
}
}

return $failure_message;
// translators: %s Stripe error message.
return sprintf( __( 'With the following message: <code>%s</code>', 'woocommerce-payments' ), $message );
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -274,9 +274,12 @@ public function scheduled_subscription_payment( $amount, $renewal_order ) {
return;
}

// Add mandate param to the order payment if needed.
$additional_api_parameters = $this->get_mandate_param_for_renewal_order( $renewal_order );

try {
$payment_information = new Payment_Information( '', $renewal_order, Payment_Type::RECURRING(), $token, Payment_Initiated_By::MERCHANT() );
$this->process_payment_for_order( null, $payment_information );
$this->process_payment_for_order( null, $payment_information, $additional_api_parameters );
} catch ( API_Exception $e ) {
Logger::error( 'Error processing subscription renewal: ' . $e->getMessage() );
// TODO: Update to use Order_Service->mark_payment_failed.
Expand All @@ -285,7 +288,7 @@ public function scheduled_subscription_payment( $amount, $renewal_order ) {
if ( ! empty( $payment_information ) ) {
$note = sprintf(
WC_Payments_Utils::esc_interpolated_html(
/* translators: %1: the failed payment amount, %2: error message */
/* translators: %1: the failed payment amount, %2: error message */
__(
'A payment of %1$s <strong>failed</strong> to complete with the following message: <code>%2$s</code>.',
'woocommerce-payments'
Expand Down Expand Up @@ -807,4 +810,113 @@ private function is_wcpay_subscription_renewal_order( WC_Order $renewal_order )

return false;
}

/**
* Get card mandate parameters for the order payment intent if needed.
* Only required for subscriptions creation for cards issued in India.
* More details https://wp.me/pc4etw-ky
*
* @param WC_Order $order The subscription order.
* @return array Params to be included or empty array.
*/
public function get_mandate_params_for_order( WC_Order $order ): array {
$result = [];

if ( ! $this->is_subscriptions_enabled() ) {
return $result;
}
$subscriptions = wcs_get_subscriptions_for_order( $order->get_id() );
$subscription = reset( $subscriptions );

if ( ! $subscription ) {
return $result;
}

// Get total by adding only subscriptions and get rid of any other product or fee.
$subs_amount = 0;
foreach ( $subscriptions as $sub ) {
$subs_amount += $sub->get_total();
}

$result['setup_future_usage'] = 'off_session';
$result['payment_method_options']['card']['mandate_options'] = [
'reference' => $order->get_id(),
'amount' => WC_Payments_Utils::prepare_amount( $subs_amount, $order->get_currency() ),
'amount_type' => 'fixed',
'start_date' => $subscription->get_time( 'date_created' ),
'interval' => $subscription->get_billing_period(),
'interval_count' => $subscription->get_billing_interval(),
'supported_types' => [ 'india' ],
];

// Multiple subscriptions per order needs:
// - Set amount type to maximum, to allow renews of any amount under the order total.
// - Set interval to sporadic, to not follow any specific interval.
// - Unset interval count, because it doesn't apply anymore.
if ( 1 < count( $subscriptions ) ) {
$result['payment_method_options']['card']['mandate_options']['amount_type'] = 'maximum';
$result['payment_method_options']['card']['mandate_options']['interval'] = 'sporadic';
unset( $result['payment_method_options']['card']['mandate_options']['interval_count'] );
}

return $result;
}

/**
* Add an order note if the renew intent customer notification requires the merchant to authenticate the payment.
* The note includes the charge attempt date and let the merchant know the need of an off-session step by the customer.
*
* @param WC_Order $order The renew order.
* @param array $processing Processing state from Stripe's intent response.
* @return void
*/
public function maybe_add_customer_notification_note( WC_Order $order, array $processing = [] ) {
$approval_requested = $processing['card']['customer_notification']['approval_requested'] ?? false;
$completes_at = $processing['card']['customer_notification']['completes_at'] ?? null;
if ( $approval_requested && $completes_at ) {
$attempt_date = wp_date( get_option( 'date_format', 'F j, Y' ), $completes_at, wp_timezone() );
$attempt_time = wp_date( get_option( 'time_format', 'g:i a' ), $completes_at, wp_timezone() );

$note = sprintf(
/* translators: 1) date in date_format or 'F j, Y'; 2) time in time_format or 'g:i a' */
__( 'The customer must authorize this payment via a notification sent to them by the bank which issued their card. The authorization must be completed before %1$s at %2$s, when the charge will be attempted.', 'woocommerce-payments' ),
$attempt_date,
$attempt_time
);

$order->add_order_note( $note );
}

}

/**
* Get mandate ID parameter to renewal payment if exists.
* Only required for subscriptions renewals for cards issued in India.
* More details https://wp.me/pc4etw-ky
*
* @param WC_Order $renewal_order The subscription renewal order.
* @return array Param to be included or empty array.
*/
public function get_mandate_param_for_renewal_order( WC_Order $renewal_order ): array {
ismaeldcom marked this conversation as resolved.
Show resolved Hide resolved
ismaeldcom marked this conversation as resolved.
Show resolved Hide resolved
$subscriptions = wcs_get_subscriptions_for_renewal_order( $renewal_order->get_id() );
$subscription = reset( $subscriptions );

if ( ! $subscription ) {
return [];
}

$parent_order = wc_get_order( $subscription->get_parent_id() );

if ( ! $parent_order ) {
return [];
}

$mandate = $parent_order->get_meta( '_stripe_mandate_id', true );

if ( empty( $mandate ) ) {
return [];
}

return [ 'mandate' => $mandate ];
}
}
5 changes: 4 additions & 1 deletion includes/payment-methods/class-upe-payment-gateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,8 @@ public function process_payment( $order_id ) {
throw new Exception( WC_Payments_Utils::get_filtered_error_message( $exception ) );
}

$additional_api_parameters = $this->get_mandate_params_for_order( $order );

try {
$updated_payment_intent = $this->payments_api_client->update_intention(
$payment_intent_id,
Expand All @@ -481,7 +483,8 @@ public function process_payment( $order_id ) {
$this->get_metadata_from_order( $order, $payment_type ),
$this->get_level3_data_from_order( $order ),
$selected_upe_payment_type,
$payment_country
$payment_country,
$additional_api_parameters
);
} catch ( Amount_Too_Small_Exception $e ) {
// This code would only be reached if the cache has already expired.
Expand Down
Loading