From 3d6d6ce11dc3da94eedd3dd807876304722a9d2a Mon Sep 17 00:00:00 2001 From: Ganesh Bhatt Date: Fri, 2 Oct 2026 14:05:40 +0545 Subject: [PATCH 1/8] Fix - Resolve the PaymentIntent of a paid Stripe renewal on API versions without invoice.payment_intent (themegrill/user-registration-pro#1823) --- .../Admin/Services/Stripe/StripeService.php | 48 ++++++- .../stripe-invoice-payment-intent.php | 119 ++++++++++++++++++ 2 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 tests/security/stripe-invoice-payment-intent.php diff --git a/modules/membership/includes/Admin/Services/Stripe/StripeService.php b/modules/membership/includes/Admin/Services/Stripe/StripeService.php index d3556b9ac..a75851aba 100644 --- a/modules/membership/includes/Admin/Services/Stripe/StripeService.php +++ b/modules/membership/includes/Admin/Services/Stripe/StripeService.php @@ -2328,7 +2328,7 @@ public function handle_succeeded_invoice( $event, $subscription_id ) { $member_id = $current_subscription['user_id']; $membership_id = $current_subscription['item_id']; $invoice_id = $event['data']['object']['id']; - $payment_intent_id = $event['data']['object']['payment_intent'] ?? null; + $payment_intent_id = $this->get_invoice_payment_intent_id( $event['data']['object'] ); $invoice_amount = $event['data']['object']['amount_due']; // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable // $0 trial invoices have no payment_intent. The initial order was already created @@ -2462,6 +2462,52 @@ public function handle_succeeded_invoice( $event, $subscription_id ) { } } + /** + * Resolve the PaymentIntent that paid an invoice, whatever Stripe API version rendered the event. + * + * Events rendered before 2025-03-31.basil carry `payment_intent` on the invoice. Later versions + * drop it and events never expand `payments`, so the invoice is fetched with it expanded. + * + * @param array $invoice Invoice object from a Stripe event. + * @return string PaymentIntent id, or an empty string when no PaymentIntent paid the invoice. + */ + private function get_invoice_payment_intent_id( $invoice ) { + if ( ! empty( $invoice['payment_intent'] ) ) { + return $invoice['payment_intent']; + } + + try { + $payments = \Stripe\Invoice::retrieve( + array( + 'id' => $invoice['id'], + 'expand' => array( 'payments' ), + ) + )->toArray()['payments']['data'] ?? array(); + } catch ( \Exception $e ) { + PaymentGatewayLogging::log_error( + 'stripe', + 'Failed to resolve the PaymentIntent of an invoice' . "\n" . wp_json_encode( + array( + 'error_code' => 'INVOICE_PAYMENT_LOOKUP_FAILED', + 'invoice_id' => $invoice['id'], + 'error_message' => $e->getMessage(), + ), + JSON_PRETTY_PRINT + ) + ); + + wp_die(); + } + + foreach ( $payments as $payment ) { + if ( 'paid' === ( $payment['status'] ?? '' ) && ! empty( $payment['payment']['payment_intent'] ) ) { + return $payment['payment']['payment_intent']; + } + } + + return ''; + } + /** * Handle failed invoice webhook. * diff --git a/tests/security/stripe-invoice-payment-intent.php b/tests/security/stripe-invoice-payment-intent.php new file mode 100644 index 000000000..57793edee --- /dev/null +++ b/tests/security/stripe-invoice-payment-intent.php @@ -0,0 +1,119 @@ + array( 'data' => $GLOBALS['stripe_payments'] ) ); + } + } +} + +namespace { + require __DIR__ . '/bootstrap.php'; + + class PaymentGatewayLogging { + public static function log_error( $gateway, $message ) { + $GLOBALS['logged_errors'][] = $message; + } + } + function wp_json_encode( $value, $flags = 0 ) { + return json_encode( $value, $flags ); + } + function wp_die() { + throw new SecurityResponse( false, 'wp_die', 500 ); + } + + eval( 'class StripeInvoiceResolver {' . security_function( 'modules/membership/includes/Admin/Services/Stripe/StripeService.php', 'get_invoice_payment_intent_id' ) . '}' ); + $resolver = new ReflectionMethod( 'StripeInvoiceResolver', 'get_invoice_payment_intent_id' ); + $resolver->setAccessible( true ); + $resolve = function ( $invoice, $payments = array(), $throws = false ) use ( $resolver ) { + $GLOBALS['retrieve_calls'] = array(); + $GLOBALS['stripe_payments'] = $payments; + $GLOBALS['stripe_throws'] = $throws; + return $resolver->invoke( new StripeInvoiceResolver(), $invoice ); + }; + + // Pre-basil event: the field is on the invoice, so no extra API call is needed. + security_assert( + 'pi_legacy' === $resolve( + array( + 'id' => 'in_1', + 'payment_intent' => 'pi_legacy', + ) + ), + 'Legacy invoice payment_intent is used as is' + ); + security_assert( array() === $GLOBALS['retrieve_calls'], 'No API call when the invoice already carries payment_intent' ); + + // Basil-or-later event: the field is gone, the paid payment lists the PaymentIntent. + $paid = array( + array( + 'status' => 'paid', + 'payment' => array( + 'type' => 'payment_intent', + 'payment_intent' => 'pi_basil', + ), + ), + ); + security_assert( 'pi_basil' === $resolve( array( 'id' => 'in_2' ), $paid ), 'PaymentIntent is read from invoice.payments' ); + security_assert( + array( + 'id' => 'in_2', + 'expand' => array( 'payments' ), + ) === $GLOBALS['retrieve_calls'][0], + 'payments is expanded on the invoice fetch' + ); + + // An open (unpaid) attempt is not a payment. + $open = array( + array( + 'status' => 'open', + 'payment' => array( 'payment_intent' => 'pi_open' ), + ), + ); + security_assert( '' === $resolve( array( 'id' => 'in_3' ), $open ), 'Unpaid payment attempts are ignored' ); + + // The paid payment wins when an unpaid attempt is listed first. + security_assert( 'pi_basil' === $resolve( array( 'id' => 'in_4' ), array_merge( $open, $paid ) ), 'Paid payment is chosen over an open one' ); + + // $0 trial invoice: no payments at all. + security_assert( '' === $resolve( array( 'id' => 'in_5' ), array() ), 'A $0 invoice has no PaymentIntent' ); + + // Out-of-band payment: paid but not through a PaymentIntent. + $oob = array( + array( + 'status' => 'paid', + 'payment' => array( + 'type' => 'charge', + 'charge' => 'ch_1', + ), + ), + ); + security_assert( '' === $resolve( array( 'id' => 'in_6' ), $oob ), 'A non-PaymentIntent payment yields no PaymentIntent' ); + + // Lookup failure must surface so Stripe retries, never be mistaken for a $0 invoice. + $GLOBALS['logged_errors'] = array(); + $failed = null; + try { + $resolve( array( 'id' => 'in_7' ), array(), true ); + } catch ( SecurityResponse $e ) { + $failed = $e; + } + security_assert( null !== $failed && 500 === $failed->status, 'A failed lookup aborts the webhook with an error status' ); + security_assert( 1 === count( $GLOBALS['logged_errors'] ), 'A failed lookup is logged' ); +} From 4856e6fa277f97711fc0ead39547295f895367db Mon Sep 17 00:00:00 2001 From: Ganesh Bhatt Date: Fri, 2 Oct 2026 14:24:22 +0545 Subject: [PATCH 2/8] Run the Stripe renewal PaymentIntent regression through the Playwright suite and keep its test doubles lint-clean (themegrill/user-registration-pro#1823) --- .../specs/security-stripe-invoice-payment-intent.spec.ts | 7 +++++++ tests/security/stripe-invoice-payment-intent.php | 7 ++++--- 2 files changed, 11 insertions(+), 3 deletions(-) create mode 100644 tests/e2e/specs/security-stripe-invoice-payment-intent.spec.ts diff --git a/tests/e2e/specs/security-stripe-invoice-payment-intent.spec.ts b/tests/e2e/specs/security-stripe-invoice-payment-intent.spec.ts new file mode 100644 index 000000000..878b56a4e --- /dev/null +++ b/tests/e2e/specs/security-stripe-invoice-payment-intent.spec.ts @@ -0,0 +1,7 @@ +import { test } from "@playwright/test"; +import { runSecurityRegression } from "../support/security"; + +// This spec executes the isolated PHP regression; it does not mutate a live site. +test("Fix - Resolve the PaymentIntent of a paid Stripe renewal on API versions without invoice.payment_intent (isolated PHP) @fresh @security", async () => { + await runSecurityRegression("stripe-invoice-payment-intent", 15); +}); diff --git a/tests/security/stripe-invoice-payment-intent.php b/tests/security/stripe-invoice-payment-intent.php index 57793edee..c6d0646ee 100644 --- a/tests/security/stripe-invoice-payment-intent.php +++ b/tests/security/stripe-invoice-payment-intent.php @@ -7,7 +7,6 @@ // Test doubles intentionally bypass production-only conventions. // phpcs:disable Squiz.Commenting.ClassComment.Missing, Squiz.Commenting.FunctionComment.Missing, Squiz.Commenting.VariableComment.Missing, Squiz.PHP.Eval.Discouraged, WordPress.Files.FileName.InvalidClassFileName, WordPress.WP.GlobalVariablesOverride.Prohibited, Generic.Files.OneObjectStructurePerFile.MultipleFound, Universal.Files.SeparateFunctionsFromOO.Mixed - namespace Stripe { class Invoice { public static function retrieve( $params ) { @@ -17,7 +16,9 @@ public static function retrieve( $params ) { } return new self(); } - public function toArray() { + /** Stands in for the SDK's camelCase `toArray()`, which cannot be renamed. */ + public function __call( $name, $arguments ) { + security_assert( 'toArray' === $name, 'Only toArray() is expected on the Invoice double' ); return array( 'payments' => array( 'data' => $GLOBALS['stripe_payments'] ) ); } } @@ -32,7 +33,7 @@ public static function log_error( $gateway, $message ) { } } function wp_json_encode( $value, $flags = 0 ) { - return json_encode( $value, $flags ); + return 'encoded:' . gettype( $value ) . ':' . (int) $flags; } function wp_die() { throw new SecurityResponse( false, 'wp_die', 500 ); From f6b1bead7c304a186e880075c97f287c2cdeba57 Mon Sep 17 00:00:00 2001 From: Ganesh Bhatt Date: Fri, 2 Oct 2026 14:27:27 +0545 Subject: [PATCH 3/8] Document the Invoice test double's __call parameters (themegrill/user-registration-pro#1823) --- tests/security/stripe-invoice-payment-intent.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/security/stripe-invoice-payment-intent.php b/tests/security/stripe-invoice-payment-intent.php index c6d0646ee..cf1f135b3 100644 --- a/tests/security/stripe-invoice-payment-intent.php +++ b/tests/security/stripe-invoice-payment-intent.php @@ -16,7 +16,13 @@ public static function retrieve( $params ) { } return new self(); } - /** Stands in for the SDK's camelCase `toArray()`, which cannot be renamed. */ + /** + * Stands in for the SDK's camelCase `toArray()`, which cannot be renamed. + * + * @param string $name Called method name. + * @param array $arguments Call arguments. + * @return array + */ public function __call( $name, $arguments ) { security_assert( 'toArray' === $name, 'Only toArray() is expected on the Invoice double' ); return array( 'payments' => array( 'data' => $GLOBALS['stripe_payments'] ) ); From 0d10689b2482779242f2a01da5ba60e01ff9289e Mon Sep 17 00:00:00 2001 From: Ganesh Bhatt Date: Thu, 8 Oct 2026 10:08:18 +0545 Subject: [PATCH 4/8] Fix - Recover paid Stripe renewals in the missed-payment backfill on current API versions, and keep the member's status while Stripe retries a failed invoice (themegrill/user-registration-pro#1888) - The backfill read invoice.subscription and invoice.payment_intent, which Stripe removed in 2025-03-31.basil, so every paid invoice was skipped. It now reads the subscription from parent.subscription_details and resolves the PaymentIntent with the shared invoice lookup, only for subscriptions this site owns. A failed lookup skips that invoice and the run continues. - The invoice lookup now throws to its caller; the webhook keeps logging it and aborting with an error so Stripe redelivers. - invoice.payment_failed no longer moves an active or trial subscription to pending while Stripe still has a retry scheduled (next_payment_attempt). A failure with no retry left still sets pending. --- .../Admin/Services/Stripe/StripeService.php | 98 ++++-- ...stripe-backfill-and-failed-invoice.spec.ts | 7 + ...rity-stripe-invoice-payment-intent.spec.ts | 2 +- .../stripe-backfill-and-failed-invoice.php | 280 ++++++++++++++++++ .../stripe-invoice-payment-intent.php | 22 +- 5 files changed, 359 insertions(+), 50 deletions(-) create mode 100644 tests/e2e/specs/security-stripe-backfill-and-failed-invoice.spec.ts create mode 100644 tests/security/stripe-backfill-and-failed-invoice.php diff --git a/modules/membership/includes/Admin/Services/Stripe/StripeService.php b/modules/membership/includes/Admin/Services/Stripe/StripeService.php index 253784ff3..ff931eddd 100644 --- a/modules/membership/includes/Admin/Services/Stripe/StripeService.php +++ b/modules/membership/includes/Admin/Services/Stripe/StripeService.php @@ -2653,10 +2653,26 @@ public function handle_succeeded_invoice( $event, $subscription_id ) { if ( is_array( $replaced_subscription ) && ( $replaced_subscription['new'] ?? '' ) === $subscription_id ) { $this->stop_replaced_subscription( $replaced_subscription['old'] ?? '', $subscription_id, $member_id, 'active' ); } - $membership_id = $current_subscription['item_id']; - $invoice_id = $event['data']['object']['id']; - $payment_intent_id = $this->get_invoice_payment_intent_id( $event['data']['object'] ); - $invoice_amount = isset( $event['data']['object']['amount_paid'] ) ? (int) $event['data']['object']['amount_paid'] : (int) ( $event['data']['object']['amount_due'] ?? 0 ); + $membership_id = $current_subscription['item_id']; + $invoice_id = $event['data']['object']['id']; + try { + $payment_intent_id = $this->get_invoice_payment_intent_id( $event['data']['object'] ); + } catch ( \Exception $e ) { + PaymentGatewayLogging::log_error( + 'stripe', + 'Failed to resolve the PaymentIntent of an invoice' . "\n" . wp_json_encode( + array( + 'error_code' => 'INVOICE_PAYMENT_LOOKUP_FAILED', + 'invoice_id' => $invoice_id, + 'error_message' => $e->getMessage(), + ), + JSON_PRETTY_PRINT + ) + ); + + wp_die(); + } + $invoice_amount = isset( $event['data']['object']['amount_paid'] ) ? (int) $event['data']['object']['amount_paid'] : (int) ( $event['data']['object']['amount_due'] ?? 0 ); // $0 invoices (typical first trial invoice) have no payment_intent. Skip duplicate order // creation, but still sync dates/status from Stripe — otherwise a renewal invoice that @@ -2812,6 +2828,7 @@ public function handle_succeeded_invoice( $event, $subscription_id ) { * * @param array $invoice Invoice object from a Stripe event. * @return string PaymentIntent id, or an empty string when no PaymentIntent paid the invoice. + * @throws \Exception When the invoice cannot be fetched from Stripe. */ private function get_invoice_payment_intent_id( $invoice ) { $payment_intent_id = $this->extract_stripe_id( $invoice['payment_intent'] ?? null ); @@ -2819,28 +2836,12 @@ private function get_invoice_payment_intent_id( $invoice ) { return $payment_intent_id; } - try { - $payments = \Stripe\Invoice::retrieve( - array( - 'id' => $invoice['id'], - 'expand' => array( 'payments' ), - ) - )->toArray()['payments']['data'] ?? array(); - } catch ( \Exception $e ) { - PaymentGatewayLogging::log_error( - 'stripe', - 'Failed to resolve the PaymentIntent of an invoice' . "\n" . wp_json_encode( - array( - 'error_code' => 'INVOICE_PAYMENT_LOOKUP_FAILED', - 'invoice_id' => $invoice['id'], - 'error_message' => $e->getMessage(), - ), - JSON_PRETTY_PRINT - ) - ); - - wp_die(); - } + $payments = \Stripe\Invoice::retrieve( + array( + 'id' => $invoice['id'], + 'expand' => array( 'payments' ), + ) + )->toArray()['payments']['data'] ?? array(); foreach ( $payments as $payment ) { if ( 'paid' === ( $payment['status'] ?? '' ) && ! empty( $payment['payment']['payment_intent'] ) ) { @@ -2901,6 +2902,25 @@ public function handle_failed_invoice( $event, $subscription_id ) { return; } + $member_id = $current_subscription['user_id']; + + // Stripe sends this event on every retry; while it is still retrying, the member keeps access like the past_due expiry hold. + if ( ! empty( $event['data']['object']['next_payment_attempt'] ) && in_array( $current_subscription['status'] ?? '', array( 'active', 'trial' ), true ) ) { + PaymentGatewayLogging::log_webhook_processed( + 'stripe', + 'Invoice payment failed, Stripe will retry; subscription status kept', + array( + 'subscription_id' => $subscription_id, + 'sub_id' => $current_subscription['sub_id'], + 'member_id' => $member_id, + 'status' => $current_subscription['status'], + 'next_payment_attempt' => $event['data']['object']['next_payment_attempt'], + ) + ); + + return; + } + $this->members_subscription_repository->update( $current_subscription['sub_id'], array( @@ -2908,7 +2928,6 @@ public function handle_failed_invoice( $event, $subscription_id ) { ) ); - $member_id = $current_subscription['user_id']; PaymentGatewayLogging::log_webhook_processed( 'stripe', 'Invoice payment failed handled', @@ -4471,11 +4490,11 @@ public function run_missed_payment_backfill( $last_synced ) { $count_skipped = 0; foreach ( $events->autoPagingIterator() as $event ) { - $invoice = $event->data->object; - $subscription_id = $invoice->subscription ?? null; - $payment_intent_id = $invoice->payment_intent ?? null; + $invoice = $event->data->object; + $invoice_data = $invoice->toArray(); + $subscription_id = $this->extract_stripe_id( $invoice_data['subscription'] ?? $invoice_data['parent']['subscription_details']['subscription'] ?? null ); - if ( empty( $subscription_id ) || empty( $payment_intent_id ) ) { + if ( empty( $subscription_id ) ) { ++$count_skipped; continue; } @@ -4483,6 +4502,23 @@ public function run_missed_payment_backfill( $last_synced ) { $membership_subscription = $this->members_subscription_repository->get_subscription_by_subscription_id_meta( $subscription_id ); if ( ! empty( $membership_subscription ) ) { + try { + $payment_intent_id = $this->get_invoice_payment_intent_id( $invoice_data ); + } catch ( \Exception $e ) { + // Skip only this invoice: the sync time advances even when the backfill throws, so aborting would drop the rest of the window. + $logger->error( + sprintf( '[Backfill][Stripe][Payments] Could not resolve the PaymentIntent of invoice %s: %s', $invoice_data['id'] ?? '', $e->getMessage() ), + array( 'source' => 'urm-missed-payment-backfill' ) + ); + ++$count_skipped; + continue; + } + + if ( empty( $payment_intent_id ) ) { + ++$count_skipped; + continue; + } + $payment = $this->orders_repository->get_order_by_transaction_id( $payment_intent_id ); if ( empty( $payment ) ) { diff --git a/tests/e2e/specs/security-stripe-backfill-and-failed-invoice.spec.ts b/tests/e2e/specs/security-stripe-backfill-and-failed-invoice.spec.ts new file mode 100644 index 000000000..f2e8a869b --- /dev/null +++ b/tests/e2e/specs/security-stripe-backfill-and-failed-invoice.spec.ts @@ -0,0 +1,7 @@ +import { test } from "@playwright/test"; +import { runSecurityRegression } from "../support/security"; + +// This spec executes the isolated PHP regression; it does not mutate a live site. +test("Fix - Backfill paid Stripe renewals on current API versions and keep the member's status while Stripe retries a failed invoice (isolated PHP) @fresh @security", async () => { + await runSecurityRegression("stripe-backfill-and-failed-invoice", 32); +}); diff --git a/tests/e2e/specs/security-stripe-invoice-payment-intent.spec.ts b/tests/e2e/specs/security-stripe-invoice-payment-intent.spec.ts index 878b56a4e..8246001b2 100644 --- a/tests/e2e/specs/security-stripe-invoice-payment-intent.spec.ts +++ b/tests/e2e/specs/security-stripe-invoice-payment-intent.spec.ts @@ -3,5 +3,5 @@ import { runSecurityRegression } from "../support/security"; // This spec executes the isolated PHP regression; it does not mutate a live site. test("Fix - Resolve the PaymentIntent of a paid Stripe renewal on API versions without invoice.payment_intent (isolated PHP) @fresh @security", async () => { - await runSecurityRegression("stripe-invoice-payment-intent", 15); + await runSecurityRegression("stripe-invoice-payment-intent", 16); }); diff --git a/tests/security/stripe-backfill-and-failed-invoice.php b/tests/security/stripe-backfill-and-failed-invoice.php new file mode 100644 index 000000000..580dc2f72 --- /dev/null +++ b/tests/security/stripe-backfill-and-failed-invoice.php @@ -0,0 +1,280 @@ +values = $values; + } + public function __get( $name ) { + return $this->values[ $name ] ?? null; + } + public function __isset( $name ) { + return isset( $this->values[ $name ] ); + } + /** + * Serves `toArray()` (an invoice) and `autoPagingIterator()` (an event list). + * + * @param string $name Called method name. + * @param array $arguments Call arguments. + * @return array + */ + public function __call( $name, $arguments ) { + security_assert( in_array( $name, array( 'toArray', 'autoPagingIterator' ), true ), 'Unexpected SDK method ' . $name ); + return $this->values; + } + } + class Event { + public static function all( $params ) { + return new StripeObject( + array_map( + function ( $invoice ) { + return (object) array( 'data' => (object) array( 'object' => new StripeObject( $invoice ) ) ); + }, + $GLOBALS['stripe_invoices'] + ) + ); + } + } + class Invoice { + public static function retrieve( $params ) { + $GLOBALS['invoice_lookups'][] = $params['id']; + if ( $GLOBALS['stripe_throws'] ) { + throw new \Exception( 'No such invoice' ); + } + return new StripeObject( array( 'payments' => array( 'data' => $GLOBALS['stripe_payments'] ) ) ); + } + } + class PaymentIntent { + public static function retrieve( $id ) { + return (object) array( 'amount_received' => 2900 ); + } + } +} + +namespace { + require __DIR__ . '/bootstrap.php'; + + class PaymentGatewayLogging { + public static function log_error( $gateway, $message ) { + $GLOBALS['logged_errors'][] = $message; + } + public static function log_webhook_received( $gateway, $message, $context = array() ) {} + public static function log_webhook_processed( $gateway, $message, $context = array() ) { + $GLOBALS['processed_logs'][] = $message; + } + } + class SecurityLogger { + public function info( $message, $context = array() ) {} + public function error( $message, $context = array() ) { + $GLOBALS['logged_errors'][] = $message; + } + } + class SecurityRepository { + public $calls = array(); + public $row; + public function __call( $name, $arguments ) { + $this->calls[] = array( $name, $arguments ); + return $this->row; + } + } + function ur_get_logger() { + return new SecurityLogger(); + } + function wp_json_encode( $value, $flags = 0 ) { + return json_encode( $value, $flags ); + } + function wp_die() { + throw new SecurityResponse( false, 'wp_die', 500 ); + } + function get_user_meta( $user_id, $key, $single = false ) { + return ''; + } + function delete_transient( $key ) {} + + $stripe_service = 'modules/membership/includes/Admin/Services/Stripe/StripeService.php'; + $methods = ''; + foreach ( array( 'extract_stripe_id', 'get_invoice_payment_intent_id', 'run_missed_payment_backfill', 'handle_failed_invoice', 'handle_succeeded_invoice' ) as $method ) { + $methods .= security_function( $stripe_service, $method ); + } + eval( 'class StripeBackfillHarness { public $members_subscription_repository; public $orders_repository; ' . $methods . '}' ); + + $harness = function ( $subscription_row, $order_row = false ) { + $GLOBALS['invoice_lookups'] = array(); + $GLOBALS['logged_errors'] = array(); + $GLOBALS['processed_logs'] = array(); + $GLOBALS['stripe_throws'] = false; + $GLOBALS['stripe_payments'] = array(); + $service = new StripeBackfillHarness(); + $service->members_subscription_repository = new SecurityRepository(); + $service->members_subscription_repository->row = $subscription_row; + $service->orders_repository = new SecurityRepository(); + $service->orders_repository->row = $order_row; + return $service; + }; + $method_names = function ( $repository ) { + return array_column( $repository->calls, 0 ); + }; + $created_orders = function ( $service ) { + return array_values( + array_filter( + $service->orders_repository->calls, + function ( $call ) { + return 'create' === $call[0]; + } + ) + ); + }; + $paid_by_pi = array( + array( + 'status' => 'paid', + 'payment' => array( + 'type' => 'payment_intent', + 'payment_intent' => 'pi_basil', + ), + ), + ); + $local_subscription = array( + 'ID' => 7, + 'user_id' => 76, + 'item_id' => 12, + 'sub_id' => 7, + 'status' => 'active', + ); + + // 1. Basil-or-later invoice: subscription under parent.subscription_details, payment under payments. + $GLOBALS['stripe_invoices'] = array( + array( + 'id' => 'in_basil', + 'amount_paid' => 2900, + 'parent' => array( 'subscription_details' => array( 'subscription' => 'sub_basil' ) ), + ), + ); + $service = $harness( $local_subscription ); + $GLOBALS['stripe_payments'] = $paid_by_pi; + $service->run_missed_payment_backfill( 1759000000 ); + security_assert( array( 'get_subscription_by_subscription_id_meta', 'sub_basil' ) === array( $service->members_subscription_repository->calls[0][0], $service->members_subscription_repository->calls[0][1][0] ), 'The subscription is read from parent.subscription_details' ); + $orders = $created_orders( $service ); + security_assert( 1 === count( $orders ), 'A paid basil renewal is backfilled as an order' ); + security_assert( 'pi_basil' === $orders[0][1][0]['orders_data']['transaction_id'], 'The order carries the PaymentIntent from invoice.payments' ); + security_assert( 29.0 === (float) $orders[0][1][0]['orders_data']['total_amount'], 'The order total comes from the PaymentIntent' ); + + // 2. Pre-basil invoice keeps working and needs no extra invoice lookup. + $GLOBALS['stripe_invoices'] = array( + array( + 'id' => 'in_legacy', + 'subscription' => 'sub_legacy', + 'payment_intent' => 'pi_legacy', + ), + ); + $service = $harness( $local_subscription ); + $service->run_missed_payment_backfill( 1759000000 ); + security_assert( 'pi_legacy' === $created_orders( $service )[0][1][0]['orders_data']['transaction_id'], 'A legacy invoice is backfilled from its own payment_intent' ); + security_assert( array() === $GLOBALS['invoice_lookups'], 'No invoice lookup when payment_intent is on the invoice' ); + + // 3. An invoice for a subscription this site does not own costs no Stripe lookup. + $GLOBALS['stripe_invoices'] = array( + array( + 'id' => 'in_foreign', + 'parent' => array( 'subscription_details' => array( 'subscription' => 'sub_foreign' ) ), + ), + ); + $service = $harness( false ); + $service->run_missed_payment_backfill( 1759000000 ); + security_assert( array() === $GLOBALS['invoice_lookups'], 'Foreign subscriptions are skipped before any invoice lookup' ); + security_assert( array() === $created_orders( $service ), 'No order for a foreign subscription' ); + + // 4. A failed lookup skips that invoice only and the next invoice is still recovered. + $GLOBALS['stripe_invoices'] = array( + array( + 'id' => 'in_unreachable', + 'parent' => array( 'subscription_details' => array( 'subscription' => 'sub_basil' ) ), + ), + array( + 'id' => 'in_next', + 'subscription' => 'sub_basil', + 'payment_intent' => 'pi_next', + ), + ); + $service = $harness( $local_subscription ); + $GLOBALS['stripe_throws'] = true; + $service->run_missed_payment_backfill( 1759000000 ); + security_assert( 1 === count( $GLOBALS['logged_errors'] ) && false !== strpos( $GLOBALS['logged_errors'][0], 'in_unreachable' ), 'A failed lookup is logged with its invoice' ); + security_assert( 'pi_next' === $created_orders( $service )[0][1][0]['orders_data']['transaction_id'], 'The backfill continues after a failed lookup' ); + + // 5. A $0 invoice (no payments) still creates no order. + $GLOBALS['stripe_invoices'] = array( + array( + 'id' => 'in_trial', + 'parent' => array( 'subscription_details' => array( 'subscription' => 'sub_basil' ) ), + ), + ); + $service = $harness( $local_subscription ); + $service->run_missed_payment_backfill( 1759000000 ); + security_assert( array() === $created_orders( $service ), 'No order for an invoice without a PaymentIntent' ); + + // 6. The webhook turns a failed lookup into a logged error response so Stripe retries the event. + $service = $harness( $local_subscription ); + $GLOBALS['stripe_throws'] = true; + $response = null; + try { + $service->handle_succeeded_invoice( array( 'data' => array( 'object' => array( 'id' => 'in_webhook' ) ) ), 'sub_basil' ); + } catch ( SecurityResponse $e ) { + $response = $e; + } + security_assert( null !== $response && 500 === $response->status, 'A failed lookup aborts the webhook with an error status' ); + security_assert( 1 === count( $GLOBALS['logged_errors'] ) && false !== strpos( $GLOBALS['logged_errors'][0], 'INVOICE_PAYMENT_LOOKUP_FAILED' ), 'The webhook logs the failed lookup' ); + + $failed_invoice = function ( $next_payment_attempt ) { + return array( + 'id' => 'evt_failed', + 'data' => array( + 'object' => array( + 'id' => 'in_failed', + 'next_payment_attempt' => $next_payment_attempt, + ), + ), + ); + }; + $status_writes = function ( $service ) { + return array_values( + array_filter( + $service->members_subscription_repository->calls, + function ( $call ) { + return 'update' === $call[0]; + } + ) + ); + }; + + // 7. Stripe will retry: an active or trial member keeps the status. + foreach ( array( 'active', 'trial' ) as $status ) { + $service = $harness( array_merge( $local_subscription, array( 'status' => $status ) ) ); + $service->handle_failed_invoice( $failed_invoice( 1759900000 ), 'sub_basil' ); + security_assert( array() === $status_writes( $service ), 'A retried failure keeps the ' . $status . ' status' ); + security_assert( 1 === count( $GLOBALS['processed_logs'] ), 'The kept status is logged' ); + } + + // 8. Stripe has stopped retrying: the subscription goes back to pending as before. + $service = $harness( $local_subscription ); + $service->handle_failed_invoice( $failed_invoice( null ), 'sub_basil' ); + $writes = $status_writes( $service ); + security_assert( 1 === count( $writes ) && array( 'status' => 'pending' ) === $writes[0][1][1], 'A final failure sets the subscription to pending' ); + + // 9. A first payment that is still pending stays pending while Stripe retries. + $service = $harness( array_merge( $local_subscription, array( 'status' => 'pending' ) ) ); + $service->handle_failed_invoice( $failed_invoice( 1759900000 ), 'sub_basil' ); + security_assert( array( 'status' => 'pending' ) === $status_writes( $service )[0][1][1], 'A pending subscription is still written as pending' ); +} diff --git a/tests/security/stripe-invoice-payment-intent.php b/tests/security/stripe-invoice-payment-intent.php index b7507622d..6178317db 100644 --- a/tests/security/stripe-invoice-payment-intent.php +++ b/tests/security/stripe-invoice-payment-intent.php @@ -33,18 +33,6 @@ public function __call( $name, $arguments ) { namespace { require __DIR__ . '/bootstrap.php'; - class PaymentGatewayLogging { - public static function log_error( $gateway, $message ) { - $GLOBALS['logged_errors'][] = $message; - } - } - function wp_json_encode( $value, $flags = 0 ) { - return 'encoded:' . gettype( $value ) . ':' . (int) $flags; - } - function wp_die() { - throw new SecurityResponse( false, 'wp_die', 500 ); - } - $stripe_service = 'modules/membership/includes/Admin/Services/Stripe/StripeService.php'; eval( 'class StripeInvoiceResolver {' . security_function( $stripe_service, 'extract_stripe_id' ) . security_function( $stripe_service, 'get_invoice_payment_intent_id' ) . '}' ); $resolver = new ReflectionMethod( 'StripeInvoiceResolver', 'get_invoice_payment_intent_id' ); @@ -126,14 +114,12 @@ function wp_die() { ); security_assert( '' === $resolve( array( 'id' => 'in_6' ), $oob ), 'A non-PaymentIntent payment yields no PaymentIntent' ); - // Lookup failure must surface so Stripe retries, never be mistaken for a $0 invoice. - $GLOBALS['logged_errors'] = array(); - $failed = null; + // Lookup failure must reach the caller, never be mistaken for a $0 invoice. + $failed = null; try { $resolve( array( 'id' => 'in_7' ), array(), true ); - } catch ( SecurityResponse $e ) { + } catch ( \Exception $e ) { $failed = $e; } - security_assert( null !== $failed && 500 === $failed->status, 'A failed lookup aborts the webhook with an error status' ); - security_assert( 1 === count( $GLOBALS['logged_errors'] ), 'A failed lookup is logged' ); + security_assert( null !== $failed && 'No such invoice' === $failed->getMessage(), 'A failed lookup throws to the caller' ); } From 1dc51e30c358ee19584232afb34dfb8a0bb741a6 Mon Sep 17 00:00:00 2001 From: Ganesh Bhatt Date: Thu, 8 Oct 2026 10:18:17 +0545 Subject: [PATCH 5/8] Address review on the Stripe backfill and failed-invoice fix (themegrill/user-registration-pro#1888) - Handle each invoice once per run: invoice.paid and invoice.payment_succeeded list the same invoice, so the second costs no extra Stripe call. - Link the signup invoice to a registration order saved without a PaymentIntent instead of creating a duplicate order, via a new OrdersRepository::get_unlinked_order_by_subscription(). - Keep only active members during Stripe retries; an ended trial whose first charge fails goes to pending as before. - Read the invoice creation time from the same array as the other fields, and update the delayed-start comment that described the old failed-invoice behaviour. --- .../Admin/Repositories/OrdersRepository.php | 19 ++ .../Admin/Services/Stripe/StripeService.php | 41 ++- ...stripe-backfill-and-failed-invoice.spec.ts | 2 +- .../stripe-backfill-and-failed-invoice.php | 271 +++++++++--------- 4 files changed, 185 insertions(+), 148 deletions(-) diff --git a/modules/membership/includes/Admin/Repositories/OrdersRepository.php b/modules/membership/includes/Admin/Repositories/OrdersRepository.php index 68b331c6a..0ac478feb 100644 --- a/modules/membership/includes/Admin/Repositories/OrdersRepository.php +++ b/modules/membership/includes/Admin/Repositories/OrdersRepository.php @@ -342,6 +342,25 @@ public function get_order_by_transaction_id( $transaction_id ) { return ! $result ? array() : $result; } + /** + * The oldest non-trial order of a subscription that has no transaction ID yet, such as a registration + * order saved before its PaymentIntent was known. + * + * @param int $subscription_id Local subscription ID. + * @return array Order row, or an empty array when every order already has a transaction ID. + */ + public function get_unlinked_order_by_subscription( $subscription_id ) { + $result = $this->wpdb()->get_row( + $this->wpdb()->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Bound by prepare(); the sniff trips on the interpolated internal table name. + "SELECT * FROM $this->table WHERE subscription_id = %d AND transaction_id = '' AND trial_status = 'off' ORDER BY ID ASC LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Internal table name; the ID is bound by prepare(). + $subscription_id // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Bound by prepare(); the sniff trips on the interpolated internal table name. + ), + ARRAY_A + ); + + return ! $result ? array() : $result; + } + public function get_all_delayed_orders( $date ) { $users_meta_table = TableList::users_meta_table(); diff --git a/modules/membership/includes/Admin/Services/Stripe/StripeService.php b/modules/membership/includes/Admin/Services/Stripe/StripeService.php index ff931eddd..a06bdd9a1 100644 --- a/modules/membership/includes/Admin/Services/Stripe/StripeService.php +++ b/modules/membership/includes/Admin/Services/Stripe/StripeService.php @@ -1761,8 +1761,8 @@ public function create_subscription( $customer_id, $payment_method_id, $member_i update_user_meta( $member_id, 'urm_stripe_schedule_id', sanitize_text_field( $schedule->id ) ); // UR-4386: schedule created (sub_sched_ returned) -> billing is set up, so activate - // now. First charge is one period later; a later charge failure reverts to pending - // via handle_failed_invoice(). + // now. First charge is one period later; if it fails, handle_failed_invoice() reverts + // to pending once Stripe has no retry left. $this->members_orders_repository->update( $member_order['ID'], array( @@ -2904,8 +2904,8 @@ public function handle_failed_invoice( $event, $subscription_id ) { $member_id = $current_subscription['user_id']; - // Stripe sends this event on every retry; while it is still retrying, the member keeps access like the past_due expiry hold. - if ( ! empty( $event['data']['object']['next_payment_attempt'] ) && in_array( $current_subscription['status'] ?? '', array( 'active', 'trial' ), true ) ) { + // Stripe sends this event on every retry; while it is still retrying, a paying member keeps access like the past_due expiry hold. + if ( ! empty( $event['data']['object']['next_payment_attempt'] ) && 'active' === ( $current_subscription['status'] ?? '' ) ) { PaymentGatewayLogging::log_webhook_processed( 'stripe', 'Invoice payment failed, Stripe will retry; subscription status kept', @@ -4487,19 +4487,22 @@ public function run_missed_payment_backfill( $last_synced ) { ); $count_created = 0; + $count_linked = 0; $count_skipped = 0; + $seen_invoices = array(); foreach ( $events->autoPagingIterator() as $event ) { - $invoice = $event->data->object; - $invoice_data = $invoice->toArray(); + $invoice_data = $event->data->object->toArray(); $subscription_id = $this->extract_stripe_id( $invoice_data['subscription'] ?? $invoice_data['parent']['subscription_details']['subscription'] ?? null ); - if ( empty( $subscription_id ) ) { + // invoice.paid and invoice.payment_succeeded both list the same invoice; handle it once. + if ( empty( $subscription_id ) || isset( $seen_invoices[ $invoice_data['id'] ] ) ) { ++$count_skipped; continue; } - $membership_subscription = $this->members_subscription_repository->get_subscription_by_subscription_id_meta( $subscription_id ); + $seen_invoices[ $invoice_data['id'] ] = true; + $membership_subscription = $this->members_subscription_repository->get_subscription_by_subscription_id_meta( $subscription_id ); if ( ! empty( $membership_subscription ) ) { try { @@ -4522,6 +4525,25 @@ public function run_missed_payment_backfill( $last_synced ) { $payment = $this->orders_repository->get_order_by_transaction_id( $payment_intent_id ); if ( empty( $payment ) ) { + // Registration can save its order before the PaymentIntent is known; link the signup invoice to it instead of duplicating it. + $unlinked_order = 'subscription_create' === ( $invoice_data['billing_reason'] ?? '' ) ? $this->orders_repository->get_unlinked_order_by_subscription( $membership_subscription['ID'] ) : array(); + if ( ! empty( $unlinked_order ) ) { + $this->orders_repository->update( + $unlinked_order['ID'], + array( + 'transaction_id' => sanitize_text_field( $payment_intent_id ), + 'status' => 'completed', + ) + ); + + ++$count_linked; + $logger->info( + sprintf( '[Backfill][Stripe][Payments] Linked PaymentIntent %s to existing order %d (subscription %s)', $payment_intent_id, $unlinked_order['ID'], $subscription_id ), + array( 'source' => 'urm-missed-payment-backfill' ) + ); + continue; + } + $payment = $this->orders_repository->get_order_by_transaction_id( $subscription_id ); if ( ! empty( $payment ) ) { $this->orders_repository->delete( $payment['ID'] ); @@ -4530,7 +4552,7 @@ public function run_missed_payment_backfill( $last_synced ) { $subscription = $this->members_subscription_repository->retrieve( $membership_subscription['ID'] ); $payment_intent = \Stripe\PaymentIntent::retrieve( $payment_intent_id ); $paid_amount = $payment_intent->amount_received / 100; // Convert from cents to dollars - $created_at = gmdate( 'Y-m-d H:i:s', $invoice->created ); + $created_at = gmdate( 'Y-m-d H:i:s', $invoice_data['created'] ); $order_data = array( 'orders_data' => array( @@ -4574,6 +4596,7 @@ public function run_missed_payment_backfill( $last_synced ) { array( 'event_type' => 'backfill_done', 'orders_created' => $count_created, + 'orders_linked' => $count_linked, 'skipped' => $count_skipped, ), JSON_PRETTY_PRINT diff --git a/tests/e2e/specs/security-stripe-backfill-and-failed-invoice.spec.ts b/tests/e2e/specs/security-stripe-backfill-and-failed-invoice.spec.ts index f2e8a869b..97eca0e36 100644 --- a/tests/e2e/specs/security-stripe-backfill-and-failed-invoice.spec.ts +++ b/tests/e2e/specs/security-stripe-backfill-and-failed-invoice.spec.ts @@ -3,5 +3,5 @@ import { runSecurityRegression } from "../support/security"; // This spec executes the isolated PHP regression; it does not mutate a live site. test("Fix - Backfill paid Stripe renewals on current API versions and keep the member's status while Stripe retries a failed invoice (isolated PHP) @fresh @security", async () => { - await runSecurityRegression("stripe-backfill-and-failed-invoice", 32); + await runSecurityRegression("stripe-backfill-and-failed-invoice", 47); }); diff --git a/tests/security/stripe-backfill-and-failed-invoice.php b/tests/security/stripe-backfill-and-failed-invoice.php index 580dc2f72..1ec9d9fbf 100644 --- a/tests/security/stripe-backfill-and-failed-invoice.php +++ b/tests/security/stripe-backfill-and-failed-invoice.php @@ -7,23 +7,16 @@ */ // Test doubles intentionally bypass production-only conventions. -// phpcs:disable Squiz.Commenting.ClassComment.Missing, Squiz.Commenting.FunctionComment.Missing, Squiz.Commenting.VariableComment.Missing, Squiz.PHP.Eval.Discouraged, WordPress.Files.FileName.InvalidClassFileName, WordPress.WP.GlobalVariablesOverride.Prohibited, Generic.Files.OneObjectStructurePerFile.MultipleFound, Universal.Files.SeparateFunctionsFromOO.Mixed, WordPress.DateTime.RestrictedFunctions.date_date, WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value, WordPress.WP.AlternativeFunctions.json_encode_json_encode +// phpcs:disable Squiz.Commenting.ClassComment.Missing, Squiz.Commenting.FunctionComment.Missing, Squiz.Commenting.VariableComment.Missing, Squiz.PHP.Eval.Discouraged, WordPress.Files.FileName.InvalidClassFileName, WordPress.WP.GlobalVariablesOverride.Prohibited, Generic.Files.OneObjectStructurePerFile.MultipleFound, Universal.Files.SeparateFunctionsFromOO.Mixed, WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value, WordPress.WP.AlternativeFunctions.json_encode_json_encode namespace Stripe { /** * Stands in for the SDK's StripeObject; camelCase SDK methods are served through __call. */ class StripeObject { - public $created = 1759300000; private $values; public function __construct( $values ) { $this->values = $values; } - public function __get( $name ) { - return $this->values[ $name ] ?? null; - } - public function __isset( $name ) { - return isset( $this->values[ $name ] ); - } /** * Serves `toArray()` (an invoice) and `autoPagingIterator()` (an event list). * @@ -41,7 +34,7 @@ public static function all( $params ) { return new StripeObject( array_map( function ( $invoice ) { - return (object) array( 'data' => (object) array( 'object' => new StripeObject( $invoice ) ) ); + return (object) array( 'data' => (object) array( 'object' => new StripeObject( $invoice + array( 'created' => 1759300000 ) ) ) ); }, $GLOBALS['stripe_invoices'] ) @@ -82,12 +75,28 @@ public function error( $message, $context = array() ) { $GLOBALS['logged_errors'][] = $message; } } + /** + * Records every repository call and answers each method from $returns (false when unset). + */ class SecurityRepository { public $calls = array(); - public $row; + public $returns; + public function __construct( $returns ) { + $this->returns = $returns; + } public function __call( $name, $arguments ) { $this->calls[] = array( $name, $arguments ); - return $this->row; + return $this->returns[ $name ] ?? false; + } + public function calls_to( $name ) { + return array_values( + array_filter( + $this->calls, + function ( $call ) use ( $name ) { + return $name === $call[0]; + } + ) + ); } } function ur_get_logger() { @@ -111,30 +120,49 @@ function delete_transient( $key ) {} } eval( 'class StripeBackfillHarness { public $members_subscription_repository; public $orders_repository; ' . $methods . '}' ); - $harness = function ( $subscription_row, $order_row = false ) { - $GLOBALS['invoice_lookups'] = array(); - $GLOBALS['logged_errors'] = array(); - $GLOBALS['processed_logs'] = array(); - $GLOBALS['stripe_throws'] = false; - $GLOBALS['stripe_payments'] = array(); - $service = new StripeBackfillHarness(); - $service->members_subscription_repository = new SecurityRepository(); - $service->members_subscription_repository->row = $subscription_row; - $service->orders_repository = new SecurityRepository(); - $service->orders_repository->row = $order_row; + $local_subscription = array( + 'ID' => 7, + 'user_id' => 76, + 'item_id' => 12, + 'sub_id' => 7, + 'status' => 'active', + ); + $harness = function ( $subscription_row, $order_returns = array() ) { + $GLOBALS['invoice_lookups'] = array(); + $GLOBALS['logged_errors'] = array(); + $GLOBALS['processed_logs'] = array(); + $GLOBALS['stripe_throws'] = false; + $GLOBALS['stripe_payments'] = array(); + $service = new StripeBackfillHarness(); + $service->members_subscription_repository = new SecurityRepository( + array( + 'get_subscription_by_subscription_id_meta' => $subscription_row, + 'get_membership_by_subscription_id' => $subscription_row, + 'retrieve' => $subscription_row, + ) + ); + $service->orders_repository = new SecurityRepository( $order_returns ); return $service; }; - $method_names = function ( $repository ) { - return array_column( $repository->calls, 0 ); + $backfill = function ( $invoices, $subscription_row, $payments = array(), $order_returns = array() ) use ( $harness ) { + $GLOBALS['stripe_invoices'] = $invoices; + $service = $harness( $subscription_row, $order_returns ); + $GLOBALS['stripe_payments'] = $payments; + $service->run_missed_payment_backfill( 1759000000 ); + return $service; }; - $created_orders = function ( $service ) { - return array_values( - array_filter( - $service->orders_repository->calls, - function ( $call ) { - return 'create' === $call[0]; - } - ) + $transaction_ids = function ( $service ) { + return array_map( + function ( $call ) { + return $call[1][0]['orders_data']['transaction_id']; + }, + $service->orders_repository->calls_to( 'create' ) + ); + }; + $basil_invoice = function ( $id ) { + return array( + 'id' => $id, + 'parent' => array( 'subscription_details' => array( 'subscription' => 'sub_basil' ) ), ); }; $paid_by_pi = array( @@ -146,135 +174,102 @@ function ( $call ) { ), ), ); - $local_subscription = array( - 'ID' => 7, - 'user_id' => 76, - 'item_id' => 12, - 'sub_id' => 7, - 'status' => 'active', - ); // 1. Basil-or-later invoice: subscription under parent.subscription_details, payment under payments. - $GLOBALS['stripe_invoices'] = array( - array( - 'id' => 'in_basil', - 'amount_paid' => 2900, - 'parent' => array( 'subscription_details' => array( 'subscription' => 'sub_basil' ) ), - ), - ); - $service = $harness( $local_subscription ); - $GLOBALS['stripe_payments'] = $paid_by_pi; - $service->run_missed_payment_backfill( 1759000000 ); - security_assert( array( 'get_subscription_by_subscription_id_meta', 'sub_basil' ) === array( $service->members_subscription_repository->calls[0][0], $service->members_subscription_repository->calls[0][1][0] ), 'The subscription is read from parent.subscription_details' ); - $orders = $created_orders( $service ); - security_assert( 1 === count( $orders ), 'A paid basil renewal is backfilled as an order' ); - security_assert( 'pi_basil' === $orders[0][1][0]['orders_data']['transaction_id'], 'The order carries the PaymentIntent from invoice.payments' ); - security_assert( 29.0 === (float) $orders[0][1][0]['orders_data']['total_amount'], 'The order total comes from the PaymentIntent' ); + $service = $backfill( array( $basil_invoice( 'in_basil' ) ), $local_subscription, $paid_by_pi ); + security_assert( array( 'sub_basil' ) === $service->members_subscription_repository->calls_to( 'get_subscription_by_subscription_id_meta' )[0][1], 'The subscription is read from parent.subscription_details' ); + security_assert( array( 'pi_basil' ) === $transaction_ids( $service ), 'A paid basil renewal is backfilled as an order with its PaymentIntent' ); + security_assert( 29.0 === (float) $service->orders_repository->calls_to( 'create' )[0][1][0]['orders_data']['total_amount'], 'The order total comes from the PaymentIntent' ); - // 2. Pre-basil invoice keeps working and needs no extra invoice lookup. - $GLOBALS['stripe_invoices'] = array( - array( - 'id' => 'in_legacy', - 'subscription' => 'sub_legacy', - 'payment_intent' => 'pi_legacy', - ), + // 2. invoice.paid and invoice.payment_succeeded list the same invoice: one lookup, one order. + $service = $backfill( array( $basil_invoice( 'in_twice' ), $basil_invoice( 'in_twice' ) ), $local_subscription, $paid_by_pi ); + security_assert( array( 'in_twice' ) === $GLOBALS['invoice_lookups'], 'An invoice listed by two events is looked up once' ); + security_assert( 1 === count( $transaction_ids( $service ) ), 'An invoice listed by two events creates one order' ); + + // 3. Pre-basil invoice keeps working and needs no extra invoice lookup. + $legacy = array( + 'id' => 'in_legacy', + 'subscription' => 'sub_legacy', + 'payment_intent' => 'pi_legacy', ); - $service = $harness( $local_subscription ); - $service->run_missed_payment_backfill( 1759000000 ); - security_assert( 'pi_legacy' === $created_orders( $service )[0][1][0]['orders_data']['transaction_id'], 'A legacy invoice is backfilled from its own payment_intent' ); + $service = $backfill( array( $legacy ), $local_subscription ); + security_assert( array( 'pi_legacy' ) === $transaction_ids( $service ), 'A legacy invoice is backfilled from its own payment_intent' ); security_assert( array() === $GLOBALS['invoice_lookups'], 'No invoice lookup when payment_intent is on the invoice' ); - // 3. An invoice for a subscription this site does not own costs no Stripe lookup. - $GLOBALS['stripe_invoices'] = array( - array( - 'id' => 'in_foreign', - 'parent' => array( 'subscription_details' => array( 'subscription' => 'sub_foreign' ) ), - ), - ); - $service = $harness( false ); - $service->run_missed_payment_backfill( 1759000000 ); + // 4. An invoice for a subscription this site does not own costs no Stripe lookup. + $service = $backfill( array( $basil_invoice( 'in_foreign' ) ), false, $paid_by_pi ); security_assert( array() === $GLOBALS['invoice_lookups'], 'Foreign subscriptions are skipped before any invoice lookup' ); - security_assert( array() === $created_orders( $service ), 'No order for a foreign subscription' ); + security_assert( array() === $transaction_ids( $service ), 'No order for a foreign subscription' ); - // 4. A failed lookup skips that invoice only and the next invoice is still recovered. - $GLOBALS['stripe_invoices'] = array( - array( - 'id' => 'in_unreachable', - 'parent' => array( 'subscription_details' => array( 'subscription' => 'sub_basil' ) ), - ), - array( - 'id' => 'in_next', - 'subscription' => 'sub_basil', - 'payment_intent' => 'pi_next', - ), + // 5. The signup invoice is linked to a registration order saved without a PaymentIntent, not duplicated. + $unlinked = array( 'get_unlinked_order_by_subscription' => array( 'ID' => 41 ) ); + $service = $backfill( array( $basil_invoice( 'in_signup' ) + array( 'billing_reason' => 'subscription_create' ) ), $local_subscription, $paid_by_pi, $unlinked ); + security_assert( array( 7 ) === $service->orders_repository->calls_to( 'get_unlinked_order_by_subscription' )[0][1], 'The unlinked order is looked up on the local subscription' ); + security_assert( array() === $transaction_ids( $service ), 'No duplicate order next to the unlinked registration order' ); + $updates = $service->orders_repository->calls_to( 'update' ); + security_assert( + 1 === count( $updates ) && array( + 41, + array( + 'transaction_id' => 'pi_basil', + 'status' => 'completed', + ), + ) === $updates[0][1], + 'The unlinked registration order receives the PaymentIntent' ); + + // 6. A renewal invoice is never linked to an unlinked order; it gets its own order. + $service = $backfill( array( $basil_invoice( 'in_renewal' ) + array( 'billing_reason' => 'subscription_cycle' ) ), $local_subscription, $paid_by_pi, $unlinked ); + security_assert( array( 'pi_basil' ) === $transaction_ids( $service ), 'A renewal gets its own order' ); + security_assert( array() === $service->orders_repository->calls_to( 'update' ), 'A renewal does not overwrite an unlinked order' ); + + // 7. A failed lookup skips that invoice only and the next invoice is still recovered. + $GLOBALS['stripe_invoices'] = array( $basil_invoice( 'in_unreachable' ), array_merge( $legacy, array( 'subscription' => 'sub_basil' ) ) ); $service = $harness( $local_subscription ); $GLOBALS['stripe_throws'] = true; $service->run_missed_payment_backfill( 1759000000 ); security_assert( 1 === count( $GLOBALS['logged_errors'] ) && false !== strpos( $GLOBALS['logged_errors'][0], 'in_unreachable' ), 'A failed lookup is logged with its invoice' ); - security_assert( 'pi_next' === $created_orders( $service )[0][1][0]['orders_data']['transaction_id'], 'The backfill continues after a failed lookup' ); + security_assert( array( 'pi_legacy' ) === $transaction_ids( $service ), 'The backfill continues after a failed lookup' ); - // 5. A $0 invoice (no payments) still creates no order. - $GLOBALS['stripe_invoices'] = array( - array( - 'id' => 'in_trial', - 'parent' => array( 'subscription_details' => array( 'subscription' => 'sub_basil' ) ), - ), - ); - $service = $harness( $local_subscription ); - $service->run_missed_payment_backfill( 1759000000 ); - security_assert( array() === $created_orders( $service ), 'No order for an invoice without a PaymentIntent' ); + // 8. A $0 invoice (no payments) still creates no order. + $service = $backfill( array( $basil_invoice( 'in_trial' ) ), $local_subscription ); + security_assert( array() === $transaction_ids( $service ), 'No order for an invoice without a PaymentIntent' ); - // 6. The webhook turns a failed lookup into a logged error response so Stripe retries the event. + // 9. The webhook turns a failed lookup into a logged error response so Stripe retries the event. $service = $harness( $local_subscription ); $GLOBALS['stripe_throws'] = true; - $response = null; - try { - $service->handle_succeeded_invoice( array( 'data' => array( 'object' => array( 'id' => 'in_webhook' ) ) ), 'sub_basil' ); - } catch ( SecurityResponse $e ) { - $response = $e; - } - security_assert( null !== $response && 500 === $response->status, 'A failed lookup aborts the webhook with an error status' ); + $response = security_response( + function () use ( $service ) { + $service->handle_succeeded_invoice( array( 'data' => array( 'object' => array( 'id' => 'in_webhook' ) ) ), 'sub_basil' ); + } + ); + security_assert( 500 === $response->status, 'A failed lookup aborts the webhook with an error status' ); security_assert( 1 === count( $GLOBALS['logged_errors'] ) && false !== strpos( $GLOBALS['logged_errors'][0], 'INVOICE_PAYMENT_LOOKUP_FAILED' ), 'The webhook logs the failed lookup' ); - $failed_invoice = function ( $next_payment_attempt ) { - return array( - 'id' => 'evt_failed', - 'data' => array( - 'object' => array( - 'id' => 'in_failed', - 'next_payment_attempt' => $next_payment_attempt, + $failed_invoice = function ( $status, $next_payment_attempt ) use ( $harness, $local_subscription ) { + $service = $harness( array_merge( $local_subscription, array( 'status' => $status ) ) ); + $service->handle_failed_invoice( + array( + 'id' => 'evt_failed', + 'data' => array( + 'object' => array( + 'id' => 'in_failed', + 'next_payment_attempt' => $next_payment_attempt, + ), ), ), + 'sub_basil' ); - }; - $status_writes = function ( $service ) { - return array_values( - array_filter( - $service->members_subscription_repository->calls, - function ( $call ) { - return 'update' === $call[0]; - } - ) - ); + return $service->members_subscription_repository->calls_to( 'update' ); }; - // 7. Stripe will retry: an active or trial member keeps the status. - foreach ( array( 'active', 'trial' ) as $status ) { - $service = $harness( array_merge( $local_subscription, array( 'status' => $status ) ) ); - $service->handle_failed_invoice( $failed_invoice( 1759900000 ), 'sub_basil' ); - security_assert( array() === $status_writes( $service ), 'A retried failure keeps the ' . $status . ' status' ); - security_assert( 1 === count( $GLOBALS['processed_logs'] ), 'The kept status is logged' ); - } + // 10. Stripe will retry: an active member keeps the status, and the hold is logged. + security_assert( array() === $failed_invoice( 'active', 1759900000 ), 'A retried failure keeps the active status' ); + security_assert( 1 === count( $GLOBALS['processed_logs'] ), 'The kept status is logged' ); - // 8. Stripe has stopped retrying: the subscription goes back to pending as before. - $service = $harness( $local_subscription ); - $service->handle_failed_invoice( $failed_invoice( null ), 'sub_basil' ); - $writes = $status_writes( $service ); - security_assert( 1 === count( $writes ) && array( 'status' => 'pending' ) === $writes[0][1][1], 'A final failure sets the subscription to pending' ); - - // 9. A first payment that is still pending stays pending while Stripe retries. - $service = $harness( array_merge( $local_subscription, array( 'status' => 'pending' ) ) ); - $service->handle_failed_invoice( $failed_invoice( 1759900000 ), 'sub_basil' ); - security_assert( array( 'status' => 'pending' ) === $status_writes( $service )[0][1][1], 'A pending subscription is still written as pending' ); + // 11. Every other case still goes to pending: no retry left, an ended trial, or a pending first payment. + foreach ( array( array( 'active', null ), array( 'trial', 1759900000 ), array( 'pending', 1759900000 ) ) as $case ) { + $writes = $failed_invoice( $case[0], $case[1] ); + security_assert( 1 === count( $writes ) && array( 'status' => 'pending' ) === $writes[0][1][1], 'A ' . $case[0] . ' subscription goes to pending when next_payment_attempt is ' . wp_json_encode( $case[1] ) ); + } } From c07591096ed48d43d7f0123b8cf4d33362c3488a Mon Sep 17 00:00:00 2001 From: Ganesh Bhatt Date: Thu, 8 Oct 2026 10:54:59 +0545 Subject: [PATCH 6/8] Keep the Stripe backfill window after a Stripe error, and read the webhook subscription from parent on current API versions (themegrill/user-registration-pro#1888) - The missed-payment scheduler moved its shared sync time forward even when the Stripe backfill threw, so one failed run dropped that window's paid renewals for good. Stripe now keeps its own sync time (urm_last_stripe_backfill_sync_time), advanced only after a fully successful run, like PayPal's. A skipped invoice lookup also keeps the window. - handle_webhook() now falls back to parent.subscription_details.subscription, so invoice events from an endpoint on API 2025-03-31.basil or newer no longer stop at NULL_SUBSCRIPTION_ID. --- .../Admin/Services/Stripe/StripeService.php | 23 ++++++- .../Admin/Services/SubscriptionService.php | 26 +++++-- ...stripe-backfill-and-failed-invoice.spec.ts | 2 +- .../stripe-backfill-and-failed-invoice.php | 67 ++++++++++++++++++- 4 files changed, 107 insertions(+), 11 deletions(-) diff --git a/modules/membership/includes/Admin/Services/Stripe/StripeService.php b/modules/membership/includes/Admin/Services/Stripe/StripeService.php index a06bdd9a1..76dcf30f5 100644 --- a/modules/membership/includes/Admin/Services/Stripe/StripeService.php +++ b/modules/membership/includes/Admin/Services/Stripe/StripeService.php @@ -48,6 +48,13 @@ class StripeService { */ protected $orders_repository; + /** + * Set when a missed-payment backfill skipped an invoice it could not resolve during this request. + * + * @var bool + */ + private $backfill_failed = false; + /** * Construct. */ @@ -2500,7 +2507,7 @@ public function handle_webhook( $event, $subscription_id ) { try { $event_id = sanitize_text_field( $event['id'] ); $event = json_decode( wp_json_encode( \Stripe\Event::retrieve( $event_id ) ), true ); - $subscription_id = $event['data']['object']['subscription'] ?? $subscription_id; + $subscription_id = $event['data']['object']['subscription'] ?? $event['data']['object']['parent']['subscription_details']['subscription'] ?? $subscription_id; } catch ( \Exception $e ) { PaymentGatewayLogging::log_webhook_received( 'stripe', @@ -4454,6 +4461,17 @@ public function run_missed_subscription_backfill( $last_synced ) { $logger->info( '[Backfill][Stripe][Status] ---------- ENDED ----------', array( 'source' => 'urm-missed-payment-backfill' ) ); } + /** + * Whether a missed-payment backfill in this request skipped an invoice it could not resolve. + * + * The scheduler keeps the Stripe sync time when this is true, so the window is searched again next run. + * + * @return bool + */ + public function has_backfill_failure() { + return $this->backfill_failed; + } + /** * Backfill missed payments events record. * @@ -4508,7 +4526,8 @@ public function run_missed_payment_backfill( $last_synced ) { try { $payment_intent_id = $this->get_invoice_payment_intent_id( $invoice_data ); } catch ( \Exception $e ) { - // Skip only this invoice: the sync time advances even when the backfill throws, so aborting would drop the rest of the window. + // Skip only this invoice so the rest of the window is still recovered; the flag keeps the window for the next run. + $this->backfill_failed = true; $logger->error( sprintf( '[Backfill][Stripe][Payments] Could not resolve the PaymentIntent of invoice %s: %s', $invoice_data['id'] ?? '', $e->getMessage() ), array( 'source' => 'urm-missed-payment-backfill' ) diff --git a/modules/membership/includes/Admin/Services/SubscriptionService.php b/modules/membership/includes/Admin/Services/SubscriptionService.php index 06194ab62..b0897512a 100644 --- a/modules/membership/includes/Admin/Services/SubscriptionService.php +++ b/modules/membership/includes/Admin/Services/SubscriptionService.php @@ -1778,13 +1778,20 @@ public function urm_backfill_missed_payment_events( $last_synced, $now = null ) ); break; } + // Stripe keeps its own sync time, advanced only on full success, so a window that hit a Stripe error is searched again next run. + $stripe_last_synced = (int) get_option( 'urm_last_stripe_backfill_sync_time', 0 ); + if ( $stripe_last_synced <= 0 ) { + $stripe_last_synced = $last_synced; + } + $stripe_synced = false; try { $stripe_service = new StripeService(); - $stripe_service->run_missed_subscription_backfill( $last_synced ); - $stripe_service->run_missed_payment_backfill( $last_synced ); - $stripe_service->run_missed_onetime_payment_backfill( $last_synced ); - $stripe_service->run_missed_refund_backfill( $last_synced ); - $stripe_service->run_missed_dispute_backfill( $last_synced ); + $stripe_service->run_missed_subscription_backfill( $stripe_last_synced ); + $stripe_service->run_missed_payment_backfill( $stripe_last_synced ); + $stripe_service->run_missed_onetime_payment_backfill( $stripe_last_synced ); + $stripe_service->run_missed_refund_backfill( $stripe_last_synced ); + $stripe_service->run_missed_dispute_backfill( $stripe_last_synced ); + $stripe_synced = ! $stripe_service->has_backfill_failure(); } catch ( \Exception $e ) { ur_get_logger()->error( sprintf( @@ -1793,8 +1800,15 @@ public function urm_backfill_missed_payment_events( $last_synced, $now = null ) ), array( 'source' => 'urm-missed-payment-backfill' ) ); - break; } + if ( ! $stripe_synced ) { + ur_get_logger()->warning( + '[Backfill][Stripe] A Stripe request failed; the Stripe sync time is kept and this window is searched again next run.', + array( 'source' => 'urm-missed-payment-backfill' ) + ); + } + // Storing the window start on failure also covers a first run, which has no stored Stripe sync time yet. + update_option( 'urm_last_stripe_backfill_sync_time', $stripe_synced ? $now : $stripe_last_synced ); break; case 'paypal': try { diff --git a/tests/e2e/specs/security-stripe-backfill-and-failed-invoice.spec.ts b/tests/e2e/specs/security-stripe-backfill-and-failed-invoice.spec.ts index 97eca0e36..b96d5f979 100644 --- a/tests/e2e/specs/security-stripe-backfill-and-failed-invoice.spec.ts +++ b/tests/e2e/specs/security-stripe-backfill-and-failed-invoice.spec.ts @@ -3,5 +3,5 @@ import { runSecurityRegression } from "../support/security"; // This spec executes the isolated PHP regression; it does not mutate a live site. test("Fix - Backfill paid Stripe renewals on current API versions and keep the member's status while Stripe retries a failed invoice (isolated PHP) @fresh @security", async () => { - await runSecurityRegression("stripe-backfill-and-failed-invoice", 47); + await runSecurityRegression("stripe-backfill-and-failed-invoice", 59); }); diff --git a/tests/security/stripe-backfill-and-failed-invoice.php b/tests/security/stripe-backfill-and-failed-invoice.php index 1ec9d9fbf..371c89af6 100644 --- a/tests/security/stripe-backfill-and-failed-invoice.php +++ b/tests/security/stripe-backfill-and-failed-invoice.php @@ -40,6 +40,9 @@ function ( $invoice ) { ) ); } + public static function retrieve( $id ) { + return $GLOBALS['stripe_event']; + } } class Invoice { public static function retrieve( $params ) { @@ -74,6 +77,26 @@ public function info( $message, $context = array() ) {} public function error( $message, $context = array() ) { $GLOBALS['logged_errors'][] = $message; } + public function warning( $message, $context = array() ) { + $GLOBALS['logged_warnings'][] = $message; + } + } + /** + * Scheduler-side double: records the window each Stripe backfill receives and fails on request. + */ + class StripeService { + public static function get_stripe_settings() { + return array( 'secret_key' => 'sk_test_double' ); + } + public function __call( $name, $arguments ) { + $GLOBALS['backfill_windows'][ $name ] = $arguments[0]; + if ( 'run_missed_payment_backfill' === $name && 'throw' === $GLOBALS['stripe_outcome'] ) { + throw new \Exception( 'Stripe API error' ); + } + } + public function has_backfill_failure() { + return 'skipped' === $GLOBALS['stripe_outcome']; + } } /** * Records every repository call and answers each method from $returns (false when unset). @@ -115,10 +138,10 @@ function delete_transient( $key ) {} $stripe_service = 'modules/membership/includes/Admin/Services/Stripe/StripeService.php'; $methods = ''; - foreach ( array( 'extract_stripe_id', 'get_invoice_payment_intent_id', 'run_missed_payment_backfill', 'handle_failed_invoice', 'handle_succeeded_invoice' ) as $method ) { + foreach ( array( 'extract_stripe_id', 'get_invoice_payment_intent_id', 'run_missed_payment_backfill', 'handle_failed_invoice', 'handle_succeeded_invoice', 'has_backfill_failure' ) as $method ) { $methods .= security_function( $stripe_service, $method ); } - eval( 'class StripeBackfillHarness { public $members_subscription_repository; public $orders_repository; ' . $methods . '}' ); + eval( 'class StripeBackfillHarness { public $members_subscription_repository; public $orders_repository; private $backfill_failed = false; ' . $methods . '}' ); $local_subscription = array( 'ID' => 7, @@ -230,10 +253,12 @@ function ( $call ) { $service->run_missed_payment_backfill( 1759000000 ); security_assert( 1 === count( $GLOBALS['logged_errors'] ) && false !== strpos( $GLOBALS['logged_errors'][0], 'in_unreachable' ), 'A failed lookup is logged with its invoice' ); security_assert( array( 'pi_legacy' ) === $transaction_ids( $service ), 'The backfill continues after a failed lookup' ); + security_assert( $service->has_backfill_failure(), 'A failed lookup flags the run so the scheduler keeps the window' ); // 8. A $0 invoice (no payments) still creates no order. $service = $backfill( array( $basil_invoice( 'in_trial' ) ), $local_subscription ); security_assert( array() === $transaction_ids( $service ), 'No order for an invoice without a PaymentIntent' ); + security_assert( ! $service->has_backfill_failure(), 'A run without lookup errors does not flag the run' ); // 9. The webhook turns a failed lookup into a logged error response so Stripe retries the event. $service = $harness( $local_subscription ); @@ -272,4 +297,42 @@ function () use ( $service ) { $writes = $failed_invoice( $case[0], $case[1] ); security_assert( 1 === count( $writes ) && array( 'status' => 'pending' ) === $writes[0][1][1], 'A ' . $case[0] . ' subscription goes to pending when next_payment_attempt is ' . wp_json_encode( $case[1] ) ); } + + // 12. A webhook from an endpoint on a current API version names the subscription only under parent.subscription_details. + eval( 'class StripeWebhookHarness { public $handled = array(); ' . security_function( $stripe_service, 'handle_webhook' ) . ' public function handle_failed_invoice( $event, $subscription_id ) { $this->handled[] = $subscription_id; } }' ); + $webhook = function ( $invoice ) { + $GLOBALS['stripe_event'] = array( + 'id' => 'evt_1', + 'type' => 'invoice.payment_failed', + 'data' => array( 'object' => $invoice ), + ); + $service = new StripeWebhookHarness(); + $service->handle_webhook( array( 'id' => 'evt_1' ), null ); + return $service->handled; + }; + security_assert( array( 'sub_basil' ) === $webhook( array( 'parent' => array( 'subscription_details' => array( 'subscription' => 'sub_basil' ) ) ) ), 'The webhook reads the subscription from parent.subscription_details' ); + security_assert( array( 'sub_legacy' ) === $webhook( array( 'subscription' => 'sub_legacy' ) ), 'The top-level subscription still wins on older API versions' ); + + // 13. Stripe keeps its own sync time and only moves it forward after a fully successful backfill. + eval( 'class StripeSchedulerHarness { ' . security_function( 'modules/membership/includes/Admin/Services/SubscriptionService.php', 'urm_backfill_missed_payment_events' ) . '}' ); + $schedule = function ( $outcome, $stripe_sync ) { + $GLOBALS['options'] = array( + 'ur_membership_payment_gateways' => array( 'stripe' => array() ), + 'urm_last_stripe_backfill_sync_time' => $stripe_sync, + ); + $GLOBALS['writes'] = array(); + $GLOBALS['logged_warnings'] = array(); + $GLOBALS['backfill_windows'] = array(); + $GLOBALS['stripe_outcome'] = $outcome; + ( new StripeSchedulerHarness() )->urm_backfill_missed_payment_events( 1000, 2000 ); + return $GLOBALS['writes']['urm_last_stripe_backfill_sync_time'] ?? null; + }; + security_assert( 2000 === $schedule( 'ok', 0 ), 'A successful first run stores the current time' ); + security_assert( 1000 === $GLOBALS['backfill_windows']['run_missed_payment_backfill'], 'A first run starts from the shared sync time' ); + security_assert( 1000 === $schedule( 'throw', 0 ), 'A Stripe error on a first run keeps the start of the failed window' ); + security_assert( 1 === count( $GLOBALS['logged_warnings'] ), 'A kept window is logged' ); + security_assert( 1500 === $schedule( 'throw', 1500 ), 'A Stripe error keeps the stored Stripe sync time' ); + security_assert( 1500 === $schedule( 'skipped', 1500 ), 'A skipped invoice keeps the stored Stripe sync time' ); + security_assert( 2000 === $schedule( 'ok', 1500 ), 'The next successful run moves the Stripe sync time forward' ); + security_assert( 1500 === $GLOBALS['backfill_windows']['run_missed_payment_backfill'], 'The retried run searches from the kept Stripe sync time' ); } From 6e7104029b634cb0a60abe5e97d58e6c6815b262 Mon Sep 17 00:00:00 2001 From: Ganesh Bhatt Date: Thu, 8 Oct 2026 11:04:57 +0545 Subject: [PATCH 7/8] Address review on the Stripe sync-time fix (themegrill/user-registration-pro#1888) - Keep a failed window only for the order backfills (payments, one-time), which match by PaymentIntent. Status, refund and dispute backfills stay on the shared sync time, because replaying their events re-applies stale snapshots. - Also keep the window when a backfilled order cannot be saved or linked, not only when Stripe throws. - Normalize the webhook's subscription ID through extract_stripe_id(). --- .../Admin/Services/Stripe/StripeService.php | 30 +++++++++++++++---- .../Admin/Services/SubscriptionService.php | 25 ++++++++-------- ...stripe-backfill-and-failed-invoice.spec.ts | 2 +- .../stripe-backfill-and-failed-invoice.php | 22 ++++++++++---- 4 files changed, 55 insertions(+), 24 deletions(-) diff --git a/modules/membership/includes/Admin/Services/Stripe/StripeService.php b/modules/membership/includes/Admin/Services/Stripe/StripeService.php index 76dcf30f5..7e2b3d0e7 100644 --- a/modules/membership/includes/Admin/Services/Stripe/StripeService.php +++ b/modules/membership/includes/Admin/Services/Stripe/StripeService.php @@ -49,7 +49,7 @@ class StripeService { protected $orders_repository; /** - * Set when a missed-payment backfill skipped an invoice it could not resolve during this request. + * Set when a missed-payment backfill skipped an invoice it could not resolve or save during this request. * * @var bool */ @@ -2507,7 +2507,7 @@ public function handle_webhook( $event, $subscription_id ) { try { $event_id = sanitize_text_field( $event['id'] ); $event = json_decode( wp_json_encode( \Stripe\Event::retrieve( $event_id ) ), true ); - $subscription_id = $event['data']['object']['subscription'] ?? $event['data']['object']['parent']['subscription_details']['subscription'] ?? $subscription_id; + $subscription_id = $this->extract_stripe_id( $event['data']['object']['subscription'] ?? $event['data']['object']['parent']['subscription_details']['subscription'] ?? null ) ?? $subscription_id; } catch ( \Exception $e ) { PaymentGatewayLogging::log_webhook_received( 'stripe', @@ -4462,9 +4462,9 @@ public function run_missed_subscription_backfill( $last_synced ) { } /** - * Whether a missed-payment backfill in this request skipped an invoice it could not resolve. + * Whether a missed-payment backfill in this request skipped an invoice it could not resolve or save. * - * The scheduler keeps the Stripe sync time when this is true, so the window is searched again next run. + * The scheduler keeps the Stripe payment sync time when this is true, so the window is searched again next run. * * @return bool */ @@ -4547,7 +4547,7 @@ public function run_missed_payment_backfill( $last_synced ) { // Registration can save its order before the PaymentIntent is known; link the signup invoice to it instead of duplicating it. $unlinked_order = 'subscription_create' === ( $invoice_data['billing_reason'] ?? '' ) ? $this->orders_repository->get_unlinked_order_by_subscription( $membership_subscription['ID'] ) : array(); if ( ! empty( $unlinked_order ) ) { - $this->orders_repository->update( + $linked = $this->orders_repository->update( $unlinked_order['ID'], array( 'transaction_id' => sanitize_text_field( $payment_intent_id ), @@ -4555,6 +4555,16 @@ public function run_missed_payment_backfill( $last_synced ) { ) ); + if ( false === $linked ) { + $this->backfill_failed = true; + $logger->error( + sprintf( '[Backfill][Stripe][Payments] Could not link PaymentIntent %s to order %d', $payment_intent_id, $unlinked_order['ID'] ), + array( 'source' => 'urm-missed-payment-backfill' ) + ); + ++$count_skipped; + continue; + } + ++$count_linked; $logger->info( sprintf( '[Backfill][Stripe][Payments] Linked PaymentIntent %s to existing order %d (subscription %s)', $payment_intent_id, $unlinked_order['ID'], $subscription_id ), @@ -4595,7 +4605,15 @@ public function run_missed_payment_backfill( $last_synced ) { ), ), ); - $this->orders_repository->create( $order_data ); + if ( empty( $this->orders_repository->create( $order_data ) ) ) { + $this->backfill_failed = true; + $logger->error( + sprintf( '[Backfill][Stripe][Payments] Could not save the order for PaymentIntent %s (subscription %s)', $payment_intent_id, $subscription_id ), + array( 'source' => 'urm-missed-payment-backfill' ) + ); + ++$count_skipped; + continue; + } ++$count_created; $logger->info( diff --git a/modules/membership/includes/Admin/Services/SubscriptionService.php b/modules/membership/includes/Admin/Services/SubscriptionService.php index b0897512a..3fcd2542d 100644 --- a/modules/membership/includes/Admin/Services/SubscriptionService.php +++ b/modules/membership/includes/Admin/Services/SubscriptionService.php @@ -1778,19 +1778,20 @@ public function urm_backfill_missed_payment_events( $last_synced, $now = null ) ); break; } - // Stripe keeps its own sync time, advanced only on full success, so a window that hit a Stripe error is searched again next run. - $stripe_last_synced = (int) get_option( 'urm_last_stripe_backfill_sync_time', 0 ); - if ( $stripe_last_synced <= 0 ) { - $stripe_last_synced = $last_synced; + // Order backfills (idempotent by PaymentIntent) keep a failed Stripe window for the next run; status and dispute replays would re-apply stale snapshots. + $stripe_payments_sync_option = 'urm_last_stripe_payment_backfill_sync_time'; + $stripe_payments_synced_from = (int) get_option( $stripe_payments_sync_option, 0 ); + if ( $stripe_payments_synced_from <= 0 ) { + $stripe_payments_synced_from = $last_synced; } $stripe_synced = false; try { $stripe_service = new StripeService(); - $stripe_service->run_missed_subscription_backfill( $stripe_last_synced ); - $stripe_service->run_missed_payment_backfill( $stripe_last_synced ); - $stripe_service->run_missed_onetime_payment_backfill( $stripe_last_synced ); - $stripe_service->run_missed_refund_backfill( $stripe_last_synced ); - $stripe_service->run_missed_dispute_backfill( $stripe_last_synced ); + $stripe_service->run_missed_subscription_backfill( $last_synced ); + $stripe_service->run_missed_payment_backfill( $stripe_payments_synced_from ); + $stripe_service->run_missed_onetime_payment_backfill( $stripe_payments_synced_from ); + $stripe_service->run_missed_refund_backfill( $last_synced ); + $stripe_service->run_missed_dispute_backfill( $last_synced ); $stripe_synced = ! $stripe_service->has_backfill_failure(); } catch ( \Exception $e ) { ur_get_logger()->error( @@ -1803,12 +1804,12 @@ public function urm_backfill_missed_payment_events( $last_synced, $now = null ) } if ( ! $stripe_synced ) { ur_get_logger()->warning( - '[Backfill][Stripe] A Stripe request failed; the Stripe sync time is kept and this window is searched again next run.', + '[Backfill][Stripe] A Stripe request or order write failed; the Stripe payment sync time is kept and this window is searched again next run.', array( 'source' => 'urm-missed-payment-backfill' ) ); } - // Storing the window start on failure also covers a first run, which has no stored Stripe sync time yet. - update_option( 'urm_last_stripe_backfill_sync_time', $stripe_synced ? $now : $stripe_last_synced ); + // Storing the window start on failure also covers a first run, which has no stored Stripe payment sync time yet. + update_option( $stripe_payments_sync_option, $stripe_synced ? $now : $stripe_payments_synced_from ); break; case 'paypal': try { diff --git a/tests/e2e/specs/security-stripe-backfill-and-failed-invoice.spec.ts b/tests/e2e/specs/security-stripe-backfill-and-failed-invoice.spec.ts index b96d5f979..2dbd88dc7 100644 --- a/tests/e2e/specs/security-stripe-backfill-and-failed-invoice.spec.ts +++ b/tests/e2e/specs/security-stripe-backfill-and-failed-invoice.spec.ts @@ -3,5 +3,5 @@ import { runSecurityRegression } from "../support/security"; // This spec executes the isolated PHP regression; it does not mutate a live site. test("Fix - Backfill paid Stripe renewals on current API versions and keep the member's status while Stripe retries a failed invoice (isolated PHP) @fresh @security", async () => { - await runSecurityRegression("stripe-backfill-and-failed-invoice", 59); + await runSecurityRegression("stripe-backfill-and-failed-invoice", 68); }); diff --git a/tests/security/stripe-backfill-and-failed-invoice.php b/tests/security/stripe-backfill-and-failed-invoice.php index 371c89af6..50c01dd32 100644 --- a/tests/security/stripe-backfill-and-failed-invoice.php +++ b/tests/security/stripe-backfill-and-failed-invoice.php @@ -164,7 +164,12 @@ function delete_transient( $key ) {} 'retrieve' => $subscription_row, ) ); - $service->orders_repository = new SecurityRepository( $order_returns ); + $service->orders_repository = new SecurityRepository( + $order_returns + array( + 'create' => array( 'ID' => 99 ), + 'update' => 1, + ) + ); return $service; }; $backfill = function ( $invoices, $subscription_row, $payments = array(), $order_returns = array() ) use ( $harness ) { @@ -255,6 +260,12 @@ function ( $call ) { security_assert( array( 'pi_legacy' ) === $transaction_ids( $service ), 'The backfill continues after a failed lookup' ); security_assert( $service->has_backfill_failure(), 'A failed lookup flags the run so the scheduler keeps the window' ); + // 7b. An order that cannot be saved, or linked, flags the run so the window is searched again. + $service = $backfill( array( $basil_invoice( 'in_db_down' ) ), $local_subscription, $paid_by_pi, array( 'create' => array() ) ); + security_assert( $service->has_backfill_failure() && false !== strpos( $GLOBALS['logged_errors'][0] ?? '', 'Could not save the order' ), 'A failed order write flags the run' ); + $service = $backfill( array( $basil_invoice( 'in_link_down' ) + array( 'billing_reason' => 'subscription_create' ) ), $local_subscription, $paid_by_pi, $unlinked + array( 'update' => false ) ); + security_assert( $service->has_backfill_failure() && false !== strpos( $GLOBALS['logged_errors'][0] ?? '', 'Could not link' ), 'A failed order link flags the run' ); + // 8. A $0 invoice (no payments) still creates no order. $service = $backfill( array( $basil_invoice( 'in_trial' ) ), $local_subscription ); security_assert( array() === $transaction_ids( $service ), 'No order for an invoice without a PaymentIntent' ); @@ -299,7 +310,7 @@ function () use ( $service ) { } // 12. A webhook from an endpoint on a current API version names the subscription only under parent.subscription_details. - eval( 'class StripeWebhookHarness { public $handled = array(); ' . security_function( $stripe_service, 'handle_webhook' ) . ' public function handle_failed_invoice( $event, $subscription_id ) { $this->handled[] = $subscription_id; } }' ); + eval( 'class StripeWebhookHarness { public $handled = array(); ' . security_function( $stripe_service, 'handle_webhook' ) . security_function( $stripe_service, 'extract_stripe_id' ) . ' public function handle_failed_invoice( $event, $subscription_id ) { $this->handled[] = $subscription_id; } }' ); $webhook = function ( $invoice ) { $GLOBALS['stripe_event'] = array( 'id' => 'evt_1', @@ -317,15 +328,15 @@ function () use ( $service ) { eval( 'class StripeSchedulerHarness { ' . security_function( 'modules/membership/includes/Admin/Services/SubscriptionService.php', 'urm_backfill_missed_payment_events' ) . '}' ); $schedule = function ( $outcome, $stripe_sync ) { $GLOBALS['options'] = array( - 'ur_membership_payment_gateways' => array( 'stripe' => array() ), - 'urm_last_stripe_backfill_sync_time' => $stripe_sync, + 'ur_membership_payment_gateways' => array( 'stripe' => array() ), + 'urm_last_stripe_payment_backfill_sync_time' => $stripe_sync, ); $GLOBALS['writes'] = array(); $GLOBALS['logged_warnings'] = array(); $GLOBALS['backfill_windows'] = array(); $GLOBALS['stripe_outcome'] = $outcome; ( new StripeSchedulerHarness() )->urm_backfill_missed_payment_events( 1000, 2000 ); - return $GLOBALS['writes']['urm_last_stripe_backfill_sync_time'] ?? null; + return $GLOBALS['writes']['urm_last_stripe_payment_backfill_sync_time'] ?? null; }; security_assert( 2000 === $schedule( 'ok', 0 ), 'A successful first run stores the current time' ); security_assert( 1000 === $GLOBALS['backfill_windows']['run_missed_payment_backfill'], 'A first run starts from the shared sync time' ); @@ -335,4 +346,5 @@ function () use ( $service ) { security_assert( 1500 === $schedule( 'skipped', 1500 ), 'A skipped invoice keeps the stored Stripe sync time' ); security_assert( 2000 === $schedule( 'ok', 1500 ), 'The next successful run moves the Stripe sync time forward' ); security_assert( 1500 === $GLOBALS['backfill_windows']['run_missed_payment_backfill'], 'The retried run searches from the kept Stripe sync time' ); + security_assert( 1000 === $GLOBALS['backfill_windows']['run_missed_subscription_backfill'] && 1000 === $GLOBALS['backfill_windows']['run_missed_dispute_backfill'], 'Status and dispute backfills stay on the shared sync time' ); } From f173d9dc66948005fac77e378b21120ba3b95b0c Mon Sep 17 00:00:00 2001 From: Ganesh Bhatt Date: Thu, 8 Oct 2026 11:09:27 +0545 Subject: [PATCH 8/8] Address Copilot review on PR #1547 (themegrill/user-registration-pro#1888) - Link a signup invoice only to a Stripe order of the subscription, and only when exactly one unlinked non-trial order exists, so a payment is never written onto a PayPal/bank order or onto a guessed order when an upgrade left a second unlinked one. - Normalize the PaymentIntent read from invoice.payments through extract_stripe_id(), in case Stripe returns it expanded. --- .../includes/Admin/Repositories/OrdersRepository.php | 10 +++++----- .../includes/Admin/Services/Stripe/StripeService.php | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/modules/membership/includes/Admin/Repositories/OrdersRepository.php b/modules/membership/includes/Admin/Repositories/OrdersRepository.php index 0ac478feb..a05a80e72 100644 --- a/modules/membership/includes/Admin/Repositories/OrdersRepository.php +++ b/modules/membership/includes/Admin/Repositories/OrdersRepository.php @@ -343,22 +343,22 @@ public function get_order_by_transaction_id( $transaction_id ) { } /** - * The oldest non-trial order of a subscription that has no transaction ID yet, such as a registration + * The one non-trial Stripe order of a subscription that has no transaction ID yet, such as a registration * order saved before its PaymentIntent was known. * * @param int $subscription_id Local subscription ID. - * @return array Order row, or an empty array when every order already has a transaction ID. + * @return array Order row, or an empty array when there is no such order or more than one, so a payment is never linked to a guessed order. */ public function get_unlinked_order_by_subscription( $subscription_id ) { - $result = $this->wpdb()->get_row( + $results = $this->wpdb()->get_results( $this->wpdb()->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Bound by prepare(); the sniff trips on the interpolated internal table name. - "SELECT * FROM $this->table WHERE subscription_id = %d AND transaction_id = '' AND trial_status = 'off' ORDER BY ID ASC LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Internal table name; the ID is bound by prepare(). + "SELECT * FROM $this->table WHERE subscription_id = %d AND payment_method = 'stripe' AND transaction_id = '' AND trial_status = 'off' LIMIT 2", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Internal table name; the ID is bound by prepare(). $subscription_id // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Bound by prepare(); the sniff trips on the interpolated internal table name. ), ARRAY_A ); - return ! $result ? array() : $result; + return 1 === count( (array) $results ) ? $results[0] : array(); } public function get_all_delayed_orders( $date ) { diff --git a/modules/membership/includes/Admin/Services/Stripe/StripeService.php b/modules/membership/includes/Admin/Services/Stripe/StripeService.php index 7e2b3d0e7..7c6cd0163 100644 --- a/modules/membership/includes/Admin/Services/Stripe/StripeService.php +++ b/modules/membership/includes/Admin/Services/Stripe/StripeService.php @@ -2852,7 +2852,7 @@ private function get_invoice_payment_intent_id( $invoice ) { foreach ( $payments as $payment ) { if ( 'paid' === ( $payment['status'] ?? '' ) && ! empty( $payment['payment']['payment_intent'] ) ) { - return $payment['payment']['payment_intent']; + return $this->extract_stripe_id( $payment['payment']['payment_intent'] ) ?? ''; } }