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 +237 -99 1.3.25 → 1.7.1 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,11 +55,37 @@
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;
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 + }
60 88 $prevOrder = $cart->order;
61 89 if ($prevOrder) {
62 90 $prevOrder->load('order_items');
63 91 }
@@ -62,19 +90,34 @@
62 90 $prevOrder->load('order_items');
63 91 }
64 92 $isLockedCart = $cart->isLocked();
65 93
66 - // todo: we should handle this logic as we have multiple options like PAYMENT_PARTIALLY_PAID....
67 94 if ($prevOrder &&
68 95 (
69 96 in_array($prevOrder->status, Status::getOrderSuccessStatuses()) ||
70 - $prevOrder->payment_status != Status::PAYMENT_PENDING
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,24 +165,24 @@
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
184 + $shippingMethod = null;
138 185 $shippingCharge = 0;
139 186 if (!$cartCheckoutService->isAllDigital()) {
140 187 $shippingMethod = ShippingMethod::query()->find($shippingMethodId);
141 188 if (!empty($shippingMethod)) {
@@ -164,13 +211,8 @@
164 211 $customer = static::getOrCreateCustomer($cartCheckoutHelper, $orderData);
165 212
166 213 $shouldCreateUser = static::shouldCreateUser($orderData, Arr::get($orderData, 'billing_address', []));
167 214
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 215 // Ensure cart has the current payment method before recalculating fees
174 216 $checkoutData = $cart->checkout_data ?? [];
175 217 $checkoutData['payment_method'] = $paymentMethod;
176 218 $cart->checkout_data = $checkoutData;
@@ -177,15 +219,41 @@
177 219
178 220 $cart->clearFeeCache();
179 221 $fees = $cart->getFees();
180 222
223 + // Recompute cart tax data so fee_tax/fee_tax_lines reflect fees for the current
224 + // payment method. The scope prevents ShippingModule from running unnecessarily.
225 + do_action('fluent_cart/cart/cart_data_items_updated', ['cart' => $cart, 'scope' => 'payment_method_fee_recalculate']);
226 +
227 + // TaxModule::recalculateTax() refreshes checkout_data['fees'] (RC-adjusted amounts,
228 + // deduplication) — reload so persisted fee items match the recomputed fee tax metadata.
229 + $fees = (array) Arr::get($cart->checkout_data, 'fees', $fees);
230 +
231 + $taxBehavior = apply_filters('fluent_cart/cart/tax_behavior', 0, ['cart' => $cart]);
232 + $taxTotal = (int)Arr::get($cart->checkout_data, 'tax_data.tax_total', 0);
233 + $shippingTax = (int)Arr::get($cart->checkout_data, 'tax_data.shipping_tax', 0);
234 + $storeTaxBehavior = (int)Arr::get($cart->checkout_data, 'tax_data.store_tax_behavior', $taxBehavior);
235 + $exclusiveTaxTotal = (int)Arr::get($cart->checkout_data, 'tax_data.exclusive_tax_total', 0);
236 + $feeTax = (int)Arr::get($cart->checkout_data, 'tax_data.fee_tax', 0);
237 + $feeTaxLines = (array)Arr::get($cart->checkout_data, 'tax_data.fee_tax_lines', []);
238 +
239 + // For dynamic RC + inclusive pricing, reduce the shipping charge to net before storing.
240 + // The fluent_cart/cart/shipping_total filter (registered by TaxModule) handles the logic.
241 + $shippingCharge = apply_filters('fluent_cart/cart/shipping_total', $shippingCharge, ['cart' => $cart]);
242 +
181 243 $checkoutProcessor = new CheckoutProcessor($cartCheckoutHelper->getItems(), [
182 244 'customer_id' => $customer->id,
183 245 'user_tz' => $userTz,
184 246 'create_account_after_paid' => $shouldCreateUser ? 'yes' : 'no',
185 247 'shipping_charge' => $shippingCharge,
248 + 'shipping_method_id' => $shippingMethod ? (int)$shippingMethod->id : 0,
249 + 'shipping_method_title' => $shippingMethod ? $shippingMethod->title : '',
186 250 'tax_total' => $taxTotal,
187 251 'tax_behavior' => $taxBehavior,
252 + 'store_tax_behavior' => $storeTaxBehavior,
253 + 'exclusive_tax_total' => $exclusiveTaxTotal,
254 + 'fee_tax' => $feeTax,
255 + 'fee_tax_lines' => $feeTaxLines,
188 256 'shipping_tax' => $shippingTax,
189 257 'payment_method' => $paymentMethod,
190 258 'applied_coupons' => $cart->getDiscountLines(),
191 259 'billing_address' => Arr::get($orderData, 'billing_address', []),
@@ -192,11 +260,16 @@
192 260 'shipping_address' => Arr::get($orderData, 'shipping_address', []),
193 261 'cart_hash' => $cart->cart_hash,
194 262 'is_locked' => $isLockedCart,
195 263 'manual_discount_total' => $cartCheckoutHelper->getManualDiscountAmount(),
264 + 'prorate_credit' => (int) Arr::get($cart->checkout_data, 'prorate_credit.amount', 0),
265 + 'upgrade_discount' => (int) Arr::get($cart->checkout_data, 'upgrade_discount.amount', 0),
196 266 'ip_address' => AddressHelper::getIpAddress(),
197 267 'note' => Arr::get($orderData, 'others.order_notes', ''),
198 - 'tax_id' => Arr::get($validatedData, 'billing_tax_id', 0),
268 + 'tax_id' => sanitize_text_field(
269 + Arr::get($data, 'fct_billing_tax_id', '')
270 + ?: Arr::get($cart->checkout_data, 'tax_data.vat_number', '')
271 + ),
199 272 'fees' => $fees,
200 273 ]);
201 274
202 275 $createdOrder = $checkoutProcessor->createDraftOrder($prevOrder);
@@ -233,14 +306,16 @@
233 306 }
234 307
235 308 private static function getOrCreateCustomer(CartCheckoutHelper $cartCheckoutHelper, $orderData)
236 309 {
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);
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();
241 316 }
242 - $customer = $cartCheckoutHelper->getCustomer($customerEmail);
317 +
243 318 return static::createCustomerWithAddress(
244 319 $customer,
245 320 $orderData,
246 321 $orderData['billing_address'],
@@ -253,11 +328,13 @@
253 328 $shippingAddressId = Arr::get($data, 'shipping_address_id');
254 329 $billingAddressId = Arr::get($data, 'billing_address_id');
255 330 $orderId = Arr::get($data, 'order_id', null);
256 331
332 + $currentCustomer = is_user_logged_in() ? ApiCustomerResource::getCurrentCustomer() : null;
333 +
257 334 $shipToDifferent = Arr::get($data, 'ship_to_different', 'no');
258 335
259 - $datKeys = ['country', 'address_1', 'address_2', 'city', 'state', 'postcode', 'phone', 'label'];
336 + $datKeys = ['country', 'address_1', 'address_2', 'city', 'state', 'postcode', 'phone', 'label', 'company_name', 'vat_number', 'legal_registration_id'];
260 337
261 338 if ($billingAddressId) {
262 339 $prevOrder = Order::query()->find($orderId);
263 340 $prevBillingId = null;
@@ -272,27 +349,28 @@
272 349 ->where('id', $billingAddressId)
273 350 ->where('type', 'billing')
274 351 ->first();
275 352 }
276 - if (empty($billingAddress)) {
353 + if (empty($billingAddress) && $currentCustomer) {
277 354 $billingAddress = CustomerAddresses::query()
278 355 ->where('id', $billingAddressId)
279 356 ->where('type', 'billing')
357 + ->where('customer_id', $currentCustomer->id)
280 358 ->first();
281 359 }
282 360
283 361 if ($billingAddress) {
284 362 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 - }
363 + $data['billing_' . $key] = $billingAddress->{$key} ?: Arr::get($data, 'billing_' . $key, '');
291 364 }
292 365 }
293 366 }
294 367
368 + if (Arr::get($data, 'is_business', 'no') !== 'yes' && !CheckoutFieldsSchema::isB2BOnlyMode()) {
369 + $data['billing_company_name'] = '';
370 + $data['billing_legal_registration_id'] = '';
371 + }
372 +
295 373 if ($shippingAddressId && $shipToDifferent === 'yes') {
296 374 $prevShippingOrder = Order::query()->find($orderId);
297 375 $prevShippingAddress = $prevShippingOrder ? $prevShippingOrder->shipping_address : null;
298 376 $prevShippingId = Arr::get($prevShippingAddress, 'id', null);
@@ -302,24 +380,21 @@
302 380 ->where('id', $shippingAddressId)
303 381 ->where('type', 'shipping')
304 382 ->first();
305 383 }
306 - if (empty($shippingAddress)) {
384 + if (empty($shippingAddress) && $currentCustomer) {
307 385 $shippingAddress = CustomerAddresses::query()
308 386 ->where('id', $shippingAddressId)
309 387 ->where('type', 'shipping')
388 + ->where('customer_id', $currentCustomer->id)
310 389 ->first();
311 390 }
312 391
313 392 if ($shippingAddress) {
314 - $data['shipping_full_name'] = $shippingAddress->name;
393 + $formFullName = Arr::get($data, 'shipping_full_name', '');
394 + $data['shipping_full_name'] = $shippingAddress->name ?: $formFullName;
315 395 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 - }
396 + $data['shipping_' . $key] = $shippingAddress->{$key} ?: Arr::get($data, 'shipping_' . $key, '');
322 397 }
323 398 }
324 399 } else if ($shipToDifferent !== 'yes') {
325 400 // if not different shipping, copy billing to shipping
@@ -362,8 +437,10 @@
362 437 }
363 438
364 439 private static function finalizeOrder(Order $order, $args = [])
365 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.
366 443 AddressHelper::insertOrderAddresses(
367 444 $order->id,
368 445 Arr::get($args, 'billing_address', []),
369 446 Arr::get($args, 'shipping_address', [])
@@ -371,15 +448,12 @@
371 448
372 449 static::syncCustomerNames($order, $args);
373 450 $cart = CartHelper::getCart();
374 451
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);
452 + $utmData = UtmHelper::resolveUtmData(
453 + UtmHelper::getUtmDataOfRequest(),
454 + !empty($cart) ? $cart->utm_data : []
455 + );
382 456 UtmHelper::addUtmToOrder($order->id, $utmData);
383 457
384 458 $prevOrder = Arr::get($args, 'prev_order', null);
385 459
@@ -399,11 +473,27 @@
399 473 }
400 474
401 475 $paymentInstance = new PaymentInstance($order);
402 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 +
403 483 $data = $gateway->makePaymentFromPaymentInstance($paymentInstance);
404 484
405 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 +
406 496 wp_send_json([
407 497 'status' => 'failed',
408 498 'message' => $data->get_error_message(),
409 499 'data' => $data->get_error_data()
@@ -412,13 +502,48 @@
412 502
413 503 wp_send_json($data, 200);
414 504 }
415 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 +
416 540 private static function syncCustomerNames($order, $args)
417 541 {
418 542 $customer = $order->customer;
419 543
420 - 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())) {
421 546 return;
422 547 }
423 548
424 549 $firstName = Arr::get($args, 'billing_address.first_name');
@@ -428,18 +553,13 @@
428 553 'first_name' => $firstName,
429 554 'last_name' => $lastName,
430 555 ]);
431 556
432 - $user = get_user_by('email', $customer->email);
433 -
434 - if (empty($user)) {
435 - 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);
436 561 }
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 562 }
443 563
444 564 public static function updateStock($order)
445 565 {
@@ -467,16 +587,18 @@
467 587 if ($current_user->ID) {
468 588 $billingAddress['email'] = $current_user->user_email;
469 589 $billingAddress['user_id'] = $current_user->ID;
470 590 } else {
471 - static::handleUserCreation($orderData, $billingAddress);
591 + unset($billingAddress['user_id']);
472 592 }
473 593
474 594 $customer = CustomerResource::create($billingAddress);
475 595 $customer = Arr::get($customer, 'data', null);
476 596 $customerId = Arr::get($customer, 'id', null);
477 - static::createCustomerAddress($billingAddress, $customerId);
478 - static::createCustomerAddress($shippingAddress, $customerId);
597 + if ($customer && $customer->wasRecentlyCreated) {
598 + static::createCustomerAddress($billingAddress, $customerId);
599 + static::createCustomerAddress($shippingAddress, $customerId);
600 + }
479 601
480 602 return $customer;
481 603 }
482 604
@@ -481,17 +603,13 @@
481 603 }
482 604
483 605 private static function updateExistingCustomer($customer, $orderData, $billingAddress, $shippingAddress)
484 606 {
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 - }
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;
492 611 }
493 -
494 612 $customer->load(['billing_address', 'shipping_address']);
495 613
496 614 if ($customer->billing_address->count() < 1) {
497 615 static::createCustomerAddress($billingAddress, $customer->id);
@@ -498,25 +616,10 @@
498 616 }
499 617 if ($customer->shipping_address->count() < 1) {
500 618 static::createCustomerAddress($shippingAddress, $customer->id);
501 619 }
502 -
503 - static::handleUserCreation($orderData, $billingAddress, $customer);
504 620 }
505 621
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 622 private static function getCustomerEmail($billingAddress)
520 623 {
521 624 return is_user_logged_in() ? wp_get_current_user()->user_email : $billingAddress['email'];
522 625 }
@@ -608,12 +711,12 @@
608 711
609 712 $billingValidations = array_filter(CheckoutFieldsSchema::getCheckoutFieldsRequirements('billing', $fulfillmentType, !$isDifferentShipping));
610 713
611 714 // 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']);
715 + // vat_number uses field name fct_billing_tax_id and is validated separately in the B2B block below
716 + unset($billingValidations['full_name'], $billingValidations['first_name'], $billingValidations['last_name'], $billingValidations['vat_number']);
613 717
614 - if (!isset($billingValidations['country'])) {
615 - // get store country
718 + if (!isset($billingValidations['country']) && empty($data['billing_country'])) {
616 719 $data['billing_country'] = (new StoreSettings())->get('store_country');
617 720 }
618 721
619 722 $billingAddress = [];
@@ -620,10 +723,11 @@
620 723 foreach ($billingValidations as $key => $billingValidation) {
621 724 $billingAddress[$key] = Arr::get($data, 'billing_' . $key, '');
622 725 }
623 726 if (!isset($billingAddress['country'])) {
624 - // get store country
625 - $billingAddress['country'] = (new StoreSettings())->get('store_country');
727 + $billingAddress['country'] = !empty($data['billing_country'])
728 + ? $data['billing_country']
729 + : (new StoreSettings())->get('store_country');
626 730 }
627 731
628 732 $shippingAddress = [];
629 733 $shippingValidations = [];
@@ -652,10 +756,15 @@
652 756
653 757 $agreeTermsRequired = CheckoutFieldsSchema::isTermsRequired();
654 758
655 759 $customTitles = [
656 - 'address_1' => 'Street Address',
657 - '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'),
658 767 ];
659 768
660 769 foreach ($billingValidations as $key => $rule) {
661 770 $value = Arr::get($billingAddress, $key, '');
@@ -839,8 +948,31 @@
839 948 }
840 949 }
841 950 }
842 951
952 + $isB2B = Arr::get($data, 'is_business', 'no') === 'yes' || CheckoutFieldsSchema::isB2BOnlyMode();
953 +
954 + if ($isB2B && CheckoutFieldsSchema::isVatNumberRequired()) {
955 + $vatNumber = Arr::get($data, 'fct_billing_tax_id', '');
956 + if (empty($vatNumber)) {
957 + $errors['fct_billing_tax_id']['required'] = __('VAT / Tax ID is required.', 'fluent-cart');
958 + }
959 + }
960 +
961 + if ($isB2B && CheckoutFieldsSchema::isCompanyNameRequired()) {
962 + $companyName = Arr::get($data, 'billing_company_name', '');
963 + if (empty($companyName)) {
964 + $errors['billing_company_name']['required'] = __('Company Name is required.', 'fluent-cart');
965 + }
966 + }
967 +
968 + if ($isB2B && CheckoutFieldsSchema::isLegalRegistrationIdRequired()) {
969 + $legalRegId = Arr::get($data, 'billing_legal_registration_id', '');
970 + if (empty($legalRegId)) {
971 + $errors['billing_legal_registration_id']['required'] = __('Legal Registration ID is required.', 'fluent-cart');
972 + }
973 + }
974 +
843 975 if (empty($data['agree_terms']) && $agreeTermsRequired) {
844 976 $errors['agree_terms']['required'] = __('You must agree to the terms and conditions.', 'fluent-cart');
845 977 }
846 978
@@ -847,10 +979,9 @@
847 979 if (empty($data['billing_email']) || !is_email($data['billing_email'])) {
848 980 $errors['billing_email']['invalid'] = __('Email must be a valid email address.', 'fluent-cart');
849 981 }
850 982
851 - if (CheckoutFieldsSchema::isFullNameRequired() || !empty($data['billing_full_name'])) {
852 - // Modal checkout always sends billing_full_name regardless of store name field settings
983 + if (CheckoutFieldsSchema::isFullNameRequired()) {
853 984 if (empty($data['billing_full_name'])) {
854 985 $errors['billing_full_name']['required'] = __('Full name is required.', 'fluent-cart');
855 986 }
856 987 } else {
@@ -867,9 +998,16 @@
867 998
868 999
869 1000 if ($cart->requireShipping()) {
870 1001 if (!empty($data['fc_selected_shipping_method'])) {
871 - $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 +
872 1010 $shippingCountry = Arr::get($data, 'billing_country', '');
873 1011 $shippingState = Arr::get($data, 'billing_state', '');
874 1012 $shipToDifferent = Arr::get($data, 'ship_to_different', 'no') === 'yes';
875 1013
@@ -885,9 +1023,9 @@
885 1023 $errors['shipping_method']['unavailable'] = __('We don\'t ship to this address. Please select a different address.', 'fluent-cart');
886 1024 } else {
887 1025 $found = false;
888 1026 foreach ($availableShippingMethods as $shippingMethod) {
889 - if ($shippingMethod->id == $selectedMethod) {
1027 + if ((int) $shippingMethod->id === $selectedMethod) {
890 1028 $found = true;
891 1029 break;
892 1030 }
893 1031 }
@@ -908,9 +1046,9 @@
908 1046 'cart' => $cart
909 1047 ]);
910 1048
911 1049 if (count($errors) > 0) {
912 - return new \Wp_Error('validation_error', 'Validation error', $errors);
1050 + return new \Wp_Error('validation_error', __('Validation error', 'fluent-cart'), $errors);
913 1051 }
914 1052
915 1053 return $data;
916 1054 }