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 +217 -87 1.3.26 → 1.6.5 View file →
@@ -1,8 +1,9 @@
1 1 <?php
2 2
3 3 namespace FluentCart\Api\Checkout;
4 4
5 +use FluentCart\Api\Resource\CustomerResource as ApiCustomerResource;
5 6 use FluentCart\Api\Resource\FrontendResource\CustomerAddressResource;
6 7 use FluentCart\Api\Resource\FrontendResource\CustomerResource;
7 8 use FluentCart\Api\StoreSettings;
8 9 use FluentCart\App\App;
@@ -20,8 +21,9 @@
20 21 use FluentCart\App\Models\Order;
21 22 use FluentCart\App\Models\OrderAddress;
22 23 use FluentCart\App\Models\ShippingMethod;
23 24 use FluentCart\App\Services\CheckoutService;
25 +use FluentCart\App\Services\CustomerIdentity\EmailVerificationService;
24 26 use FluentCart\App\Services\Localization\LocalizationManager;
25 27 use FluentCart\App\Services\OrderService;
26 28 use FluentCart\App\Services\Payments\PaymentHelper;
27 29 use FluentCart\App\Services\Payments\PaymentInstance;
@@ -53,8 +55,23 @@
53 55 'message' => __('Cart is empty or already completed', 'fluent-cart'),
54 56 ]);
55 57 }
56 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 +
57 74 $cart = $cart->reValidateCoupons();
58 75
59 76 $cartData = $cart->cart_data;
60 77 $prevOrder = $cart->order;
@@ -62,19 +79,34 @@
62 79 $prevOrder->load('order_items');
63 80 }
64 81 $isLockedCart = $cart->isLocked();
65 82
66 - // todo: we should handle this logic as we have multiple options like PAYMENT_PARTIALLY_PAID....
67 83 if ($prevOrder &&
68 84 (
69 85 in_array($prevOrder->status, Status::getOrderSuccessStatuses()) ||
70 - $prevOrder->payment_status != Status::PAYMENT_PENDING
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,10 +164,16 @@
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
175 + $shippingMethod = null;
138 176 $shippingCharge = 0;
139 177 if (!$cartCheckoutService->isAllDigital()) {
140 178 $shippingMethod = ShippingMethod::query()->find($shippingMethodId);
141 179 if (!empty($shippingMethod)) {
@@ -164,13 +202,8 @@
164 202 $customer = static::getOrCreateCustomer($cartCheckoutHelper, $orderData);
165 203
166 204 $shouldCreateUser = static::shouldCreateUser($orderData, Arr::get($orderData, 'billing_address', []));
167 205
168 - $taxTotal = (int)Arr::get($cart->checkout_data, 'tax_data.tax_total', 0); // behavoir not applied here
169 -
170 - $shippingTax = (int)Arr::get($cart->checkout_data, 'tax_data.shipping_tax', 0);
171 - $taxBehavior = apply_filters('fluent_cart/cart/tax_behavior', 0, ['cart' => $cart]);
172 -
173 206 // Ensure cart has the current payment method before recalculating fees
174 207 $checkoutData = $cart->checkout_data ?? [];
175 208 $checkoutData['payment_method'] = $paymentMethod;
176 209 $cart->checkout_data = $checkoutData;
@@ -177,15 +210,41 @@
177 210
178 211 $cart->clearFeeCache();
179 212 $fees = $cart->getFees();
180 213
214 + // Recompute cart tax data so fee_tax/fee_tax_lines reflect fees for the current
215 + // payment method. The scope prevents ShippingModule from running unnecessarily.
216 + do_action('fluent_cart/cart/cart_data_items_updated', ['cart' => $cart, 'scope' => 'payment_method_fee_recalculate']);
217 +
218 + // TaxModule::recalculateTax() refreshes checkout_data['fees'] (RC-adjusted amounts,
219 + // deduplication) — reload so persisted fee items match the recomputed fee tax metadata.
220 + $fees = (array) Arr::get($cart->checkout_data, 'fees', $fees);
221 +
222 + $taxBehavior = apply_filters('fluent_cart/cart/tax_behavior', 0, ['cart' => $cart]);
223 + $taxTotal = (int)Arr::get($cart->checkout_data, 'tax_data.tax_total', 0);
224 + $shippingTax = (int)Arr::get($cart->checkout_data, 'tax_data.shipping_tax', 0);
225 + $storeTaxBehavior = (int)Arr::get($cart->checkout_data, 'tax_data.store_tax_behavior', $taxBehavior);
226 + $exclusiveTaxTotal = (int)Arr::get($cart->checkout_data, 'tax_data.exclusive_tax_total', 0);
227 + $feeTax = (int)Arr::get($cart->checkout_data, 'tax_data.fee_tax', 0);
228 + $feeTaxLines = (array)Arr::get($cart->checkout_data, 'tax_data.fee_tax_lines', []);
229 +
230 + // For dynamic RC + inclusive pricing, reduce the shipping charge to net before storing.
231 + // The fluent_cart/cart/shipping_total filter (registered by TaxModule) handles the logic.
232 + $shippingCharge = apply_filters('fluent_cart/cart/shipping_total', $shippingCharge, ['cart' => $cart]);
233 +
181 234 $checkoutProcessor = new CheckoutProcessor($cartCheckoutHelper->getItems(), [
182 235 'customer_id' => $customer->id,
183 236 'user_tz' => $userTz,
184 237 'create_account_after_paid' => $shouldCreateUser ? 'yes' : 'no',
185 238 'shipping_charge' => $shippingCharge,
239 + 'shipping_method_id' => $shippingMethod ? (int)$shippingMethod->id : 0,
240 + 'shipping_method_title' => $shippingMethod ? $shippingMethod->title : '',
186 241 'tax_total' => $taxTotal,
187 242 'tax_behavior' => $taxBehavior,
243 + 'store_tax_behavior' => $storeTaxBehavior,
244 + 'exclusive_tax_total' => $exclusiveTaxTotal,
245 + 'fee_tax' => $feeTax,
246 + 'fee_tax_lines' => $feeTaxLines,
188 247 'shipping_tax' => $shippingTax,
189 248 'payment_method' => $paymentMethod,
190 249 'applied_coupons' => $cart->getDiscountLines(),
191 250 'billing_address' => Arr::get($orderData, 'billing_address', []),
@@ -192,11 +251,16 @@
192 251 'shipping_address' => Arr::get($orderData, 'shipping_address', []),
193 252 'cart_hash' => $cart->cart_hash,
194 253 'is_locked' => $isLockedCart,
195 254 'manual_discount_total' => $cartCheckoutHelper->getManualDiscountAmount(),
255 + 'prorate_credit' => (int) Arr::get($cart->checkout_data, 'prorate_credit.amount', 0),
256 + 'upgrade_discount' => (int) Arr::get($cart->checkout_data, 'upgrade_discount.amount', 0),
196 257 'ip_address' => AddressHelper::getIpAddress(),
197 258 'note' => Arr::get($orderData, 'others.order_notes', ''),
198 - 'tax_id' => Arr::get($validatedData, 'billing_tax_id', 0),
259 + 'tax_id' => sanitize_text_field(
260 + Arr::get($data, 'fct_billing_tax_id', '')
261 + ?: Arr::get($cart->checkout_data, 'tax_data.vat_number', '')
262 + ),
199 263 'fees' => $fees,
200 264 ]);
201 265
202 266 $createdOrder = $checkoutProcessor->createDraftOrder($prevOrder);
@@ -233,14 +297,16 @@
233 297 }
234 298
235 299 private static function getOrCreateCustomer(CartCheckoutHelper $cartCheckoutHelper, $orderData)
236 300 {
237 - $customerEmail = static::getCustomerEmail($orderData['billing_address']);
238 - if (is_user_logged_in()) {
239 - $customerEmail = wp_get_current_user()->user_email;
240 - 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();
241 307 }
242 - $customer = $cartCheckoutHelper->getCustomer($customerEmail);
308 +
243 309 return static::createCustomerWithAddress(
244 310 $customer,
245 311 $orderData,
246 312 $orderData['billing_address'],
@@ -253,11 +319,13 @@
253 319 $shippingAddressId = Arr::get($data, 'shipping_address_id');
254 320 $billingAddressId = Arr::get($data, 'billing_address_id');
255 321 $orderId = Arr::get($data, 'order_id', null);
256 322
323 + $currentCustomer = is_user_logged_in() ? ApiCustomerResource::getCurrentCustomer() : null;
324 +
257 325 $shipToDifferent = Arr::get($data, 'ship_to_different', 'no');
258 326
259 - $datKeys = ['country', 'address_1', 'address_2', 'city', 'state', 'postcode', 'phone', 'label'];
327 + $datKeys = ['country', 'address_1', 'address_2', 'city', 'state', 'postcode', 'phone', 'label', 'company_name', 'vat_number', 'legal_registration_id'];
260 328
261 329 if ($billingAddressId) {
262 330 $prevOrder = Order::query()->find($orderId);
263 331 $prevBillingId = null;
@@ -272,27 +340,28 @@
272 340 ->where('id', $billingAddressId)
273 341 ->where('type', 'billing')
274 342 ->first();
275 343 }
276 - if (empty($billingAddress)) {
344 + if (empty($billingAddress) && $currentCustomer) {
277 345 $billingAddress = CustomerAddresses::query()
278 346 ->where('id', $billingAddressId)
279 347 ->where('type', 'billing')
348 + ->where('customer_id', $currentCustomer->id)
280 349 ->first();
281 350 }
282 351
283 352 if ($billingAddress) {
284 353 foreach ($datKeys as $key) {
285 - // Guest user, take data from form
286 - if (!is_user_logged_in()) {
287 - $data['billing_' . $key] = Arr::get($data, 'billing_' . $key, '');
288 - } else {
289 - $data['billing_' . $key] = $billingAddress->{$key};
290 - }
354 + $data['billing_' . $key] = $billingAddress->{$key} ?: Arr::get($data, 'billing_' . $key, '');
291 355 }
292 356 }
293 357 }
294 358
359 + if (Arr::get($data, 'is_business', 'no') !== 'yes' && !CheckoutFieldsSchema::isB2BOnlyMode()) {
360 + $data['billing_company_name'] = '';
361 + $data['billing_legal_registration_id'] = '';
362 + }
363 +
295 364 if ($shippingAddressId && $shipToDifferent === 'yes') {
296 365 $prevShippingOrder = Order::query()->find($orderId);
297 366 $prevShippingAddress = $prevShippingOrder ? $prevShippingOrder->shipping_address : null;
298 367 $prevShippingId = Arr::get($prevShippingAddress, 'id', null);
@@ -302,24 +371,21 @@
302 371 ->where('id', $shippingAddressId)
303 372 ->where('type', 'shipping')
304 373 ->first();
305 374 }
306 - if (empty($shippingAddress)) {
375 + if (empty($shippingAddress) && $currentCustomer) {
307 376 $shippingAddress = CustomerAddresses::query()
308 377 ->where('id', $shippingAddressId)
309 378 ->where('type', 'shipping')
379 + ->where('customer_id', $currentCustomer->id)
310 380 ->first();
311 381 }
312 382
313 383 if ($shippingAddress) {
314 - $data['shipping_full_name'] = $shippingAddress->name;
384 + $formFullName = Arr::get($data, 'shipping_full_name', '');
385 + $data['shipping_full_name'] = $shippingAddress->name ?: $formFullName;
315 386 foreach ($datKeys as $key) {
316 - // Guest user, take data from form
317 - if (!is_user_logged_in()) {
318 - $data['shipping_' . $key] = Arr::get($data, 'shipping_' . $key, '');
319 - } else {
320 - $data['shipping_' . $key] = $shippingAddress->{$key};
321 - }
387 + $data['shipping_' . $key] = $shippingAddress->{$key} ?: Arr::get($data, 'shipping_' . $key, '');
322 388 }
323 389 }
324 390 } else if ($shipToDifferent !== 'yes') {
325 391 // if not different shipping, copy billing to shipping
@@ -362,8 +428,10 @@
362 428 }
363 429
364 430 private static function finalizeOrder(Order $order, $args = [])
365 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.
366 434 AddressHelper::insertOrderAddresses(
367 435 $order->id,
368 436 Arr::get($args, 'billing_address', []),
369 437 Arr::get($args, 'shipping_address', [])
@@ -371,15 +439,12 @@
371 439
372 440 static::syncCustomerNames($order, $args);
373 441 $cart = CartHelper::getCart();
374 442
375 - $utmData = [];
376 - if (!empty($cart) && is_array($cart->utm_data) && count($cart->utm_data) > 0) {
377 - $utmData = $cart->utm_data;
378 - }
379 -
380 - $requestUtmData = UtmHelper::getUtmDataOfRequest();
381 - $utmData = wp_parse_args($requestUtmData, $utmData);
443 + $utmData = UtmHelper::resolveUtmData(
444 + UtmHelper::getUtmDataOfRequest(),
445 + !empty($cart) ? $cart->utm_data : []
446 + );
382 447 UtmHelper::addUtmToOrder($order->id, $utmData);
383 448
384 449 $prevOrder = Arr::get($args, 'prev_order', null);
385 450
@@ -399,11 +464,27 @@
399 464 }
400 465
401 466 $paymentInstance = new PaymentInstance($order);
402 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 +
403 474 $data = $gateway->makePaymentFromPaymentInstance($paymentInstance);
404 475
405 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 +
406 487 wp_send_json([
407 488 'status' => 'failed',
408 489 'message' => $data->get_error_message(),
409 490 'data' => $data->get_error_data()
@@ -412,13 +493,48 @@
412 493
413 494 wp_send_json($data, 200);
414 495 }
415 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 +
416 531 private static function syncCustomerNames($order, $args)
417 532 {
418 533 $customer = $order->customer;
419 534
420 - 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())) {
421 537 return;
422 538 }
423 539
424 540 $firstName = Arr::get($args, 'billing_address.first_name');
@@ -428,18 +544,13 @@
428 544 'first_name' => $firstName,
429 545 'last_name' => $lastName,
430 546 ]);
431 547
432 - $user = get_user_by('email', $customer->email);
433 -
434 - if (empty($user)) {
435 - 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);
436 552 }
437 -
438 - if (is_user_logged_in() && $user->ID === get_current_user_id()) {
439 - update_user_meta($user->ID, 'first_name', $firstName);
440 - update_user_meta($user->ID, 'last_name', $lastName);
441 - }
442 553 }
443 554
444 555 public static function updateStock($order)
445 556 {
@@ -467,16 +578,18 @@
467 578 if ($current_user->ID) {
468 579 $billingAddress['email'] = $current_user->user_email;
469 580 $billingAddress['user_id'] = $current_user->ID;
470 581 } else {
471 - static::handleUserCreation($orderData, $billingAddress);
582 + unset($billingAddress['user_id']);
472 583 }
473 584
474 585 $customer = CustomerResource::create($billingAddress);
475 586 $customer = Arr::get($customer, 'data', null);
476 587 $customerId = Arr::get($customer, 'id', null);
477 - static::createCustomerAddress($billingAddress, $customerId);
478 - static::createCustomerAddress($shippingAddress, $customerId);
588 + if ($customer && $customer->wasRecentlyCreated) {
589 + static::createCustomerAddress($billingAddress, $customerId);
590 + static::createCustomerAddress($shippingAddress, $customerId);
591 + }
479 592
480 593 return $customer;
481 594 }
482 595
@@ -481,17 +594,13 @@
481 594 }
482 595
483 596 private static function updateExistingCustomer($customer, $orderData, $billingAddress, $shippingAddress)
484 597 {
485 - if (empty($customer->user_id)) {
486 - $currentLoggedInUser = wp_get_current_user();
487 - if ($currentLoggedInUser && $currentLoggedInUser->user_email === $customer->email) {
488 - $userId = get_current_user_id();
489 - $customer->update(['user_id' => $userId]);
490 - $billingAddress['user_id'] = $userId;
491 - }
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;
492 602 }
493 -
494 603 $customer->load(['billing_address', 'shipping_address']);
495 604
496 605 if ($customer->billing_address->count() < 1) {
497 606 static::createCustomerAddress($billingAddress, $customer->id);
@@ -498,25 +607,10 @@
498 607 }
499 608 if ($customer->shipping_address->count() < 1) {
500 609 static::createCustomerAddress($shippingAddress, $customer->id);
501 610 }
502 -
503 - static::handleUserCreation($orderData, $billingAddress, $customer);
504 611 }
505 612
506 - private static function handleUserCreation($orderData, &$billingAddress, $customer = null)
507 - {
508 - $userEmail = Arr::get($billingAddress, 'email');
509 - $user = get_user_by('email', $userEmail);
510 -
511 - if ($user) {
512 - $billingAddress['user_id'] = $user->ID;
513 - if ($customer) {
514 - $customer->update(['user_id' => $user->ID]);
515 - }
516 - }
517 - }
518 -
519 613 private static function getCustomerEmail($billingAddress)
520 614 {
521 615 return is_user_logged_in() ? wp_get_current_user()->user_email : $billingAddress['email'];
522 616 }
@@ -608,12 +702,12 @@
608 702
609 703 $billingValidations = array_filter(CheckoutFieldsSchema::getCheckoutFieldsRequirements('billing', $fulfillmentType, !$isDifferentShipping));
610 704
611 705 // Name fields are validated separately below (full_name/first_name/last_name)
612 - unset($billingValidations['full_name'], $billingValidations['first_name'], $billingValidations['last_name'], $billingValidations['company_name']);
706 + // vat_number uses field name fct_billing_tax_id and is validated separately in the B2B block below
707 + unset($billingValidations['full_name'], $billingValidations['first_name'], $billingValidations['last_name'], $billingValidations['vat_number']);
613 708
614 - if (!isset($billingValidations['country'])) {
615 - // get store country
709 + if (!isset($billingValidations['country']) && empty($data['billing_country'])) {
616 710 $data['billing_country'] = (new StoreSettings())->get('store_country');
617 711 }
618 712
619 713 $billingAddress = [];
@@ -620,10 +714,11 @@
620 714 foreach ($billingValidations as $key => $billingValidation) {
621 715 $billingAddress[$key] = Arr::get($data, 'billing_' . $key, '');
622 716 }
623 717 if (!isset($billingAddress['country'])) {
624 - // get store country
625 - $billingAddress['country'] = (new StoreSettings())->get('store_country');
718 + $billingAddress['country'] = !empty($data['billing_country'])
719 + ? $data['billing_country']
720 + : (new StoreSettings())->get('store_country');
626 721 }
627 722
628 723 $shippingAddress = [];
629 724 $shippingValidations = [];
@@ -652,10 +747,15 @@
652 747
653 748 $agreeTermsRequired = CheckoutFieldsSchema::isTermsRequired();
654 749
655 750 $customTitles = [
656 - 'address_1' => 'Street Address',
657 - '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'),
658 758 ];
659 759
660 760 foreach ($billingValidations as $key => $rule) {
661 761 $value = Arr::get($billingAddress, $key, '');
@@ -839,8 +939,31 @@
839 939 }
840 940 }
841 941 }
842 942
943 + $isB2B = Arr::get($data, 'is_business', 'no') === 'yes' || CheckoutFieldsSchema::isB2BOnlyMode();
944 +
945 + if ($isB2B && CheckoutFieldsSchema::isVatNumberRequired()) {
946 + $vatNumber = Arr::get($data, 'fct_billing_tax_id', '');
947 + if (empty($vatNumber)) {
948 + $errors['fct_billing_tax_id']['required'] = __('VAT / Tax ID is required.', 'fluent-cart');
949 + }
950 + }
951 +
952 + if ($isB2B && CheckoutFieldsSchema::isCompanyNameRequired()) {
953 + $companyName = Arr::get($data, 'billing_company_name', '');
954 + if (empty($companyName)) {
955 + $errors['billing_company_name']['required'] = __('Company Name is required.', 'fluent-cart');
956 + }
957 + }
958 +
959 + if ($isB2B && CheckoutFieldsSchema::isLegalRegistrationIdRequired()) {
960 + $legalRegId = Arr::get($data, 'billing_legal_registration_id', '');
961 + if (empty($legalRegId)) {
962 + $errors['billing_legal_registration_id']['required'] = __('Legal Registration ID is required.', 'fluent-cart');
963 + }
964 + }
965 +
843 966 if (empty($data['agree_terms']) && $agreeTermsRequired) {
844 967 $errors['agree_terms']['required'] = __('You must agree to the terms and conditions.', 'fluent-cart');
845 968 }
846 969
@@ -867,9 +990,16 @@
867 990
868 991
869 992 if ($cart->requireShipping()) {
870 993 if (!empty($data['fc_selected_shipping_method'])) {
871 - $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 +
872 1002 $shippingCountry = Arr::get($data, 'billing_country', '');
873 1003 $shippingState = Arr::get($data, 'billing_state', '');
874 1004 $shipToDifferent = Arr::get($data, 'ship_to_different', 'no') === 'yes';
875 1005
@@ -885,9 +1015,9 @@
885 1015 $errors['shipping_method']['unavailable'] = __('We don\'t ship to this address. Please select a different address.', 'fluent-cart');
886 1016 } else {
887 1017 $found = false;
888 1018 foreach ($availableShippingMethods as $shippingMethod) {
889 - if ($shippingMethod->id == $selectedMethod) {
1019 + if ((int) $shippingMethod->id === $selectedMethod) {
890 1020 $found = true;
891 1021 break;
892 1022 }
893 1023 }
@@ -908,9 +1038,9 @@
908 1038 'cart' => $cart
909 1039 ]);
910 1040
911 1041 if (count($errors) > 0) {
912 - return new \Wp_Error('validation_error', 'Validation error', $errors);
1042 + return new \Wp_Error('validation_error', __('Validation error', 'fluent-cart'), $errors);
913 1043 }
914 1044
915 1045 return $data;
916 1046 }