| @@ -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 |