PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.8.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.8.0
1.8.0 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 All 52 releases
← All changes | api/Checkout/CheckoutApi.php +292 -74 1.5.2 → 1.8.0 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()) {
@@ -165,16 +211,43 @@
165 211 $customer = static::getOrCreateCustomer($cartCheckoutHelper, $orderData);
166 212
167 213 $shouldCreateUser = static::shouldCreateUser($orderData, Arr::get($orderData, 'billing_address', []));
168 214
169 - // Ensure cart has the current payment method before recalculating fees
170 - $checkoutData = $cart->checkout_data ?? [];
171 - $checkoutData['payment_method'] = $paymentMethod;
172 - $cart->checkout_data = $checkoutData;
215 + // Ensure cart has the current payment method, the order's email and the
216 + // submitted checkout fields before recalculating fees. The page saves
217 + // each of them separately, and that save can lag or be sent on its own,
218 + // so anything that prices this order reads what the order is placed under.
219 + static::setPaymentMethod($cart, $paymentMethod);
220 + static::setFormFields($cart, $validatedData);
221 + $cart->email = $customer->email;
173 222
223 + // The helper's instance prices the order shipping charge below, so it
224 + // carries the same method, fields and email from here on.
225 + $helperCart = $cartCheckoutHelper->getCart();
226 + if ($helperCart && $helperCart !== $cart) {
227 + static::setPaymentMethod($helperCart, $paymentMethod);
228 + static::setFormFields($helperCart, $validatedData);
229 + $helperCart->email = $customer->email;
230 + }
231 +
174 232 $cart->clearFeeCache();
175 233 $fees = $cart->getFees();
176 234
235 + // A pricing rule can make this order's postage cheaper. The cart holds the
236 + // cheaper figure during the tax recalculation, then gets the real price back.
237 + // Shipping tax itself comes from each line's share, which TaxModule discounts
238 + // through this same filter. Its own hook keeps fluent_cart/cart/shipping_total,
239 + // and the Shipping row it draws, untouched.
240 + $preDiscountShippingCharge = (int)$shippingCharge;
241 + $shippingCharge = apply_filters('fluent_cart/checkout/order_shipping_charge', $shippingCharge, [
242 + 'cart' => $cartCheckoutHelper->getCart(),
243 + ]);
244 + $shippingDiscount = max(0, $preDiscountShippingCharge - (int)$shippingCharge);
245 +
246 + if ($shippingDiscount > 0) {
247 + static::setCartShippingCharge($cart, (int)$shippingCharge);
248 + }
249 +
177 250 // Recompute cart tax data so fee_tax/fee_tax_lines reflect fees for the current
178 251 // payment method. The scope prevents ShippingModule from running unnecessarily.
179 252 do_action('fluent_cart/cart/cart_data_items_updated', ['cart' => $cart, 'scope' => 'payment_method_fee_recalculate']);
180 253
@@ -189,12 +262,43 @@
189 262 $exclusiveTaxTotal = (int)Arr::get($cart->checkout_data, 'tax_data.exclusive_tax_total', 0);
190 263 $feeTax = (int)Arr::get($cart->checkout_data, 'tax_data.fee_tax', 0);
191 264 $feeTaxLines = (array)Arr::get($cart->checkout_data, 'tax_data.fee_tax_lines', []);
192 265
266 + // Tax is done, so the cart gets its real postage price back. The rule runs
267 + // fresh on every place-order, and a customer returning after a failed payment
268 + // must never be discounted from an already discounted charge. Saved here
269 + // because nothing after this writes the cart.
270 + if ($shippingDiscount > 0) {
271 + static::setCartShippingCharge($cart, $preDiscountShippingCharge);
272 + $cart->save();
273 + }
274 +
193 275 // For dynamic RC + inclusive pricing, reduce the shipping charge to net before storing.
194 276 // The fluent_cart/cart/shipping_total filter (registered by TaxModule) handles the logic.
195 277 $shippingCharge = apply_filters('fluent_cart/cart/shipping_total', $shippingCharge, ['cart' => $cart]);
196 278
279 + // The discount has to be counted in the same money as the charge it is
280 + // stored beside, or a receipt adding the two back together names a price
281 + // the order never had. The filter above can turn both into net amounts,
282 + // and it stops at zero, so the full price goes through it as well.
283 + if ($shippingDiscount > 0) {
284 + $preDiscountShippingCharge = (int)apply_filters(
285 + 'fluent_cart/cart/shipping_total',
286 + $preDiscountShippingCharge,
287 + ['cart' => $cart]
288 + );
289 + $shippingDiscount = max(0, $preDiscountShippingCharge - (int)$shippingCharge);
290 + }
291 +
292 + // Last chance to write into cart_data before the order is built from it.
293 + // The helper's copy was read before $cart was recalculated; build the order
294 + // from $cart so a discount withdrawn on a retry can't be counted twice.
295 + $cartCheckoutHelper->setCart($cart);
296 +
297 + do_action('fluent_cart/checkout/before_order_processing', [
298 + 'cart' => $cart,
299 + ]);
300 +
197 301 $checkoutProcessor = new CheckoutProcessor($cartCheckoutHelper->getItems(), [
198 302 'customer_id' => $customer->id,
199 303 'user_tz' => $userTz,
200 304 'create_account_after_paid' => $shouldCreateUser ? 'yes' : 'no',
@@ -200,8 +304,9 @@
200 304 'create_account_after_paid' => $shouldCreateUser ? 'yes' : 'no',
201 305 'shipping_charge' => $shippingCharge,
202 306 'shipping_method_id' => $shippingMethod ? (int)$shippingMethod->id : 0,
203 307 'shipping_method_title' => $shippingMethod ? $shippingMethod->title : '',
308 + 'shipping_discount' => $shippingDiscount,
204 309 'tax_total' => $taxTotal,
205 310 'tax_behavior' => $taxBehavior,
206 311 'store_tax_behavior' => $storeTaxBehavior,
207 312 'exclusive_tax_total' => $exclusiveTaxTotal,
@@ -258,16 +363,90 @@
258 363 'prev_order' => $prevOrder
259 364 ]);
260 365 }
261 366
367 + /**
368 + * Set the postage the cart advertises.
369 + *
370 + * The tax pipeline reads the charge from here rather than from any local
371 + * amount, so this is how a shipping discount is made visible to it. Does not
372 + * persist — the caller decides, because during a tax recalculation the
373 + * recalculation saves the cart itself.
374 + *
375 + * @param Cart $cart
376 + * @param int $charge In cents.
377 + * @return void
378 + */
379 + private static function setCartShippingCharge(Cart $cart, $charge)
380 + {
381 + $checkoutData = $cart->checkout_data ?? [];
382 + Arr::set($checkoutData, 'shipping_data.shipping_charge', (int)$charge);
383 + $cart->checkout_data = $checkoutData;
384 + }
385 +
386 + /**
387 + * Record the method this order is paid with as both the cart's payment
388 + * method and the form's choice, so nothing that prices the order reads an
389 + * earlier click instead — the page saves the radio separately, and that
390 + * save can lag, name a method no longer offered, or be sent on its own.
391 + *
392 + * @param Cart $cart
393 + * @param string $paymentMethod
394 + * @return void
395 + */
396 + private static function setPaymentMethod(Cart $cart, $paymentMethod)
397 + {
398 + $checkoutData = $cart->checkout_data ?? [];
399 + $checkoutData['payment_method'] = $paymentMethod;
400 + Arr::set($checkoutData, 'form_data._fct_pay_method', $paymentMethod);
401 + $cart->checkout_data = $checkoutData;
402 + }
403 +
404 + /**
405 + * Record the submitted checkout fields as the cart's form, so nothing that
406 + * prices the order reads an earlier save instead. A field the form did not
407 + * send keeps its saved value.
408 + *
409 + * Country, state, city, postcode, the ship-elsewhere and business toggles
410 + * are left as saved: tax is worked out from them, and the page refreshes
411 + * the summary each time one of them is saved, so the saved values are the
412 + * ones the shopper saw the order priced for.
413 + *
414 + * @param Cart $cart
415 + * @param array $data
416 + * @return void
417 + */
418 + private static function setFormFields(Cart $cart, array $data)
419 + {
420 + $checkoutData = $cart->checkout_data ?? [];
421 +
422 + $fields = [
423 + 'full_name', 'first_name', 'last_name', 'address_1', 'address_2',
424 + 'phone', 'company_name', 'legal_registration_id'
425 + ];
426 +
427 + foreach ($fields as $field) {
428 + foreach (['billing_', 'shipping_'] as $prefix) {
429 + $value = Arr::get($data, $prefix . $field);
430 + if (is_scalar($value)) {
431 + Arr::set($checkoutData, 'form_data.' . $prefix . $field, sanitize_text_field((string)$value));
432 + }
433 + }
434 + }
435 +
436 + $cart->checkout_data = $checkoutData;
437 + }
438 +
262 439 private static function getOrCreateCustomer(CartCheckoutHelper $cartCheckoutHelper, $orderData)
263 440 {
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);
441 + $customer = is_user_logged_in() ? ApiCustomerResource::getCurrentCustomer() : null;
442 + $email = static::getCustomerEmail($orderData['billing_address']);
443 + Arr::set($orderData, 'billing_address.email', $email);
444 + if (!$customer) {
445 + // Reuse the email's customer for the purchase without granting ownership.
446 + $customer = Customer::query()->where('email', $email)->orderBy('id')->first();
268 447 }
269 - $customer = $cartCheckoutHelper->getCustomer($customerEmail);
448 +
270 449 return static::createCustomerWithAddress(
271 450 $customer,
272 451 $orderData,
273 452 $orderData['billing_address'],
@@ -389,8 +568,10 @@
389 568 }
390 569
391 570 private static function finalizeOrder(Order $order, $args = [])
392 571 {
572 + // Duplicate/concurrent submissions are already serialized by the cart-hash lock
573 + // at the top of placeOrder() — no per-order lock needed here.
393 574 AddressHelper::insertOrderAddresses(
394 575 $order->id,
395 576 Arr::get($args, 'billing_address', []),
396 577 Arr::get($args, 'shipping_address', [])
@@ -398,15 +579,12 @@
398 579
399 580 static::syncCustomerNames($order, $args);
400 581 $cart = CartHelper::getCart();
401 582
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);
583 + $utmData = UtmHelper::resolveUtmData(
584 + UtmHelper::getUtmDataOfRequest(),
585 + !empty($cart) ? $cart->utm_data : []
586 + );
409 587 UtmHelper::addUtmToOrder($order->id, $utmData);
410 588
411 589 $prevOrder = Arr::get($args, 'prev_order', null);
412 590
@@ -426,11 +604,27 @@
426 604 }
427 605
428 606 $paymentInstance = new PaymentInstance($order);
429 607
608 + // Transition subscription from pending → intended before submitting to the gateway
609 + if ($paymentInstance->subscription && $paymentInstance->subscription->status === Status::SUBSCRIPTION_PENDING) {
610 + $paymentInstance->subscription->status = Status::SUBSCRIPTION_INTENDED;
611 + $paymentInstance->subscription->save();
612 + }
613 +
430 614 $data = $gateway->makePaymentFromPaymentInstance($paymentInstance);
431 615
432 616 if (is_wp_error($data)) {
617 + // Server-observed create failure: mark the transaction FAILED so the next
618 + // resubmit is a RETRY (payment_attempt bump -> fresh idempotency seed) —
619 + // gateways cache error responses under the key, so keeping it pending would
620 + // replay the same error on every resubmit. Client-side declines stay pending
621 + // on purpose: there the same key resolving to the same gateway object IS the
622 + // retry path.
623 + if ($paymentInstance->transaction && $paymentInstance->transaction->status === Status::PAYMENT_PENDING) {
624 + $paymentInstance->transaction->update(['status' => Status::PAYMENT_FAILED]);
625 + }
626 +
433 627 wp_send_json([
434 628 'status' => 'failed',
435 629 'message' => $data->get_error_message(),
436 630 'data' => $data->get_error_data()
@@ -439,13 +633,48 @@
439 633
440 634 wp_send_json($data, 200);
441 635 }
442 636
637 + /**
638 + * Serialize checkout submissions per cart with a MySQL named lock.
639 + *
640 + * Keyed on cart_hash (not order id) so concurrent FIRST submissions — no draft
641 + * order yet — contend on the same lock. Release goes through a shutdown function,
642 + * not try/finally: wp_send_json() exits via die() (skips finally), and persistent
643 + * DB connections don't drop the lock on request end.
644 + */
645 + private static function acquireCartLock($cartHash)
646 + {
647 + global $wpdb;
648 +
649 + // md5 keeps the name inside MySQL's 64-char lock-name limit regardless of
650 + // table-prefix length; the prefix scopes the lock per site on multisite.
651 + $lockName = 'fct_checkout_' . md5($wpdb->prefix . $cartHash);
652 +
653 + $lockAcquired = (string) $wpdb->get_var(
654 + $wpdb->prepare('SELECT GET_LOCK(%s, %d)', $lockName, 10)
655 + ) === '1';
656 +
657 + if (!$lockAcquired) {
658 + wp_send_json([
659 + 'status' => 'failed',
660 + 'message' => __('This order is already being processed. Please wait a moment — do not refresh or resubmit.', 'fluent-cart'),
661 + 'data' => []
662 + ], 429);
663 + }
664 +
665 + register_shutdown_function(function () use ($lockName) {
666 + global $wpdb;
667 + $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lockName));
668 + });
669 + }
670 +
443 671 private static function syncCustomerNames($order, $args)
444 672 {
445 673 $customer = $order->customer;
446 674
447 - if (empty($customer)) {
675 + if (empty($customer) || !is_user_logged_in() || (int) $customer->user_id !== get_current_user_id()
676 + || EmailVerificationService::isRequired(get_current_user_id())) {
448 677 return;
449 678 }
450 679
451 680 $firstName = Arr::get($args, 'billing_address.first_name');
@@ -455,18 +684,13 @@
455 684 'first_name' => $firstName,
456 685 'last_name' => $lastName,
457 686 ]);
458 687
459 - $user = get_user_by('email', $customer->email);
460 -
461 - if (empty($user)) {
462 - return;
688 + // Keep profile updates tied to the buyer's stored account link too.
689 + if (is_user_logged_in() && (int) $customer->user_id === get_current_user_id()) {
690 + update_user_meta(get_current_user_id(), 'first_name', $firstName);
691 + update_user_meta(get_current_user_id(), 'last_name', $lastName);
463 692 }
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 693 }
470 694
471 695 public static function updateStock($order)
472 696 {
@@ -494,16 +718,18 @@
494 718 if ($current_user->ID) {
495 719 $billingAddress['email'] = $current_user->user_email;
496 720 $billingAddress['user_id'] = $current_user->ID;
497 721 } else {
498 - static::handleUserCreation($orderData, $billingAddress);
722 + unset($billingAddress['user_id']);
499 723 }
500 724
501 725 $customer = CustomerResource::create($billingAddress);
502 726 $customer = Arr::get($customer, 'data', null);
503 727 $customerId = Arr::get($customer, 'id', null);
504 - static::createCustomerAddress($billingAddress, $customerId);
505 - static::createCustomerAddress($shippingAddress, $customerId);
728 + if ($customer && $customer->wasRecentlyCreated) {
729 + static::createCustomerAddress($billingAddress, $customerId);
730 + static::createCustomerAddress($shippingAddress, $customerId);
731 + }
506 732
507 733 return $customer;
508 734 }
509 735
@@ -508,17 +734,13 @@
508 734 }
509 735
510 736 private static function updateExistingCustomer($customer, $orderData, $billingAddress, $shippingAddress)
511 737 {
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 - }
738 + // Order addresses come from this checkout; saved profile data needs proof.
739 + if (!is_user_logged_in() || (int) $customer->user_id !== get_current_user_id()
740 + || EmailVerificationService::isRequired(get_current_user_id())) {
741 + return;
519 742 }
520 -
521 743 $customer->load(['billing_address', 'shipping_address']);
522 744
523 745 if ($customer->billing_address->count() < 1) {
524 746 static::createCustomerAddress($billingAddress, $customer->id);
@@ -525,25 +747,10 @@
525 747 }
526 748 if ($customer->shipping_address->count() < 1) {
527 749 static::createCustomerAddress($shippingAddress, $customer->id);
528 750 }
529 -
530 - static::handleUserCreation($orderData, $billingAddress, $customer);
531 751 }
532 752
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 753 private static function getCustomerEmail($billingAddress)
547 754 {
548 755 return is_user_logged_in() ? wp_get_current_user()->user_email : $billingAddress['email'];
549 756 }
@@ -680,10 +887,15 @@
680 887
681 888 $agreeTermsRequired = CheckoutFieldsSchema::isTermsRequired();
682 889
683 890 $customTitles = [
684 - 'address_1' => 'Street Address',
685 - 'address_2' => 'Apt, Suite, Unit',
891 + 'address_1' => __('Street Address', 'fluent-cart'),
892 + 'address_2' => __('Apt, Suite, Unit', 'fluent-cart'),
893 + 'country' => __('Country', 'fluent-cart'),
894 + 'state' => __('State', 'fluent-cart'),
895 + 'city' => __('City', 'fluent-cart'),
896 + 'postcode' => __('Postcode', 'fluent-cart'),
897 + 'phone' => __('Phone', 'fluent-cart'),
686 898 ];
687 899
688 900 foreach ($billingValidations as $key => $rule) {
689 901 $value = Arr::get($billingAddress, $key, '');
@@ -898,10 +1110,9 @@
898 1110 if (empty($data['billing_email']) || !is_email($data['billing_email'])) {
899 1111 $errors['billing_email']['invalid'] = __('Email must be a valid email address.', 'fluent-cart');
900 1112 }
901 1113
902 - if (CheckoutFieldsSchema::isFullNameRequired() || !empty($data['billing_full_name'])) {
903 - // Modal checkout always sends billing_full_name regardless of store name field settings
1114 + if (CheckoutFieldsSchema::isFullNameRequired()) {
904 1115 if (empty($data['billing_full_name'])) {
905 1116 $errors['billing_full_name']['required'] = __('Full name is required.', 'fluent-cart');
906 1117 }
907 1118 } else {
@@ -918,9 +1129,16 @@
918 1129
919 1130
920 1131 if ($cart->requireShipping()) {
921 1132 if (!empty($data['fc_selected_shipping_method'])) {
922 - $selectedMethod = $data['fc_selected_shipping_method'];
1133 + // One integer, decided here, is both what is checked and what placeOrder() prices.
1134 + // A loose compare let PHP 7.4 match "1<b>2" to method 1, and sanitize_text_field()
1135 + // then turned the same string into "12", so the order was priced by method 12.
1136 + $rawMethod = $data['fc_selected_shipping_method'];
1137 + $isPlainId = (is_string($rawMethod) || is_int($rawMethod)) && (string) absint($rawMethod) === (string) $rawMethod;
1138 + $selectedMethod = $isPlainId ? absint($rawMethod) : 0;
1139 + $data['fc_selected_shipping_method'] = $selectedMethod;
1140 +
923 1141 $shippingCountry = Arr::get($data, 'billing_country', '');
924 1142 $shippingState = Arr::get($data, 'billing_state', '');
925 1143 $shipToDifferent = Arr::get($data, 'ship_to_different', 'no') === 'yes';
926 1144
@@ -936,9 +1154,9 @@
936 1154 $errors['shipping_method']['unavailable'] = __('We don\'t ship to this address. Please select a different address.', 'fluent-cart');
937 1155 } else {
938 1156 $found = false;
939 1157 foreach ($availableShippingMethods as $shippingMethod) {
940 - if ($shippingMethod->id == $selectedMethod) {
1158 + if ((int) $shippingMethod->id === $selectedMethod) {
941 1159 $found = true;
942 1160 break;
943 1161 }
944 1162 }
@@ -959,9 +1177,9 @@
959 1177 'cart' => $cart
960 1178 ]);
961 1179
962 1180 if (count($errors) > 0) {
963 - return new \Wp_Error('validation_error', 'Validation error', $errors);
1181 + return new \Wp_Error('validation_error', __('Validation error', 'fluent-cart'), $errors);
964 1182 }
965 1183
966 1184 return $data;
967 1185 }