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 +240 -101 1.3.22 → 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,53 +349,52 @@
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 - $prevShippingAddress = Order::find($orderId)->shipping_address;
374 + $prevShippingOrder = Order::query()->find($orderId);
375 + $prevShippingAddress = $prevShippingOrder ? $prevShippingOrder->shipping_address : null;
297 376 $prevShippingId = Arr::get($prevShippingAddress, 'id', null);
298 377 $shippingAddress = null;
299 378 if ($orderId && $prevShippingId == $shippingAddressId) {
300 379 $shippingAddress = OrderAddress::query()
301 380 ->where('id', $shippingAddressId)
302 - ->where('type', 'billing')
381 + ->where('type', 'shipping')
303 382 ->first();
304 383 }
305 - if (empty($shippingAddress)) {
384 + if (empty($shippingAddress) && $currentCustomer) {
306 385 $shippingAddress = CustomerAddresses::query()
307 386 ->where('id', $shippingAddressId)
308 387 ->where('type', 'shipping')
388 + ->where('customer_id', $currentCustomer->id)
309 389 ->first();
310 390 }
311 391
312 392 if ($shippingAddress) {
313 - $data['shipping_full_name'] = $shippingAddress->name;
393 + $formFullName = Arr::get($data, 'shipping_full_name', '');
394 + $data['shipping_full_name'] = $shippingAddress->name ?: $formFullName;
314 395 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 - }
396 + $data['shipping_' . $key] = $shippingAddress->{$key} ?: Arr::get($data, 'shipping_' . $key, '');
321 397 }
322 398 }
323 399 } else if ($shipToDifferent !== 'yes') {
324 400 // if not different shipping, copy billing to shipping
@@ -361,8 +437,10 @@
361 437 }
362 438
363 439 private static function finalizeOrder(Order $order, $args = [])
364 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.
365 443 AddressHelper::insertOrderAddresses(
366 444 $order->id,
367 445 Arr::get($args, 'billing_address', []),
368 446 Arr::get($args, 'shipping_address', [])
@@ -370,15 +448,12 @@
370 448
371 449 static::syncCustomerNames($order, $args);
372 450 $cart = CartHelper::getCart();
373 451
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);
452 + $utmData = UtmHelper::resolveUtmData(
453 + UtmHelper::getUtmDataOfRequest(),
454 + !empty($cart) ? $cart->utm_data : []
455 + );
381 456 UtmHelper::addUtmToOrder($order->id, $utmData);
382 457
383 458 $prevOrder = Arr::get($args, 'prev_order', null);
384 459
@@ -398,11 +473,27 @@
398 473 }
399 474
400 475 $paymentInstance = new PaymentInstance($order);
401 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 +
402 483 $data = $gateway->makePaymentFromPaymentInstance($paymentInstance);
403 484
404 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 +
405 496 wp_send_json([
406 497 'status' => 'failed',
407 498 'message' => $data->get_error_message(),
408 499 'data' => $data->get_error_data()
@@ -411,13 +502,48 @@
411 502
412 503 wp_send_json($data, 200);
413 504 }
414 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 +
415 540 private static function syncCustomerNames($order, $args)
416 541 {
417 542 $customer = $order->customer;
418 543
419 - 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())) {
420 546 return;
421 547 }
422 548
423 549 $firstName = Arr::get($args, 'billing_address.first_name');
@@ -427,18 +553,13 @@
427 553 'first_name' => $firstName,
428 554 'last_name' => $lastName,
429 555 ]);
430 556
431 - $user = get_user_by('email', $customer->email);
432 -
433 - if (empty($user)) {
434 - 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);
435 561 }
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 562 }
442 563
443 564 public static function updateStock($order)
444 565 {
@@ -466,16 +587,18 @@
466 587 if ($current_user->ID) {
467 588 $billingAddress['email'] = $current_user->user_email;
468 589 $billingAddress['user_id'] = $current_user->ID;
469 590 } else {
470 - static::handleUserCreation($orderData, $billingAddress);
591 + unset($billingAddress['user_id']);
471 592 }
472 593
473 594 $customer = CustomerResource::create($billingAddress);
474 595 $customer = Arr::get($customer, 'data', null);
475 596 $customerId = Arr::get($customer, 'id', null);
476 - static::createCustomerAddress($billingAddress, $customerId);
477 - static::createCustomerAddress($shippingAddress, $customerId);
597 + if ($customer && $customer->wasRecentlyCreated) {
598 + static::createCustomerAddress($billingAddress, $customerId);
599 + static::createCustomerAddress($shippingAddress, $customerId);
600 + }
478 601
479 602 return $customer;
480 603 }
481 604
@@ -480,17 +603,13 @@
480 603 }
481 604
482 605 private static function updateExistingCustomer($customer, $orderData, $billingAddress, $shippingAddress)
483 606 {
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 - }
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;
491 611 }
492 -
493 612 $customer->load(['billing_address', 'shipping_address']);
494 613
495 614 if ($customer->billing_address->count() < 1) {
496 615 static::createCustomerAddress($billingAddress, $customer->id);
@@ -497,25 +616,10 @@
497 616 }
498 617 if ($customer->shipping_address->count() < 1) {
499 618 static::createCustomerAddress($shippingAddress, $customer->id);
500 619 }
501 -
502 - static::handleUserCreation($orderData, $billingAddress, $customer);
503 620 }
504 621
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 622 private static function getCustomerEmail($billingAddress)
519 623 {
520 624 return is_user_logged_in() ? wp_get_current_user()->user_email : $billingAddress['email'];
521 625 }
@@ -607,12 +711,12 @@
607 711
608 712 $billingValidations = array_filter(CheckoutFieldsSchema::getCheckoutFieldsRequirements('billing', $fulfillmentType, !$isDifferentShipping));
609 713
610 714 // 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']);
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']);
612 717
613 - if (!isset($billingValidations['country'])) {
614 - // get store country
718 + if (!isset($billingValidations['country']) && empty($data['billing_country'])) {
615 719 $data['billing_country'] = (new StoreSettings())->get('store_country');
616 720 }
617 721
618 722 $billingAddress = [];
@@ -619,10 +723,11 @@
619 723 foreach ($billingValidations as $key => $billingValidation) {
620 724 $billingAddress[$key] = Arr::get($data, 'billing_' . $key, '');
621 725 }
622 726 if (!isset($billingAddress['country'])) {
623 - // get store country
624 - $billingAddress['country'] = (new StoreSettings())->get('store_country');
727 + $billingAddress['country'] = !empty($data['billing_country'])
728 + ? $data['billing_country']
729 + : (new StoreSettings())->get('store_country');
625 730 }
626 731
627 732 $shippingAddress = [];
628 733 $shippingValidations = [];
@@ -651,10 +756,15 @@
651 756
652 757 $agreeTermsRequired = CheckoutFieldsSchema::isTermsRequired();
653 758
654 759 $customTitles = [
655 - 'address_1' => 'Street Address',
656 - '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'),
657 767 ];
658 768
659 769 foreach ($billingValidations as $key => $rule) {
660 770 $value = Arr::get($billingAddress, $key, '');
@@ -838,8 +948,31 @@
838 948 }
839 949 }
840 950 }
841 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 +
842 975 if (empty($data['agree_terms']) && $agreeTermsRequired) {
843 976 $errors['agree_terms']['required'] = __('You must agree to the terms and conditions.', 'fluent-cart');
844 977 }
845 978
@@ -846,10 +979,9 @@
846 979 if (empty($data['billing_email']) || !is_email($data['billing_email'])) {
847 980 $errors['billing_email']['invalid'] = __('Email must be a valid email address.', 'fluent-cart');
848 981 }
849 982
850 - if (CheckoutFieldsSchema::isFullNameRequired() || !empty($data['billing_full_name'])) {
851 - // Modal checkout always sends billing_full_name regardless of store name field settings
983 + if (CheckoutFieldsSchema::isFullNameRequired()) {
852 984 if (empty($data['billing_full_name'])) {
853 985 $errors['billing_full_name']['required'] = __('Full name is required.', 'fluent-cart');
854 986 }
855 987 } else {
@@ -866,9 +998,16 @@
866 998
867 999
868 1000 if ($cart->requireShipping()) {
869 1001 if (!empty($data['fc_selected_shipping_method'])) {
870 - $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 +
871 1010 $shippingCountry = Arr::get($data, 'billing_country', '');
872 1011 $shippingState = Arr::get($data, 'billing_state', '');
873 1012 $shipToDifferent = Arr::get($data, 'ship_to_different', 'no') === 'yes';
874 1013
@@ -884,9 +1023,9 @@
884 1023 $errors['shipping_method']['unavailable'] = __('We don\'t ship to this address. Please select a different address.', 'fluent-cart');
885 1024 } else {
886 1025 $found = false;
887 1026 foreach ($availableShippingMethods as $shippingMethod) {
888 - if ($shippingMethod->id == $selectedMethod) {
1027 + if ((int) $shippingMethod->id === $selectedMethod) {
889 1028 $found = true;
890 1029 break;
891 1030 }
892 1031 }
@@ -907,9 +1046,9 @@
907 1046 'cart' => $cart
908 1047 ]);
909 1048
910 1049 if (count($errors) > 0) {
911 - return new \Wp_Error('validation_error', 'Validation error', $errors);
1050 + return new \Wp_Error('validation_error', __('Validation error', 'fluent-cart'), $errors);
912 1051 }
913 1052
914 1053 return $data;
915 1054 }