PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk All 48 releases
← All changes | api/Checkout/CheckoutApi.php +139 -60 1.4.0 → 1.6.5 View file →
@@ -21,8 +21,9 @@
21 21 use FluentCart\App\Models\Order;
22 22 use FluentCart\App\Models\OrderAddress;
23 23 use FluentCart\App\Models\ShippingMethod;
24 24 use FluentCart\App\Services\CheckoutService;
25 +use FluentCart\App\Services\CustomerIdentity\EmailVerificationService;
25 26 use FluentCart\App\Services\Localization\LocalizationManager;
26 27 use FluentCart\App\Services\OrderService;
27 28 use FluentCart\App\Services\Payments\PaymentHelper;
28 29 use FluentCart\App\Services\Payments\PaymentInstance;
@@ -54,8 +55,23 @@
54 55 'message' => __('Cart is empty or already completed', 'fluent-cart'),
55 56 ]);
56 57 }
57 58
59 + // Serialize all submissions of one cart BEFORE the prevOrder read: locking later
60 + // (or per-order) lets two concurrent first submissions both see prevOrder = null
61 + // and create two orders -> two idempotency keys -> double charge.
62 + static::acquireCartLock($cart->cart_hash);
63 +
64 + // Re-read under the lock (fresh(), not getCart() — that one is request-cached):
65 + // a submission we waited on may have completed this cart meanwhile.
66 + $cart = $cart->fresh();
67 + if (!$cart || !$cart->cart_data || $cart->stage === 'completed') {
68 + wp_send_json([
69 + 'status' => 'failed',
70 + 'message' => __('Cart is empty or already completed', 'fluent-cart'),
71 + ]);
72 + }
73 +
58 74 $cart = $cart->reValidateCoupons();
59 75
60 76 $cartData = $cart->cart_data;
61 77 $prevOrder = $cart->order;
@@ -69,12 +85,28 @@
69 85 in_array($prevOrder->status, Status::getOrderSuccessStatuses()) ||
70 86 !in_array($prevOrder->payment_status, Status::getPaymentRetryableStatuses())
71 87 )
72 88 ) {
73 - wp_send_json([
74 - 'status' => 'failed',
75 - 'message' => __('You have already completed this order.', 'fluent-cart'),
76 - ]);
89 + if ($isLockedCart) {
90 + // Locked carts are bound to a specific order (e.g. pay-for-order links),
91 + // so a finalized order really means there is nothing left to pay.
92 + wp_send_json([
93 + 'status' => 'failed',
94 + 'message' => __('You have already completed this order.', 'fluent-cart'),
95 + ]);
96 + }
97 +
98 + // The linked order is already finalized but the cart was never marked
99 + // completed (e.g. a stale cart resurrected by the logged-in user lookup).
100 + // Detach the dead order so the customer can check out again instead of
101 + // being blocked on every future purchase.
102 + $cart->order_id = null;
103 + $checkoutData = $cart->checkout_data;
104 + unset($checkoutData['is_locked']);
105 + $cart->checkout_data = $checkoutData;
106 + $cart->save();
107 + $prevOrder = null;
108 + $isLockedCart = false;
77 109 }
78 110
79 111 $data = static::addLoggedUserData($data);
80 112
@@ -132,9 +164,14 @@
132 164 }
133 165
134 166 $orderData = OrderService::groupSanitizedData($validatedData);
135 167
136 - $shippingMethodId = Arr::get($orderData, 'others.fc_shipping_method');
168 + // The form posts the method twice: the checked radio (fc_shipping_method) and its
169 + // hidden mirror (fc_selected_shipping_method). validateData() checks only the mirror
170 + // against the address's zones, so pricing from the radio let a request pass with one
171 + // method and be charged by another, from a zone the address is not in. Read from
172 + // $validatedData, not the sanitized copy in others: that is the exact integer checked.
173 + $shippingMethodId = (int) Arr::get($validatedData, 'fc_selected_shipping_method', 0);
137 174
138 175 $shippingMethod = null;
139 176 $shippingCharge = 0;
140 177 if (!$cartCheckoutService->isAllDigital()) {
@@ -260,14 +297,16 @@
260 297 }
261 298
262 299 private static function getOrCreateCustomer(CartCheckoutHelper $cartCheckoutHelper, $orderData)
263 300 {
264 - $customerEmail = static::getCustomerEmail($orderData['billing_address']);
265 - if (is_user_logged_in()) {
266 - $customerEmail = wp_get_current_user()->user_email;
267 - Arr::set($orderData, 'billing_address.email', $customerEmail);
301 + $customer = is_user_logged_in() ? ApiCustomerResource::getCurrentCustomer() : null;
302 + $email = static::getCustomerEmail($orderData['billing_address']);
303 + Arr::set($orderData, 'billing_address.email', $email);
304 + if (!$customer) {
305 + // Reuse the email's customer for the purchase without granting ownership.
306 + $customer = Customer::query()->where('email', $email)->orderBy('id')->first();
268 307 }
269 - $customer = $cartCheckoutHelper->getCustomer($customerEmail);
308 +
270 309 return static::createCustomerWithAddress(
271 310 $customer,
272 311 $orderData,
273 312 $orderData['billing_address'],
@@ -316,9 +355,9 @@
316 355 }
317 356 }
318 357 }
319 358
320 - if (Arr::get($data, 'is_business', 'no') !== 'yes') {
359 + if (Arr::get($data, 'is_business', 'no') !== 'yes' && !CheckoutFieldsSchema::isB2BOnlyMode()) {
321 360 $data['billing_company_name'] = '';
322 361 $data['billing_legal_registration_id'] = '';
323 362 }
324 363
@@ -389,8 +428,10 @@
389 428 }
390 429
391 430 private static function finalizeOrder(Order $order, $args = [])
392 431 {
432 + // Duplicate/concurrent submissions are already serialized by the cart-hash lock
433 + // at the top of placeOrder() — no per-order lock needed here.
393 434 AddressHelper::insertOrderAddresses(
394 435 $order->id,
395 436 Arr::get($args, 'billing_address', []),
396 437 Arr::get($args, 'shipping_address', [])
@@ -398,15 +439,12 @@
398 439
399 440 static::syncCustomerNames($order, $args);
400 441 $cart = CartHelper::getCart();
401 442
402 - $utmData = [];
403 - if (!empty($cart) && is_array($cart->utm_data) && count($cart->utm_data) > 0) {
404 - $utmData = $cart->utm_data;
405 - }
406 -
407 - $requestUtmData = UtmHelper::getUtmDataOfRequest();
408 - $utmData = wp_parse_args($requestUtmData, $utmData);
443 + $utmData = UtmHelper::resolveUtmData(
444 + UtmHelper::getUtmDataOfRequest(),
445 + !empty($cart) ? $cart->utm_data : []
446 + );
409 447 UtmHelper::addUtmToOrder($order->id, $utmData);
410 448
411 449 $prevOrder = Arr::get($args, 'prev_order', null);
412 450
@@ -426,11 +464,27 @@
426 464 }
427 465
428 466 $paymentInstance = new PaymentInstance($order);
429 467
468 + // Transition subscription from pending → intended before submitting to the gateway
469 + if ($paymentInstance->subscription && $paymentInstance->subscription->status === Status::SUBSCRIPTION_PENDING) {
470 + $paymentInstance->subscription->status = Status::SUBSCRIPTION_INTENDED;
471 + $paymentInstance->subscription->save();
472 + }
473 +
430 474 $data = $gateway->makePaymentFromPaymentInstance($paymentInstance);
431 475
432 476 if (is_wp_error($data)) {
477 + // Server-observed create failure: mark the transaction FAILED so the next
478 + // resubmit is a RETRY (payment_attempt bump -> fresh idempotency seed) —
479 + // gateways cache error responses under the key, so keeping it pending would
480 + // replay the same error on every resubmit. Client-side declines stay pending
481 + // on purpose: there the same key resolving to the same gateway object IS the
482 + // retry path.
483 + if ($paymentInstance->transaction && $paymentInstance->transaction->status === Status::PAYMENT_PENDING) {
484 + $paymentInstance->transaction->update(['status' => Status::PAYMENT_FAILED]);
485 + }
486 +
433 487 wp_send_json([
434 488 'status' => 'failed',
435 489 'message' => $data->get_error_message(),
436 490 'data' => $data->get_error_data()
@@ -439,13 +493,48 @@
439 493
440 494 wp_send_json($data, 200);
441 495 }
442 496
497 + /**
498 + * Serialize checkout submissions per cart with a MySQL named lock.
499 + *
500 + * Keyed on cart_hash (not order id) so concurrent FIRST submissions — no draft
501 + * order yet — contend on the same lock. Release goes through a shutdown function,
502 + * not try/finally: wp_send_json() exits via die() (skips finally), and persistent
503 + * DB connections don't drop the lock on request end.
504 + */
505 + private static function acquireCartLock($cartHash)
506 + {
507 + global $wpdb;
508 +
509 + // md5 keeps the name inside MySQL's 64-char lock-name limit regardless of
510 + // table-prefix length; the prefix scopes the lock per site on multisite.
511 + $lockName = 'fct_checkout_' . md5($wpdb->prefix . $cartHash);
512 +
513 + $lockAcquired = (string) $wpdb->get_var(
514 + $wpdb->prepare('SELECT GET_LOCK(%s, %d)', $lockName, 10)
515 + ) === '1';
516 +
517 + if (!$lockAcquired) {
518 + wp_send_json([
519 + 'status' => 'failed',
520 + 'message' => __('This order is already being processed. Please wait a moment — do not refresh or resubmit.', 'fluent-cart'),
521 + 'data' => []
522 + ], 429);
523 + }
524 +
525 + register_shutdown_function(function () use ($lockName) {
526 + global $wpdb;
527 + $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lockName));
528 + });
529 + }
530 +
443 531 private static function syncCustomerNames($order, $args)
444 532 {
445 533 $customer = $order->customer;
446 534
447 - if (empty($customer)) {
535 + if (empty($customer) || !is_user_logged_in() || (int) $customer->user_id !== get_current_user_id()
536 + || EmailVerificationService::isRequired(get_current_user_id())) {
448 537 return;
449 538 }
450 539
451 540 $firstName = Arr::get($args, 'billing_address.first_name');
@@ -455,18 +544,13 @@
455 544 'first_name' => $firstName,
456 545 'last_name' => $lastName,
457 546 ]);
458 547
459 - $user = get_user_by('email', $customer->email);
460 -
461 - if (empty($user)) {
462 - return;
548 + // Keep profile updates tied to the buyer's stored account link too.
549 + if (is_user_logged_in() && (int) $customer->user_id === get_current_user_id()) {
550 + update_user_meta(get_current_user_id(), 'first_name', $firstName);
551 + update_user_meta(get_current_user_id(), 'last_name', $lastName);
463 552 }
464 -
465 - if (is_user_logged_in() && $user->ID === get_current_user_id()) {
466 - update_user_meta($user->ID, 'first_name', $firstName);
467 - update_user_meta($user->ID, 'last_name', $lastName);
468 - }
469 553 }
470 554
471 555 public static function updateStock($order)
472 556 {
@@ -494,16 +578,18 @@
494 578 if ($current_user->ID) {
495 579 $billingAddress['email'] = $current_user->user_email;
496 580 $billingAddress['user_id'] = $current_user->ID;
497 581 } else {
498 - static::handleUserCreation($orderData, $billingAddress);
582 + unset($billingAddress['user_id']);
499 583 }
500 584
501 585 $customer = CustomerResource::create($billingAddress);
502 586 $customer = Arr::get($customer, 'data', null);
503 587 $customerId = Arr::get($customer, 'id', null);
504 - static::createCustomerAddress($billingAddress, $customerId);
505 - static::createCustomerAddress($shippingAddress, $customerId);
588 + if ($customer && $customer->wasRecentlyCreated) {
589 + static::createCustomerAddress($billingAddress, $customerId);
590 + static::createCustomerAddress($shippingAddress, $customerId);
591 + }
506 592
507 593 return $customer;
508 594 }
509 595
@@ -508,17 +594,13 @@
508 594 }
509 595
510 596 private static function updateExistingCustomer($customer, $orderData, $billingAddress, $shippingAddress)
511 597 {
512 - if (empty($customer->user_id)) {
513 - $currentLoggedInUser = wp_get_current_user();
514 - if ($currentLoggedInUser && $currentLoggedInUser->user_email === $customer->email) {
515 - $userId = get_current_user_id();
516 - $customer->update(['user_id' => $userId]);
517 - $billingAddress['user_id'] = $userId;
518 - }
598 + // Order addresses come from this checkout; saved profile data needs proof.
599 + if (!is_user_logged_in() || (int) $customer->user_id !== get_current_user_id()
600 + || EmailVerificationService::isRequired(get_current_user_id())) {
601 + return;
519 602 }
520 -
521 603 $customer->load(['billing_address', 'shipping_address']);
522 604
523 605 if ($customer->billing_address->count() < 1) {
524 606 static::createCustomerAddress($billingAddress, $customer->id);
@@ -525,25 +607,10 @@
525 607 }
526 608 if ($customer->shipping_address->count() < 1) {
527 609 static::createCustomerAddress($shippingAddress, $customer->id);
528 610 }
529 -
530 - static::handleUserCreation($orderData, $billingAddress, $customer);
531 611 }
532 612
533 - private static function handleUserCreation($orderData, &$billingAddress, $customer = null)
534 - {
535 - $userEmail = Arr::get($billingAddress, 'email');
536 - $user = get_user_by('email', $userEmail);
537 -
538 - if ($user) {
539 - $billingAddress['user_id'] = $user->ID;
540 - if ($customer) {
541 - $customer->update(['user_id' => $user->ID]);
542 - }
543 - }
544 - }
545 -
546 613 private static function getCustomerEmail($billingAddress)
547 614 {
548 615 return is_user_logged_in() ? wp_get_current_user()->user_email : $billingAddress['email'];
549 616 }
@@ -680,10 +747,15 @@
680 747
681 748 $agreeTermsRequired = CheckoutFieldsSchema::isTermsRequired();
682 749
683 750 $customTitles = [
684 - 'address_1' => 'Street Address',
685 - 'address_2' => 'Apt, Suite, Unit',
751 + 'address_1' => __('Street Address', 'fluent-cart'),
752 + 'address_2' => __('Apt, Suite, Unit', 'fluent-cart'),
753 + 'country' => __('Country', 'fluent-cart'),
754 + 'state' => __('State', 'fluent-cart'),
755 + 'city' => __('City', 'fluent-cart'),
756 + 'postcode' => __('Postcode', 'fluent-cart'),
757 + 'phone' => __('Phone', 'fluent-cart'),
686 758 ];
687 759
688 760 foreach ($billingValidations as $key => $rule) {
689 761 $value = Arr::get($billingAddress, $key, '');
@@ -867,9 +939,9 @@
867 939 }
868 940 }
869 941 }
870 942
871 - $isB2B = Arr::get($data, 'is_business', 'no') === 'yes';
943 + $isB2B = Arr::get($data, 'is_business', 'no') === 'yes' || CheckoutFieldsSchema::isB2BOnlyMode();
872 944
873 945 if ($isB2B && CheckoutFieldsSchema::isVatNumberRequired()) {
874 946 $vatNumber = Arr::get($data, 'fct_billing_tax_id', '');
875 947 if (empty($vatNumber)) {
@@ -918,9 +990,16 @@
918 990
919 991
920 992 if ($cart->requireShipping()) {
921 993 if (!empty($data['fc_selected_shipping_method'])) {
922 - $selectedMethod = $data['fc_selected_shipping_method'];
994 + // One integer, decided here, is both what is checked and what placeOrder() prices.
995 + // A loose compare let PHP 7.4 match "1<b>2" to method 1, and sanitize_text_field()
996 + // then turned the same string into "12", so the order was priced by method 12.
997 + $rawMethod = $data['fc_selected_shipping_method'];
998 + $isPlainId = (is_string($rawMethod) || is_int($rawMethod)) && (string) absint($rawMethod) === (string) $rawMethod;
999 + $selectedMethod = $isPlainId ? absint($rawMethod) : 0;
1000 + $data['fc_selected_shipping_method'] = $selectedMethod;
1001 +
923 1002 $shippingCountry = Arr::get($data, 'billing_country', '');
924 1003 $shippingState = Arr::get($data, 'billing_state', '');
925 1004 $shipToDifferent = Arr::get($data, 'ship_to_different', 'no') === 'yes';
926 1005
@@ -936,9 +1015,9 @@
936 1015 $errors['shipping_method']['unavailable'] = __('We don\'t ship to this address. Please select a different address.', 'fluent-cart');
937 1016 } else {
938 1017 $found = false;
939 1018 foreach ($availableShippingMethods as $shippingMethod) {
940 - if ($shippingMethod->id == $selectedMethod) {
1019 + if ((int) $shippingMethod->id === $selectedMethod) {
941 1020 $found = true;
942 1021 break;
943 1022 }
944 1023 }
@@ -959,9 +1038,9 @@
959 1038 'cart' => $cart
960 1039 ]);
961 1040
962 1041 if (count($errors) > 0) {
963 - return new \Wp_Error('validation_error', 'Validation error', $errors);
1042 + return new \Wp_Error('validation_error', __('Validation error', 'fluent-cart'), $errors);
964 1043 }
965 1044
966 1045 return $data;
967 1046 }