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
better-payment / includes / WooCommerce / OrderHandler.php

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

628 lines 28.1 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`. A $0 free-trial order's
24 * setup-mode session goes through verify_setup_return() instead.
25 * 2. Consume `better_payment/payment_confirmed` and mark the linked
26 * WooCommerce order as paid. Transactions without a `wc_order_id`
27 * (i.e. every non-WooCommerce payment) are ignored with an early
28 * return, so existing traffic is untouched.
29 *
30 * The static build_*() helpers are pure (no WooCommerce classes) so the
31 * unit suite can cover them without WooCommerce loaded.
32 *
33 * @since 2.4.0
34 */
35 class OrderHandler {
36
37 /**
38 * Transaction statuses the engine treats as a successful payment.
39 * Superset of Classes\Helper::APPROVED_DONATION_STATUSES, plus
40 * Stripe's `no_payment_required` (fully discounted sessions).
41 *
42 * @var string[]
43 */
44 const PAID_STATUSES = array( 'paid', 'Completed', 'complete', 'completed', 'succeeded', 'success', 'no_payment_required' );
45
46 /**
47 * Wire the runtime hooks. Called only when WooCommerce is active.
48 *
49 * @return void
50 */
51 public static function register() {
52 // Priority 5: run before the order-received template renders so the
53 // customer sees the post-verification order state.
54 add_action( 'template_redirect', array( __CLASS__, 'maybe_verify_return' ), 5 );
55
56 // The engine's completion contract — same hook Campaign\CampaignStats
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.
60 add_action( 'better_payment/payment_confirmed', array( __CLASS__, 'on_payment_confirmed' ), 10, 1 );
61 }
62
63 /**
64 * Build the Stripe Checkout Session request for a WooCommerce order.
65 *
66 * Pure: takes scalars, returns the request array. The shape mirrors
67 * Classes\Actions::better_payment_stripe_get_token() — legacy
68 * `line_items` (amount/currency/name), `payment_intent_data`,
69 * `metadata.order_id` — so the engine's return-side verification and
70 * the Stripe account see the exact protocol every other Better Payment
71 * surface speaks.
72 *
73 * @param array $data {
74 * @type string $bp_order_id Better Payment order id (stripe_xxx).
75 * @type int $wc_order_id WooCommerce order id.
76 * @type string $order_number Display order number.
77 * @type float $amount Order total (major units).
78 * @type string $currency ISO currency code.
79 * @type string $success_url Return URL on success.
80 * @type string $cancel_url Return URL on cancel.
81 * @type string $customer_email Billing email.
82 * @type string $customer_name Billing name.
83 * @type string $site_name Blog name for the line item label.
84 * }
85 * @return array
86 */
87 public static function build_session_request( $data ) {
88 $order_number = ! empty( $data['order_number'] ) ? (string) $data['order_number'] : (string) ( isset( $data['wc_order_id'] ) ? $data['wc_order_id'] : '' );
89 $site_name = ! empty( $data['site_name'] ) ? (string) $data['site_name'] : '';
90
91 /* translators: 1: order number, 2: site name */
92 $description = trim( sprintf( __( 'Order %1$s — %2$s', 'better-payment' ), '#' . $order_number, $site_name ), " \t—" );
93
94 $request = array(
95 'success_url' => (string) $data['success_url'],
96 'cancel_url' => (string) $data['cancel_url'],
97 'locale' => 'auto',
98 'payment_method_types' => array( 'card' ),
99 'client_reference_id' => (string) $data['wc_order_id'],
100 'billing_address_collection' => 'auto',
101 'metadata' => array(
102 'order_id' => (string) $data['bp_order_id'],
103 'wc_order_id' => (string) $data['wc_order_id'],
104 ),
105 'line_items' => array(
106 array(
107 'amount' => (int) round( (float) $data['amount'] * 100 ),
108 'currency' => (string) $data['currency'],
109 'name' => $description,
110 'quantity' => 1,
111 ),
112 ),
113 'payment_intent_data' => array(
114 'capture_method' => 'automatic',
115 'description' => $description,
116 'metadata' => array(
117 'order_id' => (string) $data['bp_order_id'],
118 'wc_order_id' => (string) $data['wc_order_id'],
119 ),
120 ),
121 );
122
123 if ( ! empty( $data['customer_email'] ) && is_email( $data['customer_email'] ) ) {
124 $request['customer_email'] = sanitize_email( $data['customer_email'] );
125 $request['metadata']['customer_email'] = $request['customer_email'];
126 $request['payment_intent_data']['metadata']['customer_email'] = $request['customer_email'];
127 }
128
129 if ( ! empty( $data['customer_name'] ) ) {
130 $customer_name = sanitize_text_field( $data['customer_name'] );
131
132 $request['metadata']['customer_name'] = $customer_name;
133 $request['payment_intent_data']['metadata']['customer_name'] = $customer_name;
134 }
135
136 return $request;
137 }
138
139 /**
140 * Build the transaction row for Handler::payment_create().
141 *
142 * Pure. `referer` is `woocommerce` (alongside the existing `widget`,
143 * `elementor-form`, `gutenberg-block`), and `form_fields_info` carries
144 * `wc_order_id` — the back-reference on_payment_confirmed() resolves.
145 *
146 * @param array $data Same shape as build_session_request().
147 * @param object $session Decoded Stripe Checkout Session.
148 * @return array
149 */
150 public static function build_transaction_data( $data, $session ) {
151 $form_fields_info = array(
152 'wc_order_id' => (int) $data['wc_order_id'],
153 'wc_order_number' => ! empty( $data['order_number'] ) ? (string) $data['order_number'] : (string) $data['wc_order_id'],
154 'source' => 'stripe',
155 'amount' => (float) $data['amount'],
156 'primary_email' => ! empty( $data['customer_email'] ) ? sanitize_email( $data['customer_email'] ) : '',
157 'primary_name' => ! empty( $data['customer_name'] ) ? sanitize_text_field( $data['customer_name'] ) : '',
158 );
159
160 return array(
161 'amount' => (float) $data['amount'],
162 'order_id' => (string) $data['bp_order_id'],
163 // UTC, like every other writer of this table (see Classes\PaymentDateMigration).
164 'payment_date' => gmdate( 'Y-m-d H:i:s' ),
165 'source' => 'stripe',
166 'transaction_id' => ! empty( $session->payment_intent ) ? sanitize_text_field( $session->payment_intent ) : '',
167 'customer_info' => maybe_serialize( $session ),
168 'form_fields_info' => maybe_serialize( $form_fields_info ),
169 'obj_id' => sanitize_text_field( $session->id ),
170 'status' => ! empty( $session->payment_status ) ? sanitize_text_field( $session->payment_status ) : 'unpaid',
171 'currency' => (string) $data['currency'],
172 'referer' => 'woocommerce',
173 'campaign_id' => '',
174 );
175 }
176
177 /**
178 * Extract the WooCommerce order id from a transaction row's
179 * form_fields_info payload. Returns 0 for every non-WooCommerce row.
180 *
181 * @param mixed $form_fields_info Raw (possibly serialized) column value.
182 * @return int
183 */
184 public static function extract_wc_order_id( $form_fields_info ) {
185 $fields = maybe_unserialize( $form_fields_info );
186
187 if ( ! is_array( $fields ) || empty( $fields['wc_order_id'] ) ) {
188 return 0;
189 }
190
191 return (int) $fields['wc_order_id'];
192 }
193
194 /**
195 * Whether a transaction status string counts as paid.
196 *
197 * @param mixed $status Row status.
198 * @return bool
199 */
200 public static function is_paid_status( $status ) {
201 return is_string( $status ) && in_array( $status, self::PAID_STATUSES, true );
202 }
203
204 /**
205 * Whether the verified paid amount matches the order total (to the
206 * cent). Pure; used before marking an order paid so a transaction that
207 * settled for a different amount can never silently complete an order.
208 *
209 * @param mixed $paid_amount Row amount (what Stripe reports was paid).
210 * @param mixed $order_total WooCommerce order total.
211 * @return bool
212 */
213 public static function amount_matches( $paid_amount, $order_total ) {
214 return abs( (float) $paid_amount - (float) $order_total ) < 0.01;
215 }
216
217 /**
218 * On the order-received page, run the engine's Stripe verification for
219 * orders paid through this gateway.
220 *
221 * Reuses Handler::stripe_payment_success() verbatim: it reads
222 * `better_payment_stripe_id` from the request, re-retrieves the session
223 * from the Stripe API, flips the row to paid (single-shot via its
224 * `status='unpaid'` guard) and fires `better_payment/payment_confirmed`
225 * — which on_payment_confirmed() below turns into the order update.
226 *
227 * @return void
228 */
229 public static function maybe_verify_return() {
230 if ( ! function_exists( 'wc_get_order' ) || ! function_exists( 'is_wc_endpoint_url' ) ) {
231 return;
232 }
233
234 // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Stripe redirect; verification is a server-side API retrieval keyed by our own order id.
235 if ( empty( $_GET['better_payment_stripe_status'] ) || 'success' !== $_GET['better_payment_stripe_status'] || empty( $_GET['better_payment_stripe_id'] ) ) {
236 return;
237 }
238
239 if ( ! is_wc_endpoint_url( 'order-received' ) ) {
240 return;
241 }
242
243 $order_id = absint( get_query_var( 'order-received' ) );
244 $order = $order_id ? wc_get_order( $order_id ) : false;
245
246 if ( ! $order ) {
247 return;
248 }
249
250 $order_key = ! empty( $_GET['key'] ) ? wc_clean( wp_unslash( $_GET['key'] ) ) : '';
251 // phpcs:enable WordPress.Security.NonceVerification.Recommended
252
253 if ( ! $order_key || ! hash_equals( $order->get_order_key(), $order_key ) ) {
254 return;
255 }
256
257 if ( Gateway::GATEWAY_ID !== $order->get_payment_method() ) {
258 return;
259 }
260
261 // Only verify the transaction that belongs to THIS order — a visitor
262 // holding a valid order key must not be able to trigger verification
263 // of arbitrary transaction ids through this path.
264 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- see above; matched against server-side order meta.
265 $requested_id = sanitize_text_field( wp_unslash( $_GET['better_payment_stripe_id'] ) );
266
267 if ( $requested_id !== $order->get_meta( '_bp_payment_id' ) ) {
268 return;
269 }
270
271 if ( ! $order->needs_payment() ) {
272 return; // Already confirmed (page refresh / duplicate return).
273 }
274
275 $keys = StripeService::get_global_keys();
276
277 if ( empty( $keys['secret_key'] ) ) {
278 self::log( 'Return verification skipped for order #' . $order->get_id() . ': Stripe keys are no longer configured.', 'error' );
279 return;
280 }
281
282 self::log( 'Verifying Stripe return for order #' . $order->get_id() . '.' );
283
284 // A $0 free-trial subscription order went through a SETUP-mode
285 // session: there is no payment to verify, and the engine's payment
286 // verifier keys off payment_status — which a setup session reports
287 // as 'no_payment_required' from the moment it is created, completed
288 // or not. Completion is proven by its SetupIntent instead.
289 if ( (float) $order->get_total() < 0.01 && Subscriptions::order_contains_subscription( $order ) ) {
290 self::verify_setup_return( $order, $keys['secret_key'] );
291 return;
292 }
293
294 // Pass the id we just authorized against this order's own meta —
295 // never let the engine re-read it from the request. It reads
296 // $_REQUEST by default, and under PHP's request_order=GP a POST body
297 // overwrites the query string, so the id checked above and the id
298 // verified below would be different values. That gap also picks the
299 // setup-session branch from THIS order's total while verifying a
300 // DIFFERENT order's transaction: a $0 free-trial row routed through
301 // the payment verifier is written 'no_payment_required' (what Stripe
302 // reports for a setup session from creation, card entered or not),
303 // which is_paid_status() treats as paid — activating a subscription
304 // with no SetupIntent, exactly what verify_setup_return() prevents.
305 $result = Handler::stripe_payment_success(
306 array(
307 'better_payment_stripe_secret_key' => $keys['secret_key'],
308 'better_payment_stripe_id' => $requested_id,
309 )
310 );
311
312 if ( is_wp_error( $result ) ) {
313 $order->add_order_note(
314 sprintf(
315 /* translators: %s: error message */
316 __( 'Better Payment: Stripe verification could not be completed — %s. The payment may still confirm on a later visit to this page.', 'better-payment' ),
317 $result->get_error_message()
318 )
319 );
320 self::log( 'Stripe verification failed for order #' . $order->get_id() . ': ' . $result->get_error_message(), 'error' );
321 return;
322 }
323
324 if ( false === $result ) {
325 // No 'unpaid' row matched: either already verified through
326 // another visit, or the id didn't match a transaction.
327 self::log( 'Stripe verification for order #' . $order->get_id() . ' found no pending transaction (already processed or unknown id).' );
328 return;
329 }
330
331 self::log( 'Stripe payment verified for order #' . $order->get_id() . '.' );
332 }
333
334 /**
335 * Verify the return from a SETUP-mode Checkout Session (free-trial
336 * checkout: $0 order, card collected without a charge).
337 *
338 * The counterpart of Handler::stripe_payment_success() for sessions with
339 * no payment: retrieves the session with its SetupIntent expanded and
340 * treats a succeeded SetupIntent (a saved payment method) as completion.
341 * On success the transaction row flips 'unpaid' → 'paid' (single-shot,
342 * same guard the engine uses) and `better_payment/payment_confirmed`
343 * fires — from there the standard pipeline completes the order and
344 * activates the subscription.
345 *
346 * The row is written 'paid' and NOT Stripe's own 'no_payment_required',
347 * for the same reason build_renewal_transaction_data() rewrites
348 * 'succeeded': the admin transaction taxonomy (Classes\Helper's v2 map,
349 * its JS twin, and the `status IN (…)` filter DB::get_transactions()
350 * builds from them) classifies unknown statuses as "Incomplete". A row
351 * absent from BOTH the completed and incomplete buckets is worse than
352 * mislabelled — it is invisible under either filter tab while still
353 * appearing on the unfiltered list. 'paid' is the exact status a verified
354 * first payment writes, so a $0 checkout counts as completed everywhere
355 * without every consumer needing to learn a second spelling.
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.)
360 *
361 * @param \WC_Order $order The $0 subscription order.
362 * @param string $secret_key Stripe secret key.
363 * @return void
364 */
365 public static function verify_setup_return( $order, $secret_key ) {
366 $payment_id = (string) $order->get_meta( '_bp_payment_id' );
367
368 if ( '' === $payment_id ) {
369 return;
370 }
371
372 $row = Handler::get_stripe_row( 'order_id', $payment_id );
373
374 if ( empty( $row->id ) || Handler::STRIPE_PENDING_STATUS !== $row->status || empty( $row->obj_id ) || 0 !== strpos( (string) $row->obj_id, 'cs_' ) ) {
375 self::log( 'Setup verification for order #' . $order->get_id() . ' found no pending transaction (already processed or unknown id).' );
376 return;
377 }
378
379 $session = StripeService::retrieve_checkout_session( (string) $row->obj_id, $secret_key, array( 'setup_intent' ) );
380
381 if ( is_wp_error( $session ) ) {
382 $order->add_order_note(
383 sprintf(
384 /* translators: %s: error message */
385 __( 'Better Payment: Stripe verification could not be completed — %s. The payment may still confirm on a later visit to this page.', 'better-payment' ),
386 $session->get_error_message()
387 )
388 );
389 self::log( 'Setup-session verification failed for order #' . $order->get_id() . ': ' . $session->get_error_message(), 'error' );
390 return;
391 }
392
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;
397
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.' );
410 }
411 }
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
445 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- single-shot flip, mirrors the engine verifier.
446 $updated = $wpdb->update(
447 $table,
448 array(
449 'status' => 'paid',
450 'transaction_id' => ! empty( $setup_intent->id ) ? sanitize_text_field( $setup_intent->id ) : '',
451 'customer_info' => maybe_serialize( $session ),
452 ),
453 array(
454 'id' => (int) $row->id,
455 'status' => 'unpaid',
456 )
457 );
458
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';
469 }
470
471 do_action( 'better_payment/payment_confirmed', (int) $row->id );
472
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 ) );
512 }
513
514 /**
515 * Consume the engine's completion hook and mark the linked WooCommerce
516 * order as paid. No-op for every transaction without a wc_order_id.
517 *
518 * @param int $transaction_row_id Better Payment table row id.
519 * @return void
520 */
521 public static function on_payment_confirmed( $transaction_row_id ) {
522 $row = DB::get_transaction( (int) $transaction_row_id );
523
524 if ( empty( $row->id ) ) {
525 return;
526 }
527
528 $wc_order_id = self::extract_wc_order_id( isset( $row->form_fields_info ) ? $row->form_fields_info : '' );
529
530 if ( ! $wc_order_id ) {
531 return; // Not a WooCommerce transaction — existing flows land here.
532 }
533
534 // Trust wc_order_id ONLY on rows this gateway created. Every other
535 // surface hardcodes its own referer at insert time, and form-field
536 // payloads on those surfaces are visitor-influenced — a custom form
537 // field could otherwise smuggle a wc_order_id into an unrelated
538 // (e.g. $1 widget) transaction and mark an arbitrary order paid.
539 if ( ! isset( $row->referer ) || 'woocommerce' !== $row->referer ) {
540 return;
541 }
542
543 if ( ! function_exists( 'wc_get_order' ) ) {
544 return;
545 }
546
547 $order = wc_get_order( $wc_order_id );
548
549 if ( ! $order ) {
550 self::log( 'Payment confirmed for transaction #' . (int) $transaction_row_id . ' but WooCommerce order #' . $wc_order_id . ' was not found.', 'error' );
551 return;
552 }
553
554 if ( ! $order->needs_payment() ) {
555 return; // Duplicate confirmation — order already paid/cancelled.
556 }
557
558 if ( ! self::is_paid_status( isset( $row->status ) ? $row->status : '' ) ) {
559 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' );
560 return;
561 }
562
563 // The verified paid amount/currency must match the order — never
564 // complete an order from a transaction that settled differently.
565 $row_currency = isset( $row->currency ) ? strtoupper( (string) $row->currency ) : '';
566
567 if ( ! self::amount_matches( isset( $row->amount ) ? $row->amount : 0, $order->get_total() ) || strtoupper( $order->get_currency() ) !== $row_currency ) {
568 $order->update_status(
569 'on-hold',
570 sprintf(
571 /* translators: 1: paid amount, 2: paid currency, 3: order total, 4: order currency */
572 __( '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' ),
573 isset( $row->amount ) ? $row->amount : '0',
574 $row_currency,
575 $order->get_total(),
576 $order->get_currency()
577 )
578 );
579 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' );
580 return;
581 }
582
583 $transaction_id = ! empty( $row->transaction_id ) ? sanitize_text_field( $row->transaction_id ) : '';
584
585 $order->update_meta_data( '_bp_payment_status', sanitize_text_field( $row->status ) );
586 $order->update_meta_data( '_bp_paid_at', current_time( 'mysql' ) );
587 $order->add_order_note(
588 sprintf(
589 /* translators: 1: Stripe transaction id, 2: Better Payment record id */
590 __( 'Better Payment: Stripe payment confirmed. Transaction ID: %1$s (Better Payment record #%2$d).', 'better-payment' ),
591 $transaction_id ? $transaction_id : '—',
592 (int) $transaction_row_id
593 )
594 );
595 $order->payment_complete( $transaction_id );
596 $order->save();
597
598 self::log( 'WooCommerce order #' . $wc_order_id . ' marked paid from Better Payment transaction #' . (int) $transaction_row_id . '.' );
599
600 /**
601 * Fires after a WooCommerce order has been marked paid from a
602 * Better Payment transaction.
603 *
604 * @since 2.4.0
605 *
606 * @param \WC_Order $order The WooCommerce order.
607 * @param object $row The Better Payment transaction row.
608 */
609 do_action( 'better_payment/woocommerce/payment_complete', $order, $row );
610 }
611
612 /**
613 * Log through WooCommerce's logger (Better Payment core has no logging
614 * service; inside a WooCommerce module the host logger is the sink).
615 *
616 * @param string $message Log message.
617 * @param string $level 'info' or 'error'.
618 * @return void
619 */
620 public static function log( $message, $level = 'info' ) {
621 if ( ! function_exists( 'wc_get_logger' ) ) {
622 return;
623 }
624
625 wc_get_logger()->log( $level, $message, array( 'source' => 'better-payment' ) );
626 }
627 }
628