diff --git a/modules/membership/includes/Admin/Repositories/OrdersRepository.php b/modules/membership/includes/Admin/Repositories/OrdersRepository.php index 68b331c6a..a05a80e72 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 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 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 ) { + $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 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 1 === count( (array) $results ) ? $results[0] : array(); + } + 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 c4240595e..08a4c2243 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 or save during this request. + * + * @var bool + */ + private $backfill_failed = false; + /** * Construct. */ @@ -1761,8 +1768,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( @@ -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 = $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', @@ -2653,10 +2660,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 +2835,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,32 +2843,16 @@ 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'] ) ) { - return $payment['payment']['payment_intent']; + return $this->extract_stripe_id( $payment['payment']['payment_intent'] ) ?? ''; } } @@ -2901,6 +2909,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, 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', + 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 +2935,6 @@ public function handle_failed_invoice( $event, $subscription_id ) { ) ); - $member_id = $current_subscription['user_id']; PaymentGatewayLogging::log_webhook_processed( 'stripe', 'Invoice payment failed handled', @@ -4539,6 +4565,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 or save. + * + * The scheduler keeps the Stripe payment 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. * @@ -4572,24 +4609,74 @@ 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; - $subscription_id = $invoice->subscription ?? null; - $payment_intent_id = $invoice->payment_intent ?? null; + $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 ) || empty( $payment_intent_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 { + $payment_intent_id = $this->get_invoice_payment_intent_id( $invoice_data ); + } catch ( \Exception $e ) { + // 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' ) + ); + ++$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 ) ) { + // 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 ) ) { + $linked = $this->orders_repository->update( + $unlinked_order['ID'], + array( + 'transaction_id' => sanitize_text_field( $payment_intent_id ), + 'status' => 'completed', + ) + ); + + 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 ), + 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'] ); @@ -4598,7 +4685,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( @@ -4622,7 +4709,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( @@ -4642,6 +4737,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/modules/membership/includes/Admin/Services/SubscriptionService.php b/modules/membership/includes/Admin/Services/SubscriptionService.php index 06194ab62..3fcd2542d 100644 --- a/modules/membership/includes/Admin/Services/SubscriptionService.php +++ b/modules/membership/includes/Admin/Services/SubscriptionService.php @@ -1778,13 +1778,21 @@ public function urm_backfill_missed_payment_events( $last_synced, $now = null ) ); break; } + // 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( $last_synced ); - $stripe_service->run_missed_payment_backfill( $last_synced ); - $stripe_service->run_missed_onetime_payment_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( sprintf( @@ -1793,8 +1801,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 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 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 new file mode 100644 index 000000000..2dbd88dc7 --- /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", 68); +}); 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..50c01dd32 --- /dev/null +++ b/tests/security/stripe-backfill-and-failed-invoice.php @@ -0,0 +1,350 @@ +values = $values; + } + /** + * 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 + array( 'created' => 1759300000 ) ) ) ); + }, + $GLOBALS['stripe_invoices'] + ) + ); + } + public static function retrieve( $id ) { + return $GLOBALS['stripe_event']; + } + } + 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; + } + 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). + */ + class SecurityRepository { + public $calls = array(); + public $returns; + public function __construct( $returns ) { + $this->returns = $returns; + } + public function __call( $name, $arguments ) { + $this->calls[] = array( $name, $arguments ); + 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() { + 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', 'has_backfill_failure' ) as $method ) { + $methods .= security_function( $stripe_service, $method ); + } + eval( 'class StripeBackfillHarness { public $members_subscription_repository; public $orders_repository; private $backfill_failed = false; ' . $methods . '}' ); + + $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 + array( + 'create' => array( 'ID' => 99 ), + 'update' => 1, + ) + ); + return $service; + }; + $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; + }; + $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( + array( + 'status' => 'paid', + 'payment' => array( + 'type' => 'payment_intent', + 'payment_intent' => 'pi_basil', + ), + ), + ); + + // 1. Basil-or-later invoice: subscription under parent.subscription_details, payment under payments. + $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. 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 = $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' ); + + // 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() === $transaction_ids( $service ), 'No order for a foreign subscription' ); + + // 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( 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' ); + 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 ); + $GLOBALS['stripe_throws'] = true; + $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 ( $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' + ); + return $service->members_subscription_repository->calls_to( 'update' ); + }; + + // 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' ); + + // 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] ) ); + } + + // 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' ) . 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', + '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_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_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' ); + 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' ); + 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' ); +} 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' ); }