Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
172 changes: 134 additions & 38 deletions modules/membership/includes/Admin/Services/Stripe/StripeService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -2812,39 +2835,24 @@ 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 );
if ( ! empty( $payment_intent_id ) ) {
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'] ) ) {
Comment thread
bhattaganesh marked this conversation as resolved.
return $payment['payment']['payment_intent'];
return $this->extract_stripe_id( $payment['payment']['payment_intent'] ) ?? '';
}
}

Expand Down Expand Up @@ -2901,14 +2909,32 @@ 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(
'status' => 'pending',
)
);

$member_id = $current_subscription['user_id'];
PaymentGatewayLogging::log_webhook_processed(
'stripe',
'Invoice payment failed handled',
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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;
Comment thread
bhattaganesh marked this conversation as resolved.
$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();
Comment thread
bhattaganesh marked this conversation as resolved.
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'] );
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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
Expand Down
21 changes: 18 additions & 3 deletions modules/membership/includes/Admin/Services/SubscriptionService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Loading
Loading