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 +220 -89 1.3.21 → 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,53 +340,52 @@
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 - $prevShippingAddress = Order::find($orderId)->shipping_address;
365 + $prevShippingOrder = Order::query()->find($orderId);
366 + $prevShippingAddress = $prevShippingOrder ? $prevShippingOrder->shipping_address : null;
297 367 $prevShippingId = Arr::get($prevShippingAddress, 'id', null);
298 368 $shippingAddress = null;
299 369 if ($orderId && $prevShippingId == $shippingAddressId) {
300 370 $shippingAddress = OrderAddress::query()
301 371 ->where('id', $shippingAddressId)
302 - ->where('type', 'billing')
372 + ->where('type', 'shipping')
303 373 ->first();
304 374 }
305 - if (empty($shippingAddress)) {
375 + if (empty($shippingAddress) && $currentCustomer) {
306 376 $shippingAddress = CustomerAddresses::query()
307 377 ->where('id', $shippingAddressId)
308 378 ->where('type', 'shipping')
379 + ->where('customer_id', $currentCustomer->id)
309 380 ->first();
310 381 }
311 382
312 383 if ($shippingAddress) {
313 - $data['shipping_full_name'] = $shippingAddress->name;
384 + $formFullName = Arr::get($data, 'shipping_full_name', '');
385 + $data['shipping_full_name'] = $shippingAddress->name ?: $formFullName;
314 386 foreach ($datKeys as $key) {
315 - // Guest user, take data from form
316 - if (!is_user_logged_in()) {
317 - $data['shipping_' . $key] = Arr::get($data, 'shipping_' . $key, '');
318 - } else {
319 - $data['shipping_' . $key] = $shippingAddress->{$key};
320 - }
387 + $data['shipping_' . $key] = $shippingAddress->{$key} ?: Arr::get($data, 'shipping_' . $key, '');
321 388 }
322 389 }
323 390 } else if ($shipToDifferent !== 'yes') {
324 391 // if not different shipping, copy billing to shipping
@@ -361,8 +428,10 @@
361 428 }
362 429
363 430 private static function finalizeOrder(Order $order, $args = [])
364 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.
365 434 AddressHelper::insertOrderAddresses(
366 435 $order->id,
367 436 Arr::get($args, 'billing_address', []),
368 437 Arr::get($args, 'shipping_address', [])
@@ -370,15 +439,12 @@
370 439
371 440 static::syncCustomerNames($order, $args);
372 441 $cart = CartHelper::getCart();
373 442
374 - $utmData = [];
375 - if (!empty($cart) && is_array($cart->utm_data) && count($cart->utm_data) > 0) {
376 - $utmData = $cart->utm_data;
377 - }
378 -
379 - $requestUtmData = UtmHelper::getUtmDataOfRequest();
380 - $utmData = wp_parse_args($requestUtmData, $utmData);
443 + $utmData = UtmHelper::resolveUtmData(
444 + UtmHelper::getUtmDataOfRequest(),
445 + !empty($cart) ? $cart->utm_data : []
446 + );
381 447 UtmHelper::addUtmToOrder($order->id, $utmData);
382 448
383 449 $prevOrder = Arr::get($args, 'prev_order', null);
384 450
@@ -398,11 +464,27 @@
398 464 }
399 465
400 466 $paymentInstance = new PaymentInstance($order);
401 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 +
402 474 $data = $gateway->makePaymentFromPaymentInstance($paymentInstance);
403 475
404 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 +
405 487 wp_send_json([
406 488 'status' => 'failed',
407 489 'message' => $data->get_error_message(),
408 490 'data' => $data->get_error_data()
@@ -411,13 +493,48 @@
411 493
412 494 wp_send_json($data, 200);
413 495 }
414 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 +
415 531 private static function syncCustomerNames($order, $args)
416 532 {
417 533 $customer = $order->customer;
418 534
419 - 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())) {
420 537 return;
421 538 }
422 539
423 540 $firstName = Arr::get($args, 'billing_address.first_name');
@@ -427,18 +544,13 @@
427 544 'first_name' => $firstName,
428 545 'last_name' => $lastName,
429 546 ]);
430 547
431 - $user = get_user_by('email', $customer->email);
432 -
433 - if (empty($user)) {
434 - 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);
435 552 }
436 -
437 - if (is_user_logged_in() && $user->ID === get_current_user_id()) {
438 - update_user_meta($user->ID, 'first_name', $firstName);
439 - update_user_meta($user->ID, 'last_name', $lastName);
440 - }
441 553 }
442 554
443 555 public static function updateStock($order)
444 556 {
@@ -466,16 +578,18 @@
466 578 if ($current_user->ID) {
467 579 $billingAddress['email'] = $current_user->user_email;
468 580 $billingAddress['user_id'] = $current_user->ID;
469 581 } else {
470 - static::handleUserCreation($orderData, $billingAddress);
582 + unset($billingAddress['user_id']);
471 583 }
472 584
473 585 $customer = CustomerResource::create($billingAddress);
474 586 $customer = Arr::get($customer, 'data', null);
475 587 $customerId = Arr::get($customer, 'id', null);
476 - static::createCustomerAddress($billingAddress, $customerId);
477 - static::createCustomerAddress($shippingAddress, $customerId);
588 + if ($customer && $customer->wasRecentlyCreated) {
589 + static::createCustomerAddress($billingAddress, $customerId);
590 + static::createCustomerAddress($shippingAddress, $customerId);
591 + }
478 592
479 593 return $customer;
480 594 }
481 595
@@ -480,17 +594,13 @@
480 594 }
481 595
482 596 private static function updateExistingCustomer($customer, $orderData, $billingAddress, $shippingAddress)
483 597 {
484 - if (empty($customer->user_id)) {
485 - $currentLoggedInUser = wp_get_current_user();
486 - if ($currentLoggedInUser && $currentLoggedInUser->user_email === $customer->email) {
487 - $userId = get_current_user_id();
488 - $customer->update(['user_id' => $userId]);
489 - $billingAddress['user_id'] = $userId;
490 - }
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;
491 602 }
492 -
493 603 $customer->load(['billing_address', 'shipping_address']);
494 604
495 605 if ($customer->billing_address->count() < 1) {
496 606 static::createCustomerAddress($billingAddress, $customer->id);
@@ -497,25 +607,10 @@
497 607 }
498 608 if ($customer->shipping_address->count() < 1) {
499 609 static::createCustomerAddress($shippingAddress, $customer->id);
500 610 }
501 -
502 - static::handleUserCreation($orderData, $billingAddress, $customer);
503 611 }
504 612
505 - private static function handleUserCreation($orderData, &$billingAddress, $customer = null)
506 - {
507 - $userEmail = Arr::get($billingAddress, 'email');
508 - $user = get_user_by('email', $userEmail);
509 -
510 - if ($user) {
511 - $billingAddress['user_id'] = $user->ID;
512 - if ($customer) {
513 - $customer->update(['user_id' => $user->ID]);
514 - }
515 - }
516 - }
517 -
518 613 private static function getCustomerEmail($billingAddress)
519 614 {
520 615 return is_user_logged_in() ? wp_get_current_user()->user_email : $billingAddress['email'];
521 616 }
@@ -607,12 +702,12 @@
607 702
608 703 $billingValidations = array_filter(CheckoutFieldsSchema::getCheckoutFieldsRequirements('billing', $fulfillmentType, !$isDifferentShipping));
609 704
610 705 // Name fields are validated separately below (full_name/first_name/last_name)
611 - 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']);
612 708
613 - if (!isset($billingValidations['country'])) {
614 - // get store country
709 + if (!isset($billingValidations['country']) && empty($data['billing_country'])) {
615 710 $data['billing_country'] = (new StoreSettings())->get('store_country');
616 711 }
617 712
618 713 $billingAddress = [];
@@ -619,10 +714,11 @@
619 714 foreach ($billingValidations as $key => $billingValidation) {
620 715 $billingAddress[$key] = Arr::get($data, 'billing_' . $key, '');
621 716 }
622 717 if (!isset($billingAddress['country'])) {
623 - // get store country
624 - $billingAddress['country'] = (new StoreSettings())->get('store_country');
718 + $billingAddress['country'] = !empty($data['billing_country'])
719 + ? $data['billing_country']
720 + : (new StoreSettings())->get('store_country');
625 721 }
626 722
627 723 $shippingAddress = [];
628 724 $shippingValidations = [];
@@ -651,10 +747,15 @@
651 747
652 748 $agreeTermsRequired = CheckoutFieldsSchema::isTermsRequired();
653 749
654 750 $customTitles = [
655 - 'address_1' => 'Street Address',
656 - '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'),
657 758 ];
658 759
659 760 foreach ($billingValidations as $key => $rule) {
660 761 $value = Arr::get($billingAddress, $key, '');
@@ -838,8 +939,31 @@
838 939 }
839 940 }
840 941 }
841 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 +
842 966 if (empty($data['agree_terms']) && $agreeTermsRequired) {
843 967 $errors['agree_terms']['required'] = __('You must agree to the terms and conditions.', 'fluent-cart');
844 968 }
845 969
@@ -866,9 +990,16 @@
866 990
867 991
868 992 if ($cart->requireShipping()) {
869 993 if (!empty($data['fc_selected_shipping_method'])) {
870 - $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 +
871 1002 $shippingCountry = Arr::get($data, 'billing_country', '');
872 1003 $shippingState = Arr::get($data, 'billing_state', '');
873 1004 $shipToDifferent = Arr::get($data, 'ship_to_different', 'no') === 'yes';
874 1005
@@ -884,9 +1015,9 @@
884 1015 $errors['shipping_method']['unavailable'] = __('We don\'t ship to this address. Please select a different address.', 'fluent-cart');
885 1016 } else {
886 1017 $found = false;
887 1018 foreach ($availableShippingMethods as $shippingMethod) {
888 - if ($shippingMethod->id == $selectedMethod) {
1019 + if ((int) $shippingMethod->id === $selectedMethod) {
889 1020 $found = true;
890 1021 break;
891 1022 }
892 1023 }
@@ -907,9 +1038,9 @@
907 1038 'cart' => $cart
908 1039 ]);
909 1040
910 1041 if (count($errors) > 0) {
911 - return new \Wp_Error('validation_error', 'Validation error', $errors);
1042 + return new \Wp_Error('validation_error', __('Validation error', 'fluent-cart'), $errors);
912 1043 }
913 1044
914 1045 return $data;
915 1046 }