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

999 lines 37.6 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\FrontendResource\CustomerAddressResource;
6 use FluentCart\Api\Resource\FrontendResource\CustomerResource;
7 use FluentCart\Api\StoreSettings;
8 use FluentCart\App\App;
9 use FluentCart\App\Events\Order\OrderCreated;
10 use FluentCart\App\Events\StockChanged;
11 use FluentCart\App\Helpers\AddressHelper;
12 use FluentCart\App\Helpers\CartCheckoutHelper;
13 use FluentCart\App\Helpers\CartHelper;
14 use FluentCart\App\Helpers\CheckoutProcessor;
15 use FluentCart\App\Helpers\Status;
16 use FluentCart\App\Helpers\UtmHelper;
17 use FluentCart\App\Models\Cart;
18 use FluentCart\App\Models\Customer;
19 use FluentCart\App\Models\CustomerAddresses;
20 use FluentCart\App\Models\Order;
21 use FluentCart\App\Models\OrderAddress;
22 use FluentCart\App\Models\ShippingMethod;
23 use FluentCart\App\Services\CheckoutService;
24 use FluentCart\App\Services\Localization\LocalizationManager;
25 use FluentCart\App\Services\OrderService;
26 use FluentCart\App\Services\Payments\PaymentHelper;
27 use FluentCart\App\Services\Payments\PaymentInstance;
28 use FluentCart\App\Services\Renderer\CheckoutFieldsSchema;
29 use FluentCart\Framework\Http\Response;
30 use FluentCart\App\Services\RateLimiter;
31 use FluentCart\Framework\Support\Arr;
32 use FluentCart\Framework\Support\Str;
33 use FluentCart\Framework\Validator\Validator;
34
35 class CheckoutApi
36 {
37 /**
38 * @throws \Exception
39 */
40
41 public static function placeOrder(array $data, $fromCheckout = false)
42 {
43
44 RateLimiter::isSpamming('place_order_attempt', 5, 60, true);
45
46 $userTz = Arr::get($data, 'user_tz', 'UTC');
47
48 $cart = CartHelper::getCart();
49
50 if (!$cart || !$cart->cart_data || $cart->stage === 'completed') {
51 wp_send_json([
52 'status' => 'failed',
53 'message' => __('Cart is empty or already completed', 'fluent-cart'),
54 ]);
55 }
56
57 $cart = $cart->reValidateCoupons();
58
59 $cartData = $cart->cart_data;
60 $prevOrder = $cart->order;
61 if ($prevOrder) {
62 $prevOrder->load('order_items');
63 }
64 $isLockedCart = $cart->isLocked();
65
66 // todo: we should handle this logic as we have multiple options like PAYMENT_PARTIALLY_PAID....
67 if ($prevOrder &&
68 (
69 in_array($prevOrder->status, Status::getOrderSuccessStatuses()) ||
70 $prevOrder->payment_status != Status::PAYMENT_PENDING
71 )
72 ) {
73 wp_send_json([
74 'status' => 'failed',
75 'message' => __('You have already completed this order.', 'fluent-cart'),
76 ]);
77 }
78
79 $data = static::addLoggedUserData($data);
80
81
82 // Validate using filter hook (allows addons/modules to validate)
83 $validation = apply_filters('fluent_cart/checkout/validate_before_process', true, $data);
84
85 if (is_wp_error($validation)) {
86 wp_send_json([
87 'status' => 'failed',
88 'message' => $validation->get_error_message(),
89 ], 403);
90 }
91
92 if (empty($data['billing_address_id'])) {
93 if ($prevOrder instanceof Order) {
94 $oldCustomer = $prevOrder->customer;
95 if ($oldCustomer) {
96 $fallbackAddress = $oldCustomer->billing_address;
97 if ($fallbackAddress) {
98 $data['billing_address_id'] = $fallbackAddress->first()->id;
99 }
100 }
101 }
102 } else {
103 $data['order_id'] = $cart->order->id;
104 }
105
106 $data = static::prepareAddressData($data);
107
108 $cartCheckoutService = new CheckoutService($cartData);
109 $validatedData = static::validateData($data, $cart, $cartCheckoutService, $prevOrder);
110
111
112
113
114 if (is_wp_error($validatedData)) {
115 wp_send_json([
116 'status' => 'failed',
117 'errors' => $validatedData->get_error_data(),
118 ]);
119 }
120
121 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 }
132 }
133
134 $orderData = OrderService::groupSanitizedData($validatedData);
135
136 $shippingMethodId = Arr::get($orderData, 'others.fc_shipping_method');
137
138 $shippingCharge = 0;
139 if (!$cartCheckoutService->isAllDigital()) {
140 $shippingMethod = ShippingMethod::query()->find($shippingMethodId);
141 if (!empty($shippingMethod)) {
142 $shippingCharge = CartHelper::calculateShippingMethodCharge($shippingMethod, $cartData);
143 }
144 }
145
146 Arr::set($orderData, 'others.shipping_total', $shippingCharge);
147
148 $cartCheckoutHelper = CartCheckoutHelper::make();
149
150 try {
151 OrderService::validateProducts($cartCheckoutHelper->getItems(), $prevOrder);
152 } catch (\Exception $e) {
153 wp_send_json([
154 'message' => $e->getMessage(),
155 ], 422);
156 }
157
158 $paymentMethod = PaymentHelper::validateAndGetPayMethod($cartCheckoutHelper, $orderData, $shippingCharge);
159
160 Arr::set($orderData, 'others.payment_method', $paymentMethod);
161
162 $orderData['user_tz'] = $userTz;
163
164 $customer = static::getOrCreateCustomer($cartCheckoutHelper, $orderData);
165
166 $shouldCreateUser = static::shouldCreateUser($orderData, Arr::get($orderData, 'billing_address', []));
167
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 // Ensure cart has the current payment method before recalculating fees
174 $checkoutData = $cart->checkout_data ?? [];
175 $checkoutData['payment_method'] = $paymentMethod;
176 $cart->checkout_data = $checkoutData;
177
178 $cart->clearFeeCache();
179 $fees = $cart->getFees();
180
181 $checkoutProcessor = new CheckoutProcessor($cartCheckoutHelper->getItems(), [
182 'customer_id' => $customer->id,
183 'user_tz' => $userTz,
184 'create_account_after_paid' => $shouldCreateUser ? 'yes' : 'no',
185 'shipping_charge' => $shippingCharge,
186 'tax_total' => $taxTotal,
187 'tax_behavior' => $taxBehavior,
188 'shipping_tax' => $shippingTax,
189 'payment_method' => $paymentMethod,
190 'applied_coupons' => $cart->getDiscountLines(),
191 'billing_address' => Arr::get($orderData, 'billing_address', []),
192 'shipping_address' => Arr::get($orderData, 'shipping_address', []),
193 'cart_hash' => $cart->cart_hash,
194 'is_locked' => $isLockedCart,
195 'manual_discount_total' => $cartCheckoutHelper->getManualDiscountAmount(),
196 'ip_address' => AddressHelper::getIpAddress(),
197 'note' => Arr::get($orderData, 'others.order_notes', ''),
198 'tax_id' => Arr::get($validatedData, 'billing_tax_id', 0),
199 'fees' => $fees,
200 ]);
201
202 $createdOrder = $checkoutProcessor->createDraftOrder($prevOrder);
203 if (is_wp_error($createdOrder)) {
204 wp_send_json([
205 'status' => 'failed',
206 'message' => $createdOrder->get_error_message(),
207 'data' => $createdOrder->get_error_data()
208 ], 423);
209 }
210
211 do_action('fluent_cart/order/after_items_calculated', [
212 'order' => $createdOrder,
213 'cart' => $cart,
214 'cart_items' => $cartCheckoutHelper->getItems(),
215 ]);
216
217 // prepare other data if any module needs to add data to the order data
218 do_action('fluent_cart/checkout/prepare_other_data', [
219 'cart' => $cart,
220 'order' => $createdOrder,
221 'prev_order' => $prevOrder,
222 'request_data' => $data,
223 'validated_data' => $validatedData
224 ]);
225
226 static::finalizeOrder($createdOrder, [
227 'billing_address' => Arr::get($orderData, 'billing_address', []),
228 'shipping_address' => Arr::get($orderData, 'shipping_address', []),
229 'items' => $cartCheckoutHelper->getItems(),
230 'from_checkout' => true,
231 'prev_order' => $prevOrder
232 ]);
233 }
234
235 private static function getOrCreateCustomer(CartCheckoutHelper $cartCheckoutHelper, $orderData)
236 {
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);
241 }
242 $customer = $cartCheckoutHelper->getCustomer($customerEmail);
243 return static::createCustomerWithAddress(
244 $customer,
245 $orderData,
246 $orderData['billing_address'],
247 $orderData['shipping_address']
248 );
249 }
250
251 private static function prepareAddressData($data)
252 {
253 $shippingAddressId = Arr::get($data, 'shipping_address_id');
254 $billingAddressId = Arr::get($data, 'billing_address_id');
255 $orderId = Arr::get($data, 'order_id', null);
256
257 $shipToDifferent = Arr::get($data, 'ship_to_different', 'no');
258
259 $datKeys = ['country', 'address_1', 'address_2', 'city', 'state', 'postcode', 'phone', 'label'];
260
261 if ($billingAddressId) {
262 $prevOrder = Order::query()->find($orderId);
263 $prevBillingId = null;
264 if ($prevOrder) {
265 $prevBillingAddress = $prevOrder->billing_address;
266 $prevBillingId = Arr::get($prevBillingAddress, 'id');
267 }
268
269 $billingAddress = null;
270 if ($orderId && $prevBillingId == $billingAddressId) {
271 $billingAddress = OrderAddress::query()
272 ->where('id', $billingAddressId)
273 ->where('type', 'billing')
274 ->first();
275 }
276 if (empty($billingAddress)) {
277 $billingAddress = CustomerAddresses::query()
278 ->where('id', $billingAddressId)
279 ->where('type', 'billing')
280 ->first();
281 }
282
283 if ($billingAddress) {
284 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 }
291 }
292 }
293 }
294
295 if ($shippingAddressId && $shipToDifferent === 'yes') {
296 $prevShippingAddress = Order::find($orderId)->shipping_address;
297 $prevShippingId = Arr::get($prevShippingAddress, 'id', null);
298 $shippingAddress = null;
299 if ($orderId && $prevShippingId == $shippingAddressId) {
300 $shippingAddress = OrderAddress::query()
301 ->where('id', $shippingAddressId)
302 ->where('type', 'billing')
303 ->first();
304 }
305 if (empty($shippingAddress)) {
306 $shippingAddress = CustomerAddresses::query()
307 ->where('id', $shippingAddressId)
308 ->where('type', 'shipping')
309 ->first();
310 }
311
312 if ($shippingAddress) {
313 $data['shipping_full_name'] = $shippingAddress->name;
314 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 }
321 }
322 }
323 } else if ($shipToDifferent !== 'yes') {
324 // if not different shipping, copy billing to shipping
325 foreach ($datKeys as $key) {
326 $data['shipping_' . $key] = Arr::get($data, 'billing_' . $key, '');
327 }
328 $data['shipping_full_name'] = Arr::get($data, 'billing_full_name', '');
329 }
330
331 return $data;
332 }
333
334 private static function addLoggedUserData(array $data): array
335 {
336 if (is_user_logged_in()) {
337 $checkoutHelper = CartCheckoutHelper::make();
338 $data['billing_email'] = wp_get_current_user()->user_email;
339
340 if (CheckoutFieldsSchema::isFullNameRequired()) {
341 $userFullName = $checkoutHelper->getFullName();
342 if (!empty($userFullName) && empty($data['billing_full_name'])) {
343 $data['billing_full_name'] = $userFullName;
344 }
345 } else {
346 $firstName = $checkoutHelper->getFirstName();
347 if (!empty($firstName) && empty($data['billing_first_name'])) {
348 $data['billing_first_name'] = $firstName;
349 }
350
351 $lastName = $checkoutHelper->getLastName();
352 if (!empty($lastName) && empty($data['billing_last_name'])) {
353 $data['billing_last_name'] = $lastName;
354 }
355
356 }
357
358
359 }
360 return $data;
361 }
362
363 private static function finalizeOrder(Order $order, $args = [])
364 {
365 AddressHelper::insertOrderAddresses(
366 $order->id,
367 Arr::get($args, 'billing_address', []),
368 Arr::get($args, 'shipping_address', [])
369 );
370
371 static::syncCustomerNames($order, $args);
372 $cart = CartHelper::getCart();
373
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);
381 UtmHelper::addUtmToOrder($order->id, $utmData);
382
383 $prevOrder = Arr::get($args, 'prev_order', null);
384
385 (new OrderCreated($order, $prevOrder, $order->customer, $order->getLatestTransaction()))->dispatch();
386
387 static::updateStock($order);
388
389 // we don't have to validate the payment method again, as it's already validated in placeOrder method
390 $gateway = App::gateway($order->payment_method);
391
392 if (!$gateway) {
393 wp_send_json([
394 'status' => 'failed',
395 'message' => __('Payment method not found!', 'fluent-cart'),
396 'data' => []
397 ], 404);
398 }
399
400 $paymentInstance = new PaymentInstance($order);
401
402 $data = $gateway->makePaymentFromPaymentInstance($paymentInstance);
403
404 if (is_wp_error($data)) {
405 wp_send_json([
406 'status' => 'failed',
407 'message' => $data->get_error_message(),
408 'data' => $data->get_error_data()
409 ], 422);
410 }
411
412 wp_send_json($data, 200);
413 }
414
415 private static function syncCustomerNames($order, $args)
416 {
417 $customer = $order->customer;
418
419 if (empty($customer)) {
420 return;
421 }
422
423 $firstName = Arr::get($args, 'billing_address.first_name');
424 $lastName = Arr::get($args, 'billing_address.last_name');
425
426 $customer->update([
427 'first_name' => $firstName,
428 'last_name' => $lastName,
429 ]);
430
431 $user = get_user_by('email', $customer->email);
432
433 if (empty($user)) {
434 return;
435 }
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 }
442
443 public static function updateStock($order)
444 {
445 // pluck product ids to update product default variation as stock has changed
446 $productIds = OrderService::pluckProductIds($order);
447 if (!empty($productIds)) {
448 (new StockChanged($productIds))->dispatch();
449 }
450 }
451
452 public static function createCustomerWithAddress($customer, $orderData, $billingAddress, $shippingAddress)
453 {
454 if (empty($customer)) {
455 $customer = static::createNewCustomer($orderData, $billingAddress, $shippingAddress);
456 } else if ($customer instanceof Customer) {
457 static::updateExistingCustomer($customer, $orderData, $billingAddress, $shippingAddress);
458 }
459
460 return $customer;
461 }
462
463 private static function createNewCustomer($orderData, &$billingAddress, $shippingAddress)
464 {
465 global $current_user;
466 if ($current_user->ID) {
467 $billingAddress['email'] = $current_user->user_email;
468 $billingAddress['user_id'] = $current_user->ID;
469 } else {
470 static::handleUserCreation($orderData, $billingAddress);
471 }
472
473 $customer = CustomerResource::create($billingAddress);
474 $customer = Arr::get($customer, 'data', null);
475 $customerId = Arr::get($customer, 'id', null);
476 static::createCustomerAddress($billingAddress, $customerId);
477 static::createCustomerAddress($shippingAddress, $customerId);
478
479 return $customer;
480 }
481
482 private static function updateExistingCustomer($customer, $orderData, $billingAddress, $shippingAddress)
483 {
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 }
491 }
492
493 $customer->load(['billing_address', 'shipping_address']);
494
495 if ($customer->billing_address->count() < 1) {
496 static::createCustomerAddress($billingAddress, $customer->id);
497 }
498 if ($customer->shipping_address->count() < 1) {
499 static::createCustomerAddress($shippingAddress, $customer->id);
500 }
501
502 static::handleUserCreation($orderData, $billingAddress, $customer);
503 }
504
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 private static function getCustomerEmail($billingAddress)
519 {
520 return is_user_logged_in() ? wp_get_current_user()->user_email : $billingAddress['email'];
521 }
522
523 public static function shouldCreateUser($data, $billingAddress): bool
524 {
525 $accountCreationMod = (new StoreSettings())->get('user_account_creation_mode');
526 $hasSubscription = (CartCheckoutHelper::make())->hasSubscription() === 'yes';
527 if ($accountCreationMod === 'all' || $hasSubscription) {
528 return true;
529 }
530
531 if (is_user_logged_in()) {
532 return false;
533 }
534
535 $allow_create_account = Arr::get($data, 'others.allow_create_account') === 'yes';
536
537 $userEmail = Arr::get($billingAddress, 'email');
538 $user = get_user_by('email', $userEmail);
539
540 return ($allow_create_account && $accountCreationMod === 'user_choice') || !empty($user);
541 }
542
543 private static function createCustomerAddress(array $address, $customerId)
544 {
545 CustomerAddressResource::create($address, ['id' => $customerId]);
546 }
547
548 /**
549 * Check if a customer is logged in and has the given address type.
550 *
551 * @param string $type Address type to check ('billing' or 'shipping').
552 * @return bool True if the customer is logged in and the address is available.
553 */
554 private static function hasCustomerWithAddress(string $type): bool
555 {
556 $addressType = $type . '_address';
557 $currentCustomer = \FluentCart\Api\Resource\CustomerResource::getCurrentCustomer();
558
559 return !empty($currentCustomer) && $currentCustomer->$addressType->count() === 1;
560 }
561
562
563 /**
564 * Validate the data against the provided rules.
565 * @return array
566 */
567
568 public static function billingRules($data = []): array
569 {
570 $baseRules = [
571 'billing_full_name' => 'required|sanitizeText|maxLength:255',
572 'billing_email' => 'required|sanitizeText|email|maxLength:255',
573 'order_notes' => 'nullable|sanitizeTextArea|maxLength:200',
574 ];
575
576 return static::generateAddressRules('billing', $data, $baseRules, 'getBillingAddressFields');
577 }
578
579 public static function shippingIdRules(): array
580 {
581 return [
582 'shipping_address' => 'exists:fct_customer_addresses,id',
583 ];
584 }
585
586 public static function shippingRules($data = []): array
587 {
588 $baseRules = [
589 'shipping_full_name' => 'required|sanitizeText|maxLength:255'
590 ];
591
592 return static::generateAddressRules('shipping', $data, $baseRules, 'getShippingAddressFields');
593
594 }
595
596 /**
597 * Validate the data against the provided rules.
598 *
599 * @return false|\WP_Error|string
600 */
601
602 public static function validateData($data, Cart $cart, CheckoutService $cartCheckoutService, $prevOrder)
603 {
604 $shippingRequired = $cart->requireShipping();
605 $isDifferentShipping = $shippingRequired && Arr::get($data, 'ship_to_different', 'no') === 'yes';
606 $fulfillmentType = $shippingRequired ? 'physical' : 'digital';
607
608 $billingValidations = array_filter(CheckoutFieldsSchema::getCheckoutFieldsRequirements('billing', $fulfillmentType, !$isDifferentShipping));
609
610 // 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']);
612
613 if (!isset($billingValidations['country'])) {
614 // get store country
615 $data['billing_country'] = (new StoreSettings())->get('store_country');
616 }
617
618 $billingAddress = [];
619 foreach ($billingValidations as $key => $billingValidation) {
620 $billingAddress[$key] = Arr::get($data, 'billing_' . $key, '');
621 }
622 if (!isset($billingAddress['country'])) {
623 // get store country
624 $billingAddress['country'] = (new StoreSettings())->get('store_country');
625 }
626
627 $shippingAddress = [];
628 $shippingValidations = [];
629 if ($isDifferentShipping) {
630 $shippingValidations = array_filter(CheckoutFieldsSchema::getCheckoutFieldsRequirements('shipping', 'physical'));
631 // Name fields are validated via billing basic_info, not shipping address
632 unset($shippingValidations['full_name'], $shippingValidations['first_name'], $shippingValidations['last_name'], $shippingValidations['company_name']);
633 foreach ($shippingValidations as $key => $shippingValidation) {
634 $shippingAddress[$key] = Arr::get($data, 'shipping_' . $key, '');
635 }
636 }
637
638 if (Arr::get($data, 'ship_to_different', 'no') === 'yes') {
639 if (!isset($data['shipping_country'])) {
640 // get store country
641 $data['shipping_country'] = (new StoreSettings())->get('store_country');
642 }
643 if (!isset($shippingAddress['country'])) {
644 // get store country
645 $shippingAddress['country'] = (new StoreSettings())->get('store_country');
646 }
647
648 }
649
650 $errors = [];
651
652 $agreeTermsRequired = CheckoutFieldsSchema::isTermsRequired();
653
654 $customTitles = [
655 'address_1' => 'Street Address',
656 'address_2' => 'Apt, Suite, Unit',
657 ];
658
659 foreach ($billingValidations as $key => $rule) {
660 $value = Arr::get($billingAddress, $key, '');
661 $prefixedKey = 'billing_' . $key;
662 $titledKey = $customTitles[$key] ?? Str::headline($key);
663
664 if ($key === 'country') {
665 $countries = LocalizationManager::getInstance()->countries();
666
667 if ($rule === 'required' && empty($value)) {
668 if (!isset($errors[$key])) {
669 $errors[$prefixedKey] = [];
670 }
671 $errors[$prefixedKey]['required'] = sprintf(
672 /* translators: %s attribute name */
673 __('%s is required.', 'fluent-cart'),
674 $titledKey
675 );
676 continue;
677 }
678
679 if (!empty($value) && !Arr::has($countries, $value)) {
680 if (!isset($errors[$key])) {
681 $errors[$prefixedKey] = [];
682 }
683 $errors[$prefixedKey]['invalid'] = sprintf(
684 /* translators: %s attribute name */
685 __('%s is invalid.', 'fluent-cart'),
686 $titledKey
687 );
688 continue;
689 }
690
691 continue;
692 }
693
694 if ($key === 'state') {
695 $country = Arr::get($billingAddress, 'country', '');
696
697 $states = LocalizationManager::getInstance()->statesOptions($country);
698
699 if (empty($states)) {
700 continue;
701 }
702
703 if ($rule === 'required' && empty($value)) {
704 if (!isset($errors[$key])) {
705 $errors[$prefixedKey] = [];
706 }
707 $errors[$prefixedKey]['required'] = sprintf(
708 /* translators: %s attribute name */
709 __('%s is required.', 'fluent-cart'),
710 $titledKey
711 );
712 continue;
713 }
714
715 if (!empty($value) && !in_array($value, array_column($states, 'value'))) {
716 if (!isset($errors[$key])) {
717 $errors[$prefixedKey] = [];
718 }
719 $errors[$prefixedKey]['invalid'] = sprintf(
720 /* translators: %s attribute name */
721 __('%s is invalid.', 'fluent-cart'),
722 $titledKey
723 );
724 }
725
726 continue;
727 }
728
729 if ($rule === 'required' && empty($value)) {
730 if (!isset($errors[$key])) {
731 $errors[$prefixedKey] = [];
732 }
733 $errors[$prefixedKey]['required'] = sprintf(
734 /* translators: %s attribute name */
735 __('%s is required.', 'fluent-cart'),
736 $titledKey
737 );
738 }
739 }
740
741 foreach ($shippingValidations as $key => $rule) {
742 $value = Arr::get($shippingAddress, $key, '');
743 $prefixedKey = 'shipping_' . $key;
744 $titledKey = $customTitles[$key] ?? Str::headline($key);
745
746 if ($key === 'country') {
747 $countries = LocalizationManager::getInstance()->countries();
748
749 if ($rule === 'required' && empty($value)) {
750 if (!isset($errors[$key])) {
751 $errors[$prefixedKey] = [];
752 }
753 $errors[$prefixedKey]['required'] = sprintf(
754 /* translators: %s attribute name */
755 __('%s is required.', 'fluent-cart'),
756 $titledKey
757 );
758
759 continue;
760 }
761
762 if (!empty($value) && !Arr::has($countries, $value)) {
763 if (!isset($errors[$key])) {
764 $errors[$prefixedKey] = [];
765 }
766 $errors[$prefixedKey]['invalid'] = sprintf(
767 /* translators: %s attribute name */
768 __('%s is invalid.', 'fluent-cart'),
769 $titledKey
770 );
771 continue;
772 }
773
774 continue;
775 }
776
777 if ($key === 'state') {
778 $country = Arr::get($shippingAddress, 'country', '');
779
780 $states = LocalizationManager::getInstance()->statesOptions($country);
781
782 if (empty($states)) {
783 continue;
784 }
785
786 if ($rule === 'required' && empty($value)) {
787 if (!isset($errors[$key])) {
788 $errors[$prefixedKey] = [];
789 }
790 $errors[$prefixedKey]['required'] = sprintf(
791 /* translators: %s attribute name */
792 __('%s is required.', 'fluent-cart'),
793 $titledKey
794 );
795
796 continue;
797 }
798
799 if (!empty($value) && !in_array($value, array_column($states, 'value'))) {
800 if (!isset($errors[$key])) {
801 $errors[$prefixedKey] = [];
802 }
803 $errors[$prefixedKey]['invalid'] = sprintf(
804 /* translators: %s attribute name */
805 __('%s is invalid.', 'fluent-cart'),
806 $titledKey
807 );
808 }
809
810 continue;
811 }
812
813 if ($rule === 'required' && empty($value)) {
814 if (!isset($errors[$key])) {
815 $errors[$prefixedKey] = [];
816 }
817 $errors[$prefixedKey]['required'] = sprintf(
818 /* translators: %s attribute name */
819 __('%s is required.', 'fluent-cart'),
820 $titledKey
821 );
822 }
823 }
824
825 $basicInfoFields = (CheckoutFieldsSchema::getNameEmailFieldsSchema())['fields'];
826
827 foreach ($basicInfoFields as $field) {
828 $fieldName = (string)Arr::get($field, 'name', '');
829 $isRequired = Arr::get($field, 'required', 'no') === 'yes';
830 if ($fieldName && $isRequired) {
831 $value = Arr::get($data, $fieldName, '');
832 if (empty($value)) {
833 Arr::set($errors, $fieldName . '.required', sprintf(
834 /* translators: %s attribute name */
835 __('%s is required.', 'fluent-cart'),
836 Arr::get($field, 'aria-label')
837 ));
838 }
839 }
840 }
841
842 if (empty($data['agree_terms']) && $agreeTermsRequired) {
843 $errors['agree_terms']['required'] = __('You must agree to the terms and conditions.', 'fluent-cart');
844 }
845
846 if (empty($data['billing_email']) || !is_email($data['billing_email'])) {
847 $errors['billing_email']['invalid'] = __('Email must be a valid email address.', 'fluent-cart');
848 }
849
850 if (CheckoutFieldsSchema::isFullNameRequired() || !empty($data['billing_full_name'])) {
851 // Modal checkout always sends billing_full_name regardless of store name field settings
852 if (empty($data['billing_full_name'])) {
853 $errors['billing_full_name']['required'] = __('Full name is required.', 'fluent-cart');
854 }
855 } else {
856 if (empty($data['billing_first_name'])) {
857 $errors['billing_first_name']['required'] = __('First name is required.', 'fluent-cart');
858 }
859
860 if(CheckoutFieldsSchema::isLastNameRequired()) {
861 if (empty($data['billing_last_name'])) {
862 $errors['billing_last_name']['required'] = __('Last name is required.', 'fluent-cart');
863 }
864 }
865 }
866
867
868 if ($cart->requireShipping()) {
869 if (!empty($data['fc_selected_shipping_method'])) {
870 $selectedMethod = $data['fc_selected_shipping_method'];
871 $shippingCountry = Arr::get($data, 'billing_country', '');
872 $shippingState = Arr::get($data, 'billing_state', '');
873 $shipToDifferent = Arr::get($data, 'ship_to_different', 'no') === 'yes';
874
875 if ($shipToDifferent) {
876 $shippingCountry = Arr::get($data, 'shipping_country', '');
877 $shippingState = Arr::get($data, 'shipping_state', '');
878 }
879
880 $availableShippingMethods = AddressHelper::getShippingMethods($shippingCountry, $shippingState);
881
882
883 if (empty($availableShippingMethods) || is_wp_error($availableShippingMethods)) {
884 $errors['shipping_method']['unavailable'] = __('We don\'t ship to this address. Please select a different address.', 'fluent-cart');
885 } else {
886 $found = false;
887 foreach ($availableShippingMethods as $shippingMethod) {
888 if ($shippingMethod->id == $selectedMethod) {
889 $found = true;
890 break;
891 }
892 }
893
894 if (!$found) {
895 $errors['shipping_method']['invalid'] = __('The selected shipping method is not available.', 'fluent-cart');
896 }
897 }
898
899
900 } else {
901 $errors['shipping_method']['required'] = __('You must select a shipping method.', 'fluent-cart');
902 }
903 }
904
905 $errors = apply_filters('fluent_cart/checkout/validate_data', $errors, [
906 'data' => $data,
907 'cart' => $cart
908 ]);
909
910 if (count($errors) > 0) {
911 return new \Wp_Error('validation_error', 'Validation error', $errors);
912 }
913
914 return $data;
915 }
916
917 public static function validateShippingMethod(array $data, CheckoutService $cartCheckoutService): bool
918 {
919
920 if ($cartCheckoutService->isAllDigital()) {
921 return true;
922 }
923
924 $shipping_country = Arr::get($data, 'shipping_country');
925 $shipping_state = Arr::get($data, 'shipping_state');
926
927
928 $methods = ShippingMethod::getApplicableForCountry($shipping_country, $shipping_state)->keyBy('id');
929 if ($methods->count() === 0 || \FluentCart\App\App::isDevMode()) {
930 return true;
931 }
932
933 $shipping_method = Arr::get($data, 'fc_selected_shipping_method');
934
935 if (empty($shipping_method)) {
936 return false;
937 }
938
939
940 $exist = $methods->has($shipping_method);
941 if (!$exist) {
942 return false;
943 }
944
945 return true;
946 }
947
948
949 public static function messages(): array
950 {
951 return [
952 'billing_full_name.required' => esc_html__('Full name field is required.', 'fluent-cart'),
953 'billing_email.required' => esc_html__('Email field is required.', 'fluent-cart'),
954 'billing_email.email' => esc_html__('Email must be a valid email address.', 'fluent-cart'),
955 'billing_address.required' => esc_html__('Address field is required.', 'fluent-cart'),
956 'billing_country.required' => esc_html__('Country field is required.', 'fluent-cart'),
957 'billing_address_1.required' => esc_html__('Street Address field is required.', 'fluent-cart'),
958 'billing_city.required' => esc_html__('City field is required.', 'fluent-cart'),
959 'billing_postcode.required' => esc_html__('Postcode field is required.', 'fluent-cart'),
960 'shipping_full_name.required' => esc_html__('Full name field is required.', 'fluent-cart'),
961 // 'shipping_email.required' => esc_html__('Email field is required.', 'fluent-cart'),
962 // 'shipping_email.email' => esc_html__('Email must be a valid email address.', 'fluent-cart'),
963 'shipping_address.required' => esc_html__('Address field is required.', 'fluent-cart'),
964 'shipping_city.required' => esc_html__('City field is required.', 'fluent-cart'),
965 'shipping_postcode.required' => esc_html__('Postcode field is required.', 'fluent-cart'),
966 ];
967 }
968
969 private static function generateAddressRules($type, $data, $baseRules, $fieldGetter): array
970 {
971 $hasAddress = static::hasCustomerWithAddress($type);
972 $rules = App::localization()->getValidationRule($data, $type);
973
974 if ($hasAddress) {
975 $baseRules["{$type}_address"] = 'required|numeric';
976 return $baseRules;
977 }
978
979 $cartCheckoutHelper = CartCheckoutHelper::make();
980 $fields = $cartCheckoutHelper->{$fieldGetter}();
981 $addressFields = Arr::get($fields, 'address_section.schema', []);
982 $addressFields = Arr::wrap($addressFields);
983
984 $validFields = array_keys($addressFields);
985
986 // Add prefix to each field (billing_ or shipping_)
987 $validFields = array_map(function ($field) use ($type) {
988 return $type . '_' . $field;
989 }, $validFields);
990
991 // Filter only relevant rules
992 $filteredRules = array_filter($rules, function ($key) use ($validFields) {
993 return in_array($key, $validFields);
994 }, ARRAY_FILTER_USE_KEY);
995
996 return $baseRules + $filteredRules;
997 }
998 }
999