PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / 2.3.4
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More v2.3.4
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 1.0.5 1.0.6 All 66 releases
better-payment / includes / WooCommerce / OrderHandler.php

OrderHandler.php in Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More 2.3.4, at includes/WooCommerce/OrderHandler.php

542 lines 24.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Better_Payment\Lite\WooCommerce;
4
5 use Better_Payment\Lite\Admin\DB;
6 use Better_Payment\Lite\Classes\Handler;
7 use Better_Payment\Lite\Classes\StripeService;
8
9 /**
10 * Exit if accessed directly
11 */
12 if ( ! defined( 'ABSPATH' ) ) {
13 exit;
14 }
15
16 /**
17 * Bridges WooCommerce orders to the Better Payment payment engine.
18 *
19 * Two runtime responsibilities:
20 * 1. On the order-received page, trigger the EXISTING verifier
21 * (Handler::stripe_payment_success()) — the engine re-retrieves the
22 * Checkout Session server-side, flips the transaction row to paid and
23 * fires `better_payment/payment_confirmed`.
24 * 2. Consume `better_payment/payment_confirmed` and mark the linked
25 * WooCommerce order as paid. Transactions without a `wc_order_id`
26 * (i.e. every non-WooCommerce payment) are ignored with an early
27 * return, so existing traffic is untouched.
28 *
29 * The static build_*() helpers are pure (no WooCommerce classes) so the
30 * unit suite can cover them without WooCommerce loaded.
31 *
32 * @since 2.4.0
33 */
34 class OrderHandler {
35
36 /**
37 * Transaction statuses the engine treats as a successful payment.
38 * Superset of Campaign\CampaignStats::approved_statuses(), plus
39 * Stripe's `no_payment_required` (fully discounted sessions).
40 *
41 * @var string[]
42 */
43 const PAID_STATUSES = array( 'paid', 'Completed', 'complete', 'completed', 'succeeded', 'success', 'no_payment_required' );
44
45 /**
46 * Wire the runtime hooks. Called only when WooCommerce is active.
47 *
48 * @return void
49 */
50 public static function register() {
51 // Priority 5: run before the order-received template renders so the
52 // customer sees the post-verification order state.
53 add_action( 'template_redirect', array( __CLASS__, 'maybe_verify_return' ), 5 );
54
55 // The engine's completion contract — same hook Campaign\CampaignStats
56 // consumes. Fired by Handler::stripe_payment_success() after the
57 // verified status write.
58 add_action( 'better_payment/payment_confirmed', array( __CLASS__, 'on_payment_confirmed' ), 10, 1 );
59 }
60
61 /**
62 * Build the Stripe Checkout Session request for a WooCommerce order.
63 *
64 * Pure: takes scalars, returns the request array. The shape mirrors
65 * Classes\Actions::better_payment_stripe_get_token() — legacy
66 * `line_items` (amount/currency/name), `payment_intent_data`,
67 * `metadata.order_id` — so the engine's return-side verification and
68 * the Stripe account see the exact protocol every other Better Payment
69 * surface speaks.
70 *
71 * @param array $data {
72 * @type string $bp_order_id Better Payment order id (stripe_xxx).
73 * @type int $wc_order_id WooCommerce order id.
74 * @type string $order_number Display order number.
75 * @type float $amount Order total (major units).
76 * @type string $currency ISO currency code.
77 * @type string $success_url Return URL on success.
78 * @type string $cancel_url Return URL on cancel.
79 * @type string $customer_email Billing email.
80 * @type string $customer_name Billing name.
81 * @type string $site_name Blog name for the line item label.
82 * }
83 * @return array
84 */
85 public static function build_session_request( $data ) {
86 $order_number = ! empty( $data['order_number'] ) ? (string) $data['order_number'] : (string) ( isset( $data['wc_order_id'] ) ? $data['wc_order_id'] : '' );
87 $site_name = ! empty( $data['site_name'] ) ? (string) $data['site_name'] : '';
88
89 /* translators: 1: order number, 2: site name */
90 $description = trim( sprintf( __( 'Order %1$s — %2$s', 'better-payment' ), '#' . $order_number, $site_name ), " \t" );
91
92 $request = array(
93 'success_url' => (string) $data['success_url'],
94 'cancel_url' => (string) $data['cancel_url'],
95 'locale' => 'auto',
96 'payment_method_types' => array( 'card' ),
97 'client_reference_id' => (string) $data['wc_order_id'],
98 'billing_address_collection' => 'auto',
99 'metadata' => array(
100 'order_id' => (string) $data['bp_order_id'],
101 'wc_order_id' => (string) $data['wc_order_id'],
102 ),
103 'line_items' => array(
104 array(
105 'amount' => (int) round( (float) $data['amount'] * 100 ),
106 'currency' => (string) $data['currency'],
107 'name' => $description,
108 'quantity' => 1,
109 ),
110 ),
111 'payment_intent_data' => array(
112 'capture_method' => 'automatic',
113 'description' => $description,
114 'metadata' => array(
115 'order_id' => (string) $data['bp_order_id'],
116 'wc_order_id' => (string) $data['wc_order_id'],
117 ),
118 ),
119 );
120
121 if ( ! empty( $data['customer_email'] ) && is_email( $data['customer_email'] ) ) {
122 $request['customer_email'] = sanitize_email( $data['customer_email'] );
123 $request['metadata']['customer_email'] = $request['customer_email'];
124 $request['payment_intent_data']['metadata']['customer_email'] = $request['customer_email'];
125 }
126
127 if ( ! empty( $data['customer_name'] ) ) {
128 $customer_name = sanitize_text_field( $data['customer_name'] );
129
130 $request['metadata']['customer_name'] = $customer_name;
131 $request['payment_intent_data']['metadata']['customer_name'] = $customer_name;
132 }
133
134 return $request;
135 }
136
137 /**
138 * Build the transaction row for Handler::payment_create().
139 *
140 * Pure. `referer` is `woocommerce` (alongside the existing `widget`,
141 * `elementor-form`, `gutenberg-block`), and `form_fields_info` carries
142 * `wc_order_id` — the back-reference on_payment_confirmed() resolves.
143 *
144 * @param array $data Same shape as build_session_request().
145 * @param object $session Decoded Stripe Checkout Session.
146 * @return array
147 */
148 public static function build_transaction_data( $data, $session ) {
149 $form_fields_info = array(
150 'wc_order_id' => (int) $data['wc_order_id'],
151 'wc_order_number' => ! empty( $data['order_number'] ) ? (string) $data['order_number'] : (string) $data['wc_order_id'],
152 'source' => 'stripe',
153 'amount' => (float) $data['amount'],
154 'primary_email' => ! empty( $data['customer_email'] ) ? sanitize_email( $data['customer_email'] ) : '',
155 'primary_name' => ! empty( $data['customer_name'] ) ? sanitize_text_field( $data['customer_name'] ) : '',
156 );
157
158 return array(
159 'amount' => (float) $data['amount'],
160 'order_id' => (string) $data['bp_order_id'],
161 'payment_date' => current_time( 'mysql' ),
162 'source' => 'stripe',
163 'transaction_id' => ! empty( $session->payment_intent ) ? sanitize_text_field( $session->payment_intent ) : '',
164 'customer_info' => maybe_serialize( $session ),
165 'form_fields_info' => maybe_serialize( $form_fields_info ),
166 'obj_id' => sanitize_text_field( $session->id ),
167 'status' => ! empty( $session->payment_status ) ? sanitize_text_field( $session->payment_status ) : 'unpaid',
168 'currency' => (string) $data['currency'],
169 'referer' => 'woocommerce',
170 'campaign_id' => '',
171 );
172 }
173
174 /**
175 * Extract the WooCommerce order id from a transaction row's
176 * form_fields_info payload. Returns 0 for every non-WooCommerce row.
177 *
178 * @param mixed $form_fields_info Raw (possibly serialized) column value.
179 * @return int
180 */
181 public static function extract_wc_order_id( $form_fields_info ) {
182 $fields = maybe_unserialize( $form_fields_info );
183
184 if ( ! is_array( $fields ) || empty( $fields['wc_order_id'] ) ) {
185 return 0;
186 }
187
188 return (int) $fields['wc_order_id'];
189 }
190
191 /**
192 * Whether a transaction status string counts as paid.
193 *
194 * @param mixed $status Row status.
195 * @return bool
196 */
197 public static function is_paid_status( $status ) {
198 return is_string( $status ) && in_array( $status, self::PAID_STATUSES, true );
199 }
200
201 /**
202 * Whether the verified paid amount matches the order total (to the
203 * cent). Pure; used before marking an order paid so a transaction that
204 * settled for a different amount can never silently complete an order.
205 *
206 * @param mixed $paid_amount Row amount (what Stripe reports was paid).
207 * @param mixed $order_total WooCommerce order total.
208 * @return bool
209 */
210 public static function amount_matches( $paid_amount, $order_total ) {
211 return abs( (float) $paid_amount - (float) $order_total ) < 0.01;
212 }
213
214 /**
215 * On the order-received page, run the engine's Stripe verification for
216 * orders paid through this gateway.
217 *
218 * Reuses Handler::stripe_payment_success() verbatim: it reads
219 * `better_payment_stripe_id` from the request, re-retrieves the session
220 * from the Stripe API, flips the row to paid (single-shot via its
221 * `status='unpaid'` guard) and fires `better_payment/payment_confirmed`
222 * — which on_payment_confirmed() below turns into the order update.
223 *
224 * @return void
225 */
226 public static function maybe_verify_return() {
227 if ( ! function_exists( 'wc_get_order' ) || ! function_exists( 'is_wc_endpoint_url' ) ) {
228 return;
229 }
230
231 // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Stripe redirect; verification is a server-side API retrieval keyed by our own order id.
232 if ( empty( $_GET['better_payment_stripe_status'] ) || 'success' !== $_GET['better_payment_stripe_status'] || empty( $_GET['better_payment_stripe_id'] ) ) {
233 return;
234 }
235
236 if ( ! is_wc_endpoint_url( 'order-received' ) ) {
237 return;
238 }
239
240 $order_id = absint( get_query_var( 'order-received' ) );
241 $order = $order_id ? wc_get_order( $order_id ) : false;
242
243 if ( ! $order ) {
244 return;
245 }
246
247 $order_key = ! empty( $_GET['key'] ) ? wc_clean( wp_unslash( $_GET['key'] ) ) : '';
248 // phpcs:enable WordPress.Security.NonceVerification.Recommended
249
250 if ( ! $order_key || ! hash_equals( $order->get_order_key(), $order_key ) ) {
251 return;
252 }
253
254 if ( Gateway::GATEWAY_ID !== $order->get_payment_method() ) {
255 return;
256 }
257
258 // Only verify the transaction that belongs to THIS order — a visitor
259 // holding a valid order key must not be able to trigger verification
260 // of arbitrary transaction ids through this path.
261 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- see above; matched against server-side order meta.
262 $requested_id = sanitize_text_field( wp_unslash( $_GET['better_payment_stripe_id'] ) );
263
264 if ( $requested_id !== $order->get_meta( '_bp_payment_id' ) ) {
265 return;
266 }
267
268 if ( ! $order->needs_payment() ) {
269 return; // Already confirmed (page refresh / duplicate return).
270 }
271
272 $keys = StripeService::get_global_keys();
273
274 if ( empty( $keys['secret_key'] ) ) {
275 self::log( 'Return verification skipped for order #' . $order->get_id() . ': Stripe keys are no longer configured.', 'error' );
276 return;
277 }
278
279 self::log( 'Verifying Stripe return for order #' . $order->get_id() . '.' );
280
281 // A $0 free-trial subscription order went through a SETUP-mode
282 // session: there is no payment to verify, and the engine's payment
283 // verifier keys off payment_status — which a setup session reports
284 // as 'no_payment_required' from the moment it is created, completed
285 // or not. Completion is proven by its SetupIntent instead.
286 if ( (float) $order->get_total() < 0.01 && Subscriptions::order_contains_subscription( $order ) ) {
287 self::verify_setup_return( $order, $keys['secret_key'] );
288 return;
289 }
290
291 // Pass the id we just authorized against this order's own meta —
292 // never let the engine re-read it from the request. It reads
293 // $_REQUEST by default, and under PHP's request_order=GP a POST body
294 // overwrites the query string, so the id checked above and the id
295 // verified below would be different values. That gap also picks the
296 // setup-session branch from THIS order's total while verifying a
297 // DIFFERENT order's transaction: a $0 free-trial row routed through
298 // the payment verifier is written 'no_payment_required' (what Stripe
299 // reports for a setup session from creation, card entered or not),
300 // which is_paid_status() treats as paid — activating a subscription
301 // with no SetupIntent, exactly what verify_setup_return() prevents.
302 $result = Handler::stripe_payment_success(
303 array(
304 'better_payment_stripe_secret_key' => $keys['secret_key'],
305 'better_payment_stripe_id' => $requested_id,
306 )
307 );
308
309 if ( is_wp_error( $result ) ) {
310 $order->add_order_note(
311 sprintf(
312 /* translators: %s: error message */
313 __( 'Better Payment: Stripe verification could not be completed — %s. The payment may still confirm on a later visit to this page.', 'better-payment' ),
314 $result->get_error_message()
315 )
316 );
317 self::log( 'Stripe verification failed for order #' . $order->get_id() . ': ' . $result->get_error_message(), 'error' );
318 return;
319 }
320
321 if ( false === $result ) {
322 // No 'unpaid' row matched: either already verified through
323 // another visit, or the id didn't match a transaction.
324 self::log( 'Stripe verification for order #' . $order->get_id() . ' found no pending transaction (already processed or unknown id).' );
325 return;
326 }
327
328 self::log( 'Stripe payment verified for order #' . $order->get_id() . '.' );
329 }
330
331 /**
332 * Verify the return from a SETUP-mode Checkout Session (free-trial
333 * checkout: $0 order, card collected without a charge).
334 *
335 * The counterpart of Handler::stripe_payment_success() for sessions with
336 * no payment: retrieves the session with its SetupIntent expanded and
337 * treats a succeeded SetupIntent (a saved payment method) as completion.
338 * On success the transaction row flips 'unpaid' → 'paid' (single-shot,
339 * same guard the engine uses) and `better_payment/payment_confirmed`
340 * fires — from there the standard pipeline completes the order and
341 * activates the subscription.
342 *
343 * The row is written 'paid' and NOT Stripe's own 'no_payment_required',
344 * for the same reason build_renewal_transaction_data() rewrites
345 * 'succeeded': the admin transaction taxonomy (Classes\Helper's v2 map,
346 * its JS twin, and the `status IN (…)` filter DB::get_transactions()
347 * builds from them) classifies unknown statuses as "Incomplete". A row
348 * absent from BOTH the completed and incomplete buckets is worse than
349 * mislabelled — it is invisible under either filter tab while still
350 * appearing on the unfiltered list. 'paid' is the exact status a verified
351 * first payment writes, so a $0 checkout counts as completed everywhere
352 * 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.)
357 *
358 * @param \WC_Order $order The $0 subscription order.
359 * @param string $secret_key Stripe secret key.
360 * @return void
361 */
362 public static function verify_setup_return( $order, $secret_key ) {
363 global $wpdb;
364
365 $payment_id = (string) $order->get_meta( '_bp_payment_id' );
366
367 if ( '' === $payment_id ) {
368 return;
369 }
370
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 );
376
377 if ( empty( $row->obj_id ) || 0 !== strpos( (string) $row->obj_id, 'cs_' ) ) {
378 self::log( 'Setup verification for order #' . $order->get_id() . ' found no pending transaction (already processed or unknown id).' );
379 return;
380 }
381
382 $session = StripeService::retrieve_checkout_session( (string) $row->obj_id, $secret_key, array( 'setup_intent' ) );
383
384 if ( is_wp_error( $session ) ) {
385 $order->add_order_note(
386 sprintf(
387 /* translators: %s: error message */
388 __( 'Better Payment: Stripe verification could not be completed — %s. The payment may still confirm on a later visit to this page.', 'better-payment' ),
389 $session->get_error_message()
390 )
391 );
392 self::log( 'Setup-session verification failed for order #' . $order->get_id() . ': ' . $session->get_error_message(), 'error' );
393 return;
394 }
395
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 ) );
399
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;
403 }
404
405 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- single-shot flip, mirrors the engine verifier.
406 $updated = $wpdb->update(
407 $table,
408 array(
409 'status' => 'paid',
410 'transaction_id' => ! empty( $setup_intent->id ) ? sanitize_text_field( $setup_intent->id ) : '',
411 'customer_info' => maybe_serialize( $session ),
412 ),
413 array(
414 'id' => (int) $row->id,
415 'status' => 'unpaid',
416 )
417 );
418
419 if ( ! $updated ) {
420 return; // A parallel request won the flip — nothing left to do.
421 }
422
423 self::log( 'Setup session verified for order #' . $order->get_id() . ' — payment method saved, no payment required; transaction recorded as paid.' );
424
425 do_action( 'better_payment/payment_confirmed', (int) $row->id );
426 }
427
428 /**
429 * Consume the engine's completion hook and mark the linked WooCommerce
430 * order as paid. No-op for every transaction without a wc_order_id.
431 *
432 * @param int $transaction_row_id Better Payment table row id.
433 * @return void
434 */
435 public static function on_payment_confirmed( $transaction_row_id ) {
436 $row = DB::get_transaction( (int) $transaction_row_id );
437
438 if ( empty( $row->id ) ) {
439 return;
440 }
441
442 $wc_order_id = self::extract_wc_order_id( isset( $row->form_fields_info ) ? $row->form_fields_info : '' );
443
444 if ( ! $wc_order_id ) {
445 return; // Not a WooCommerce transaction — existing flows land here.
446 }
447
448 // Trust wc_order_id ONLY on rows this gateway created. Every other
449 // surface hardcodes its own referer at insert time, and form-field
450 // payloads on those surfaces are visitor-influenced — a custom form
451 // field could otherwise smuggle a wc_order_id into an unrelated
452 // (e.g. $1 widget) transaction and mark an arbitrary order paid.
453 if ( ! isset( $row->referer ) || 'woocommerce' !== $row->referer ) {
454 return;
455 }
456
457 if ( ! function_exists( 'wc_get_order' ) ) {
458 return;
459 }
460
461 $order = wc_get_order( $wc_order_id );
462
463 if ( ! $order ) {
464 self::log( 'Payment confirmed for transaction #' . (int) $transaction_row_id . ' but WooCommerce order #' . $wc_order_id . ' was not found.', 'error' );
465 return;
466 }
467
468 if ( ! $order->needs_payment() ) {
469 return; // Duplicate confirmation — order already paid/cancelled.
470 }
471
472 if ( ! self::is_paid_status( isset( $row->status ) ? $row->status : '' ) ) {
473 self::log( 'Payment confirmed hook fired for transaction #' . (int) $transaction_row_id . ' with non-paid status "' . ( isset( $row->status ) ? $row->status : '' ) . '" — order #' . $wc_order_id . ' left unchanged.', 'error' );
474 return;
475 }
476
477 // The verified paid amount/currency must match the order — never
478 // complete an order from a transaction that settled differently.
479 $row_currency = isset( $row->currency ) ? strtoupper( (string) $row->currency ) : '';
480
481 if ( ! self::amount_matches( isset( $row->amount ) ? $row->amount : 0, $order->get_total() ) || strtoupper( $order->get_currency() ) !== $row_currency ) {
482 $order->update_status(
483 'on-hold',
484 sprintf(
485 /* translators: 1: paid amount, 2: paid currency, 3: order total, 4: order currency */
486 __( 'Better Payment: verified payment amount (%1$s %2$s) does not match the order total (%3$s %4$s). Order placed on hold for manual review.', 'better-payment' ),
487 isset( $row->amount ) ? $row->amount : '0',
488 $row_currency,
489 $order->get_total(),
490 $order->get_currency()
491 )
492 );
493 self::log( 'Amount/currency mismatch for order #' . $wc_order_id . ' (transaction #' . (int) $transaction_row_id . '): paid ' . ( isset( $row->amount ) ? $row->amount : '0' ) . ' ' . $row_currency . ' vs order ' . $order->get_total() . ' ' . $order->get_currency() . '. Order set to on-hold.', 'error' );
494 return;
495 }
496
497 $transaction_id = ! empty( $row->transaction_id ) ? sanitize_text_field( $row->transaction_id ) : '';
498
499 $order->update_meta_data( '_bp_payment_status', sanitize_text_field( $row->status ) );
500 $order->update_meta_data( '_bp_paid_at', current_time( 'mysql' ) );
501 $order->add_order_note(
502 sprintf(
503 /* translators: 1: Stripe transaction id, 2: Better Payment record id */
504 __( 'Better Payment: Stripe payment confirmed. Transaction ID: %1$s (Better Payment record #%2$d).', 'better-payment' ),
505 $transaction_id ? $transaction_id : '',
506 (int) $transaction_row_id
507 )
508 );
509 $order->payment_complete( $transaction_id );
510 $order->save();
511
512 self::log( 'WooCommerce order #' . $wc_order_id . ' marked paid from Better Payment transaction #' . (int) $transaction_row_id . '.' );
513
514 /**
515 * Fires after a WooCommerce order has been marked paid from a
516 * Better Payment transaction.
517 *
518 * @since 2.4.0
519 *
520 * @param \WC_Order $order The WooCommerce order.
521 * @param object $row The Better Payment transaction row.
522 */
523 do_action( 'better_payment/woocommerce/payment_complete', $order, $row );
524 }
525
526 /**
527 * Log through WooCommerce's logger (Better Payment core has no logging
528 * service; inside a WooCommerce module the host logger is the sink).
529 *
530 * @param string $message Log message.
531 * @param string $level 'info' or 'error'.
532 * @return void
533 */
534 public static function log( $message, $level = 'info' ) {
535 if ( ! function_exists( 'wc_get_logger' ) ) {
536 return;
537 }
538
539 wc_get_logger()->log( $level, $message, array( 'source' => 'better-payment' ) );
540 }
541 }
542