class-wc-payments-subscriptions-event-handler.php
10.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
<?php
/**
* Class WC_Payments_Subscriptions_Event_Handler
*
* @package WooCommerce\Payments
*/
use WCPay\Logger;
use WCPay\Exceptions\Invalid_Webhook_Data_Exception;
/**
* 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' );
$wcpay_customer_id = $this->get_event_property( $event_object, 'customer' );
$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 WCPay Subscription in invoice.upcoming webhook handler because subscription next_payment date is 0.', 'woocommerce-payments' ) );
Logger::log(
sprintf(
'Suspended WCPay Subscription in invoice.upcoming webhook handler because subscription next_payment date is 0. WC ID: %d; WCPay 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.
*/
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' );
$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 );
}
/**
* 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' );
$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.upcoming" event.', 'woocommerce-payments' ) );
}
$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 );
}
}
// Translators: %d Number of failed renewal attempts.
$subscription->add_order_note( sprintf( _n( 'WCPay subscription renewal attempt %d failed.', 'WCPay 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 );
}
/**
* 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;
}
}