PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / 2.4.0
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More v2.4.0
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.0, at includes/WooCommerce/OrderHandler.php

627 lines 28.0 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 'payment_date' => current_time( 'mysql' ),
164 'source' => 'stripe',
165 'transaction_id' => ! empty( $session->payment_intent ) ? sanitize_text_field( $session->payment_intent ) : '',
166 'customer_info' => maybe_serialize( $session ),
167 'form_fields_info' => maybe_serialize( $form_fields_info ),
168 'obj_id' => sanitize_text_field( $session->id ),
169 'status' => ! empty( $session->payment_status ) ? sanitize_text_field( $session->payment_status ) : 'unpaid',
170 'currency' => (string) $data['currency'],
171 'referer' => 'woocommerce',
172 'campaign_id' => '',
173 );
174 }
175
176 /**
177 * Extract the WooCommerce order id from a transaction row's
178 * form_fields_info payload. Returns 0 for every non-WooCommerce row.
179 *
180 * @param mixed $form_fields_info Raw (possibly serialized) column value.
181 * @return int
182 */
183 public static function extract_wc_order_id( $form_fields_info ) {
184 $fields = maybe_unserialize( $form_fields_info );
185
186 if ( ! is_array( $fields ) || empty( $fields['wc_order_id'] ) ) {
187 return 0;
188 }
189
190 return (int) $fields['wc_order_id'];
191 }
192
193 /**
194 * Whether a transaction status string counts as paid.
195 *
196 * @param mixed $status Row status.
197 * @return bool
198 */
199 public static function is_paid_status( $status ) {
200 return is_string( $status ) && in_array( $status, self::PAID_STATUSES, true );
201 }
202
203 /**
204 * Whether the verified paid amount matches the order total (to the
205 * cent). Pure; used before marking an order paid so a transaction that
206 * settled for a different amount can never silently complete an order.
207 *
208 * @param mixed $paid_amount Row amount (what Stripe reports was paid).
209 * @param mixed $order_total WooCommerce order total.
210 * @return bool
211 */
212 public static function amount_matches( $paid_amount, $order_total ) {
213 return abs( (float) $paid_amount - (float) $order_total ) < 0.01;
214 }
215
216 /**
217 * On the order-received page, run the engine's Stripe verification for
218 * orders paid through this gateway.
219 *
220 * Reuses Handler::stripe_payment_success() verbatim: it reads
221 * `better_payment_stripe_id` from the request, re-retrieves the session
222 * from the Stripe API, flips the row to paid (single-shot via its
223 * `status='unpaid'` guard) and fires `better_payment/payment_confirmed`
224 * — which on_payment_confirmed() below turns into the order update.
225 *
226 * @return void
227 */
228 public static function maybe_verify_return() {
229 if ( ! function_exists( 'wc_get_order' ) || ! function_exists( 'is_wc_endpoint_url' ) ) {
230 return;
231 }
232
233 // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Stripe redirect; verification is a server-side API retrieval keyed by our own order id.
234 if ( empty( $_GET['better_payment_stripe_status'] ) || 'success' !== $_GET['better_payment_stripe_status'] || empty( $_GET['better_payment_stripe_id'] ) ) {
235 return;
236 }
237
238 if ( ! is_wc_endpoint_url( 'order-received' ) ) {
239 return;
240 }
241
242 $order_id = absint( get_query_var( 'order-received' ) );
243 $order = $order_id ? wc_get_order( $order_id ) : false;
244
245 if ( ! $order ) {
246 return;
247 }
248
249 $order_key = ! empty( $_GET['key'] ) ? wc_clean( wp_unslash( $_GET['key'] ) ) : '';
250 // phpcs:enable WordPress.Security.NonceVerification.Recommended
251
252 if ( ! $order_key || ! hash_equals( $order->get_order_key(), $order_key ) ) {
253 return;
254 }
255
256 if ( Gateway::GATEWAY_ID !== $order->get_payment_method() ) {
257 return;
258 }
259
260 // Only verify the transaction that belongs to THIS order — a visitor
261 // holding a valid order key must not be able to trigger verification
262 // of arbitrary transaction ids through this path.
263 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- see above; matched against server-side order meta.
264 $requested_id = sanitize_text_field( wp_unslash( $_GET['better_payment_stripe_id'] ) );
265
266 if ( $requested_id !== $order->get_meta( '_bp_payment_id' ) ) {
267 return;
268 }
269
270 if ( ! $order->needs_payment() ) {
271 return; // Already confirmed (page refresh / duplicate return).
272 }
273
274 $keys = StripeService::get_global_keys();
275
276 if ( empty( $keys['secret_key'] ) ) {
277 self::log( 'Return verification skipped for order #' . $order->get_id() . ': Stripe keys are no longer configured.', 'error' );
278 return;
279 }
280
281 self::log( 'Verifying Stripe return for order #' . $order->get_id() . '.' );
282
283 // A $0 free-trial subscription order went through a SETUP-mode
284 // session: there is no payment to verify, and the engine's payment
285 // verifier keys off payment_status — which a setup session reports
286 // as 'no_payment_required' from the moment it is created, completed
287 // or not. Completion is proven by its SetupIntent instead.
288 if ( (float) $order->get_total() < 0.01 && Subscriptions::order_contains_subscription( $order ) ) {
289 self::verify_setup_return( $order, $keys['secret_key'] );
290 return;
291 }
292
293 // Pass the id we just authorized against this order's own meta —
294 // never let the engine re-read it from the request. It reads
295 // $_REQUEST by default, and under PHP's request_order=GP a POST body
296 // overwrites the query string, so the id checked above and the id
297 // verified below would be different values. That gap also picks the
298 // setup-session branch from THIS order's total while verifying a
299 // DIFFERENT order's transaction: a $0 free-trial row routed through
300 // the payment verifier is written 'no_payment_required' (what Stripe
301 // reports for a setup session from creation, card entered or not),
302 // which is_paid_status() treats as paid — activating a subscription
303 // with no SetupIntent, exactly what verify_setup_return() prevents.
304 $result = Handler::stripe_payment_success(
305 array(
306 'better_payment_stripe_secret_key' => $keys['secret_key'],
307 'better_payment_stripe_id' => $requested_id,
308 )
309 );
310
311 if ( is_wp_error( $result ) ) {
312 $order->add_order_note(
313 sprintf(
314 /* translators: %s: error message */
315 __( 'Better Payment: Stripe verification could not be completed — %s. The payment may still confirm on a later visit to this page.', 'better-payment' ),
316 $result->get_error_message()
317 )
318 );
319 self::log( 'Stripe verification failed for order #' . $order->get_id() . ': ' . $result->get_error_message(), 'error' );
320 return;
321 }
322
323 if ( false === $result ) {
324 // No 'unpaid' row matched: either already verified through
325 // another visit, or the id didn't match a transaction.
326 self::log( 'Stripe verification for order #' . $order->get_id() . ' found no pending transaction (already processed or unknown id).' );
327 return;
328 }
329
330 self::log( 'Stripe payment verified for order #' . $order->get_id() . '.' );
331 }
332
333 /**
334 * Verify the return from a SETUP-mode Checkout Session (free-trial
335 * checkout: $0 order, card collected without a charge).
336 *
337 * The counterpart of Handler::stripe_payment_success() for sessions with
338 * no payment: retrieves the session with its SetupIntent expanded and
339 * treats a succeeded SetupIntent (a saved payment method) as completion.
340 * On success the transaction row flips 'unpaid' → 'paid' (single-shot,
341 * same guard the engine uses) and `better_payment/payment_confirmed`
342 * fires — from there the standard pipeline completes the order and
343 * activates the subscription.
344 *
345 * The row is written 'paid' and NOT Stripe's own 'no_payment_required',
346 * for the same reason build_renewal_transaction_data() rewrites
347 * 'succeeded': the admin transaction taxonomy (Classes\Helper's v2 map,
348 * its JS twin, and the `status IN (…)` filter DB::get_transactions()
349 * builds from them) classifies unknown statuses as "Incomplete". A row
350 * absent from BOTH the completed and incomplete buckets is worse than
351 * mislabelled — it is invisible under either filter tab while still
352 * appearing on the unfiltered list. 'paid' is the exact status a verified
353 * first payment writes, so a $0 checkout counts as completed everywhere
354 * without every consumer needing to learn a second spelling.
355 * ('no_payment_required' remains in PAID_STATUSES and in the taxonomy for
356 * rows confirmed before 2.4.0, which stored Stripe's raw payment_status
357 * for a $0 session. Handler::confirm_stripe_session() writes 'paid' for
358 * those too now, for the same reason.)
359 *
360 * @param \WC_Order $order The $0 subscription order.
361 * @param string $secret_key Stripe secret key.
362 * @return void
363 */
364 public static function verify_setup_return( $order, $secret_key ) {
365 $payment_id = (string) $order->get_meta( '_bp_payment_id' );
366
367 if ( '' === $payment_id ) {
368 return;
369 }
370
371 $row = Handler::get_stripe_row( 'order_id', $payment_id );
372
373 if ( empty( $row->id ) || Handler::STRIPE_PENDING_STATUS !== $row->status || empty( $row->obj_id ) || 0 !== strpos( (string) $row->obj_id, 'cs_' ) ) {
374 self::log( 'Setup verification for order #' . $order->get_id() . ' found no pending transaction (already processed or unknown id).' );
375 return;
376 }
377
378 $session = StripeService::retrieve_checkout_session( (string) $row->obj_id, $secret_key, array( 'setup_intent' ) );
379
380 if ( is_wp_error( $session ) ) {
381 $order->add_order_note(
382 sprintf(
383 /* translators: %s: error message */
384 __( 'Better Payment: Stripe verification could not be completed — %s. The payment may still confirm on a later visit to this page.', 'better-payment' ),
385 $session->get_error_message()
386 )
387 );
388 self::log( 'Setup-session verification failed for order #' . $order->get_id() . ': ' . $session->get_error_message(), 'error' );
389 return;
390 }
391
392 switch ( self::confirm_setup_session( $row, $session ) ) {
393 case 'confirmed':
394 self::log( 'Setup session verified for order #' . $order->get_id() . ' — payment method saved, no payment required; transaction recorded as paid.' );
395 return;
396
397 case 'open':
398 case 'expired':
399 self::log( 'Setup session for order #' . $order->get_id() . ' is not completed yet — no payment method was saved.', 'error' );
400 return;
401
402 case 'mismatch':
403 self::log( 'Setup verification for order #' . $order->get_id() . ' retrieved a session that is not this transaction\'s setup session.', 'error' );
404 return;
405
406 default:
407 // A parallel return (a reload, a second tab) won the claim.
408 self::log( 'Setup verification for order #' . $order->get_id() . ' found the transaction already processed.' );
409 }
410 }
411
412 /**
413 * Confirm a pending $0 free-trial row from its retrieved setup-mode
414 * session — the order-received page's claim. WooCommerce-free: it writes
415 * the row and fires the engine hook; on_payment_confirmed() does the rest.
416 *
417 * The row flips 'unpaid' → 'paid' with ONE conditional update (see
418 * verify_setup_return() for why 'paid'), storing the SetupIntent id as the
419 * transaction id and the session as the customer info. Only the request
420 * whose update matched fires `better_payment/payment_confirmed`; a return
421 * that arrives second (a reload, a second tab) fires nothing.
422 *
423 * @param object|null $row Pending row (`id`, `obj_id`).
424 * @param mixed $session Checkout Session retrieved with `setup_intent` expanded.
425 * @return string 'confirmed' | 'settled' (no longer pending) | 'open' (no payment method saved
426 * yet) | 'expired' | 'mismatch' (not this row's setup session) | 'error'.
427 */
428 public static function confirm_setup_session( $row, $session ) {
429 global $wpdb;
430
431 if ( ! is_object( $row ) || empty( $row->id ) || empty( $row->obj_id ) ) {
432 return 'mismatch';
433 }
434
435 $decision = self::setup_session_decision( (string) $row->obj_id, $session );
436
437 if ( 'confirm' !== $decision ) {
438 return 'not_setup' === $decision ? 'mismatch' : $decision;
439 }
440
441 $setup_intent = $session->setup_intent;
442 $table = $wpdb->prefix . 'better_payment';
443
444 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- single-shot flip, mirrors the engine verifier.
445 $updated = $wpdb->update(
446 $table,
447 array(
448 'status' => 'paid',
449 'transaction_id' => ! empty( $setup_intent->id ) ? sanitize_text_field( $setup_intent->id ) : '',
450 'customer_info' => maybe_serialize( $session ),
451 ),
452 array(
453 'id' => (int) $row->id,
454 'status' => 'unpaid',
455 )
456 );
457
458 if ( 1 !== (int) $updated ) {
459 // A parallel return won the flip — nothing left to do.
460 $current = Handler::get_stripe_row( 'id', (int) $row->id );
461
462 if ( ! empty( $current->id ) && Handler::STRIPE_PENDING_STATUS !== $current->status ) {
463 self::log( 'Setup session for transaction #' . (int) $row->id . ' was already settled when the return reached it.' );
464 return 'settled';
465 }
466
467 return 'error';
468 }
469
470 do_action( 'better_payment/payment_confirmed', (int) $row->id );
471
472 return 'confirmed';
473 }
474
475 /**
476 * What a retrieved setup-mode Checkout Session means for its pending row. Pure.
477 *
478 * @param string $obj_id The row's Checkout Session id.
479 * @param mixed $session Session retrieved with `setup_intent` expanded.
480 * @return string 'confirm' | 'mismatch' (another session) | 'not_setup' (a payment session) |
481 * 'expired' | 'open' (no payment method saved yet).
482 */
483 public static function setup_session_decision( $obj_id, $session ) {
484 if ( ! is_object( $session ) || ( isset( $session->id ) && (string) $session->id !== (string) $obj_id ) ) {
485 return 'mismatch';
486 }
487
488 if ( isset( $session->mode ) && 'setup' !== $session->mode ) {
489 return 'not_setup';
490 }
491
492 if ( self::setup_intent_succeeded( $session ) ) {
493 return 'confirm';
494 }
495
496 return isset( $session->status ) && 'expired' === $session->status ? 'expired' : 'open';
497 }
498
499 /**
500 * Whether a setup session's (expanded) SetupIntent saved a payment method:
501 * `succeeded`, or a payment method attached. Pure.
502 *
503 * @param mixed $session Session retrieved with `setup_intent` expanded.
504 * @return bool
505 */
506 public static function setup_intent_succeeded( $session ) {
507 $setup_intent = is_object( $session ) && ! empty( $session->setup_intent ) && is_object( $session->setup_intent ) ? $session->setup_intent : null;
508
509 return null !== $setup_intent
510 && ( ( isset( $setup_intent->status ) && 'succeeded' === $setup_intent->status ) || ! empty( $setup_intent->payment_method ) );
511 }
512
513 /**
514 * Consume the engine's completion hook and mark the linked WooCommerce
515 * order as paid. No-op for every transaction without a wc_order_id.
516 *
517 * @param int $transaction_row_id Better Payment table row id.
518 * @return void
519 */
520 public static function on_payment_confirmed( $transaction_row_id ) {
521 $row = DB::get_transaction( (int) $transaction_row_id );
522
523 if ( empty( $row->id ) ) {
524 return;
525 }
526
527 $wc_order_id = self::extract_wc_order_id( isset( $row->form_fields_info ) ? $row->form_fields_info : '' );
528
529 if ( ! $wc_order_id ) {
530 return; // Not a WooCommerce transaction — existing flows land here.
531 }
532
533 // Trust wc_order_id ONLY on rows this gateway created. Every other
534 // surface hardcodes its own referer at insert time, and form-field
535 // payloads on those surfaces are visitor-influenced — a custom form
536 // field could otherwise smuggle a wc_order_id into an unrelated
537 // (e.g. $1 widget) transaction and mark an arbitrary order paid.
538 if ( ! isset( $row->referer ) || 'woocommerce' !== $row->referer ) {
539 return;
540 }
541
542 if ( ! function_exists( 'wc_get_order' ) ) {
543 return;
544 }
545
546 $order = wc_get_order( $wc_order_id );
547
548 if ( ! $order ) {
549 self::log( 'Payment confirmed for transaction #' . (int) $transaction_row_id . ' but WooCommerce order #' . $wc_order_id . ' was not found.', 'error' );
550 return;
551 }
552
553 if ( ! $order->needs_payment() ) {
554 return; // Duplicate confirmation — order already paid/cancelled.
555 }
556
557 if ( ! self::is_paid_status( isset( $row->status ) ? $row->status : '' ) ) {
558 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' );
559 return;
560 }
561
562 // The verified paid amount/currency must match the order — never
563 // complete an order from a transaction that settled differently.
564 $row_currency = isset( $row->currency ) ? strtoupper( (string) $row->currency ) : '';
565
566 if ( ! self::amount_matches( isset( $row->amount ) ? $row->amount : 0, $order->get_total() ) || strtoupper( $order->get_currency() ) !== $row_currency ) {
567 $order->update_status(
568 'on-hold',
569 sprintf(
570 /* translators: 1: paid amount, 2: paid currency, 3: order total, 4: order currency */
571 __( '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' ),
572 isset( $row->amount ) ? $row->amount : '0',
573 $row_currency,
574 $order->get_total(),
575 $order->get_currency()
576 )
577 );
578 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' );
579 return;
580 }
581
582 $transaction_id = ! empty( $row->transaction_id ) ? sanitize_text_field( $row->transaction_id ) : '';
583
584 $order->update_meta_data( '_bp_payment_status', sanitize_text_field( $row->status ) );
585 $order->update_meta_data( '_bp_paid_at', current_time( 'mysql' ) );
586 $order->add_order_note(
587 sprintf(
588 /* translators: 1: Stripe transaction id, 2: Better Payment record id */
589 __( 'Better Payment: Stripe payment confirmed. Transaction ID: %1$s (Better Payment record #%2$d).', 'better-payment' ),
590 $transaction_id ? $transaction_id : '—',
591 (int) $transaction_row_id
592 )
593 );
594 $order->payment_complete( $transaction_id );
595 $order->save();
596
597 self::log( 'WooCommerce order #' . $wc_order_id . ' marked paid from Better Payment transaction #' . (int) $transaction_row_id . '.' );
598
599 /**
600 * Fires after a WooCommerce order has been marked paid from a
601 * Better Payment transaction.
602 *
603 * @since 2.4.0
604 *
605 * @param \WC_Order $order The WooCommerce order.
606 * @param object $row The Better Payment transaction row.
607 */
608 do_action( 'better_payment/woocommerce/payment_complete', $order, $row );
609 }
610
611 /**
612 * Log through WooCommerce's logger (Better Payment core has no logging
613 * service; inside a WooCommerce module the host logger is the sink).
614 *
615 * @param string $message Log message.
616 * @param string $level 'info' or 'error'.
617 * @return void
618 */
619 public static function log( $message, $level = 'info' ) {
620 if ( ! function_exists( 'wc_get_logger' ) ) {
621 return;
622 }
623
624 wc_get_logger()->log( $level, $message, array( 'source' => 'better-payment' ) );
625 }
626 }
627