937 lines
35 KiB
PHP
937 lines
35 KiB
PHP
<?php
|
|
/**
|
|
* WC_Payments_Webhook_Processing_Service class
|
|
*
|
|
* @package WooCommerce\Payments
|
|
*/
|
|
|
|
use WCPay\Constants\Order_Status;
|
|
use WCPay\Constants\Payment_Method;
|
|
use WCPay\Core\Server\Request\Get_Intention;
|
|
use WCPay\Database_Cache;
|
|
use WCPay\Exceptions\Invalid_Payment_Method_Exception;
|
|
use WCPay\Exceptions\Invalid_Webhook_Data_Exception;
|
|
use WCPay\Exceptions\Order_Not_Found_Exception;
|
|
use WCPay\Exceptions\Rest_Request_Exception;
|
|
use WCPay\Logger;
|
|
use WCPay\Constants\Refund_Status;
|
|
use WCPay\Constants\Refund_Failure_Reason;
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit; // Exit if accessed directly.
|
|
}
|
|
|
|
/**
|
|
* Service to process webhook data.
|
|
*/
|
|
class WC_Payments_Webhook_Processing_Service {
|
|
/**
|
|
* Client for making requests to the WooCommerce Payments API
|
|
*
|
|
* @var WC_Payments_API_Client
|
|
*/
|
|
protected $api_client;
|
|
|
|
/**
|
|
* DB wrapper.
|
|
*
|
|
* @var WC_Payments_DB
|
|
*/
|
|
private $wcpay_db;
|
|
|
|
/**
|
|
* WC Payments Account.
|
|
*
|
|
* @var WC_Payments_Account
|
|
*/
|
|
private $account;
|
|
|
|
/**
|
|
* WC Payments Remote Note Service.
|
|
*
|
|
* @var WC_Payments_Remote_Note_Service
|
|
*/
|
|
private $remote_note_service;
|
|
|
|
/**
|
|
* WC_Payments_Order_Service instance
|
|
*
|
|
* @var WC_Payments_Order_Service
|
|
*/
|
|
protected $order_service;
|
|
|
|
/**
|
|
* WC_Payments_In_Person_Payments_Receipts_Service
|
|
*
|
|
* @var WC_Payments_In_Person_Payments_Receipts_Service
|
|
*/
|
|
private $receipt_service;
|
|
|
|
/**
|
|
* WC_Payment_Gateway_WCPay
|
|
*
|
|
* @var WC_Payment_Gateway_WCPay
|
|
*/
|
|
private $wcpay_gateway;
|
|
|
|
/**
|
|
* WC_Payment_Gateway_WCPay
|
|
*
|
|
* @var WC_Payments_Customer_Service
|
|
*/
|
|
private $customer_service;
|
|
|
|
/**
|
|
* Database_Cache instance.
|
|
*
|
|
* @var Database_Cache
|
|
*/
|
|
private $database_cache;
|
|
|
|
/**
|
|
* WC_Payments_Onboarding_Service instance.
|
|
*
|
|
* @var WC_Payments_Onboarding_Service
|
|
*/
|
|
private $onboarding_service;
|
|
|
|
/**
|
|
* WC_Payments_Webhook_Processing_Service constructor.
|
|
*
|
|
* @param WC_Payments_API_Client $api_client WooCommerce Payments API client.
|
|
* @param WC_Payments_DB $wcpay_db WC_Payments_DB instance.
|
|
* @param WC_Payments_Account $account WC_Payments_Account instance.
|
|
* @param WC_Payments_Remote_Note_Service $remote_note_service WC_Payments_Remote_Note_Service instance.
|
|
* @param WC_Payments_Order_Service $order_service WC_Payments_Order_Service instance.
|
|
* @param WC_Payments_In_Person_Payments_Receipts_Service $receipt_service WC_Payments_In_Person_Payments_Receipts_Service instance.
|
|
* @param WC_Payment_Gateway_WCPay $wcpay_gateway WC_Payment_Gateway_WCPay instance.
|
|
* @param WC_Payments_Customer_Service $customer_service WC_Payments_Customer_Service instance.
|
|
* @param Database_Cache $database_cache Database_Cache instance.
|
|
* @param WC_Payments_Onboarding_Service $onboarding_service WC_Payments_Onboarding_Service instance.
|
|
*/
|
|
public function __construct(
|
|
WC_Payments_API_Client $api_client,
|
|
WC_Payments_DB $wcpay_db,
|
|
WC_Payments_Account $account,
|
|
WC_Payments_Remote_Note_Service $remote_note_service,
|
|
WC_Payments_Order_Service $order_service,
|
|
WC_Payments_In_Person_Payments_Receipts_Service $receipt_service,
|
|
WC_Payment_Gateway_WCPay $wcpay_gateway,
|
|
WC_Payments_Customer_Service $customer_service,
|
|
Database_Cache $database_cache,
|
|
WC_Payments_Onboarding_Service $onboarding_service
|
|
) {
|
|
$this->wcpay_db = $wcpay_db;
|
|
$this->account = $account;
|
|
$this->remote_note_service = $remote_note_service;
|
|
$this->order_service = $order_service;
|
|
$this->api_client = $api_client;
|
|
$this->receipt_service = $receipt_service;
|
|
$this->wcpay_gateway = $wcpay_gateway;
|
|
$this->customer_service = $customer_service;
|
|
$this->database_cache = $database_cache;
|
|
$this->onboarding_service = $onboarding_service;
|
|
}
|
|
|
|
/**
|
|
* Process webhook event data.
|
|
*
|
|
* @param array $event_body Body data of webhook request.
|
|
*
|
|
* @return void
|
|
*
|
|
* @throws Invalid_Webhook_Data_Exception
|
|
*/
|
|
public function process( array $event_body ) {
|
|
// Extract information about the webhook event.
|
|
$event_type = $this->read_webhook_property( $event_body, 'type' );
|
|
$event_id = '';
|
|
try {
|
|
$event_id = $this->read_webhook_property( $event_body, 'id' );
|
|
} catch ( Invalid_Webhook_Data_Exception $e ) {
|
|
Logger::error( 'Webhook event ID not found' );
|
|
}
|
|
|
|
Logger::debug(
|
|
'WEBHOOK RECEIVED: ' . $event_type . ' ' . $event_id,
|
|
[
|
|
'body' => WC_Payments_Utils::redact_array( $event_body, WC_Payments_API_Client::API_KEYS_TO_REDACT ),
|
|
]
|
|
);
|
|
|
|
if ( $this->is_webhook_mode_mismatch( $event_body ) ) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
do_action( 'woocommerce_payments_before_webhook_delivery', $event_type, $event_body );
|
|
} catch ( Exception $e ) {
|
|
Logger::error( $e );
|
|
}
|
|
|
|
switch ( $event_type ) {
|
|
case 'charge.refunded':
|
|
$this->process_webhook_refund_triggered_externally( $event_body );
|
|
break;
|
|
case 'charge.refund.updated':
|
|
$this->process_webhook_refund_updated( $event_body );
|
|
break;
|
|
case 'charge.dispute.created':
|
|
$this->process_webhook_dispute_created( $event_body );
|
|
break;
|
|
case 'charge.dispute.closed':
|
|
$this->process_webhook_dispute_closed( $event_body );
|
|
break;
|
|
case 'charge.dispute.funds_reinstated':
|
|
case 'charge.dispute.funds_withdrawn':
|
|
case 'charge.dispute.updated':
|
|
$this->process_webhook_dispute_updated( $event_body );
|
|
break;
|
|
case 'charge.expired':
|
|
$this->process_webhook_expired_authorization( $event_body );
|
|
break;
|
|
case 'account.updated':
|
|
$this->account->refresh_account_data();
|
|
$this->customer_service->delete_cached_payment_methods();
|
|
break;
|
|
case 'account.deleted':
|
|
$this->onboarding_service->cleanup_on_account_reset();
|
|
// Reset the WooCommerce NOX data, if it is not already.
|
|
delete_option( WC_Payments_Account::NOX_PROFILE_OPTION_KEY );
|
|
// NOX onboarding should be unlocked by the time we receive this event,
|
|
// but unlock it just in case, to maintain sanity.
|
|
delete_option( WC_Payments_Account::NOX_ONBOARDING_LOCKED_KEY );
|
|
|
|
// Refetch the account data to allow the platform to drive the available next steps.
|
|
$this->account->refresh_account_data();
|
|
break;
|
|
case 'wcpay.notification':
|
|
$this->process_wcpay_notification( $event_body );
|
|
break;
|
|
case 'payment_intent.payment_failed':
|
|
$this->process_webhook_payment_intent_failed( $event_body );
|
|
break;
|
|
case 'payment_intent.succeeded':
|
|
$this->process_webhook_payment_intent_succeeded( $event_body );
|
|
break;
|
|
case 'payment_intent.canceled':
|
|
$this->process_webhook_payment_intent_canceled( $event_body );
|
|
break;
|
|
case 'payment_intent.amount_capturable_updated':
|
|
$this->process_webhook_payment_intent_amount_capturable_updated( $event_body );
|
|
break;
|
|
case 'invoice.upcoming':
|
|
case 'invoice.paid':
|
|
case 'invoice.payment_failed':
|
|
$this->process_webhook_stripe_billing_invoice( $event_type, $event_body );
|
|
break;
|
|
}
|
|
|
|
try {
|
|
do_action( 'woocommerce_payments_after_webhook_delivery', $event_type, $event_body );
|
|
} catch ( Exception $e ) {
|
|
Logger::error( $e );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check webhook mode against the gateway mode.
|
|
*
|
|
* @param array $event_body The event that triggered the webhook.
|
|
*
|
|
* @return bool Indicates whether the event's mode is different from the gateway's mode
|
|
* @throws Invalid_Webhook_Data_Exception Event mode does not match the gateway mode.
|
|
*/
|
|
private function is_webhook_mode_mismatch( array $event_body ): bool {
|
|
if ( ! $this->has_webhook_property( $event_body, 'livemode' ) ) {
|
|
return false;
|
|
}
|
|
|
|
$is_gateway_live_mode = WC_Payments::mode()->is_live();
|
|
$is_event_live_mode = $this->read_webhook_property( $event_body, 'livemode' );
|
|
|
|
if ( $is_gateway_live_mode !== $is_event_live_mode ) {
|
|
$event_id = $this->read_webhook_property( $event_body, 'id' );
|
|
|
|
Logger::error(
|
|
sprintf(
|
|
'Webhook event mode did not match the gateway mode (event ID: %s)',
|
|
$event_id
|
|
)
|
|
);
|
|
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Process webhook refund updated.
|
|
*
|
|
* @param array $event_body The event that triggered the webhook.
|
|
*
|
|
* @throws Invalid_Webhook_Data_Exception Required parameters not found.
|
|
* @throws Invalid_Payment_Method_Exception When unable to resolve charge ID to order.
|
|
*/
|
|
private function process_webhook_refund_updated( $event_body ) {
|
|
$event_data = $this->read_webhook_property( $event_body, 'data' );
|
|
$event_object = $this->read_webhook_property( $event_data, 'object' );
|
|
|
|
// Fetch the details of the failed refund so that we can find the associated order and write a note.
|
|
$charge_id = $this->read_webhook_property( $event_object, 'charge' );
|
|
$refund_id = $this->read_webhook_property( $event_object, 'id' );
|
|
$amount = $this->read_webhook_property( $event_object, 'amount' );
|
|
$currency = $this->read_webhook_property( $event_object, 'currency' );
|
|
$status = $this->read_webhook_property( $event_object, 'status' );
|
|
$balance_transaction = $this->has_webhook_property( $event_object, 'balance_transaction' )
|
|
? $this->read_webhook_property( $event_object, 'balance_transaction' )
|
|
: null;
|
|
|
|
// Look up the order related to this charge.
|
|
$order = $this->wcpay_db->order_from_charge_id( $charge_id );
|
|
if ( ! $order ) {
|
|
throw new Invalid_Payment_Method_Exception(
|
|
sprintf(
|
|
/* translators: %1: charge ID */
|
|
__( 'Could not find order via charge ID: %1$s', 'woocommerce-payments' ),
|
|
$charge_id
|
|
),
|
|
'order_not_found'
|
|
);
|
|
}
|
|
|
|
$matched_wc_refund = null;
|
|
/**
|
|
* Get the WC_Refund from the WCPay refund ID.
|
|
*
|
|
* @var WC_Order_Refund[] $wc_refunds
|
|
* */
|
|
$wc_refunds = $order->get_refunds();
|
|
if ( ! empty( $wc_refunds ) ) {
|
|
foreach ( $wc_refunds as $wc_refund ) {
|
|
$wcpay_refund_id = $this->order_service->get_wcpay_refund_id_for_order( $wc_refund );
|
|
if ( $refund_id === $wcpay_refund_id ) {
|
|
$matched_wc_refund = $wc_refund;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Refund update webhook events can be either failed, cancelled (basically it's also a failure but triggered by the merchant), succeeded only.
|
|
switch ( $status ) {
|
|
case Refund_Status::FAILED:
|
|
$failure_reason = $this->has_webhook_property( $event_object, 'failure_reason' )
|
|
? $this->read_webhook_property( $event_object, 'failure_reason' )
|
|
: null;
|
|
$this->order_service->handle_failed_refund( $order, $refund_id, $amount, $currency, $matched_wc_refund, false, $failure_reason );
|
|
break;
|
|
case Refund_Status::CANCELED:
|
|
$this->order_service->handle_failed_refund( $order, $refund_id, $amount, $currency, $matched_wc_refund, true );
|
|
break;
|
|
case Refund_Status::SUCCEEDED:
|
|
if ( $matched_wc_refund ) {
|
|
$this->order_service->add_note_and_metadata_for_created_refund( $order, $matched_wc_refund, $refund_id, $balance_transaction ?? null );
|
|
}
|
|
break;
|
|
default:
|
|
throw new Invalid_Webhook_Data_Exception( 'Invalid refund update status: ' . $status );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process webhook for an expired uncaptured payment.
|
|
*
|
|
* @param array $event_body The event that triggered the webhook.
|
|
*
|
|
* @throws Invalid_Webhook_Data_Exception Required parameters not found.
|
|
* @throws Invalid_Payment_Method_Exception When unable to resolve charge ID to order.
|
|
*/
|
|
private function process_webhook_expired_authorization( $event_body ) {
|
|
$event_data = $this->read_webhook_property( $event_body, 'data' );
|
|
$event_object = $this->read_webhook_property( $event_data, 'object' );
|
|
|
|
// Fetch the details of the expired auth so that we can find the associated order.
|
|
$charge_id = $this->read_webhook_property( $event_object, 'id' );
|
|
|
|
// Look up the order related to this charge.
|
|
$order = $this->wcpay_db->order_from_charge_id( $charge_id );
|
|
if ( ! $order ) {
|
|
throw new Invalid_Payment_Method_Exception(
|
|
sprintf(
|
|
/* translators: %1: charge ID */
|
|
__( 'Could not find order via charge ID: %1$s', 'woocommerce-payments' ),
|
|
$charge_id
|
|
),
|
|
'order_not_found'
|
|
);
|
|
}
|
|
|
|
// Get the intent_id and then its status.
|
|
$intent_id = $event_object['payment_intent'] ?? $order->get_meta( '_intent_id' );
|
|
|
|
$request = Get_Intention::create( $intent_id );
|
|
$request->set_hook_args( $order );
|
|
$intent = $request->send();
|
|
|
|
$intent_status = $intent->get_status();
|
|
|
|
// TODO: Revisit this logic once we support partial captures or multiple charges for order. We'll need to handle the "payment_intent.canceled" event too.
|
|
$this->order_service->mark_payment_capture_expired( $order, $intent_id, $intent_status, $charge_id );
|
|
|
|
// Clear the authorization summary cache to trigger a fetch of new data.
|
|
$this->database_cache->delete( DATABASE_CACHE::AUTHORIZATION_SUMMARY_KEY );
|
|
$this->database_cache->delete( DATABASE_CACHE::AUTHORIZATION_SUMMARY_KEY_TEST_MODE );
|
|
}
|
|
|
|
/**
|
|
* Process webhook for a payment intent canceled event.
|
|
*
|
|
* @param array $event_body The event that triggered the webhook.
|
|
*
|
|
* @return void
|
|
*/
|
|
private function process_webhook_payment_intent_canceled( $event_body ) {
|
|
// Clear the authorization summary cache to trigger a fetch of new data.
|
|
$this->database_cache->delete( DATABASE_CACHE::AUTHORIZATION_SUMMARY_KEY );
|
|
$this->database_cache->delete( DATABASE_CACHE::AUTHORIZATION_SUMMARY_KEY_TEST_MODE );
|
|
}
|
|
|
|
/**
|
|
* Process webhook for a payment intent amount capturable updated event.
|
|
*
|
|
* @param array $event_body The event that triggered the webhook.
|
|
*
|
|
* @return void
|
|
*/
|
|
private function process_webhook_payment_intent_amount_capturable_updated( $event_body ) {
|
|
// Clear the authorization summary cache to trigger a fetch of new data.
|
|
$this->database_cache->delete( DATABASE_CACHE::AUTHORIZATION_SUMMARY_KEY );
|
|
$this->database_cache->delete( DATABASE_CACHE::AUTHORIZATION_SUMMARY_KEY_TEST_MODE );
|
|
}
|
|
|
|
/**
|
|
* Process webhook for a failed payment intent.
|
|
*
|
|
* @param array $event_body The event that triggered the webhook.
|
|
*
|
|
* @throws Invalid_Webhook_Data_Exception Required parameters not found.
|
|
* @throws Invalid_Payment_Method_Exception When unable to resolve charge ID to order.
|
|
*/
|
|
private function process_webhook_payment_intent_failed( $event_body ) {
|
|
// Check to make sure we should process this according to the payment method.
|
|
$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;
|
|
|
|
$actionable_methods = [
|
|
Payment_Method::CARD,
|
|
Payment_Method::CARD_PRESENT,
|
|
Payment_Method::US_BANK_ACCOUNT,
|
|
Payment_Method::BECS,
|
|
Payment_Method::WECHAT_PAY,
|
|
];
|
|
|
|
if ( empty( $payment_method_type ) || ! in_array( $payment_method_type, $actionable_methods, true ) ) {
|
|
return;
|
|
}
|
|
|
|
// Get the order and make sure it is an order and the payment methods match.
|
|
$order = $this->get_order_from_event_body( $event_body );
|
|
$payment_method_id = $payment_method['id'] ?? null;
|
|
|
|
if ( ! $order || empty( $payment_method_id ) ) {
|
|
return;
|
|
}
|
|
|
|
if ( Payment_Method::CARD_PRESENT !== $payment_method_type && $payment_method_id !== $order->get_meta( '_payment_method_id' ) ) {
|
|
return;
|
|
}
|
|
|
|
$event_data = $this->read_webhook_property( $event_body, 'data' );
|
|
$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' );
|
|
if ( Payment_Method::CARD_PRESENT === $payment_method_type ) {
|
|
$this->order_service->mark_terminal_payment_failed( $order, $intent_id, $intent_status, $charge_id, $this->get_failure_message_from_error( $last_payment_error ) );
|
|
} else {
|
|
$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.
|
|
*
|
|
* @param array $event_body The event that triggered the webhook.
|
|
*
|
|
* @throws Invalid_Webhook_Data_Exception Required parameters not found.
|
|
* @throws Invalid_Payment_Method_Exception When unable to resolve intent ID to order.
|
|
*/
|
|
private function process_webhook_payment_intent_succeeded( $event_body ) {
|
|
$event_data = $this->read_webhook_property( $event_body, 'data' );
|
|
$event_object = $this->read_webhook_property( $event_data, 'object' );
|
|
$intent_id = $this->read_webhook_property( $event_object, 'id' );
|
|
$currency = $this->read_webhook_property( $event_object, 'currency' );
|
|
$order = $this->get_order_from_event_body( $event_body );
|
|
$event_charges = $this->read_webhook_property( $event_object, 'charges' );
|
|
$charges_data = $this->read_webhook_property( $event_charges, 'data' );
|
|
$charge_id = $this->read_webhook_property( $charges_data[0], 'id' );
|
|
$charge_amount = $this->read_webhook_property( $event_object, 'amount' );
|
|
|
|
$payment_method_id = $charges_data[0]['payment_method'] ?? null;
|
|
if ( ! $order ) {
|
|
return;
|
|
}
|
|
|
|
// Update missing intents because webhook can be delivered before order is processed on the client.
|
|
$meta_data_to_update = [
|
|
'_intent_id' => $intent_id,
|
|
'_charge_id' => $charge_id,
|
|
'_payment_method_id' => $payment_method_id,
|
|
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;
|
|
}
|
|
|
|
$application_fee_amount = $charges_data[0]['application_fee_amount'] ?? null;
|
|
|
|
if ( $application_fee_amount ) {
|
|
$fee = WC_Payments_Utils::interpret_stripe_amount( $application_fee_amount, $currency );
|
|
$meta_data_to_update['_wcpay_transaction_fee'] = $fee;
|
|
|
|
$charge_amount = WC_Payments_Utils::interpret_stripe_amount( $charge_amount, $currency );
|
|
$meta_data_to_update['_wcpay_net'] = $charge_amount - $fee;
|
|
}
|
|
|
|
foreach ( $meta_data_to_update as $key => $value ) {
|
|
// Override existing meta data with incoming values, if present.
|
|
if ( $value ) {
|
|
$order->update_meta_data( $key, $value );
|
|
}
|
|
}
|
|
// Save the order after updating the meta data values.
|
|
$order->save();
|
|
|
|
// This is an incoming request from WCPay server rather than an outgoing request to WCPay server.
|
|
// However, the shape of the payment intent object are the same.
|
|
// Using this extraction method will reduce the code duplication.
|
|
$payment_intent = $this->api_client->deserialize_payment_intention_object_from_array( $event_object );
|
|
$this->order_service->update_order_status_from_intent( $order, $payment_intent );
|
|
|
|
$payment_method = $charges_data[0]['payment_method_details']['type'] ?? null;
|
|
// Send the customer a card reader receipt if it's an in person payment type.
|
|
if ( Payment_Method::CARD_PRESENT === $payment_method || Payment_Method::INTERAC_PRESENT === $payment_method ) {
|
|
$merchant_settings = [
|
|
'business_name' => $this->wcpay_gateway->get_option( 'account_business_name' ),
|
|
'support_info' => [
|
|
'address' => $this->wcpay_gateway->get_option( 'account_business_support_address' ),
|
|
'phone' => $this->wcpay_gateway->get_option( 'account_business_support_phone' ),
|
|
'email' => $this->wcpay_gateway->get_option( 'account_business_support_email' ),
|
|
],
|
|
];
|
|
$this->receipt_service->send_customer_ipp_receipt_email( $order, $merchant_settings, $charges_data[0] );
|
|
}
|
|
|
|
// Clear the authorization summary cache to trigger a fetch of new data.
|
|
$this->database_cache->delete( DATABASE_CACHE::AUTHORIZATION_SUMMARY_KEY );
|
|
$this->database_cache->delete( DATABASE_CACHE::AUTHORIZATION_SUMMARY_KEY_TEST_MODE );
|
|
}
|
|
|
|
/**
|
|
* Process webhook dispute created.
|
|
*
|
|
* @param array $event_body The event that triggered the webhook.
|
|
*
|
|
* @throws Invalid_Webhook_Data_Exception Required parameters not found.
|
|
*/
|
|
private function process_webhook_dispute_created( $event_body ) {
|
|
$event_data = $this->read_webhook_property( $event_body, 'data' );
|
|
$event_object = $this->read_webhook_property( $event_data, 'object' );
|
|
$charge_id = $this->read_webhook_property( $event_object, 'charge' );
|
|
$reason = $this->read_webhook_property( $event_object, 'reason' );
|
|
$amount_raw = $this->read_webhook_property( $event_object, 'amount' );
|
|
$evidence = $this->read_webhook_property( $event_object, 'evidence_details' );
|
|
$status = $this->read_webhook_property( $event_object, 'status' );
|
|
$due_by = $this->read_webhook_property( $evidence, 'due_by' );
|
|
|
|
$order = $this->wcpay_db->order_from_charge_id( $charge_id );
|
|
|
|
$currency = $order->get_currency();
|
|
$amount_string = wc_price( WC_Payments_Utils::interpret_stripe_amount( $amount_raw, $currency ), [ 'currency' => strtoupper( $currency ) ] );
|
|
|
|
// Explicitly add currency info if needed (multi-currency stores).
|
|
$amount = WC_Payments_Explicit_Price_Formatter::get_explicit_price_with_currency( $amount_string, $currency );
|
|
|
|
// Convert due_by to a date string in the store timezone.
|
|
$due_by = date_i18n( wc_date_format(), $due_by );
|
|
|
|
if ( ! $order ) {
|
|
throw new Invalid_Webhook_Data_Exception(
|
|
sprintf(
|
|
/* translators: %1: charge ID */
|
|
__( 'Could not find order via charge ID: %1$s', 'woocommerce-payments' ),
|
|
$charge_id
|
|
)
|
|
);
|
|
}
|
|
|
|
$this->order_service->mark_payment_dispute_created( $order, $charge_id, $amount, $reason, $due_by, $status );
|
|
|
|
// Clear dispute caches to trigger a fetch of new data.
|
|
$this->database_cache->delete_dispute_caches();
|
|
}
|
|
|
|
/**
|
|
* Process webhook dispute closed.
|
|
*
|
|
* @param array $event_body The event that triggered the webhook.
|
|
*
|
|
* @throws Invalid_Webhook_Data_Exception Required parameters not found.
|
|
*/
|
|
private function process_webhook_dispute_closed( $event_body ) {
|
|
$event_data = $this->read_webhook_property( $event_body, 'data' );
|
|
$event_object = $this->read_webhook_property( $event_data, 'object' );
|
|
$charge_id = $this->read_webhook_property( $event_object, 'charge' );
|
|
$status = $this->read_webhook_property( $event_object, 'status' );
|
|
$order = $this->wcpay_db->order_from_charge_id( $charge_id );
|
|
|
|
if ( ! $order ) {
|
|
throw new Invalid_Webhook_Data_Exception(
|
|
sprintf(
|
|
/* translators: %1: charge ID */
|
|
__( 'Could not find order via charge ID: %1$s', 'woocommerce-payments' ),
|
|
$charge_id
|
|
)
|
|
);
|
|
}
|
|
|
|
$this->order_service->mark_payment_dispute_closed( $order, $charge_id, $status );
|
|
|
|
// Clear dispute caches to trigger a fetch of new data.
|
|
$this->database_cache->delete_dispute_caches();
|
|
}
|
|
|
|
/**
|
|
* Process webhook dispute updated.
|
|
*
|
|
* @param array $event_body The event that triggered the webhook.
|
|
*
|
|
* @throws Invalid_Webhook_Data_Exception Required parameters not found.
|
|
*/
|
|
private function process_webhook_dispute_updated( $event_body ) {
|
|
$event_type = $this->read_webhook_property( $event_body, 'type' );
|
|
$event_data = $this->read_webhook_property( $event_body, 'data' );
|
|
$event_object = $this->read_webhook_property( $event_data, 'object' );
|
|
$charge_id = $this->read_webhook_property( $event_object, 'charge' );
|
|
$order = $this->wcpay_db->order_from_charge_id( $charge_id );
|
|
|
|
if ( ! $order ) {
|
|
throw new Invalid_Webhook_Data_Exception(
|
|
sprintf(
|
|
/* translators: %1: charge ID */
|
|
__( 'Could not find order via charge ID: %1$s', 'woocommerce-payments' ),
|
|
$charge_id
|
|
)
|
|
);
|
|
}
|
|
|
|
switch ( $event_type ) {
|
|
case 'charge.dispute.funds_withdrawn':
|
|
$message = __( 'Payment dispute and fees have been deducted from your next payout', 'woocommerce-payments' );
|
|
break;
|
|
case 'charge.dispute.funds_reinstated':
|
|
$message = __( 'Payment dispute funds have been reinstated', 'woocommerce-payments' );
|
|
break;
|
|
default:
|
|
$message = __( 'Payment dispute has been updated', 'woocommerce-payments' );
|
|
}
|
|
|
|
$note = sprintf(
|
|
/* translators: %1: the dispute message, %2: the dispute details URL */
|
|
__( '%1$s. See <a href="%2$s">dispute overview</a> for more details.', 'woocommerce-payments' ),
|
|
$message,
|
|
add_query_arg(
|
|
[ 'id' => $charge_id ],
|
|
admin_url( 'admin.php?page=wc-admin&path=/payments/transactions/details' )
|
|
)
|
|
);
|
|
|
|
if ( $this->order_service->order_note_exists( $order, $note ) ) {
|
|
return;
|
|
}
|
|
|
|
$order->add_order_note( $note );
|
|
|
|
// Clear dispute caches to trigger a fetch of new data.
|
|
$this->database_cache->delete_dispute_caches();
|
|
}
|
|
|
|
/**
|
|
* Process notification data.
|
|
*
|
|
* @param array $event_body The event that triggered the webhook.
|
|
*
|
|
* @return void
|
|
*
|
|
* @throws Invalid_Webhook_Data_Exception When data is not valid.
|
|
*/
|
|
private function process_wcpay_notification( array $event_body ) {
|
|
$note = $this->read_webhook_property( $event_body, 'data' );
|
|
|
|
// Convert exception Rest_Request_Exception to Invalid_Webhook_Data_Exception
|
|
// to be compatible with the expected exception in process().
|
|
try {
|
|
$this->remote_note_service->put_note( $note );
|
|
} catch ( Rest_Request_Exception $e ) {
|
|
throw new Invalid_Webhook_Data_Exception( $e->getMessage() );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Safely get a value from the webhook event body array.
|
|
*
|
|
* @param array $items Array to read from.
|
|
* @param string $key ID to fetch on.
|
|
*
|
|
* @return string|array|int|bool
|
|
* @throws Invalid_Webhook_Data_Exception Thrown if ID not set.
|
|
*/
|
|
private function read_webhook_property( $items, $key ) {
|
|
if ( ! isset( $items[ $key ] ) ) {
|
|
throw new Invalid_Webhook_Data_Exception(
|
|
sprintf(
|
|
/* translators: %1: ID being fetched */
|
|
__( '%1$s not found in array', 'woocommerce-payments' ),
|
|
$key
|
|
)
|
|
);
|
|
}
|
|
return $items[ $key ];
|
|
}
|
|
|
|
/**
|
|
* Safely check whether a webhook contains a property.
|
|
*
|
|
* @param array $items Array to read from.
|
|
* @param string $key ID to fetch on.
|
|
*
|
|
* @return bool
|
|
*/
|
|
private function has_webhook_property( $items, $key ) {
|
|
return isset( $items[ $key ] );
|
|
}
|
|
|
|
/**
|
|
* Gets the order related to the event.
|
|
*
|
|
* @param array $event_body The event that triggered the webhook.
|
|
*
|
|
* @throws Invalid_Webhook_Data_Exception Required parameters not found.
|
|
* @throws Invalid_Payment_Method_Exception When unable to resolve intent ID to order.
|
|
*
|
|
* @return null|WC_Order
|
|
*/
|
|
private function get_order_from_event_body( $event_body ) {
|
|
$event_data = $this->read_webhook_property( $event_body, 'data' );
|
|
$event_object = $this->read_webhook_property( $event_data, 'object' );
|
|
$intent_id = $this->read_webhook_property( $event_object, 'id' );
|
|
$order_key = $this->read_webhook_property( $event_object, 'metadata' )['order_key'] ?? null;
|
|
|
|
// Look up the order related to this intent.
|
|
$order = $this->wcpay_db->order_from_intent_id( $intent_id );
|
|
|
|
if ( ! $order instanceof \WC_Order ) {
|
|
// Retrieving order with order_id in case intent_id was not properly set.
|
|
Logger::debug( 'intent_id not found, using order_id to retrieve order' );
|
|
$metadata = $this->read_webhook_property( $event_object, 'metadata' );
|
|
$order_id = $metadata['order_id'] ?? null;
|
|
// If metadata order id is null, try to read from the charges metadata.
|
|
if ( null === $order_id ) {
|
|
$charges = $this->read_webhook_property( $event_object, 'charges' );
|
|
$charge = $charges[0] ?? [];
|
|
$order_id = $charge['metadata']['order_id'] ?? null;
|
|
}
|
|
|
|
if ( $order_id ) {
|
|
$order = $this->wcpay_db->order_from_order_id( $order_id );
|
|
} elseif ( ! empty( $event_object['invoice'] ) ) {
|
|
// If the payment intent contains an invoice it is a WCPay Subscription-related intent and will be handled by the `invoice.paid` event.
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* If the order has been found, but there is an order key mismatch, it
|
|
* could be caused by another site creating orders with the same IDs
|
|
* while this site remains the primary webhook receiver.
|
|
*/
|
|
if ( null !== $order_key && $order instanceof WC_Order && $order->get_order_key() !== $order_key ) {
|
|
Logger::debug(
|
|
'Mismatching order key found while retrieving an order for webhook processing',
|
|
[
|
|
'intent_id' => $intent_id,
|
|
'order_id' => $order->get_id(),
|
|
'webhook_order_key' => $order_key,
|
|
'local_order_key' => $order->get_order_key(),
|
|
]
|
|
);
|
|
return null;
|
|
}
|
|
|
|
if ( ! $order instanceof \WC_Order ) {
|
|
throw new Invalid_Payment_Method_Exception(
|
|
sprintf(
|
|
/* translators: %1: intent ID */
|
|
__( 'Could not find order via intent ID: %1$s', 'woocommerce-payments' ),
|
|
$intent_id
|
|
),
|
|
'order_not_found'
|
|
);
|
|
}
|
|
|
|
return $order;
|
|
}
|
|
|
|
/**
|
|
* Gets the proper failure message from the code in the error.
|
|
* Error codes from https://stripe.com/docs/error-codes.
|
|
*
|
|
* @param array $error The last payment error from the payment failed event.
|
|
*
|
|
* @return string The failure message.
|
|
*/
|
|
private function get_failure_message_from_error( $error ): string {
|
|
$code = $error['code'] ?? '';
|
|
$decline_code = $error['decline_code'] ?? '';
|
|
$message = $error['message'] ?? '';
|
|
|
|
switch ( $code ) {
|
|
case 'account_closed':
|
|
return __( "The customer's bank account has been closed.", 'woocommerce-payments' );
|
|
case 'debit_not_authorized':
|
|
return __( 'The customer has notified their bank that this payment was unauthorized.', 'woocommerce-payments' );
|
|
case 'insufficient_funds':
|
|
return __( "The customer's account has insufficient funds to cover this payment.", 'woocommerce-payments' );
|
|
case 'no_account':
|
|
return __( "The customer's bank account could not be located.", 'woocommerce-payments' );
|
|
case 'payment_method_microdeposit_failed':
|
|
return __( 'Microdeposit transfers failed. Please check the account, institution and transit numbers.', 'woocommerce-payments' );
|
|
case 'payment_method_microdeposit_verification_attempts_exceeded':
|
|
return __( 'You have exceeded the number of allowed verification attempts.', 'woocommerce-payments' );
|
|
case 'payment_intent_mandate_invalid':
|
|
return __( 'The mandate used for this renewal payment is invalid. You may need to bring the customer back to your store and ask them to resubmit their payment information.', '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' );
|
|
}
|
|
}
|
|
|
|
// translators: %s Stripe error message.
|
|
return sprintf( __( 'With the following message: <code>%s</code>', 'woocommerce-payments' ), $message );
|
|
}
|
|
|
|
/**
|
|
* Process webhook refund for events triggered externally.
|
|
*
|
|
* @param array $event_body The event that triggered the webhook.
|
|
*
|
|
* @throws Invalid_Webhook_Data_Exception Required parameters not found.
|
|
* @throws Invalid_Webhook_Data_Exception When the refund amount is not valid.
|
|
* @throws Order_Not_Found_Exception When unable to resolve charge ID to order.
|
|
*/
|
|
private function process_webhook_refund_triggered_externally( array $event_body ): void {
|
|
$event_data = $this->read_webhook_property( $event_body, 'data' );
|
|
$event_object = $this->read_webhook_property( $event_data, 'object' );
|
|
|
|
$is_refunded_event = isset( $event_body['type'] ) && 'charge.refunded' === $event_body['type'];
|
|
$status = $this->read_webhook_property( $event_object, 'status' );
|
|
if ( 'succeeded' !== $status || ! $is_refunded_event ) {
|
|
return;
|
|
}
|
|
|
|
// Fetch the details of the refund so that we can find the associated order and write a note.
|
|
$charge_id = $this->read_webhook_property( $event_object, 'id' );
|
|
$refund = $this->read_webhook_property( $event_object, 'refunds' )['data'][0]; // Most recent refund.
|
|
$refund_id = $refund['id'] ?? '';
|
|
$refund_reason = $refund['reason'] ?? '';
|
|
$refund_balance_transaction_id = $refund['balance_transaction'] ?? '';
|
|
$charge_amount = $this->read_webhook_property( $event_object, 'amount' );
|
|
$currency = $this->read_webhook_property( $event_object, 'currency' );
|
|
$refunded_amount = WC_Payments_Utils::interpret_stripe_amount( $refund['amount'], $currency );
|
|
$is_partial_refund = $refund['amount'] < $charge_amount;
|
|
|
|
// Look up the order related to this charge.
|
|
$order = $this->wcpay_db->order_from_charge_id( $charge_id );
|
|
if ( ! $order ) {
|
|
throw new Order_Not_Found_Exception(
|
|
sprintf(
|
|
/* translators: %1: charge ID */
|
|
__( 'Could not find order via charge ID: %1$s', 'woocommerce-payments' ),
|
|
$charge_id
|
|
),
|
|
'order_not_found'
|
|
);
|
|
}
|
|
// Only care about refunds that are triggered externally, i.e. outside WP Admin.
|
|
// Refunds triggered in WP Admin are handled by WC_Payment_Gateway_WCPay::process_refund.
|
|
$wc_refunds = $order->get_refunds();
|
|
if ( ! empty( $wc_refunds ) ) {
|
|
foreach ( $wc_refunds as $wc_refund ) {
|
|
$wcpay_refund_id = $this->order_service->get_wcpay_refund_id_for_order( $wc_refund );
|
|
if ( $refund_id === $wcpay_refund_id ) {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
if ( $charge_amount < 0 || $refunded_amount > $order->get_total() ) {
|
|
throw new Invalid_Webhook_Data_Exception(
|
|
sprintf(
|
|
/* translators: %1: charge ID */
|
|
__( 'The refund amount is not valid for charge ID: %1$s', 'woocommerce-payments' ),
|
|
$charge_id
|
|
)
|
|
);
|
|
}
|
|
|
|
$wc_refund = $this->order_service->create_refund_for_order( $order, $refunded_amount, $refund_reason, ( ! $is_partial_refund ? $order->get_items() : [] ) );
|
|
// Process the refund in the order service.
|
|
$this->order_service->add_note_and_metadata_for_created_refund( $order, $wc_refund, $refund_id, $refund_balance_transaction_id, Refund_Status::PENDING === $refund['status'] );
|
|
}
|
|
|
|
/**
|
|
* Process webhook for Stripe Billing invoice events.
|
|
*
|
|
* @param string $event_type The type of event that triggered the webhook.
|
|
* @param array $event_body The event that triggered the webhook.
|
|
*
|
|
* @return void
|
|
*
|
|
* @throws Invalid_Webhook_Data_Exception When the linked subscription is not found.
|
|
*/
|
|
private function process_webhook_stripe_billing_invoice( $event_type, $event_body ) {
|
|
if ( ! class_exists( 'WC_Payments_Subscriptions' ) ) {
|
|
return;
|
|
}
|
|
|
|
switch ( $event_type ) {
|
|
case 'invoice.upcoming':
|
|
WC_Payments_Subscriptions::get_event_handler()->handle_invoice_upcoming( $event_body );
|
|
break;
|
|
case 'invoice.paid':
|
|
WC_Payments_Subscriptions::get_event_handler()->handle_invoice_paid( $event_body );
|
|
break;
|
|
case 'invoice.payment_failed':
|
|
WC_Payments_Subscriptions::get_event_handler()->handle_invoice_payment_failed( $event_body );
|
|
break;
|
|
}
|
|
}
|
|
}
|