PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / 2.2.0
Subscriptions for WooCommerce with Stripe Recurring Payments v2.2.0
2.4.0 2.3.0 2.2.0 2.1.0 2.0.0 1.11.2 1.11.1 1.11.0 1.10.9 1.10.8 1.10.7 1.10.6 1.10.5 1.10.4 1.10.3 1.10.2 1.10.1 1.10.0 1.9.6 1.9.5 trunk 1.3.0 1.3.1 1.3.2 1.4.0 All 65 releases
subscription / includes / Illuminate / Gateways / Stripe / Stripe.php

Stripe.php in Subscriptions for WooCommerce with Stripe Recurring Payments 2.2.0, at includes/Illuminate/Gateways/Stripe/Stripe.php

774 lines 28.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Stripe integration helpers for subscription auto-renewals.
4 *
5 * Ensures payment methods are saved with mandates (SEPA, etc.) so that
6 * off-session renewals can be charged automatically by Stripe.
7 *
8 * @package SpringDevs\Subscription
9 */
10
11 namespace SpringDevs\Subscription\Illuminate\Gateways\Stripe;
12
13 use SpringDevs\Subscription\Illuminate\Helper;
14
15 /**
16 * Class Stripe
17 *
18 * @package SpringDevs\SubscriptionPro\Illuminate
19 */
20 class Stripe extends \WC_Stripe_Payment_Gateway {
21
22 /**
23 * Subscriptions supported Stripe payment methods.
24 */
25 public const WPSUBS_SUPPORTED_METHODS = [ 'stripe', 'stripe_ideal', 'stripe_sepa', 'sepa_debit', 'stripe_bancontact' ];
26
27 /**
28 * Mandate needed methods.
29 */
30 public const WPSUBS_MANDATE_NEEDED_METHODS = [ 'stripe_ideal', 'stripe_sepa', 'sepa_debit', 'stripe_bancontact' ];
31
32 /**
33 * Initialize the class
34 */
35 public function __construct() {
36 // Hook into WPSubscription renewal events
37 add_action( 'subscrpt_after_create_renew_order', array( $this, 'after_create_renew_order' ), 10, 3 );
38 add_filter( 'subscrpt_before_saving_renewal_order', array( $this, 'copy_stripe_metadata' ), 10, 3 );
39
40 add_filter( 'wc_stripe_payment_metadata', array( $this, 'add_payment_metadata' ), 10, 2 );
41
42 // Ensure a reusable payment method is stored for subscription checkouts (needed for iDEAL/SEPA auto-renewals).
43 add_filter( 'wc_stripe_force_save_payment_method', array( $this, 'force_save_payment_method_for_subscriptions' ), 10, 2 );
44
45 // Modify create intent request to add setup_future_usage and customer when needed.
46 add_filter( 'wc_stripe_generate_create_intent_request', [ $this, 'modify_create_intent_request_for_subscriptions' ], 20, 3 );
47
48 // Keep subscription carts off Stripe's Checkout Session (Optimized Checkout) path.
49 add_filter( 'wc_stripe_is_adaptive_pricing_supported', [ $this, 'disable_adaptive_pricing_for_subscriptions' ], 10, 1 );
50
51 // Last-resort guard: any Checkout Session that still gets created must carry a customer.
52 add_filter( 'wc_stripe_request_body', [ $this, 'ensure_customer_on_checkout_session' ], 10, 2 );
53
54 // SetupIntents ($0 trial orders) reject setup_future_usage.
55 add_filter( 'wc_stripe_request_body', [ $this, 'strip_setup_future_usage_from_setup_intents' ], 10, 2 );
56
57 // Persist whatever Stripe ended up using, so renewals can charge it.
58 add_action( 'woocommerce_payment_complete', [ $this, 'backfill_stripe_meta_for_subscription_order' ], 20, 1 );
59 }
60
61 /**
62 * Process stripe auto renewal process.
63 *
64 * @param \WC_Order $new_order New Order.
65 * @param \WC_Order $old_order Old Order.
66 * @param int $subscription_id Subscription ID.
67 */
68 public function after_create_renew_order( $new_order, $old_order, $subscription_id ) {
69 $is_auto_renew = get_post_meta( $subscription_id, '_subscrpt_auto_renew', true );
70 $is_auto_renew = in_array( $is_auto_renew, [ 1,'1' ], true );
71
72 $is_global_auto_renew = get_option( 'wp_subscription_stripe_auto_renew', '1' );
73 $is_global_auto_renew = in_array( $is_global_auto_renew, [ 1,'1' ], true );
74
75 $stripe_supported_methods = self::WPSUBS_SUPPORTED_METHODS;
76 $old_method = $old_order->get_payment_method();
77 $is_stripe_pm = ! empty( $old_method ) && in_array( $old_method, $stripe_supported_methods, true );
78
79 $has_stripe_meta = ! empty( $old_order->get_meta( '_stripe_customer_id' ) ) || ! empty( $old_order->get_meta( '_stripe_source_id' ) );
80
81 // Old order is not a stripe order, skip auto renewal processing.
82 if ( ! $is_stripe_pm && ! $has_stripe_meta ) {
83 return;
84 }
85
86 $stripe_enabled = $is_auto_renew && $is_global_auto_renew && subscrpt_is_auto_renew_enabled();
87
88 if ( ! $stripe_enabled ) {
89 $log_message = "Stripe auto renewal not enabled. [ Subscription: {$subscription_id}, Order #{$new_order->get_id()} ]";
90 subscrpt_write_log( $log_message );
91 return;
92 }
93
94 $this->pay_renew_order( $new_order );
95 }
96
97 /**
98 * Copy Stripe metadata from old order to renewal order
99 *
100 * @param \WC_Order $new_order Renewal order.
101 * @param \WC_Order $old_order Parent order.
102 * @param int $subscription_id Subscription ID.
103 */
104 public function copy_stripe_metadata( $new_order, $old_order, $subscription_id ) {
105 $stripe_supported_methods = self::WPSUBS_SUPPORTED_METHODS;
106 $old_method = $old_order->get_payment_method();
107 $is_stripe_pm = ! empty( $old_method ) && in_array( $old_method, $stripe_supported_methods, true );
108
109 if ( ! $is_stripe_pm ) {
110 return $new_order;
111 }
112
113 Helper::clone_stripe_metadata_for_renewal( $subscription_id, $old_order, $new_order );
114
115 // Store Stripe subscription ID if available
116 $stripe_subscription_id = $old_order->get_meta( '_stripe_subscription_id' );
117 if ( $stripe_subscription_id ) {
118 $new_order->update_meta_data( '_stripe_subscription_id', $stripe_subscription_id );
119 }
120
121 // Bancontact is single-use. After the first charge, Stripe creates a sepa_debit
122 // PaymentMethod on the customer. Swap the source so renewals use that SEPA PM.
123 if ( 'stripe_bancontact' === $old_method ) {
124 $customer_id = $old_order->get_meta( '_stripe_customer_id' );
125 if ( $customer_id ) {
126 $sepa_pm_id = $this->resolve_sepa_pm_for_bancontact( $customer_id );
127 if ( $sepa_pm_id ) {
128 $new_order->update_meta_data( '_stripe_source_id', $sepa_pm_id );
129 $new_order->set_payment_method( 'stripe_sepa' );
130 $new_order->set_payment_method_title( __( 'SEPA Direct Debit', 'subscription' ) );
131 subscrpt_write_log( "Stripe: Bancontact → SEPA resolved PM {$sepa_pm_id} for renewal order #{$new_order->get_id()} (Subscription: #{$subscription_id})" );
132 } else {
133 subscrpt_write_log( "Stripe: Could not find SEPA PM for Bancontact customer {$customer_id} — renewal order #{$new_order->get_id()} (Subscription: #{$subscription_id}) may fail" );
134 }
135 }
136 }
137
138 return $new_order;
139 }
140
141 /**
142 * Resolve a SEPA Direct Debit PaymentMethod for a customer that originally paid with Bancontact.
143 *
144 * Bancontact is single-use. After the initial charge Stripe automatically creates a sepa_debit
145 * PaymentMethod on the same customer for future off-session use.
146 *
147 * @param string $customer_id Stripe customer ID.
148 * @return string|null sepa_debit PaymentMethod ID, or null if none found.
149 */
150 private function resolve_sepa_pm_for_bancontact( $customer_id ) {
151 $response = \WC_Stripe_API::retrieve(
152 'customers/' . rawurlencode( $customer_id ) . '/payment_methods?type=sepa_debit&limit=10'
153 );
154
155 if ( ! empty( $response->error ) || empty( $response->data ) ) {
156 return null;
157 }
158
159 foreach ( $response->data as $pm ) {
160 if ( 'sepa_debit' === $pm->type ) {
161 return $pm->id;
162 }
163 }
164
165 return null;
166 }
167
168 /**
169 * Pay renewal Order
170 *
171 * @param \WC_Order $renewal_order Renewal order.
172 * @throws \WC_Stripe_Exception $e exception.
173 */
174 public function pay_renew_order( $renewal_order ) {
175 subscrpt_write_log( "Processing renewal order #{$renewal_order->get_id()} for payment." );
176 subscrpt_write_debug_log( "Processing renewal order #{$renewal_order->get_id()} for payment." );
177
178 $stripe_order_helper = new \WC_Stripe_Order_Helper();
179 $order_locked = false;
180
181 try {
182 $stripe_order_helper->validate_minimum_order_amount( $renewal_order );
183
184 $amount = $renewal_order->get_total();
185 $order_id = $renewal_order->get_id();
186
187 // Get source from order.
188 $prepared_source = $this->prepare_order_source( $renewal_order );
189 if ( ! $prepared_source->customer ) {
190 subscrpt_write_log( "Customer not found for renewal order #{$renewal_order->get_id()}. Skipping payment." );
191 $this->trigger_renewal_payment_failed( $renewal_order );
192 return new \WP_Error( 'stripe_error', __( 'Customer not found', 'subscription' ) );
193 }
194
195 \WC_Stripe_Logger::info( "Begin processing subscription payment for order {$order_id} for the amount of {$amount}" );
196
197 // Create AND confirm the PaymentIntent off-session in a single request so Stripe
198 // actually charges the saved payment method (using the stored mandate / MIT
199 // exemption). The previous flow created an unconfirmed intent and only confirmed
200 // when status was requires_confirmation — cards needing authentication were left
201 // at requires_action with no charge, producing a null charge that then crashed
202 // process_response() (Attempt to read property "id" on null) and silently aborted
203 // the renewal without flagging it as failed.
204 $stripe_order_helper->lock_order_payment( $renewal_order );
205 $order_locked = true;
206
207 $intent = $this->create_and_confirm_intent_for_off_session( $renewal_order, $prepared_source, $amount );
208
209 if ( ! empty( $intent->error ) ) {
210 $this->maybe_remove_non_existent_customer( $intent->error, $renewal_order );
211 $this->throw_localized_message( $intent, $renewal_order );
212 }
213
214 // An off-session intent that did not succeed (e.g. requires_action) yields no
215 // charge. Treat that as a failed renewal instead of dereferencing a null charge.
216 $response = $this->get_latest_charge_from_intent( $intent );
217 if ( empty( $response ) ) {
218 $status = isset( $intent->status ) ? $intent->status : 'unknown';
219 throw new \WC_Stripe_Exception(
220 "No charge on renewal intent for order #{$renewal_order->get_id()} (status: {$status})",
221 __( 'The subscription renewal payment could not be completed automatically. Customer authentication may be required.', 'subscription' )
222 );
223 }
224
225 $this->process_response( $response, $renewal_order );
226
227 $stripe_order_helper->unlock_order_payment( $renewal_order );
228 $order_locked = false;
229
230 } catch ( \WC_Stripe_Exception $e ) {
231 \WC_Stripe_Logger::error( 'Error: ' . $e->getMessage() );
232
233 $log_message = "Error processing renewal order #{$renewal_order->get_id()}: " . $e->getMessage();
234 subscrpt_write_log( $log_message );
235 subscrpt_write_debug_log( $log_message );
236
237 if ( $order_locked ) {
238 $stripe_order_helper->unlock_order_payment( $renewal_order );
239 }
240
241 do_action( 'wc_gateway_stripe_process_payment_error', $e, $renewal_order );
242
243 // Trigger failed actions.
244 $this->trigger_renewal_payment_failed( $renewal_order );
245 }
246 }
247
248 /**
249 * Fire the subscription payment failure action for a renewal order.
250 *
251 * @param \WC_Order $renewal_order Renewal order whose subscription should be flagged.
252 * @return void
253 */
254 private function trigger_renewal_payment_failed( $renewal_order ) {
255 $subscription = Helper::get_subscriptions_from_order( $renewal_order->get_id() ?? 0 );
256 $subscription = reset( $subscription );
257 $subscription_id = $subscription->subscription_id ?? 0;
258
259 do_action( 'subscrpt_subscription_payment_failed', $subscription_id );
260 }
261
262 /**
263 * Confirms an intent if it is the `requires_confirmation` state with SEPA mandate support.
264 *
265 * @param object $intent The intent to confirm.
266 * @param \WC_Order $order The order that the intent is associated with.
267 * @param object $prepared_source The source that is being charged.
268 * @return object Either an error or the updated intent.
269 */
270 public function confirm_intent( $intent, $order, $prepared_source ) {
271 if ( \WC_Stripe_Intent_Status::REQUIRES_CONFIRMATION !== $intent->status ) {
272 return $intent;
273 }
274
275 // Build confirm request and include SEPA mandate_data when needed.
276 $confirm_request = \WC_Stripe_Helper::add_payment_method_to_request_array( $prepared_source->source, array() );
277
278 $payment_method_types = array();
279 if ( isset( $intent->payment_method_types ) && is_array( $intent->payment_method_types ) ) {
280 $payment_method_types = $intent->payment_method_types;
281 } elseif ( isset( $prepared_source->source_object->type ) ) {
282 $payment_method_types = array( $prepared_source->source_object->type );
283 }
284
285 if ( in_array( 'sepa_debit', $payment_method_types, true ) ) {
286 $confirm_request['mandate_data'] = array(
287 'customer_acceptance' => array(
288 'type' => 'offline',
289 ),
290 );
291 }
292
293 $level3_data = $this->get_level3_data_from_order( $order );
294 $confirmed_intent = \WC_Stripe_API::request_with_level3_data(
295 $confirm_request,
296 "payment_intents/$intent->id/confirm",
297 $level3_data,
298 $order
299 );
300
301 if ( ! empty( $confirmed_intent->error ) ) {
302 return $confirmed_intent;
303 }
304
305 // Save a note about the status of the intent.
306 $order_id = $order->get_id();
307 if ( \WC_Stripe_Intent_Status::SUCCEEDED === $confirmed_intent->status ) {
308 \WC_Stripe_Logger::info( "Stripe PaymentIntent $intent->id succeeded for order $order_id" );
309 } elseif ( \WC_Stripe_Intent_Status::REQUIRES_ACTION === $confirmed_intent->status ) {
310 \WC_Stripe_Logger::info( "Stripe PaymentIntent $intent->id requires authentication for order $order_id" );
311 }
312
313 return $confirmed_intent;
314 }
315
316 /**
317 * Generates the request when creating a new payment intent.
318 *
319 * @param \WC_Order $order The order that is being paid for.
320 * @param object $prepared_source The source that is used for the payment.
321 * @return array The arguments for the request.
322 */
323 public function generate_create_intent_request( $order, $prepared_source ) {
324 // The request for a charge contains metadata for the intent.
325 $full_request = $this->generate_payment_request( $order, $prepared_source );
326
327 $payment_method_types = array( 'card' );
328 if ( isset( $prepared_source->source_object->type ) ) {
329 $payment_method_types = array( $prepared_source->source_object->type );
330 }
331
332 // Determine capture method safely; default to 'automatic'.
333 $requires_automatic_capture = in_array( 'sepa_debit', $payment_method_types, true );
334 $capture_method = 'automatic';
335 if ( ! $requires_automatic_capture && isset( $full_request['capture'] ) ) {
336 $capture_method = ( 'true' === $full_request['capture'] ) ? 'automatic' : 'manual';
337 }
338
339 $currency = strtolower( $order->get_currency() );
340
341 $request = array(
342 'amount' => \WC_Stripe_Helper::get_stripe_amount( $order->get_total(), $currency ),
343 'currency' => $currency,
344 'description' => $full_request['description'],
345 'metadata' => $full_request['metadata'],
346 'capture_method' => $capture_method,
347 'payment_method_types' => $payment_method_types,
348 );
349
350 $request = \WC_Stripe_Helper::add_payment_method_to_request_array( $prepared_source->source, $request );
351
352 $force_save_source = apply_filters( 'wc_stripe_force_save_payment_method', false, $order->get_id() );
353
354 // Only ask Stripe to set up future usage when we actually have a Stripe customer
355 // (logged-in user or a customer created for this order). For guest + iDEAL, this can
356 // leave orders pending if webhooks are not completing the flow.
357 $has_stripe_customer = ! empty( $prepared_source->customer );
358
359 if ( $has_stripe_customer && ( $this->save_payment_method_requested() || $this->has_subscription( $order->get_id() ) || $force_save_source ) ) {
360 $request['setup_future_usage'] = 'off_session';
361 $request['metadata']['save_payment_method'] = 'true';
362 }
363
364 // For renewal orders, do not set setup_future_usage to avoid mandate_data requirement on confirmation.
365 if ( $this->is_subscription_renewal_order( $order->get_id() ) && isset( $request['setup_future_usage'] ) ) {
366 unset( $request['setup_future_usage'] );
367 }
368
369 if ( $prepared_source->customer ) {
370 $request['customer'] = $prepared_source->customer;
371 }
372
373 if ( isset( $full_request['statement_descriptor_suffix'] ) ) {
374 $request['statement_descriptor_suffix'] = $full_request['statement_descriptor_suffix'];
375 }
376
377 if ( isset( $full_request['shipping'] ) ) {
378 $request['shipping'] = $full_request['shipping'];
379 }
380
381 if ( isset( $full_request['receipt_email'] ) ) {
382 $request['receipt_email'] = $full_request['receipt_email'];
383 }
384
385 /**
386 * Filter the return value of the WC_Payment_Gateway_CC::generate_create_intent_request.
387 *
388 * @since 3.1.0
389 * @param array $request
390 * @param WC_Order $order
391 * @param object $source
392 */
393 return apply_filters( 'wc_stripe_generate_create_intent_request', $request, $order, $prepared_source );
394 }
395
396 /**
397 * Add metadata to stripe payment.
398 *
399 * @param mixed $metadata Metadata.
400 * @param \WC_Order|null $order Order, null when no order exists yet.
401 *
402 * @return mixed
403 */
404 public function add_payment_metadata( $metadata, $order = null ) {
405 // Note: Stripe's Checkout Session AJAX handler applies this filter before an order exists, passing null. Bail out in that case.
406 if ( ! is_array( $metadata ) || ! $order instanceof \WC_Order ) {
407 return $metadata;
408 }
409
410 if ( ! subscrpt_is_auto_renew_enabled() ) {
411 return $metadata;
412 }
413
414 global $wpdb;
415 $recurring = false;
416 $renewal_limit = null;
417 foreach ( $order->get_items() as $order_item ) {
418 $table_name = $wpdb->prefix . 'subscrpt_order_relation';
419 // @phpcs:ignore
420 $relation = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE order_id=%d AND order_item_id=%d', array( $table_name, $order->get_id(), $order_item->get_id() ) ) );
421
422 if ( 0 < count( $relation ) ) {
423 $relation = $relation[0];
424 $is_auto_renew = get_post_meta( (int) $relation->subscription_id, '_subscrpt_auto_renew', true );
425
426 // Get renewal limit from product meta (handles variations)
427 $max_payments = subscrpt_get_max_payments( (int) $relation->subscription_id );
428 $renewal_limit = $max_payments ? $max_payments : 0;
429
430 if ( in_array( $is_auto_renew, array( 1, '1' ), true ) && in_array( $relation->type, array( 'early-renew', 'renew' ), true ) ) {
431 $recurring = true;
432 break;
433 }
434 }
435 }
436
437 if ( $recurring ) {
438 $metadata += array(
439 'payment_type' => 'recurring',
440 );
441 if ( null !== $renewal_limit ) {
442 $metadata['renewal_limit'] = $renewal_limit;
443 }
444 }
445
446 return $metadata;
447 }
448
449 /**
450 * Mirror of the above for gateways using wc_stripe_force_save_payment_method filter.
451 *
452 * @param bool $force Whether to force save the payment method.
453 * @param int $order_id Order ID if available during confirmation.
454 * @return bool
455 */
456 public function force_save_payment_method_for_subscriptions( $force, $order_id = 0 ) {
457 if ( $this->cart_has_subscription_items() ) {
458 return true;
459 }
460 if ( $order_id && $this->order_has_subscription_relation( (int) $order_id ) ) {
461 return true;
462 }
463 return $force;
464 }
465
466 /**
467 * Check if current cart contains subscription items added by this plugin.
468 *
469 * @return bool
470 */
471 private function cart_has_subscription_items(): bool {
472 if ( function_exists( 'WC' ) && WC()->cart ) {
473 $cart_items = WC()->cart->get_cart_contents() ?? [];
474 $recurs = Helper::get_recurrs_from_cart( $cart_items );
475
476 if ( ! empty( $recurs ) ) {
477 return true;
478 }
479 }
480 return false;
481 }
482
483 /**
484 * Determine if a given order has subscription relation rows in our mapping table.
485 *
486 * @param int $order_id The WooCommerce order ID to check.
487 * @return bool
488 */
489 private function order_has_subscription_relation( int $order_id ): bool {
490 $histories = Helper::get_subscriptions_from_order( $order_id );
491 return ! empty( $histories );
492 }
493
494 /**
495 * Detect if given order id is a renewal order created by this plugin.
496 *
497 * @param int $order_id Order ID.
498 * @return bool
499 */
500 private function is_subscription_renewal_order( $order_id ) {
501 global $wpdb;
502 $table_name = $wpdb->prefix . 'subscrpt_order_relation';
503 // @phpcs:ignore
504 $row = $wpdb->get_row( $wpdb->prepare( 'SELECT type FROM %i WHERE order_id=%d ORDER BY id DESC', array( $table_name, $order_id ) ) );
505 return ( $row && isset( $row->type ) && 'renew' === $row->type );
506 }
507
508 /**
509 * Modify create intent request to add setup_future_usage and customer when needed.
510 *
511 * @param array $request The arguments for the request.
512 * @param \WC_Order $order The order that is being paid for.
513 * @param object $prepared_source The source that is used for the payment.
514 */
515 public function modify_create_intent_request_for_subscriptions( $request, $order, $prepared_source ) {
516 if ( ! $order instanceof \WC_Order ) {
517 return $request;
518 }
519
520 $is_subscription_order = $this->order_has_subscription_relation( $order->get_id() );
521 $is_renewal_order = $this->is_subscription_renewal_order( $order->get_id() );
522
523 // Don't add setup_future_usage for renewal orders (payment method already saved)
524 if ( ! $is_subscription_order || $is_renewal_order ) {
525 return $request;
526 }
527
528 $request['setup_future_usage'] = 'off_session';
529 $request['metadata']['save_payment_method'] = 'true';
530
531 // Ensure we have a customer for future payments
532 if ( ! empty( $prepared_source->customer ) ) {
533 $request['customer'] = $prepared_source->customer;
534 }
535
536 if ( isset( $request['confirm'] ) && true === $request['confirm'] ) {
537 if ( in_array( $order->get_payment_method(), self::WPSUBS_MANDATE_NEEDED_METHODS, true ) ) {
538 $request['mandate_data'] = [
539 'customer_acceptance' => [
540 'type' => 'offline',
541 ],
542 ];
543 }
544 }
545
546 return $request;
547 }
548
549 /**
550 * Opt subscription carts out of Stripe's Checkout Session (Optimized Checkout) path.
551 *
552 * A Checkout Session is created on page load and only carries a customer when the
553 * shopper is already logged in, which never holds for guest checkout.
554 *
555 * @param bool $supported Whether adaptive pricing is supported for the current cart.
556 *
557 * @return bool
558 */
559 public function disable_adaptive_pricing_for_subscriptions( $supported ) {
560 return $this->cart_has_subscription_items() ? false : $supported;
561 }
562
563 /**
564 * Force a customer and off-session reuse onto a Checkout Session for a subscription cart.
565 *
566 * Backstop for {@see disable_adaptive_pricing_for_subscriptions()}, applied at the API
567 * boundary so it holds for any code path and either Stripe mode.
568 *
569 * @param array $request Request body sent to the Stripe API.
570 * @param string $api Stripe API endpoint.
571 *
572 * @return array
573 */
574 public function ensure_customer_on_checkout_session( $request, $api ) {
575 if ( 'checkout/sessions' !== $api || ! is_array( $request ) ) {
576 return $request;
577 }
578
579 if ( ! $this->cart_has_subscription_items() ) {
580 return $request;
581 }
582
583 if ( empty( $request['customer'] ) ) {
584 $customer_id = $this->resolve_stripe_customer_id();
585
586 if ( empty( $customer_id ) ) {
587 subscrpt_write_log( 'Could not attach a Stripe customer to a subscription Checkout Session. Auto renewal may fail.' );
588 return $request;
589 }
590
591 $request['customer'] = $customer_id;
592 }
593
594 // `saved_payment_method_options` is deliberately not sent: Stripe rejects it
595 // alongside `setup_future_usage`.
596 if ( ! isset( $request['payment_intent_data'] ) || ! is_array( $request['payment_intent_data'] ) ) {
597 $request['payment_intent_data'] = [];
598 }
599
600 $request['payment_intent_data']['setup_future_usage'] = 'off_session';
601
602 return $request;
603 }
604
605 /**
606 * Strip the PaymentIntent-only `setup_future_usage` from SetupIntent requests
607 * ($0 trial orders), which Stripe otherwise rejects as an unknown parameter.
608 *
609 * @param array $request Stripe API request body.
610 * @param string $api Stripe API endpoint.
611 * @return array
612 */
613 public function strip_setup_future_usage_from_setup_intents( $request, $api ) {
614 if ( is_array( $request ) && is_string( $api ) && 0 === strpos( $api, 'setup_intents' ) ) {
615 unset( $request['setup_future_usage'] );
616 }
617
618 return $request;
619 }
620
621 /**
622 * Resolve — creating when needed — the Stripe customer for the current shopper.
623 *
624 * @return string Stripe customer ID, or an empty string when one cannot be resolved.
625 */
626 private function resolve_stripe_customer_id() {
627 if ( ! class_exists( '\WC_Stripe_Customer' ) ) {
628 return '';
629 }
630
631 try {
632 $customer = new \WC_Stripe_Customer( get_current_user_id() );
633
634 // A minimal-billing-details context, so this is valid before the shopper types anything.
635 return (string) $customer->maybe_create_customer( \WC_Stripe_Customer::CUSTOMER_CONTEXT_CHECKOUT_SESSION );
636 } catch ( \Exception $e ) {
637 subscrpt_write_log( 'Stripe customer creation failed for Checkout Session: ' . $e->getMessage() );
638 return '';
639 }
640 }
641
642 /**
643 * Persist the Stripe customer / payment-method ids a subscription order needs for renewals.
644 *
645 * The gateway only writes them when it saved the payment method, which it never does for
646 * a shopper who was logged out during `process_payment()`. Read them off the
647 * PaymentIntent instead.
648 *
649 * @param int $order_id Order ID.
650 *
651 * @return void
652 */
653 public function backfill_stripe_meta_for_subscription_order( $order_id ) {
654 $order = wc_get_order( $order_id );
655
656 if ( ! $order instanceof \WC_Order || ! class_exists( '\WC_Stripe_API' ) || ! class_exists( '\WC_Stripe_Order_Helper' ) ) {
657 return;
658 }
659
660 if ( ! in_array( $order->get_payment_method(), self::WPSUBS_SUPPORTED_METHODS, true ) ) {
661 return;
662 }
663
664 if ( ! $this->order_has_subscription_relation( (int) $order_id ) ) {
665 return;
666 }
667
668 $order_helper = \WC_Stripe_Order_Helper::get_instance();
669
670 // Healthy order — skip the API round trip.
671 if (
672 ! empty( $order_helper->get_stripe_customer_id( $order ) )
673 && ! empty( $order_helper->get_stripe_source_id( $order ) )
674 && ( ! $order->get_customer_id() || get_user_option( '_stripe_customer_id', $order->get_customer_id() ) )
675 ) {
676 return;
677 }
678
679 $intent_id = $order_helper->get_stripe_intent_id( $order );
680
681 if ( empty( $intent_id ) || 0 !== strpos( $intent_id, 'pi_' ) ) {
682 return;
683 }
684
685 $intent = \WC_Stripe_API::retrieve( 'payment_intents/' . $intent_id );
686
687 if ( empty( $intent ) || is_wp_error( $intent ) || ! empty( $intent->error ) ) {
688 subscrpt_write_log( "Could not read Stripe intent {$intent_id} for order #{$order_id}." );
689 return;
690 }
691
692 $customer_id = isset( $intent->customer ) ? ( is_object( $intent->customer ) ? $intent->customer->id : (string) $intent->customer ) : '';
693 $source_id = isset( $intent->payment_method ) ? ( is_object( $intent->payment_method ) ? $intent->payment_method->id : (string) $intent->payment_method ) : '';
694
695 if ( empty( $customer_id ) ) {
696 $order->add_order_note( __( 'Stripe did not attach a customer to this payment, so no reusable payment method was stored. Automatic renewal will fail until the customer saves a payment method.', 'subscription' ) );
697 $order->save();
698
699 subscrpt_write_log( "No Stripe customer on intent {$intent_id} for order #{$order_id}. Auto renewal will fail." );
700
701 /**
702 * Fires when a paid subscription order has no reusable Stripe payment method.
703 *
704 * @param int $order_id Order ID.
705 * @param string $intent_id Stripe PaymentIntent ID.
706 */
707 do_action( 'subscrpt_stripe_reusable_pm_missing', (int) $order_id, $intent_id );
708 return;
709 }
710
711 $updated = false;
712
713 if ( empty( $order_helper->get_stripe_customer_id( $order ) ) ) {
714 $order_helper->update_stripe_customer_id( $order, $customer_id );
715 $updated = true;
716 }
717
718 if ( ! empty( $source_id ) && empty( $order_helper->get_stripe_source_id( $order ) ) ) {
719 $order_helper->update_stripe_source_id( $order, $source_id );
720 $updated = true;
721 }
722
723 if ( $updated ) {
724 $order->save();
725 subscrpt_write_debug_log( "Backfilled Stripe customer {$customer_id} on order #{$order_id} from intent {$intent_id}." );
726 }
727
728 $this->maybe_attach_stripe_customer_to_user( $order, $customer_id );
729 }
730
731 /**
732 * Bind a Stripe customer created during guest checkout to the account behind the order.
733 *
734 * Otherwise it keeps the gateway's "Guest" description and every later order orphans
735 * another customer.
736 *
737 * @param \WC_Order $order Order the customer paid for.
738 * @param string $customer_id Stripe customer ID.
739 *
740 * @return void
741 */
742 private function maybe_attach_stripe_customer_to_user( $order, $customer_id ) {
743 $user_id = $order->get_customer_id();
744
745 if ( ! $user_id || get_user_option( '_stripe_customer_id', $user_id ) ) {
746 return;
747 }
748
749 $user = get_userdata( $user_id );
750
751 if ( ! $user ) {
752 return;
753 }
754
755 update_user_option( $user_id, '_stripe_customer_id', $customer_id, false );
756
757 $first_name = $order->get_billing_first_name();
758 $last_name = $order->get_billing_last_name();
759
760 \WC_Stripe_API::request(
761 [
762 'email' => $order->get_billing_email(),
763 'name' => trim( $first_name . ' ' . $last_name ),
764 // translators: %1$s first name, %2$s last name, %3$s username.
765 'description' => sprintf( __( 'Name: %1$s %2$s, Username: %3$s', 'subscription' ), $first_name, $last_name, $user->user_login ),
766 'metadata' => [ 'user_id' => (string) $user_id ],
767 ],
768 'customers/' . $customer_id
769 );
770
771 subscrpt_write_debug_log( "Linked Stripe customer {$customer_id} to user {$user_id} after guest checkout." );
772 }
773 }
774