358 lines
15 KiB
PHP
358 lines
15 KiB
PHP
<?php
|
|
/**
|
|
* Class WC_Payments_Subscriptions_Event_Handler
|
|
*
|
|
* @package WooCommerce\Payments
|
|
*/
|
|
|
|
use WCPay\Core\Server\Request\Get_Charge;
|
|
use WCPay\Exceptions\API_Exception;
|
|
use WCPay\Exceptions\Invalid_Webhook_Data_Exception;
|
|
use WCPay\Exceptions\Order_Not_Found_Exception;
|
|
use WCPay\Logger;
|
|
|
|
/**
|
|
* Subscriptions Event/Webhook Handler class
|
|
*/
|
|
class WC_Payments_Subscriptions_Event_Handler {
|
|
|
|
/**
|
|
* Maximum amount of payment retries to handle before cancelling the subscription.
|
|
*
|
|
* @var int
|
|
*/
|
|
const MAX_RETRIES = 4;
|
|
|
|
/**
|
|
* Invoice Service.
|
|
*
|
|
* @var WC_Payments_Invoice_Service
|
|
*/
|
|
private $invoice_service;
|
|
|
|
/**
|
|
* Subscription Service.
|
|
*
|
|
* @var WC_Payments_Subscription_Service
|
|
*/
|
|
private $subscription_service;
|
|
|
|
/**
|
|
* Subscriptions event handler constructor.
|
|
*
|
|
* @param WC_Payments_Invoice_Service $invoice_service Invoice service.
|
|
* @param WC_Payments_Subscription_Service $subscription_service Subscription service.
|
|
*/
|
|
public function __construct( WC_Payments_Invoice_Service $invoice_service, WC_Payments_Subscription_Service $subscription_service ) {
|
|
$this->invoice_service = $invoice_service;
|
|
$this->subscription_service = $subscription_service;
|
|
}
|
|
|
|
/**
|
|
* Validate and correct subscription status, date, and lines.
|
|
*
|
|
* @param array $body The event body that triggered the webhook.
|
|
*
|
|
* @throws Invalid_Webhook_Data_Exception Required parameters not found.
|
|
*/
|
|
public function handle_invoice_upcoming( array $body ) {
|
|
$event_object = $this->get_event_property( $body, [ 'data', 'object' ] );
|
|
$wcpay_subscription_id = $this->get_event_property( $event_object, 'subscription' );
|
|
|
|
/**
|
|
* When a store is in staging mode, we don't want any webhook handling response to be sent to the server.
|
|
*
|
|
* Sending requests from staging sites can have unintended consequences for the live store. For example,
|
|
* Subscriptions which renew on the staging site will lead to paused subscriptions at Stripe and result in
|
|
* missed renewal payments.
|
|
*/
|
|
if ( WC_Payments_Subscriptions::is_duplicate_site() ) {
|
|
$this->log_skipped_webhook_due_to_staging( 'invoice.upcoming', $wcpay_subscription_id );
|
|
return;
|
|
}
|
|
|
|
$wcpay_discounts = $this->get_event_property( $event_object, 'discounts' );
|
|
$wcpay_lines = $this->get_event_property( $event_object, [ 'lines', 'data' ] );
|
|
$subscription = WC_Payments_Subscription_Service::get_subscription_from_wcpay_subscription_id( $wcpay_subscription_id );
|
|
|
|
if ( ! $subscription ) {
|
|
throw new Invalid_Webhook_Data_Exception( __( 'Cannot find subscription to handle the "invoice.upcoming" event.', 'woocommerce-payments' ) );
|
|
}
|
|
|
|
$wcpay_subscription = $this->subscription_service->get_wcpay_subscription( $subscription );
|
|
|
|
// Suspend or cancel subscription if we didn't expect a next payment.
|
|
if ( 0 === $subscription->get_time( 'next_payment' ) ) {
|
|
// TODO: Add error handling to these {cancel/suspend}_subscription calls i.e. add a subscription order note if the WCPay subscription wasn't cancelled.
|
|
if ( ! $subscription->has_status( 'on-hold' ) && 0 !== $subscription->get_time( 'end' ) ) {
|
|
$this->subscription_service->cancel_subscription( $subscription );
|
|
} else {
|
|
$this->subscription_service->suspend_subscription( $subscription );
|
|
$subscription->add_order_note( __( 'Suspended WooPayments Subscription in invoice.upcoming webhook handler because subscription next_payment date is 0.', 'woocommerce-payments' ) );
|
|
Logger::log(
|
|
sprintf(
|
|
'Suspended WooPayments Subscription in invoice.upcoming webhook handler because subscription next_payment date is 0. WC ID: %d; WooPayments ID: %s.',
|
|
$subscription->get_id(),
|
|
$wcpay_subscription_id
|
|
)
|
|
);
|
|
}
|
|
} else {
|
|
// Translators: %s Scheduled/upcoming payment date in Y-m-d H:i:s format.
|
|
$subscription->add_order_note( sprintf( __( 'Next automatic payment scheduled for %s.', 'woocommerce-payments' ), get_date_from_gmt( gmdate( 'Y-m-d H:i:s', $wcpay_subscription['current_period_end'] ), wc_date_format() . ' ' . wc_time_format() ) ) );
|
|
|
|
$this->subscription_service->update_dates_to_match_wcpay_subscription( $wcpay_subscription, $subscription );
|
|
$this->invoice_service->validate_invoice( $wcpay_lines, $wcpay_discounts ? $wcpay_discounts : [], $subscription );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Renews a subscription associated with paid invoice.
|
|
*
|
|
* @param array $body The event body that triggered the webhook.
|
|
*
|
|
* @throws Invalid_Webhook_Data_Exception Required parameters not found.
|
|
* @throws Order_Not_Found_Exception
|
|
*/
|
|
public function handle_invoice_paid( array $body ) {
|
|
$event_data = $this->get_event_property( $body, 'data' );
|
|
$event_object = $this->get_event_property( $event_data, 'object' );
|
|
$wcpay_subscription_id = $this->get_event_property( $event_object, 'subscription' );
|
|
|
|
/**
|
|
* When a store is in staging mode, we don't want any webhook handling response to be sent to the server.
|
|
*
|
|
* Sending requests from staging sites can have unintended consequences for the live store. For example,
|
|
* Subscriptions which renew on the staging site will lead to paused subscriptions at Stripe and result in
|
|
* missed renewal payments.
|
|
*/
|
|
if ( WC_Payments_Subscriptions::is_duplicate_site() ) {
|
|
$this->log_skipped_webhook_due_to_staging( 'invoice.paid', $wcpay_subscription_id );
|
|
return;
|
|
}
|
|
|
|
$wcpay_invoice_id = $this->get_event_property( $event_object, 'id' );
|
|
$subscription = WC_Payments_Subscription_Service::get_subscription_from_wcpay_subscription_id( $wcpay_subscription_id );
|
|
|
|
if ( ! $subscription ) {
|
|
throw new Invalid_Webhook_Data_Exception( __( 'Cannot find subscription for the incoming "invoice.paid" event.', 'woocommerce-payments' ) );
|
|
}
|
|
|
|
// This incoming invoice.paid event is linked to the subscription parent invoice and can be ignored.
|
|
if ( WC_Payments_Invoice_Service::get_subscription_invoice_id( $subscription ) === $wcpay_invoice_id ) {
|
|
return;
|
|
}
|
|
|
|
$order = wc_get_order( WC_Payments_Invoice_Service::get_order_id_by_invoice_id( $wcpay_invoice_id ) );
|
|
|
|
if ( ! $order ) {
|
|
$order = wcs_create_renewal_order( $subscription );
|
|
|
|
if ( is_wp_error( $order ) ) {
|
|
throw new Invalid_Webhook_Data_Exception( __( 'Unable to generate renewal order for subscription on the "invoice.paid" event.', 'woocommerce-payments' ) );
|
|
} else {
|
|
$order->set_payment_method( WC_Payment_Gateway_WCPay::GATEWAY_ID );
|
|
$this->invoice_service->set_order_invoice_id( $order, $wcpay_invoice_id );
|
|
}
|
|
}
|
|
|
|
if ( $order->needs_payment() ) {
|
|
/*
|
|
* Temporarily place the subscription on-hold to imitate the normal subscription renewal flow.
|
|
* This ensures the downstream effects take place, e.g. a payment status order note is added and the
|
|
* 'woocommerce_subscription_payment_complete' action is fired.
|
|
*/
|
|
remove_action( 'woocommerce_subscription_status_on-hold', [ $this->subscription_service, 'handle_subscription_status_on_hold' ] );
|
|
$subscription->update_status( 'on-hold' );
|
|
add_action( 'woocommerce_subscription_status_on-hold', [ $this->subscription_service, 'handle_subscription_status_on_hold' ] );
|
|
|
|
/*
|
|
* Remove the reactivate_subscription callback that occurs when a subscription transitions from on-hold to active.
|
|
* The WCPay subscription will remain active throughout this process and does not need to be reactivated.
|
|
*/
|
|
remove_action( 'woocommerce_subscription_status_on-hold_to_active', [ $this->subscription_service, 'reactivate_subscription' ] );
|
|
$order->payment_complete();
|
|
add_action( 'woocommerce_subscription_status_on-hold_to_active', [ $this->subscription_service, 'reactivate_subscription' ] );
|
|
|
|
/**
|
|
* Fetch a new instance of the subscription.
|
|
*
|
|
* After marking the order as paid, a parallel instance of the subscription would have been reactivated.
|
|
* To avoid race conditions and cache pollution, fetch a new instance to ensure our current instance doesn't override the active subscription status.
|
|
*/
|
|
$subscription = wcs_get_subscription( $subscription->get_id() );
|
|
}
|
|
|
|
if ( isset( $event_object['payment_intent'] ) ) {
|
|
// Add the payment intent data to the order.
|
|
$this->invoice_service->get_and_attach_intent_info_to_order( $order, $event_object['payment_intent'] );
|
|
}
|
|
|
|
// Remove pending invoice ID in case one was recorded for previous failed renewal attempts.
|
|
$this->invoice_service->mark_pending_invoice_paid_for_subscription( $subscription );
|
|
|
|
// Record the store's Stripe Billing environment context on the payment intent.
|
|
$invoice = $this->invoice_service->record_subscription_payment_context( $wcpay_invoice_id );
|
|
|
|
// Update charge and transaction metadata - add order id for Stripe Billing.
|
|
$this->invoice_service->update_charge_details( $invoice, $order->get_id() );
|
|
|
|
// Update transaction customer details for Stripe Billing.
|
|
$this->invoice_service->update_transaction_details( $invoice, $order );
|
|
}
|
|
|
|
/**
|
|
* Marks a subscription payment associated with invoice as failed.
|
|
*
|
|
* @param array $body The event body that triggered the webhook.
|
|
*
|
|
* @throws Invalid_Webhook_Data_Exception Required parameters not found.
|
|
*/
|
|
public function handle_invoice_payment_failed( array $body ) {
|
|
$event_data = $this->get_event_property( $body, 'data' );
|
|
$event_object = $this->get_event_property( $event_data, 'object' );
|
|
$wcpay_subscription_id = $this->get_event_property( $event_object, 'subscription' );
|
|
|
|
/**
|
|
* When a store is in staging mode, we don't want any webhook handling response to be sent to the server.
|
|
*
|
|
* Sending requests from staging sites can have unintended consequences for the live store. For example,
|
|
* Subscriptions which renew on the staging site will lead to paused subscriptions at Stripe and result in
|
|
* missed renewal payments.
|
|
*/
|
|
if ( WC_Payments_Subscriptions::is_duplicate_site() ) {
|
|
$this->log_skipped_webhook_due_to_staging( 'invoice.payment_failed', $wcpay_subscription_id );
|
|
return;
|
|
}
|
|
|
|
$wcpay_invoice_id = $this->get_event_property( $event_object, 'id' );
|
|
$attempts = (int) $this->get_event_property( $event_object, 'attempt_count' );
|
|
$subscription = WC_Payments_Subscription_Service::get_subscription_from_wcpay_subscription_id( $wcpay_subscription_id );
|
|
|
|
if ( ! $subscription ) {
|
|
throw new Invalid_Webhook_Data_Exception( __( 'Cannot find subscription for the incoming "invoice.payment_failed" event.', 'woocommerce-payments' ) );
|
|
}
|
|
|
|
$charge = null;
|
|
if ( isset( $event_object['charge'] ) ) {
|
|
try {
|
|
$charge = Get_Charge::create( $event_object['charge'] );
|
|
$charge = $charge->send();
|
|
} catch ( API_Exception $e ) {
|
|
Logger::error( sprintf( 'Unable to retrieve charge data for invoice.payment_failed webhook. Charge ID: %s; Error: %s', $event_object['charge'], $e->getMessage() ) );
|
|
}
|
|
}
|
|
$error_details = '';
|
|
$error_code = '';
|
|
if ( $charge ) {
|
|
if ( isset( $charge['outcome'] ) && isset( $charge['outcome']['seller_message'] ) ) {
|
|
$error_details = $charge['outcome']['seller_message'];
|
|
$error_code = $charge['failure_code'];
|
|
}
|
|
}
|
|
|
|
$order = wc_get_order( WC_Payments_Invoice_Service::get_order_id_by_invoice_id( $wcpay_invoice_id ) );
|
|
|
|
if ( ! $order ) {
|
|
$order = wcs_create_renewal_order( $subscription );
|
|
|
|
if ( is_wp_error( $order ) ) {
|
|
throw new Invalid_Webhook_Data_Exception( __( 'Unable to generate renewal order for subscription to record the incoming "invoice.payment_failed" event.', 'woocommerce-payments' ) );
|
|
} else {
|
|
$order->set_payment_method( WC_Payment_Gateway_WCPay::GATEWAY_ID );
|
|
$this->invoice_service->set_order_invoice_id( $order, $wcpay_invoice_id );
|
|
}
|
|
}
|
|
|
|
if ( $error_details ) {
|
|
$subscription->add_order_note(
|
|
sprintf(
|
|
// Translators: %1$d Number of failed renewal attempts. %2$s contains failure message, %3$s contains error code.
|
|
_n(
|
|
'WooPayments subscription renewal attempt %1$d failed with the following message "%2$s" and failure code <code>%3$s</code>',
|
|
'WooPayments subscription renewal attempt %1$d failed with the following message "%2$s" and failure code <code>%3$s</code>',
|
|
$attempts,
|
|
'woocommerce-payments'
|
|
),
|
|
$attempts,
|
|
$error_details,
|
|
$error_code
|
|
)
|
|
);
|
|
$order->add_order_note(
|
|
sprintf(
|
|
// Translators: %1$s contains failure message. %2$s contains error code.
|
|
__(
|
|
'Payment for the order failed with the following message: "%1$s" and failure code <code>%2$s</code>',
|
|
'woocommerce-payments'
|
|
),
|
|
$error_details,
|
|
$error_code
|
|
)
|
|
);
|
|
} else {
|
|
// Translators: %d Number of failed renewal attempts.
|
|
$subscription->add_order_note( sprintf( _n( 'WooPayments subscription renewal attempt %d failed.', 'WooPayments subscription renewal attempt %d failed.', $attempts, 'woocommerce-payments' ), $attempts ) );
|
|
}
|
|
|
|
if ( self::MAX_RETRIES > $attempts ) {
|
|
remove_action( 'woocommerce_subscription_status_on-hold', [ $this->subscription_service, 'handle_subscription_status_on_hold' ] );
|
|
$subscription->payment_failed();
|
|
add_action( 'woocommerce_subscription_status_on-hold', [ $this->subscription_service, 'handle_subscription_status_on_hold' ] );
|
|
} else {
|
|
$subscription->payment_failed( 'cancelled' );
|
|
}
|
|
|
|
// Record invoice ID so we can trigger repayment on payment method update.
|
|
$this->invoice_service->mark_pending_invoice_for_subscription( $subscription, $wcpay_invoice_id );
|
|
|
|
// Record the store's Stripe Billing environment context on the payment intent.
|
|
$this->invoice_service->record_subscription_payment_context( $wcpay_invoice_id );
|
|
}
|
|
|
|
/**
|
|
* Gets the event data by property.
|
|
*
|
|
* @param array $event_data Event data.
|
|
* @param mixed $key Requested key.
|
|
*
|
|
* @return mixed
|
|
*
|
|
* @throws Invalid_Webhook_Data_Exception Event data not found by key.
|
|
*/
|
|
private function get_event_property( array $event_data, $key ) {
|
|
$keys = is_array( $key ) ? $key : [ $key ];
|
|
$data = $event_data;
|
|
|
|
foreach ( $keys as $k ) {
|
|
if ( ! isset( $data[ $k ] ) ) {
|
|
// Translators: %s Property name not found in event data array.
|
|
throw new Invalid_Webhook_Data_Exception( sprintf( __( '%s not found in array', 'woocommerce-payments' ), $k ) );
|
|
}
|
|
|
|
$data = $data[ $k ];
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* Creates a log entry noting that a subscription-related webhook has been skipped due to the current site being in staging mode.
|
|
*
|
|
* @param string $event The webhook event type. eg "invoice.paid".
|
|
* @param string $wcpay_subscription_id The WCPay subsciption ID.
|
|
*/
|
|
private function log_skipped_webhook_due_to_staging( string $event, string $wcpay_subscription_id ) {
|
|
Logger::info(
|
|
sprintf(
|
|
// Example message: "invoice.paid webhook processing for sub_abc123defg456 was skipped. The current site (https://staging.example.com) is in staging mode. Live site is https://example.com.
|
|
'%s webhook processing for %s was skipped. The current site (%s) is in staging mode. Live site is %s.',
|
|
$event,
|
|
$wcpay_subscription_id,
|
|
WCS_Staging::get_site_url_from_source( 'current_wp_site' ),
|
|
WCS_Staging::get_site_url_from_source( 'subscriptions_install' )
|
|
)
|
|
);
|
|
}
|
|
}
|