PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.1
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.1
1.7.1 1.7.0 1.6.6 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 All 51 releases
← All changes | api/Checkout/CheckoutApi.php +159 -72 1.4.2 → 1.7.1 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,11 +55,37 @@
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;
77 +
78 + // Carts stored before the quantity ceiling existed can still hold an overflowing line.
79 + foreach ($cartData as $cartItem) {
80 + $quantityError = CartHelper::validateQuantity(Arr::get($cartItem, 'quantity', 1));
81 + if ($quantityError) {
82 + wp_send_json([
83 + 'status' => 'failed',
84 + 'message' => $quantityError->get_error_message(),
85 + ], 422);
86 + }
87 + }
61 88 $prevOrder = $cart->order;
62 89 if ($prevOrder) {
63 90 $prevOrder->load('order_items');
64 91 }
@@ -69,12 +96,28 @@
69 96 in_array($prevOrder->status, Status::getOrderSuccessStatuses()) ||
70 97 !in_array($prevOrder->payment_status, Status::getPaymentRetryableStatuses())
71 98 )
72 99 ) {
73 - wp_send_json([
74 - 'status' => 'failed',
75 - 'message' => __('You have already completed this order.', 'fluent-cart'),
76 - ]);
100 + if ($isLockedCart) {
101 + // Locked carts are bound to a specific order (e.g. pay-for-order links),
102 + // so a finalized order really means there is nothing left to pay.
103 + wp_send_json([
104 + 'status' => 'failed',
105 + 'message' => __('You have already completed this order.', 'fluent-cart'),
106 + ]);
107 + }
108 +
109 + // The linked order is already finalized but the cart was never marked
110 + // completed (e.g. a stale cart resurrected by the logged-in user lookup).
111 + // Detach the dead order so the customer can check out again instead of
112 + // being blocked on every future purchase.
113 + $cart->order_id = null;
114 + $checkoutData = $cart->checkout_data;
115 + unset($checkoutData['is_locked']);
116 + $cart->checkout_data = $checkoutData;
117 + $cart->save();
118 + $prevOrder = null;
119 + $isLockedCart = false;
77 120 }
78 121
79 122 $data = static::addLoggedUserData($data);
80 123
@@ -88,8 +131,12 @@
88 131 'message' => $validation->get_error_message(),
89 132 ], 403);
90 133 }
91 134
135 + // order_id unlocks another order's addresses in prepareAddressData(), so it
136 + // may only come from this cart's own order, never from the request.
137 + unset($data['order_id']);
138 +
92 139 if (empty($data['billing_address_id'])) {
93 140 if ($prevOrder instanceof Order) {
94 141 $oldCustomer = $prevOrder->customer;
95 142 if ($oldCustomer) {
@@ -118,23 +165,22 @@
118 165 ]);
119 166 }
120 167
121 168 if (!CheckoutFieldsSchema::isFullNameRequired()) {
122 - if (!empty($validatedData['billing_full_name']) && empty($validatedData['billing_first_name'])) {
123 - // Modal checkout sends billing_full_name — split into first/last name
124 - $nameParts = explode(' ', $validatedData['billing_full_name'], 2);
125 - $validatedData['billing_first_name'] = $nameParts[0];
126 - $validatedData['billing_last_name'] = $nameParts[1] ?? '';
127 - } else {
128 - $validatedData['billing_full_name'] = trim(
129 - Arr::get($validatedData, 'billing_first_name') . ' ' . Arr::get($validatedData, 'billing_last_name')
130 - );
131 - }
169 + // First/Last name mode: the form posts those fields; the full name is derived from them.
170 + $validatedData['billing_full_name'] = trim(
171 + Arr::get($validatedData, 'billing_first_name') . ' ' . Arr::get($validatedData, 'billing_last_name')
172 + );
132 173 }
133 174
134 175 $orderData = OrderService::groupSanitizedData($validatedData);
135 176
136 - $shippingMethodId = Arr::get($orderData, 'others.fc_shipping_method');
177 + // The form posts the method twice: the checked radio (fc_shipping_method) and its
178 + // hidden mirror (fc_selected_shipping_method). validateData() checks only the mirror
179 + // against the address's zones, so pricing from the radio let a request pass with one
180 + // method and be charged by another, from a zone the address is not in. Read from
181 + // $validatedData, not the sanitized copy in others: that is the exact integer checked.
182 + $shippingMethodId = (int) Arr::get($validatedData, 'fc_selected_shipping_method', 0);
137 183
138 184 $shippingMethod = null;
139 185 $shippingCharge = 0;
140 186 if (!$cartCheckoutService->isAllDigital()) {
@@ -260,14 +306,16 @@
260 306 }
261 307
262 308 private static function getOrCreateCustomer(CartCheckoutHelper $cartCheckoutHelper, $orderData)
263 309 {
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);
310 + $customer = is_user_logged_in() ? ApiCustomerResource::getCurrentCustomer() : null;
311 + $email = static::getCustomerEmail($orderData['billing_address']);
312 + Arr::set($orderData, 'billing_address.email', $email);
313 + if (!$customer) {
314 + // Reuse the email's customer for the purchase without granting ownership.
315 + $customer = Customer::query()->where('email', $email)->orderBy('id')->first();
268 316 }
269 - $customer = $cartCheckoutHelper->getCustomer($customerEmail);
317 +
270 318 return static::createCustomerWithAddress(
271 319 $customer,
272 320 $orderData,
273 321 $orderData['billing_address'],
@@ -316,9 +364,9 @@
316 364 }
317 365 }
318 366 }
319 367
320 - if (Arr::get($data, 'is_business', 'no') !== 'yes') {
368 + if (Arr::get($data, 'is_business', 'no') !== 'yes' && !CheckoutFieldsSchema::isB2BOnlyMode()) {
321 369 $data['billing_company_name'] = '';
322 370 $data['billing_legal_registration_id'] = '';
323 371 }
324 372
@@ -389,8 +437,10 @@
389 437 }
390 438
391 439 private static function finalizeOrder(Order $order, $args = [])
392 440 {
441 + // Duplicate/concurrent submissions are already serialized by the cart-hash lock
442 + // at the top of placeOrder() — no per-order lock needed here.
393 443 AddressHelper::insertOrderAddresses(
394 444 $order->id,
395 445 Arr::get($args, 'billing_address', []),
396 446 Arr::get($args, 'shipping_address', [])
@@ -398,15 +448,12 @@
398 448
399 449 static::syncCustomerNames($order, $args);
400 450 $cart = CartHelper::getCart();
401 451
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);
452 + $utmData = UtmHelper::resolveUtmData(
453 + UtmHelper::getUtmDataOfRequest(),
454 + !empty($cart) ? $cart->utm_data : []
455 + );
409 456 UtmHelper::addUtmToOrder($order->id, $utmData);
410 457
411 458 $prevOrder = Arr::get($args, 'prev_order', null);
412 459
@@ -426,11 +473,27 @@
426 473 }
427 474
428 475 $paymentInstance = new PaymentInstance($order);
429 476
477 + // Transition subscription from pending → intended before submitting to the gateway
478 + if ($paymentInstance->subscription && $paymentInstance->subscription->status === Status::SUBSCRIPTION_PENDING) {
479 + $paymentInstance->subscription->status = Status::SUBSCRIPTION_INTENDED;
480 + $paymentInstance->subscription->save();
481 + }
482 +
430 483 $data = $gateway->makePaymentFromPaymentInstance($paymentInstance);
431 484
432 485 if (is_wp_error($data)) {
486 + // Server-observed create failure: mark the transaction FAILED so the next
487 + // resubmit is a RETRY (payment_attempt bump -> fresh idempotency seed) —
488 + // gateways cache error responses under the key, so keeping it pending would
489 + // replay the same error on every resubmit. Client-side declines stay pending
490 + // on purpose: there the same key resolving to the same gateway object IS the
491 + // retry path.
492 + if ($paymentInstance->transaction && $paymentInstance->transaction->status === Status::PAYMENT_PENDING) {
493 + $paymentInstance->transaction->update(['status' => Status::PAYMENT_FAILED]);
494 + }
495 +
433 496 wp_send_json([
434 497 'status' => 'failed',
435 498 'message' => $data->get_error_message(),
436 499 'data' => $data->get_error_data()
@@ -439,13 +502,48 @@
439 502
440 503 wp_send_json($data, 200);
441 504 }
442 505
506 + /**
507 + * Serialize checkout submissions per cart with a MySQL named lock.
508 + *
509 + * Keyed on cart_hash (not order id) so concurrent FIRST submissions — no draft
510 + * order yet — contend on the same lock. Release goes through a shutdown function,
511 + * not try/finally: wp_send_json() exits via die() (skips finally), and persistent
512 + * DB connections don't drop the lock on request end.
513 + */
514 + private static function acquireCartLock($cartHash)
515 + {
516 + global $wpdb;
517 +
518 + // md5 keeps the name inside MySQL's 64-char lock-name limit regardless of
519 + // table-prefix length; the prefix scopes the lock per site on multisite.
520 + $lockName = 'fct_checkout_' . md5($wpdb->prefix . $cartHash);
521 +
522 + $lockAcquired = (string) $wpdb->get_var(
523 + $wpdb->prepare('SELECT GET_LOCK(%s, %d)', $lockName, 10)
524 + ) === '1';
525 +
526 + if (!$lockAcquired) {
527 + wp_send_json([
528 + 'status' => 'failed',
529 + 'message' => __('This order is already being processed. Please wait a moment — do not refresh or resubmit.', 'fluent-cart'),
530 + 'data' => []
531 + ], 429);
532 + }
533 +
534 + register_shutdown_function(function () use ($lockName) {
535 + global $wpdb;
536 + $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lockName));
537 + });
538 + }
539 +
443 540 private static function syncCustomerNames($order, $args)
444 541 {
445 542 $customer = $order->customer;
446 543
447 - if (empty($customer)) {
544 + if (empty($customer) || !is_user_logged_in() || (int) $customer->user_id !== get_current_user_id()
545 + || EmailVerificationService::isRequired(get_current_user_id())) {
448 546 return;
449 547 }
450 548
451 549 $firstName = Arr::get($args, 'billing_address.first_name');
@@ -455,18 +553,13 @@
455 553 'first_name' => $firstName,
456 554 'last_name' => $lastName,
457 555 ]);
458 556
459 - $user = get_user_by('email', $customer->email);
460 -
461 - if (empty($user)) {
462 - return;
557 + // Keep profile updates tied to the buyer's stored account link too.
558 + if (is_user_logged_in() && (int) $customer->user_id === get_current_user_id()) {
559 + update_user_meta(get_current_user_id(), 'first_name', $firstName);
560 + update_user_meta(get_current_user_id(), 'last_name', $lastName);
463 561 }
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 562 }
470 563
471 564 public static function updateStock($order)
472 565 {
@@ -494,16 +587,18 @@
494 587 if ($current_user->ID) {
495 588 $billingAddress['email'] = $current_user->user_email;
496 589 $billingAddress['user_id'] = $current_user->ID;
497 590 } else {
498 - static::handleUserCreation($orderData, $billingAddress);
591 + unset($billingAddress['user_id']);
499 592 }
500 593
501 594 $customer = CustomerResource::create($billingAddress);
502 595 $customer = Arr::get($customer, 'data', null);
503 596 $customerId = Arr::get($customer, 'id', null);
504 - static::createCustomerAddress($billingAddress, $customerId);
505 - static::createCustomerAddress($shippingAddress, $customerId);
597 + if ($customer && $customer->wasRecentlyCreated) {
598 + static::createCustomerAddress($billingAddress, $customerId);
599 + static::createCustomerAddress($shippingAddress, $customerId);
600 + }
506 601
507 602 return $customer;
508 603 }
509 604
@@ -508,17 +603,13 @@
508 603 }
509 604
510 605 private static function updateExistingCustomer($customer, $orderData, $billingAddress, $shippingAddress)
511 606 {
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 - }
607 + // Order addresses come from this checkout; saved profile data needs proof.
608 + if (!is_user_logged_in() || (int) $customer->user_id !== get_current_user_id()
609 + || EmailVerificationService::isRequired(get_current_user_id())) {
610 + return;
519 611 }
520 -
521 612 $customer->load(['billing_address', 'shipping_address']);
522 613
523 614 if ($customer->billing_address->count() < 1) {
524 615 static::createCustomerAddress($billingAddress, $customer->id);
@@ -525,25 +616,10 @@
525 616 }
526 617 if ($customer->shipping_address->count() < 1) {
527 618 static::createCustomerAddress($shippingAddress, $customer->id);
528 619 }
529 -
530 - static::handleUserCreation($orderData, $billingAddress, $customer);
531 620 }
532 621
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 622 private static function getCustomerEmail($billingAddress)
547 623 {
548 624 return is_user_logged_in() ? wp_get_current_user()->user_email : $billingAddress['email'];
549 625 }
@@ -680,10 +756,15 @@
680 756
681 757 $agreeTermsRequired = CheckoutFieldsSchema::isTermsRequired();
682 758
683 759 $customTitles = [
684 - 'address_1' => 'Street Address',
685 - 'address_2' => 'Apt, Suite, Unit',
760 + 'address_1' => __('Street Address', 'fluent-cart'),
761 + 'address_2' => __('Apt, Suite, Unit', 'fluent-cart'),
762 + 'country' => __('Country', 'fluent-cart'),
763 + 'state' => __('State', 'fluent-cart'),
764 + 'city' => __('City', 'fluent-cart'),
765 + 'postcode' => __('Postcode', 'fluent-cart'),
766 + 'phone' => __('Phone', 'fluent-cart'),
686 767 ];
687 768
688 769 foreach ($billingValidations as $key => $rule) {
689 770 $value = Arr::get($billingAddress, $key, '');
@@ -867,9 +948,9 @@
867 948 }
868 949 }
869 950 }
870 951
871 - $isB2B = Arr::get($data, 'is_business', 'no') === 'yes';
952 + $isB2B = Arr::get($data, 'is_business', 'no') === 'yes' || CheckoutFieldsSchema::isB2BOnlyMode();
872 953
873 954 if ($isB2B && CheckoutFieldsSchema::isVatNumberRequired()) {
874 955 $vatNumber = Arr::get($data, 'fct_billing_tax_id', '');
875 956 if (empty($vatNumber)) {
@@ -898,10 +979,9 @@
898 979 if (empty($data['billing_email']) || !is_email($data['billing_email'])) {
899 980 $errors['billing_email']['invalid'] = __('Email must be a valid email address.', 'fluent-cart');
900 981 }
901 982
902 - if (CheckoutFieldsSchema::isFullNameRequired() || !empty($data['billing_full_name'])) {
903 - // Modal checkout always sends billing_full_name regardless of store name field settings
983 + if (CheckoutFieldsSchema::isFullNameRequired()) {
904 984 if (empty($data['billing_full_name'])) {
905 985 $errors['billing_full_name']['required'] = __('Full name is required.', 'fluent-cart');
906 986 }
907 987 } else {
@@ -918,9 +998,16 @@
918 998
919 999
920 1000 if ($cart->requireShipping()) {
921 1001 if (!empty($data['fc_selected_shipping_method'])) {
922 - $selectedMethod = $data['fc_selected_shipping_method'];
1002 + // One integer, decided here, is both what is checked and what placeOrder() prices.
1003 + // A loose compare let PHP 7.4 match "1<b>2" to method 1, and sanitize_text_field()
1004 + // then turned the same string into "12", so the order was priced by method 12.
1005 + $rawMethod = $data['fc_selected_shipping_method'];
1006 + $isPlainId = (is_string($rawMethod) || is_int($rawMethod)) && (string) absint($rawMethod) === (string) $rawMethod;
1007 + $selectedMethod = $isPlainId ? absint($rawMethod) : 0;
1008 + $data['fc_selected_shipping_method'] = $selectedMethod;
1009 +
923 1010 $shippingCountry = Arr::get($data, 'billing_country', '');
924 1011 $shippingState = Arr::get($data, 'billing_state', '');
925 1012 $shipToDifferent = Arr::get($data, 'ship_to_different', 'no') === 'yes';
926 1013
@@ -936,9 +1023,9 @@
936 1023 $errors['shipping_method']['unavailable'] = __('We don\'t ship to this address. Please select a different address.', 'fluent-cart');
937 1024 } else {
938 1025 $found = false;
939 1026 foreach ($availableShippingMethods as $shippingMethod) {
940 - if ($shippingMethod->id == $selectedMethod) {
1027 + if ((int) $shippingMethod->id === $selectedMethod) {
941 1028 $found = true;
942 1029 break;
943 1030 }
944 1031 }
@@ -959,9 +1046,9 @@
959 1046 'cart' => $cart
960 1047 ]);
961 1048
962 1049 if (count($errors) > 0) {
963 - return new \Wp_Error('validation_error', 'Validation error', $errors);
1050 + return new \Wp_Error('validation_error', __('Validation error', 'fluent-cart'), $errors);
964 1051 }
965 1052
966 1053 return $data;
967 1054 }