PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.5.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.5.4
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
fluent-cart / api / Checkout / CheckoutApi.php

CheckoutApi.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.5.4, at api/Checkout/CheckoutApi.php

1,133 lines 45.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\Api\Checkout;
4
5 use FluentCart\Api\Resource\CustomerResource as ApiCustomerResource;
6 use FluentCart\Api\Resource\FrontendResource\CustomerAddressResource;
7 use FluentCart\Api\Resource\FrontendResource\CustomerResource;
8 use FluentCart\Api\StoreSettings;
9 use FluentCart\App\App;
10 use FluentCart\App\Events\Order\OrderCreated;
11 use FluentCart\App\Events\StockChanged;
12 use FluentCart\App\Helpers\AddressHelper;
13 use FluentCart\App\Helpers\CartCheckoutHelper;
14 use FluentCart\App\Helpers\CartHelper;
15 use FluentCart\App\Helpers\CheckoutProcessor;
16 use FluentCart\App\Helpers\Status;
17 use FluentCart\App\Helpers\UtmHelper;
18 use FluentCart\App\Models\Cart;
19 use FluentCart\App\Models\Customer;
20 use FluentCart\App\Models\CustomerAddresses;
21 use FluentCart\App\Models\Order;
22 use FluentCart\App\Models\OrderAddress;
23 use FluentCart\App\Models\ShippingMethod;
24 use FluentCart\App\Services\CheckoutService;
25 use FluentCart\App\Services\Localization\LocalizationManager;
26 use FluentCart\App\Services\OrderService;
27 use FluentCart\App\Services\Payments\PaymentHelper;
28 use FluentCart\App\Services\Payments\PaymentInstance;
29 use FluentCart\App\Services\Renderer\CheckoutFieldsSchema;
30 use FluentCart\Framework\Http\Response;
31 use FluentCart\App\Services\RateLimiter;
32 use FluentCart\Framework\Support\Arr;
33 use FluentCart\Framework\Support\Str;
34 use FluentCart\Framework\Validator\Validator;
35
36 class CheckoutApi
37 {
38 /**
39 * @throws \Exception
40 */
41
42 public static function placeOrder(array $data, $fromCheckout = false)
43 {
44
45 RateLimiter::isSpamming('place_order_attempt', 5, 60, true);
46
47 $userTz = Arr::get($data, 'user_tz', 'UTC');
48
49 $cart = CartHelper::getCart();
50
51 if (!$cart || !$cart->cart_data || $cart->stage === 'completed') {
52 wp_send_json([
53 'status' => 'failed',
54 'message' => __('Cart is empty or already completed', 'fluent-cart'),
55 ]);
56 }
57
58 // Serialize all submissions of one cart BEFORE the prevOrder read: locking later
59 // (or per-order) lets two concurrent first submissions both see prevOrder = null
60 // and create two orders -> two idempotency keys -> double charge.
61 static::acquireCartLock($cart->cart_hash);
62
63 // Re-read under the lock (fresh(), not getCart() — that one is request-cached):
64 // a submission we waited on may have completed this cart meanwhile.
65 $cart = $cart->fresh();
66 if (!$cart || !$cart->cart_data || $cart->stage === 'completed') {
67 wp_send_json([
68 'status' => 'failed',
69 'message' => __('Cart is empty or already completed', 'fluent-cart'),
70 ]);
71 }
72
73 $cart = $cart->reValidateCoupons();
74
75 $cartData = $cart->cart_data;
76 $prevOrder = $cart->order;
77 if ($prevOrder) {
78 $prevOrder->load('order_items');
79 }
80 $isLockedCart = $cart->isLocked();
81
82 if ($prevOrder &&
83 (
84 in_array($prevOrder->status, Status::getOrderSuccessStatuses()) ||
85 !in_array($prevOrder->payment_status, Status::getPaymentRetryableStatuses())
86 )
87 ) {
88 if ($isLockedCart) {
89 // Locked carts are bound to a specific order (e.g. pay-for-order links),
90 // so a finalized order really means there is nothing left to pay.
91 wp_send_json([
92 'status' => 'failed',
93 'message' => __('You have already completed this order.', 'fluent-cart'),
94 ]);
95 }
96
97 // The linked order is already finalized but the cart was never marked
98 // completed (e.g. a stale cart resurrected by the logged-in user lookup).
99 // Detach the dead order so the customer can check out again instead of
100 // being blocked on every future purchase.
101 $cart->order_id = null;
102 $checkoutData = $cart->checkout_data;
103 unset($checkoutData['is_locked']);
104 $cart->checkout_data = $checkoutData;
105 $cart->save();
106 $prevOrder = null;
107 $isLockedCart = false;
108 }
109
110 $data = static::addLoggedUserData($data);
111
112
113 // Validate using filter hook (allows addons/modules to validate)
114 $validation = apply_filters('fluent_cart/checkout/validate_before_process', true, $data);
115
116 if (is_wp_error($validation)) {
117 wp_send_json([
118 'status' => 'failed',
119 'message' => $validation->get_error_message(),
120 ], 403);
121 }
122
123 if (empty($data['billing_address_id'])) {
124 if ($prevOrder instanceof Order) {
125 $oldCustomer = $prevOrder->customer;
126 if ($oldCustomer) {
127 $fallbackAddress = $oldCustomer->billing_address;
128 if ($fallbackAddress) {
129 $data['billing_address_id'] = $fallbackAddress->first()->id;
130 }
131 }
132 }
133 } else if ($prevOrder) {
134 $data['order_id'] = $prevOrder->id;
135 }
136
137 $data = static::prepareAddressData($data);
138
139 $cartCheckoutService = new CheckoutService($cartData);
140 $validatedData = static::validateData($data, $cart, $cartCheckoutService, $prevOrder);
141
142
143
144
145 if (is_wp_error($validatedData)) {
146 wp_send_json([
147 'status' => 'failed',
148 'errors' => $validatedData->get_error_data(),
149 ]);
150 }
151
152 if (!CheckoutFieldsSchema::isFullNameRequired()) {
153 if (!empty($validatedData['billing_full_name']) && empty($validatedData['billing_first_name'])) {
154 // Modal checkout sends billing_full_name — split into first/last name
155 $nameParts = explode(' ', $validatedData['billing_full_name'], 2);
156 $validatedData['billing_first_name'] = $nameParts[0];
157 $validatedData['billing_last_name'] = $nameParts[1] ?? '';
158 } else {
159 $validatedData['billing_full_name'] = trim(
160 Arr::get($validatedData, 'billing_first_name') . ' ' . Arr::get($validatedData, 'billing_last_name')
161 );
162 }
163 }
164
165 $orderData = OrderService::groupSanitizedData($validatedData);
166
167 $shippingMethodId = Arr::get($orderData, 'others.fc_shipping_method');
168
169 $shippingMethod = null;
170 $shippingCharge = 0;
171 if (!$cartCheckoutService->isAllDigital()) {
172 $shippingMethod = ShippingMethod::query()->find($shippingMethodId);
173 if (!empty($shippingMethod)) {
174 $shippingCharge = CartHelper::calculateShippingMethodCharge($shippingMethod, $cartData);
175 }
176 }
177
178 Arr::set($orderData, 'others.shipping_total', $shippingCharge);
179
180 $cartCheckoutHelper = CartCheckoutHelper::make();
181
182 try {
183 OrderService::validateProducts($cartCheckoutHelper->getItems(), $prevOrder);
184 } catch (\Exception $e) {
185 wp_send_json([
186 'message' => $e->getMessage(),
187 ], 422);
188 }
189
190 $paymentMethod = PaymentHelper::validateAndGetPayMethod($cartCheckoutHelper, $orderData, $shippingCharge);
191
192 Arr::set($orderData, 'others.payment_method', $paymentMethod);
193
194 $orderData['user_tz'] = $userTz;
195
196 $customer = static::getOrCreateCustomer($cartCheckoutHelper, $orderData);
197
198 $shouldCreateUser = static::shouldCreateUser($orderData, Arr::get($orderData, 'billing_address', []));
199
200 // Ensure cart has the current payment method before recalculating fees
201 $checkoutData = $cart->checkout_data ?? [];
202 $checkoutData['payment_method'] = $paymentMethod;
203 $cart->checkout_data = $checkoutData;
204
205 $cart->clearFeeCache();
206 $fees = $cart->getFees();
207
208 // Recompute cart tax data so fee_tax/fee_tax_lines reflect fees for the current
209 // payment method. The scope prevents ShippingModule from running unnecessarily.
210 do_action('fluent_cart/cart/cart_data_items_updated', ['cart' => $cart, 'scope' => 'payment_method_fee_recalculate']);
211
212 // TaxModule::recalculateTax() refreshes checkout_data['fees'] (RC-adjusted amounts,
213 // deduplication) — reload so persisted fee items match the recomputed fee tax metadata.
214 $fees = (array) Arr::get($cart->checkout_data, 'fees', $fees);
215
216 $taxBehavior = apply_filters('fluent_cart/cart/tax_behavior', 0, ['cart' => $cart]);
217 $taxTotal = (int)Arr::get($cart->checkout_data, 'tax_data.tax_total', 0);
218 $shippingTax = (int)Arr::get($cart->checkout_data, 'tax_data.shipping_tax', 0);
219 $storeTaxBehavior = (int)Arr::get($cart->checkout_data, 'tax_data.store_tax_behavior', $taxBehavior);
220 $exclusiveTaxTotal = (int)Arr::get($cart->checkout_data, 'tax_data.exclusive_tax_total', 0);
221 $feeTax = (int)Arr::get($cart->checkout_data, 'tax_data.fee_tax', 0);
222 $feeTaxLines = (array)Arr::get($cart->checkout_data, 'tax_data.fee_tax_lines', []);
223
224 // For dynamic RC + inclusive pricing, reduce the shipping charge to net before storing.
225 // The fluent_cart/cart/shipping_total filter (registered by TaxModule) handles the logic.
226 $shippingCharge = apply_filters('fluent_cart/cart/shipping_total', $shippingCharge, ['cart' => $cart]);
227
228 $checkoutProcessor = new CheckoutProcessor($cartCheckoutHelper->getItems(), [
229 'customer_id' => $customer->id,
230 'user_tz' => $userTz,
231 'create_account_after_paid' => $shouldCreateUser ? 'yes' : 'no',
232 'shipping_charge' => $shippingCharge,
233 'shipping_method_id' => $shippingMethod ? (int)$shippingMethod->id : 0,
234 'shipping_method_title' => $shippingMethod ? $shippingMethod->title : '',
235 'tax_total' => $taxTotal,
236 'tax_behavior' => $taxBehavior,
237 'store_tax_behavior' => $storeTaxBehavior,
238 'exclusive_tax_total' => $exclusiveTaxTotal,
239 'fee_tax' => $feeTax,
240 'fee_tax_lines' => $feeTaxLines,
241 'shipping_tax' => $shippingTax,
242 'payment_method' => $paymentMethod,
243 'applied_coupons' => $cart->getDiscountLines(),
244 'billing_address' => Arr::get($orderData, 'billing_address', []),
245 'shipping_address' => Arr::get($orderData, 'shipping_address', []),
246 'cart_hash' => $cart->cart_hash,
247 'is_locked' => $isLockedCart,
248 'manual_discount_total' => $cartCheckoutHelper->getManualDiscountAmount(),
249 'prorate_credit' => (int) Arr::get($cart->checkout_data, 'prorate_credit.amount', 0),
250 'upgrade_discount' => (int) Arr::get($cart->checkout_data, 'upgrade_discount.amount', 0),
251 'ip_address' => AddressHelper::getIpAddress(),
252 'note' => Arr::get($orderData, 'others.order_notes', ''),
253 'tax_id' => sanitize_text_field(
254 Arr::get($data, 'fct_billing_tax_id', '')
255 ?: Arr::get($cart->checkout_data, 'tax_data.vat_number', '')
256 ),
257 'fees' => $fees,
258 ]);
259
260 $createdOrder = $checkoutProcessor->createDraftOrder($prevOrder);
261 if (is_wp_error($createdOrder)) {
262 wp_send_json([
263 'status' => 'failed',
264 'message' => $createdOrder->get_error_message(),
265 'data' => $createdOrder->get_error_data()
266 ], 423);
267 }
268
269 do_action('fluent_cart/order/after_items_calculated', [
270 'order' => $createdOrder,
271 'cart' => $cart,
272 'cart_items' => $cartCheckoutHelper->getItems(),
273 ]);
274
275 // prepare other data if any module needs to add data to the order data
276 do_action('fluent_cart/checkout/prepare_other_data', [
277 'cart' => $cart,
278 'order' => $createdOrder,
279 'prev_order' => $prevOrder,
280 'request_data' => $data,
281 'validated_data' => $validatedData
282 ]);
283
284 static::finalizeOrder($createdOrder, [
285 'billing_address' => Arr::get($orderData, 'billing_address', []),
286 'shipping_address' => Arr::get($orderData, 'shipping_address', []),
287 'items' => $cartCheckoutHelper->getItems(),
288 'from_checkout' => true,
289 'prev_order' => $prevOrder
290 ]);
291 }
292
293 private static function getOrCreateCustomer(CartCheckoutHelper $cartCheckoutHelper, $orderData)
294 {
295 $customerEmail = static::getCustomerEmail($orderData['billing_address']);
296 if (is_user_logged_in()) {
297 $customerEmail = wp_get_current_user()->user_email;
298 Arr::set($orderData, 'billing_address.email', $customerEmail);
299 }
300 $customer = $cartCheckoutHelper->getCustomer($customerEmail);
301 return static::createCustomerWithAddress(
302 $customer,
303 $orderData,
304 $orderData['billing_address'],
305 $orderData['shipping_address']
306 );
307 }
308
309 private static function prepareAddressData($data)
310 {
311 $shippingAddressId = Arr::get($data, 'shipping_address_id');
312 $billingAddressId = Arr::get($data, 'billing_address_id');
313 $orderId = Arr::get($data, 'order_id', null);
314
315 $currentCustomer = is_user_logged_in() ? ApiCustomerResource::getCurrentCustomer() : null;
316
317 $shipToDifferent = Arr::get($data, 'ship_to_different', 'no');
318
319 $datKeys = ['country', 'address_1', 'address_2', 'city', 'state', 'postcode', 'phone', 'label', 'company_name', 'vat_number', 'legal_registration_id'];
320
321 if ($billingAddressId) {
322 $prevOrder = Order::query()->find($orderId);
323 $prevBillingId = null;
324 if ($prevOrder) {
325 $prevBillingAddress = $prevOrder->billing_address;
326 $prevBillingId = Arr::get($prevBillingAddress, 'id');
327 }
328
329 $billingAddress = null;
330 if ($orderId && $prevBillingId == $billingAddressId) {
331 $billingAddress = OrderAddress::query()
332 ->where('id', $billingAddressId)
333 ->where('type', 'billing')
334 ->first();
335 }
336 if (empty($billingAddress) && $currentCustomer) {
337 $billingAddress = CustomerAddresses::query()
338 ->where('id', $billingAddressId)
339 ->where('type', 'billing')
340 ->where('customer_id', $currentCustomer->id)
341 ->first();
342 }
343
344 if ($billingAddress) {
345 foreach ($datKeys as $key) {
346 $data['billing_' . $key] = $billingAddress->{$key} ?: Arr::get($data, 'billing_' . $key, '');
347 }
348 }
349 }
350
351 if (Arr::get($data, 'is_business', 'no') !== 'yes' && !CheckoutFieldsSchema::isB2BOnlyMode()) {
352 $data['billing_company_name'] = '';
353 $data['billing_legal_registration_id'] = '';
354 }
355
356 if ($shippingAddressId && $shipToDifferent === 'yes') {
357 $prevShippingOrder = Order::query()->find($orderId);
358 $prevShippingAddress = $prevShippingOrder ? $prevShippingOrder->shipping_address : null;
359 $prevShippingId = Arr::get($prevShippingAddress, 'id', null);
360 $shippingAddress = null;
361 if ($orderId && $prevShippingId == $shippingAddressId) {
362 $shippingAddress = OrderAddress::query()
363 ->where('id', $shippingAddressId)
364 ->where('type', 'shipping')
365 ->first();
366 }
367 if (empty($shippingAddress) && $currentCustomer) {
368 $shippingAddress = CustomerAddresses::query()
369 ->where('id', $shippingAddressId)
370 ->where('type', 'shipping')
371 ->where('customer_id', $currentCustomer->id)
372 ->first();
373 }
374
375 if ($shippingAddress) {
376 $formFullName = Arr::get($data, 'shipping_full_name', '');
377 $data['shipping_full_name'] = $shippingAddress->name ?: $formFullName;
378 foreach ($datKeys as $key) {
379 $data['shipping_' . $key] = $shippingAddress->{$key} ?: Arr::get($data, 'shipping_' . $key, '');
380 }
381 }
382 } else if ($shipToDifferent !== 'yes') {
383 // if not different shipping, copy billing to shipping
384 foreach ($datKeys as $key) {
385 $data['shipping_' . $key] = Arr::get($data, 'billing_' . $key, '');
386 }
387 $data['shipping_full_name'] = Arr::get($data, 'billing_full_name', '');
388 }
389
390 return $data;
391 }
392
393 private static function addLoggedUserData(array $data): array
394 {
395 if (is_user_logged_in()) {
396 $checkoutHelper = CartCheckoutHelper::make();
397 $data['billing_email'] = wp_get_current_user()->user_email;
398
399 if (CheckoutFieldsSchema::isFullNameRequired()) {
400 $userFullName = $checkoutHelper->getFullName();
401 if (!empty($userFullName) && empty($data['billing_full_name'])) {
402 $data['billing_full_name'] = $userFullName;
403 }
404 } else {
405 $firstName = $checkoutHelper->getFirstName();
406 if (!empty($firstName) && empty($data['billing_first_name'])) {
407 $data['billing_first_name'] = $firstName;
408 }
409
410 $lastName = $checkoutHelper->getLastName();
411 if (!empty($lastName) && empty($data['billing_last_name'])) {
412 $data['billing_last_name'] = $lastName;
413 }
414
415 }
416
417
418 }
419 return $data;
420 }
421
422 private static function finalizeOrder(Order $order, $args = [])
423 {
424 // Duplicate/concurrent submissions are already serialized by the cart-hash lock
425 // at the top of placeOrder() — no per-order lock needed here.
426 AddressHelper::insertOrderAddresses(
427 $order->id,
428 Arr::get($args, 'billing_address', []),
429 Arr::get($args, 'shipping_address', [])
430 );
431
432 static::syncCustomerNames($order, $args);
433 $cart = CartHelper::getCart();
434
435 $utmData = [];
436 if (!empty($cart) && is_array($cart->utm_data) && count($cart->utm_data) > 0) {
437 $utmData = $cart->utm_data;
438 }
439
440 $requestUtmData = UtmHelper::getUtmDataOfRequest();
441 $utmData = wp_parse_args($requestUtmData, $utmData);
442 UtmHelper::addUtmToOrder($order->id, $utmData);
443
444 $prevOrder = Arr::get($args, 'prev_order', null);
445
446 (new OrderCreated($order, $prevOrder, $order->customer, $order->getLatestTransaction()))->dispatch();
447
448 static::updateStock($order);
449
450 // we don't have to validate the payment method again, as it's already validated in placeOrder method
451 $gateway = App::gateway($order->payment_method);
452
453 if (!$gateway) {
454 wp_send_json([
455 'status' => 'failed',
456 'message' => __('Payment method not found!', 'fluent-cart'),
457 'data' => []
458 ], 404);
459 }
460
461 $paymentInstance = new PaymentInstance($order);
462
463 $data = $gateway->makePaymentFromPaymentInstance($paymentInstance);
464
465 if (is_wp_error($data)) {
466 // Server-observed create failure: mark the transaction FAILED so the next
467 // resubmit is a RETRY (payment_attempt bump -> fresh idempotency seed) —
468 // gateways cache error responses under the key, so keeping it pending would
469 // replay the same error on every resubmit. Client-side declines stay pending
470 // on purpose: there the same key resolving to the same gateway object IS the
471 // retry path.
472 if ($paymentInstance->transaction && $paymentInstance->transaction->status === Status::PAYMENT_PENDING) {
473 $paymentInstance->transaction->update(['status' => Status::PAYMENT_FAILED]);
474 }
475
476 wp_send_json([
477 'status' => 'failed',
478 'message' => $data->get_error_message(),
479 'data' => $data->get_error_data()
480 ], 422);
481 }
482
483 wp_send_json($data, 200);
484 }
485
486 /**
487 * Serialize checkout submissions per cart with a MySQL named lock.
488 *
489 * Keyed on cart_hash (not order id) so concurrent FIRST submissions — no draft
490 * order yet — contend on the same lock. Release goes through a shutdown function,
491 * not try/finally: wp_send_json() exits via die() (skips finally), and persistent
492 * DB connections don't drop the lock on request end.
493 */
494 private static function acquireCartLock($cartHash)
495 {
496 global $wpdb;
497
498 // md5 keeps the name inside MySQL's 64-char lock-name limit regardless of
499 // table-prefix length; the prefix scopes the lock per site on multisite.
500 $lockName = 'fct_checkout_' . md5($wpdb->prefix . $cartHash);
501
502 $lockAcquired = (string) $wpdb->get_var(
503 $wpdb->prepare('SELECT GET_LOCK(%s, %d)', $lockName, 10)
504 ) === '1';
505
506 if (!$lockAcquired) {
507 wp_send_json([
508 'status' => 'failed',
509 'message' => __('This order is already being processed. Please wait a moment — do not refresh or resubmit.', 'fluent-cart'),
510 'data' => []
511 ], 429);
512 }
513
514 register_shutdown_function(function () use ($lockName) {
515 global $wpdb;
516 $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lockName));
517 });
518 }
519
520 private static function syncCustomerNames($order, $args)
521 {
522 $customer = $order->customer;
523
524 if (empty($customer)) {
525 return;
526 }
527
528 $firstName = Arr::get($args, 'billing_address.first_name');
529 $lastName = Arr::get($args, 'billing_address.last_name');
530
531 $customer->update([
532 'first_name' => $firstName,
533 'last_name' => $lastName,
534 ]);
535
536 $user = get_user_by('email', $customer->email);
537
538 if (empty($user)) {
539 return;
540 }
541
542 if (is_user_logged_in() && $user->ID === get_current_user_id()) {
543 update_user_meta($user->ID, 'first_name', $firstName);
544 update_user_meta($user->ID, 'last_name', $lastName);
545 }
546 }
547
548 public static function updateStock($order)
549 {
550 // pluck product ids to update product default variation as stock has changed
551 $productIds = OrderService::pluckProductIds($order);
552 if (!empty($productIds)) {
553 (new StockChanged($productIds))->dispatch();
554 }
555 }
556
557 public static function createCustomerWithAddress($customer, $orderData, $billingAddress, $shippingAddress)
558 {
559 if (empty($customer)) {
560 $customer = static::createNewCustomer($orderData, $billingAddress, $shippingAddress);
561 } else if ($customer instanceof Customer) {
562 static::updateExistingCustomer($customer, $orderData, $billingAddress, $shippingAddress);
563 }
564
565 return $customer;
566 }
567
568 private static function createNewCustomer($orderData, &$billingAddress, $shippingAddress)
569 {
570 global $current_user;
571 if ($current_user->ID) {
572 $billingAddress['email'] = $current_user->user_email;
573 $billingAddress['user_id'] = $current_user->ID;
574 } else {
575 static::handleUserCreation($orderData, $billingAddress);
576 }
577
578 $customer = CustomerResource::create($billingAddress);
579 $customer = Arr::get($customer, 'data', null);
580 $customerId = Arr::get($customer, 'id', null);
581 static::createCustomerAddress($billingAddress, $customerId);
582 static::createCustomerAddress($shippingAddress, $customerId);
583
584 return $customer;
585 }
586
587 private static function updateExistingCustomer($customer, $orderData, $billingAddress, $shippingAddress)
588 {
589 if (empty($customer->user_id)) {
590 $currentLoggedInUser = wp_get_current_user();
591 if ($currentLoggedInUser && $currentLoggedInUser->user_email === $customer->email) {
592 $userId = get_current_user_id();
593 $customer->update(['user_id' => $userId]);
594 $billingAddress['user_id'] = $userId;
595 }
596 }
597
598 $customer->load(['billing_address', 'shipping_address']);
599
600 if ($customer->billing_address->count() < 1) {
601 static::createCustomerAddress($billingAddress, $customer->id);
602 }
603 if ($customer->shipping_address->count() < 1) {
604 static::createCustomerAddress($shippingAddress, $customer->id);
605 }
606
607 static::handleUserCreation($orderData, $billingAddress, $customer);
608 }
609
610 private static function handleUserCreation($orderData, &$billingAddress, $customer = null)
611 {
612 $userEmail = Arr::get($billingAddress, 'email');
613 $user = get_user_by('email', $userEmail);
614
615 if ($user) {
616 $billingAddress['user_id'] = $user->ID;
617 if ($customer) {
618 $customer->update(['user_id' => $user->ID]);
619 }
620 }
621 }
622
623 private static function getCustomerEmail($billingAddress)
624 {
625 return is_user_logged_in() ? wp_get_current_user()->user_email : $billingAddress['email'];
626 }
627
628 public static function shouldCreateUser($data, $billingAddress): bool
629 {
630 $accountCreationMod = (new StoreSettings())->get('user_account_creation_mode');
631 $hasSubscription = (CartCheckoutHelper::make())->hasSubscription() === 'yes';
632 if ($accountCreationMod === 'all' || $hasSubscription) {
633 return true;
634 }
635
636 if (is_user_logged_in()) {
637 return false;
638 }
639
640 $allow_create_account = Arr::get($data, 'others.allow_create_account') === 'yes';
641
642 $userEmail = Arr::get($billingAddress, 'email');
643 $user = get_user_by('email', $userEmail);
644
645 return ($allow_create_account && $accountCreationMod === 'user_choice') || !empty($user);
646 }
647
648 private static function createCustomerAddress(array $address, $customerId)
649 {
650 CustomerAddressResource::create($address, ['id' => $customerId]);
651 }
652
653 /**
654 * Check if a customer is logged in and has the given address type.
655 *
656 * @param string $type Address type to check ('billing' or 'shipping').
657 * @return bool True if the customer is logged in and the address is available.
658 */
659 private static function hasCustomerWithAddress(string $type): bool
660 {
661 $addressType = $type . '_address';
662 $currentCustomer = \FluentCart\Api\Resource\CustomerResource::getCurrentCustomer();
663
664 return !empty($currentCustomer) && $currentCustomer->$addressType->count() === 1;
665 }
666
667
668 /**
669 * Validate the data against the provided rules.
670 * @return array
671 */
672
673 public static function billingRules($data = []): array
674 {
675 $baseRules = [
676 'billing_full_name' => 'required|sanitizeText|maxLength:255',
677 'billing_email' => 'required|sanitizeText|email|maxLength:255',
678 'order_notes' => 'nullable|sanitizeTextArea|maxLength:200',
679 ];
680
681 return static::generateAddressRules('billing', $data, $baseRules, 'getBillingAddressFields');
682 }
683
684 public static function shippingIdRules(): array
685 {
686 return [
687 'shipping_address' => 'exists:fct_customer_addresses,id',
688 ];
689 }
690
691 public static function shippingRules($data = []): array
692 {
693 $baseRules = [
694 'shipping_full_name' => 'required|sanitizeText|maxLength:255'
695 ];
696
697 return static::generateAddressRules('shipping', $data, $baseRules, 'getShippingAddressFields');
698
699 }
700
701 /**
702 * Validate the data against the provided rules.
703 *
704 * @return false|\WP_Error|string
705 */
706
707 public static function validateData($data, Cart $cart, CheckoutService $cartCheckoutService, $prevOrder)
708 {
709 $shippingRequired = $cart->requireShipping();
710 $isDifferentShipping = $shippingRequired && Arr::get($data, 'ship_to_different', 'no') === 'yes';
711 $fulfillmentType = $shippingRequired ? 'physical' : 'digital';
712
713 $billingValidations = array_filter(CheckoutFieldsSchema::getCheckoutFieldsRequirements('billing', $fulfillmentType, !$isDifferentShipping));
714
715 // Name fields are validated separately below (full_name/first_name/last_name)
716 // vat_number uses field name fct_billing_tax_id and is validated separately in the B2B block below
717 unset($billingValidations['full_name'], $billingValidations['first_name'], $billingValidations['last_name'], $billingValidations['vat_number']);
718
719 if (!isset($billingValidations['country']) && empty($data['billing_country'])) {
720 $data['billing_country'] = (new StoreSettings())->get('store_country');
721 }
722
723 $billingAddress = [];
724 foreach ($billingValidations as $key => $billingValidation) {
725 $billingAddress[$key] = Arr::get($data, 'billing_' . $key, '');
726 }
727 if (!isset($billingAddress['country'])) {
728 $billingAddress['country'] = !empty($data['billing_country'])
729 ? $data['billing_country']
730 : (new StoreSettings())->get('store_country');
731 }
732
733 $shippingAddress = [];
734 $shippingValidations = [];
735 if ($isDifferentShipping) {
736 $shippingValidations = array_filter(CheckoutFieldsSchema::getCheckoutFieldsRequirements('shipping', 'physical'));
737 // Name fields are validated via billing basic_info, not shipping address
738 unset($shippingValidations['full_name'], $shippingValidations['first_name'], $shippingValidations['last_name'], $shippingValidations['company_name']);
739 foreach ($shippingValidations as $key => $shippingValidation) {
740 $shippingAddress[$key] = Arr::get($data, 'shipping_' . $key, '');
741 }
742 }
743
744 if (Arr::get($data, 'ship_to_different', 'no') === 'yes') {
745 if (!isset($data['shipping_country'])) {
746 // get store country
747 $data['shipping_country'] = (new StoreSettings())->get('store_country');
748 }
749 if (!isset($shippingAddress['country'])) {
750 // get store country
751 $shippingAddress['country'] = (new StoreSettings())->get('store_country');
752 }
753
754 }
755
756 $errors = [];
757
758 $agreeTermsRequired = CheckoutFieldsSchema::isTermsRequired();
759
760 $customTitles = [
761 'address_1' => __('Street Address', 'fluent-cart'),
762 'address_2' => __('Apt, Suite, Unit', 'fluent-cart'),
763 'country' => __('Country', 'fluent-cart'),
764 'state' => __('State', 'fluent-cart'),
765 'city' => __('City', 'fluent-cart'),
766 'postcode' => __('Postcode', 'fluent-cart'),
767 'phone' => __('Phone', 'fluent-cart'),
768 ];
769
770 foreach ($billingValidations as $key => $rule) {
771 $value = Arr::get($billingAddress, $key, '');
772 $prefixedKey = 'billing_' . $key;
773 $titledKey = $customTitles[$key] ?? Str::headline($key);
774
775 if ($key === 'country') {
776 $countries = LocalizationManager::getInstance()->countries();
777
778 if ($rule === 'required' && empty($value)) {
779 if (!isset($errors[$key])) {
780 $errors[$prefixedKey] = [];
781 }
782 $errors[$prefixedKey]['required'] = sprintf(
783 /* translators: %s attribute name */
784 __('%s is required.', 'fluent-cart'),
785 $titledKey
786 );
787 continue;
788 }
789
790 if (!empty($value) && !Arr::has($countries, $value)) {
791 if (!isset($errors[$key])) {
792 $errors[$prefixedKey] = [];
793 }
794 $errors[$prefixedKey]['invalid'] = sprintf(
795 /* translators: %s attribute name */
796 __('%s is invalid.', 'fluent-cart'),
797 $titledKey
798 );
799 continue;
800 }
801
802 continue;
803 }
804
805 if ($key === 'state') {
806 $country = Arr::get($billingAddress, 'country', '');
807
808 $states = LocalizationManager::getInstance()->statesOptions($country);
809
810 if (empty($states)) {
811 continue;
812 }
813
814 if ($rule === 'required' && empty($value)) {
815 if (!isset($errors[$key])) {
816 $errors[$prefixedKey] = [];
817 }
818 $errors[$prefixedKey]['required'] = sprintf(
819 /* translators: %s attribute name */
820 __('%s is required.', 'fluent-cart'),
821 $titledKey
822 );
823 continue;
824 }
825
826 if (!empty($value) && !in_array($value, array_column($states, 'value'))) {
827 if (!isset($errors[$key])) {
828 $errors[$prefixedKey] = [];
829 }
830 $errors[$prefixedKey]['invalid'] = sprintf(
831 /* translators: %s attribute name */
832 __('%s is invalid.', 'fluent-cart'),
833 $titledKey
834 );
835 }
836
837 continue;
838 }
839
840 if ($rule === 'required' && empty($value)) {
841 if (!isset($errors[$key])) {
842 $errors[$prefixedKey] = [];
843 }
844 $errors[$prefixedKey]['required'] = sprintf(
845 /* translators: %s attribute name */
846 __('%s is required.', 'fluent-cart'),
847 $titledKey
848 );
849 }
850 }
851
852 foreach ($shippingValidations as $key => $rule) {
853 $value = Arr::get($shippingAddress, $key, '');
854 $prefixedKey = 'shipping_' . $key;
855 $titledKey = $customTitles[$key] ?? Str::headline($key);
856
857 if ($key === 'country') {
858 $countries = LocalizationManager::getInstance()->countries();
859
860 if ($rule === 'required' && empty($value)) {
861 if (!isset($errors[$key])) {
862 $errors[$prefixedKey] = [];
863 }
864 $errors[$prefixedKey]['required'] = sprintf(
865 /* translators: %s attribute name */
866 __('%s is required.', 'fluent-cart'),
867 $titledKey
868 );
869
870 continue;
871 }
872
873 if (!empty($value) && !Arr::has($countries, $value)) {
874 if (!isset($errors[$key])) {
875 $errors[$prefixedKey] = [];
876 }
877 $errors[$prefixedKey]['invalid'] = sprintf(
878 /* translators: %s attribute name */
879 __('%s is invalid.', 'fluent-cart'),
880 $titledKey
881 );
882 continue;
883 }
884
885 continue;
886 }
887
888 if ($key === 'state') {
889 $country = Arr::get($shippingAddress, 'country', '');
890
891 $states = LocalizationManager::getInstance()->statesOptions($country);
892
893 if (empty($states)) {
894 continue;
895 }
896
897 if ($rule === 'required' && empty($value)) {
898 if (!isset($errors[$key])) {
899 $errors[$prefixedKey] = [];
900 }
901 $errors[$prefixedKey]['required'] = sprintf(
902 /* translators: %s attribute name */
903 __('%s is required.', 'fluent-cart'),
904 $titledKey
905 );
906
907 continue;
908 }
909
910 if (!empty($value) && !in_array($value, array_column($states, 'value'))) {
911 if (!isset($errors[$key])) {
912 $errors[$prefixedKey] = [];
913 }
914 $errors[$prefixedKey]['invalid'] = sprintf(
915 /* translators: %s attribute name */
916 __('%s is invalid.', 'fluent-cart'),
917 $titledKey
918 );
919 }
920
921 continue;
922 }
923
924 if ($rule === 'required' && empty($value)) {
925 if (!isset($errors[$key])) {
926 $errors[$prefixedKey] = [];
927 }
928 $errors[$prefixedKey]['required'] = sprintf(
929 /* translators: %s attribute name */
930 __('%s is required.', 'fluent-cart'),
931 $titledKey
932 );
933 }
934 }
935
936 $basicInfoFields = (CheckoutFieldsSchema::getNameEmailFieldsSchema())['fields'];
937
938 foreach ($basicInfoFields as $field) {
939 $fieldName = (string)Arr::get($field, 'name', '');
940 $isRequired = Arr::get($field, 'required', 'no') === 'yes';
941 if ($fieldName && $isRequired) {
942 $value = Arr::get($data, $fieldName, '');
943 if (empty($value)) {
944 Arr::set($errors, $fieldName . '.required', sprintf(
945 /* translators: %s attribute name */
946 __('%s is required.', 'fluent-cart'),
947 Arr::get($field, 'aria-label')
948 ));
949 }
950 }
951 }
952
953 $isB2B = Arr::get($data, 'is_business', 'no') === 'yes' || CheckoutFieldsSchema::isB2BOnlyMode();
954
955 if ($isB2B && CheckoutFieldsSchema::isVatNumberRequired()) {
956 $vatNumber = Arr::get($data, 'fct_billing_tax_id', '');
957 if (empty($vatNumber)) {
958 $errors['fct_billing_tax_id']['required'] = __('VAT / Tax ID is required.', 'fluent-cart');
959 }
960 }
961
962 if ($isB2B && CheckoutFieldsSchema::isCompanyNameRequired()) {
963 $companyName = Arr::get($data, 'billing_company_name', '');
964 if (empty($companyName)) {
965 $errors['billing_company_name']['required'] = __('Company Name is required.', 'fluent-cart');
966 }
967 }
968
969 if ($isB2B && CheckoutFieldsSchema::isLegalRegistrationIdRequired()) {
970 $legalRegId = Arr::get($data, 'billing_legal_registration_id', '');
971 if (empty($legalRegId)) {
972 $errors['billing_legal_registration_id']['required'] = __('Legal Registration ID is required.', 'fluent-cart');
973 }
974 }
975
976 if (empty($data['agree_terms']) && $agreeTermsRequired) {
977 $errors['agree_terms']['required'] = __('You must agree to the terms and conditions.', 'fluent-cart');
978 }
979
980 if (empty($data['billing_email']) || !is_email($data['billing_email'])) {
981 $errors['billing_email']['invalid'] = __('Email must be a valid email address.', 'fluent-cart');
982 }
983
984 if (CheckoutFieldsSchema::isFullNameRequired() || !empty($data['billing_full_name'])) {
985 // Modal checkout always sends billing_full_name regardless of store name field settings
986 if (empty($data['billing_full_name'])) {
987 $errors['billing_full_name']['required'] = __('Full name is required.', 'fluent-cart');
988 }
989 } else {
990 if (empty($data['billing_first_name'])) {
991 $errors['billing_first_name']['required'] = __('First name is required.', 'fluent-cart');
992 }
993
994 if(CheckoutFieldsSchema::isLastNameRequired()) {
995 if (empty($data['billing_last_name'])) {
996 $errors['billing_last_name']['required'] = __('Last name is required.', 'fluent-cart');
997 }
998 }
999 }
1000
1001
1002 if ($cart->requireShipping()) {
1003 if (!empty($data['fc_selected_shipping_method'])) {
1004 $selectedMethod = $data['fc_selected_shipping_method'];
1005 $shippingCountry = Arr::get($data, 'billing_country', '');
1006 $shippingState = Arr::get($data, 'billing_state', '');
1007 $shipToDifferent = Arr::get($data, 'ship_to_different', 'no') === 'yes';
1008
1009 if ($shipToDifferent) {
1010 $shippingCountry = Arr::get($data, 'shipping_country', '');
1011 $shippingState = Arr::get($data, 'shipping_state', '');
1012 }
1013
1014 $availableShippingMethods = AddressHelper::getShippingMethods($shippingCountry, $shippingState);
1015
1016
1017 if (empty($availableShippingMethods) || is_wp_error($availableShippingMethods)) {
1018 $errors['shipping_method']['unavailable'] = __('We don\'t ship to this address. Please select a different address.', 'fluent-cart');
1019 } else {
1020 $found = false;
1021 foreach ($availableShippingMethods as $shippingMethod) {
1022 if ($shippingMethod->id == $selectedMethod) {
1023 $found = true;
1024 break;
1025 }
1026 }
1027
1028 if (!$found) {
1029 $errors['shipping_method']['invalid'] = __('The selected shipping method is not available.', 'fluent-cart');
1030 }
1031 }
1032
1033
1034 } else {
1035 $errors['shipping_method']['required'] = __('You must select a shipping method.', 'fluent-cart');
1036 }
1037 }
1038
1039 $errors = apply_filters('fluent_cart/checkout/validate_data', $errors, [
1040 'data' => $data,
1041 'cart' => $cart
1042 ]);
1043
1044 if (count($errors) > 0) {
1045 return new \Wp_Error('validation_error', __('Validation error', 'fluent-cart'), $errors);
1046 }
1047
1048 return $data;
1049 }
1050
1051 public static function validateShippingMethod(array $data, CheckoutService $cartCheckoutService): bool
1052 {
1053
1054 if ($cartCheckoutService->isAllDigital()) {
1055 return true;
1056 }
1057
1058 $shipping_country = Arr::get($data, 'shipping_country');
1059 $shipping_state = Arr::get($data, 'shipping_state');
1060
1061
1062 $methods = ShippingMethod::getApplicableForCountry($shipping_country, $shipping_state)->keyBy('id');
1063 if ($methods->count() === 0 || \FluentCart\App\App::isDevMode()) {
1064 return true;
1065 }
1066
1067 $shipping_method = Arr::get($data, 'fc_selected_shipping_method');
1068
1069 if (empty($shipping_method)) {
1070 return false;
1071 }
1072
1073
1074 $exist = $methods->has($shipping_method);
1075 if (!$exist) {
1076 return false;
1077 }
1078
1079 return true;
1080 }
1081
1082
1083 public static function messages(): array
1084 {
1085 return [
1086 'billing_full_name.required' => esc_html__('Full name field is required.', 'fluent-cart'),
1087 'billing_email.required' => esc_html__('Email field is required.', 'fluent-cart'),
1088 'billing_email.email' => esc_html__('Email must be a valid email address.', 'fluent-cart'),
1089 'billing_address.required' => esc_html__('Address field is required.', 'fluent-cart'),
1090 'billing_country.required' => esc_html__('Country field is required.', 'fluent-cart'),
1091 'billing_address_1.required' => esc_html__('Street Address field is required.', 'fluent-cart'),
1092 'billing_city.required' => esc_html__('City field is required.', 'fluent-cart'),
1093 'billing_postcode.required' => esc_html__('Postcode field is required.', 'fluent-cart'),
1094 'shipping_full_name.required' => esc_html__('Full name field is required.', 'fluent-cart'),
1095 // 'shipping_email.required' => esc_html__('Email field is required.', 'fluent-cart'),
1096 // 'shipping_email.email' => esc_html__('Email must be a valid email address.', 'fluent-cart'),
1097 'shipping_address.required' => esc_html__('Address field is required.', 'fluent-cart'),
1098 'shipping_city.required' => esc_html__('City field is required.', 'fluent-cart'),
1099 'shipping_postcode.required' => esc_html__('Postcode field is required.', 'fluent-cart'),
1100 ];
1101 }
1102
1103 private static function generateAddressRules($type, $data, $baseRules, $fieldGetter): array
1104 {
1105 $hasAddress = static::hasCustomerWithAddress($type);
1106 $rules = App::localization()->getValidationRule($data, $type);
1107
1108 if ($hasAddress) {
1109 $baseRules["{$type}_address"] = 'required|numeric';
1110 return $baseRules;
1111 }
1112
1113 $cartCheckoutHelper = CartCheckoutHelper::make();
1114 $fields = $cartCheckoutHelper->{$fieldGetter}();
1115 $addressFields = Arr::get($fields, 'address_section.schema', []);
1116 $addressFields = Arr::wrap($addressFields);
1117
1118 $validFields = array_keys($addressFields);
1119
1120 // Add prefix to each field (billing_ or shipping_)
1121 $validFields = array_map(function ($field) use ($type) {
1122 return $type . '_' . $field;
1123 }, $validFields);
1124
1125 // Filter only relevant rules
1126 $filteredRules = array_filter($rules, function ($key) use ($validFields) {
1127 return in_array($key, $validFields);
1128 }, ARRAY_FILTER_USE_KEY);
1129
1130 return $baseRules + $filteredRules;
1131 }
1132 }
1133