PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / 2.4.1
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More v2.4.1
2.4.1 2.4.0 2.3.4 2.3.3 2.3.2 2.3.1 2.3.0 2.2.2 2.2.1 2.2.0 2.1.2 2.1.1 trunk 0.0.1 0.0.2 0.0.3 0.0.4 0.0.5 0.0.6 0.0.7 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 All 68 releases
← All changes | includes/WooCommerce/OrderHandler.php +113 -27 2.3.4 → 2.4.1 View file →
@@ -19,9 +19,10 @@
19 19 * Two runtime responsibilities:
20 20 * 1. On the order-received page, trigger the EXISTING verifier
21 21 * (Handler::stripe_payment_success()) — the engine re-retrieves the
22 22 * Checkout Session server-side, flips the transaction row to paid and
23 - * fires `better_payment/payment_confirmed`.
23 + * fires `better_payment/payment_confirmed`. A $0 free-trial order's
24 + * setup-mode session goes through verify_setup_return() instead.
24 25 * 2. Consume `better_payment/payment_confirmed` and mark the linked
25 26 * WooCommerce order as paid. Transactions without a `wc_order_id`
26 27 * (i.e. every non-WooCommerce payment) are ignored with an early
27 28 * return, so existing traffic is untouched.
@@ -34,9 +35,9 @@
34 35 class OrderHandler {
35 36
36 37 /**
37 38 * Transaction statuses the engine treats as a successful payment.
38 - * Superset of Campaign\CampaignStats::approved_statuses(), plus
39 + * Superset of Classes\Helper::APPROVED_DONATION_STATUSES, plus
39 40 * Stripe's `no_payment_required` (fully discounted sessions).
40 41 *
41 42 * @var string[]
42 43 */
@@ -52,10 +53,11 @@
52 53 // customer sees the post-verification order state.
53 54 add_action( 'template_redirect', array( __CLASS__, 'maybe_verify_return' ), 5 );
54 55
55 56 // The engine's completion contract — same hook Campaign\CampaignStats
56 - // consumes. Fired by Handler::stripe_payment_success() after the
57 - // verified status write.
57 + // consumes. Fired once per transaction by the return that claimed it:
58 + // Handler::confirm_stripe_session(), or confirm_setup_session() for a
59 + // free trial's setup-mode session.
58 60 add_action( 'better_payment/payment_confirmed', array( __CLASS__, 'on_payment_confirmed' ), 10, 1 );
59 61 }
60 62
61 63 /**
@@ -157,9 +159,10 @@
157 159
158 160 return array(
159 161 'amount' => (float) $data['amount'],
160 162 'order_id' => (string) $data['bp_order_id'],
161 - 'payment_date' => current_time( 'mysql' ),
163 + // UTC, like every other writer of this table (see Classes\PaymentDateMigration).
164 + 'payment_date' => gmdate( 'Y-m-d H:i:s' ),
162 165 'source' => 'stripe',
163 166 'transaction_id' => ! empty( $session->payment_intent ) ? sanitize_text_field( $session->payment_intent ) : '',
164 167 'customer_info' => maybe_serialize( $session ),
165 168 'form_fields_info' => maybe_serialize( $form_fields_info ),
@@ -349,12 +352,12 @@
349 352 * mislabelled — it is invisible under either filter tab while still
350 353 * appearing on the unfiltered list. 'paid' is the exact status a verified
351 354 * first payment writes, so a $0 checkout counts as completed everywhere
352 355 * without every consumer needing to learn a second spelling.
353 - * ('no_payment_required' remains in PAID_STATUSES and in the taxonomy,
354 - * because Handler::stripe_payment_success() still stores Stripe's raw
355 - * payment_status for every other surface — an Elementor form with a 100%
356 - * coupon lands there.)
356 + * ('no_payment_required' remains in PAID_STATUSES and in the taxonomy for
357 + * rows confirmed before 2.4.0, which stored Stripe's raw payment_status
358 + * for a $0 session. Handler::confirm_stripe_session() writes 'paid' for
359 + * those too now, for the same reason.)
357 360 *
358 361 * @param \WC_Order $order The $0 subscription order.
359 362 * @param string $secret_key Stripe secret key.
360 363 * @return void
@@ -359,10 +362,8 @@
359 362 * @param string $secret_key Stripe secret key.
360 363 * @return void
361 364 */
362 365 public static function verify_setup_return( $order, $secret_key ) {
363 - global $wpdb;
364 -
365 366 $payment_id = (string) $order->get_meta( '_bp_payment_id' );
366 367
367 368 if ( '' === $payment_id ) {
368 369 return;
@@ -367,15 +368,11 @@
367 368 if ( '' === $payment_id ) {
368 369 return;
369 370 }
370 371
371 - $table = $wpdb->prefix . 'better_payment';
372 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- engine-owned table; same row protocol as Handler::stripe_payment_success().
373 - $row = $wpdb->get_row(
374 - $wpdb->prepare( "SELECT id, obj_id FROM {$table} WHERE order_id=%s AND status = 'unpaid' LIMIT 1", $payment_id ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
375 - );
372 + $row = Handler::get_stripe_row( 'order_id', $payment_id );
376 373
377 - if ( empty( $row->obj_id ) || 0 !== strpos( (string) $row->obj_id, 'cs_' ) ) {
374 + if ( empty( $row->id ) || Handler::STRIPE_PENDING_STATUS !== $row->status || empty( $row->obj_id ) || 0 !== strpos( (string) $row->obj_id, 'cs_' ) ) {
378 375 self::log( 'Setup verification for order #' . $order->get_id() . ' found no pending transaction (already processed or unknown id).' );
379 376 return;
380 377 }
381 378
@@ -392,17 +389,60 @@
392 389 self::log( 'Setup-session verification failed for order #' . $order->get_id() . ': ' . $session->get_error_message(), 'error' );
393 390 return;
394 391 }
395 392
396 - $setup_intent = ! empty( $session->setup_intent ) && is_object( $session->setup_intent ) ? $session->setup_intent : null;
397 - $succeeded = $setup_intent
398 - && ( ( isset( $setup_intent->status ) && 'succeeded' === $setup_intent->status ) || ! empty( $setup_intent->payment_method ) );
393 + switch ( self::confirm_setup_session( $row, $session ) ) {
394 + case 'confirmed':
395 + self::log( 'Setup session verified for order #' . $order->get_id() . ' — payment method saved, no payment required; transaction recorded as paid.' );
396 + return;
399 397
400 - if ( ! $succeeded ) {
401 - self::log( 'Setup session for order #' . $order->get_id() . ' is not completed yet — no payment method was saved.', 'error' );
402 - return;
398 + case 'open':
399 + case 'expired':
400 + self::log( 'Setup session for order #' . $order->get_id() . ' is not completed yet — no payment method was saved.', 'error' );
401 + return;
402 +
403 + case 'mismatch':
404 + self::log( 'Setup verification for order #' . $order->get_id() . ' retrieved a session that is not this transaction\'s setup session.', 'error' );
405 + return;
406 +
407 + default:
408 + // A parallel return (a reload, a second tab) won the claim.
409 + self::log( 'Setup verification for order #' . $order->get_id() . ' found the transaction already processed.' );
403 410 }
411 + }
404 412
413 + /**
414 + * Confirm a pending $0 free-trial row from its retrieved setup-mode
415 + * session — the order-received page's claim. WooCommerce-free: it writes
416 + * the row and fires the engine hook; on_payment_confirmed() does the rest.
417 + *
418 + * The row flips 'unpaid' → 'paid' with ONE conditional update (see
419 + * verify_setup_return() for why 'paid'), storing the SetupIntent id as the
420 + * transaction id and the session as the customer info. Only the request
421 + * whose update matched fires `better_payment/payment_confirmed`; a return
422 + * that arrives second (a reload, a second tab) fires nothing.
423 + *
424 + * @param object|null $row Pending row (`id`, `obj_id`).
425 + * @param mixed $session Checkout Session retrieved with `setup_intent` expanded.
426 + * @return string 'confirmed' | 'settled' (no longer pending) | 'open' (no payment method saved
427 + * yet) | 'expired' | 'mismatch' (not this row's setup session) | 'error'.
428 + */
429 + public static function confirm_setup_session( $row, $session ) {
430 + global $wpdb;
431 +
432 + if ( ! is_object( $row ) || empty( $row->id ) || empty( $row->obj_id ) ) {
433 + return 'mismatch';
434 + }
435 +
436 + $decision = self::setup_session_decision( (string) $row->obj_id, $session );
437 +
438 + if ( 'confirm' !== $decision ) {
439 + return 'not_setup' === $decision ? 'mismatch' : $decision;
440 + }
441 +
442 + $setup_intent = $session->setup_intent;
443 + $table = $wpdb->prefix . 'better_payment';
444 +
405 445 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- single-shot flip, mirrors the engine verifier.
406 446 $updated = $wpdb->update(
407 447 $table,
408 448 array(
@@ -415,15 +455,61 @@
415 455 'status' => 'unpaid',
416 456 )
417 457 );
418 458
419 - if ( ! $updated ) {
420 - return; // A parallel request won the flip — nothing left to do.
459 + if ( 1 !== (int) $updated ) {
460 + // A parallel return won the flip — nothing left to do.
461 + $current = Handler::get_stripe_row( 'id', (int) $row->id );
462 +
463 + if ( ! empty( $current->id ) && Handler::STRIPE_PENDING_STATUS !== $current->status ) {
464 + self::log( 'Setup session for transaction #' . (int) $row->id . ' was already settled when the return reached it.' );
465 + return 'settled';
466 + }
467 +
468 + return 'error';
421 469 }
422 470
423 - self::log( 'Setup session verified for order #' . $order->get_id() . ' — payment method saved, no payment required; transaction recorded as paid.' );
471 + do_action( 'better_payment/payment_confirmed', (int) $row->id );
424 472
425 - do_action( 'better_payment/payment_confirmed', (int) $row->id );
473 + return 'confirmed';
474 + }
475 +
476 + /**
477 + * What a retrieved setup-mode Checkout Session means for its pending row. Pure.
478 + *
479 + * @param string $obj_id The row's Checkout Session id.
480 + * @param mixed $session Session retrieved with `setup_intent` expanded.
481 + * @return string 'confirm' | 'mismatch' (another session) | 'not_setup' (a payment session) |
482 + * 'expired' | 'open' (no payment method saved yet).
483 + */
484 + public static function setup_session_decision( $obj_id, $session ) {
485 + if ( ! is_object( $session ) || ( isset( $session->id ) && (string) $session->id !== (string) $obj_id ) ) {
486 + return 'mismatch';
487 + }
488 +
489 + if ( isset( $session->mode ) && 'setup' !== $session->mode ) {
490 + return 'not_setup';
491 + }
492 +
493 + if ( self::setup_intent_succeeded( $session ) ) {
494 + return 'confirm';
495 + }
496 +
497 + return isset( $session->status ) && 'expired' === $session->status ? 'expired' : 'open';
498 + }
499 +
500 + /**
501 + * Whether a setup session's (expanded) SetupIntent saved a payment method:
502 + * `succeeded`, or a payment method attached. Pure.
503 + *
504 + * @param mixed $session Session retrieved with `setup_intent` expanded.
505 + * @return bool
506 + */
507 + public static function setup_intent_succeeded( $session ) {
508 + $setup_intent = is_object( $session ) && ! empty( $session->setup_intent ) && is_object( $session->setup_intent ) ? $session->setup_intent : null;
509 +
510 + return null !== $setup_intent
511 + && ( ( isset( $setup_intent->status ) && 'succeeded' === $setup_intent->status ) || ! empty( $setup_intent->payment_method ) );
426 512 }
427 513
428 514 /**
429 515 * Consume the engine's completion hook and mark the linked WooCommerce