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

1,051 lines 41.1 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 $cart = $cart->reValidateCoupons();
59
60 $cartData = $cart->cart_data;
61 $prevOrder = $cart->order;
62 if ($prevOrder) {
63 $prevOrder->load('order_items');
64 }
65 $isLockedCart = $cart->isLocked();
66
67 if ($prevOrder &&
68 (
69 in_array($prevOrder->status, Status::getOrderSuccessStatuses()) ||
70 !in_array($prevOrder->payment_status, Status::getPaymentRetryableStatuses())
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 if ($prevOrder) {
103 $data['order_id'] = $prevOrder->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 $shippingMethod = null;
139 $shippingCharge = 0;
140 if (!$cartCheckoutService->isAllDigital()) {
141 $shippingMethod = ShippingMethod::query()->find($shippingMethodId);
142 if (!empty($shippingMethod)) {
143 $shippingCharge = CartHelper::calculateShippingMethodCharge($shippingMethod, $cartData);
144 }
145 }
146
147 Arr::set($orderData, 'others.shipping_total', $shippingCharge);
148
149 $cartCheckoutHelper = CartCheckoutHelper::make();
150
151 try {
152 OrderService::validateProducts($cartCheckoutHelper->getItems(), $prevOrder);
153 } catch (\Exception $e) {
154 wp_send_json([
155 'message' => $e->getMessage(),
156 ], 422);
157 }
158
159 $paymentMethod = PaymentHelper::validateAndGetPayMethod($cartCheckoutHelper, $orderData, $shippingCharge);
160
161 Arr::set($orderData, 'others.payment_method', $paymentMethod);
162
163 $orderData['user_tz'] = $userTz;
164
165 $customer = static::getOrCreateCustomer($cartCheckoutHelper, $orderData);
166
167 $shouldCreateUser = static::shouldCreateUser($orderData, Arr::get($orderData, 'billing_address', []));
168
169 // Ensure cart has the current payment method before recalculating fees
170 $checkoutData = $cart->checkout_data ?? [];
171 $checkoutData['payment_method'] = $paymentMethod;
172 $cart->checkout_data = $checkoutData;
173
174 $cart->clearFeeCache();
175 $fees = $cart->getFees();
176
177 // Recompute cart tax data so fee_tax/fee_tax_lines reflect fees for the current
178 // payment method. The scope prevents ShippingModule from running unnecessarily.
179 do_action('fluent_cart/cart/cart_data_items_updated', ['cart' => $cart, 'scope' => 'payment_method_fee_recalculate']);
180
181 // TaxModule::recalculateTax() refreshes checkout_data['fees'] (RC-adjusted amounts,
182 // deduplication) — reload so persisted fee items match the recomputed fee tax metadata.
183 $fees = (array) Arr::get($cart->checkout_data, 'fees', $fees);
184
185 $taxBehavior = apply_filters('fluent_cart/cart/tax_behavior', 0, ['cart' => $cart]);
186 $taxTotal = (int)Arr::get($cart->checkout_data, 'tax_data.tax_total', 0);
187 $shippingTax = (int)Arr::get($cart->checkout_data, 'tax_data.shipping_tax', 0);
188 $storeTaxBehavior = (int)Arr::get($cart->checkout_data, 'tax_data.store_tax_behavior', $taxBehavior);
189 $exclusiveTaxTotal = (int)Arr::get($cart->checkout_data, 'tax_data.exclusive_tax_total', 0);
190 $feeTax = (int)Arr::get($cart->checkout_data, 'tax_data.fee_tax', 0);
191 $feeTaxLines = (array)Arr::get($cart->checkout_data, 'tax_data.fee_tax_lines', []);
192
193 // For dynamic RC + inclusive pricing, reduce the shipping charge to net before storing.
194 // The fluent_cart/cart/shipping_total filter (registered by TaxModule) handles the logic.
195 $shippingCharge = apply_filters('fluent_cart/cart/shipping_total', $shippingCharge, ['cart' => $cart]);
196
197 $checkoutProcessor = new CheckoutProcessor($cartCheckoutHelper->getItems(), [
198 'customer_id' => $customer->id,
199 'user_tz' => $userTz,
200 'create_account_after_paid' => $shouldCreateUser ? 'yes' : 'no',
201 'shipping_charge' => $shippingCharge,
202 'shipping_method_id' => $shippingMethod ? (int)$shippingMethod->id : 0,
203 'shipping_method_title' => $shippingMethod ? $shippingMethod->title : '',
204 'tax_total' => $taxTotal,
205 'tax_behavior' => $taxBehavior,
206 'store_tax_behavior' => $storeTaxBehavior,
207 'exclusive_tax_total' => $exclusiveTaxTotal,
208 'fee_tax' => $feeTax,
209 'fee_tax_lines' => $feeTaxLines,
210 'shipping_tax' => $shippingTax,
211 'payment_method' => $paymentMethod,
212 'applied_coupons' => $cart->getDiscountLines(),
213 'billing_address' => Arr::get($orderData, 'billing_address', []),
214 'shipping_address' => Arr::get($orderData, 'shipping_address', []),
215 'cart_hash' => $cart->cart_hash,
216 'is_locked' => $isLockedCart,
217 'manual_discount_total' => $cartCheckoutHelper->getManualDiscountAmount(),
218 'prorate_credit' => (int) Arr::get($cart->checkout_data, 'prorate_credit.amount', 0),
219 'upgrade_discount' => (int) Arr::get($cart->checkout_data, 'upgrade_discount.amount', 0),
220 'ip_address' => AddressHelper::getIpAddress(),
221 'note' => Arr::get($orderData, 'others.order_notes', ''),
222 'tax_id' => sanitize_text_field(
223 Arr::get($data, 'fct_billing_tax_id', '')
224 ?: Arr::get($cart->checkout_data, 'tax_data.vat_number', '')
225 ),
226 'fees' => $fees,
227 ]);
228
229 $createdOrder = $checkoutProcessor->createDraftOrder($prevOrder);
230 if (is_wp_error($createdOrder)) {
231 wp_send_json([
232 'status' => 'failed',
233 'message' => $createdOrder->get_error_message(),
234 'data' => $createdOrder->get_error_data()
235 ], 423);
236 }
237
238 do_action('fluent_cart/order/after_items_calculated', [
239 'order' => $createdOrder,
240 'cart' => $cart,
241 'cart_items' => $cartCheckoutHelper->getItems(),
242 ]);
243
244 // prepare other data if any module needs to add data to the order data
245 do_action('fluent_cart/checkout/prepare_other_data', [
246 'cart' => $cart,
247 'order' => $createdOrder,
248 'prev_order' => $prevOrder,
249 'request_data' => $data,
250 'validated_data' => $validatedData
251 ]);
252
253 static::finalizeOrder($createdOrder, [
254 'billing_address' => Arr::get($orderData, 'billing_address', []),
255 'shipping_address' => Arr::get($orderData, 'shipping_address', []),
256 'items' => $cartCheckoutHelper->getItems(),
257 'from_checkout' => true,
258 'prev_order' => $prevOrder
259 ]);
260 }
261
262 private static function getOrCreateCustomer(CartCheckoutHelper $cartCheckoutHelper, $orderData)
263 {
264 $customerEmail = static::getCustomerEmail($orderData['billing_address']);
265 if (is_user_logged_in()) {
266 $customerEmail = wp_get_current_user()->user_email;
267 Arr::set($orderData, 'billing_address.email', $customerEmail);
268 }
269 $customer = $cartCheckoutHelper->getCustomer($customerEmail);
270 return static::createCustomerWithAddress(
271 $customer,
272 $orderData,
273 $orderData['billing_address'],
274 $orderData['shipping_address']
275 );
276 }
277
278 private static function prepareAddressData($data)
279 {
280 $shippingAddressId = Arr::get($data, 'shipping_address_id');
281 $billingAddressId = Arr::get($data, 'billing_address_id');
282 $orderId = Arr::get($data, 'order_id', null);
283
284 $currentCustomer = is_user_logged_in() ? ApiCustomerResource::getCurrentCustomer() : null;
285
286 $shipToDifferent = Arr::get($data, 'ship_to_different', 'no');
287
288 $datKeys = ['country', 'address_1', 'address_2', 'city', 'state', 'postcode', 'phone', 'label', 'company_name', 'vat_number', 'legal_registration_id'];
289
290 if ($billingAddressId) {
291 $prevOrder = Order::query()->find($orderId);
292 $prevBillingId = null;
293 if ($prevOrder) {
294 $prevBillingAddress = $prevOrder->billing_address;
295 $prevBillingId = Arr::get($prevBillingAddress, 'id');
296 }
297
298 $billingAddress = null;
299 if ($orderId && $prevBillingId == $billingAddressId) {
300 $billingAddress = OrderAddress::query()
301 ->where('id', $billingAddressId)
302 ->where('type', 'billing')
303 ->first();
304 }
305 if (empty($billingAddress) && $currentCustomer) {
306 $billingAddress = CustomerAddresses::query()
307 ->where('id', $billingAddressId)
308 ->where('type', 'billing')
309 ->where('customer_id', $currentCustomer->id)
310 ->first();
311 }
312
313 if ($billingAddress) {
314 foreach ($datKeys as $key) {
315 $data['billing_' . $key] = $billingAddress->{$key} ?: Arr::get($data, 'billing_' . $key, '');
316 }
317 }
318 }
319
320 if (Arr::get($data, 'is_business', 'no') !== 'yes' && !CheckoutFieldsSchema::isB2BOnlyMode()) {
321 $data['billing_company_name'] = '';
322 $data['billing_legal_registration_id'] = '';
323 }
324
325 if ($shippingAddressId && $shipToDifferent === 'yes') {
326 $prevShippingOrder = Order::query()->find($orderId);
327 $prevShippingAddress = $prevShippingOrder ? $prevShippingOrder->shipping_address : null;
328 $prevShippingId = Arr::get($prevShippingAddress, 'id', null);
329 $shippingAddress = null;
330 if ($orderId && $prevShippingId == $shippingAddressId) {
331 $shippingAddress = OrderAddress::query()
332 ->where('id', $shippingAddressId)
333 ->where('type', 'shipping')
334 ->first();
335 }
336 if (empty($shippingAddress) && $currentCustomer) {
337 $shippingAddress = CustomerAddresses::query()
338 ->where('id', $shippingAddressId)
339 ->where('type', 'shipping')
340 ->where('customer_id', $currentCustomer->id)
341 ->first();
342 }
343
344 if ($shippingAddress) {
345 $formFullName = Arr::get($data, 'shipping_full_name', '');
346 $data['shipping_full_name'] = $shippingAddress->name ?: $formFullName;
347 foreach ($datKeys as $key) {
348 $data['shipping_' . $key] = $shippingAddress->{$key} ?: Arr::get($data, 'shipping_' . $key, '');
349 }
350 }
351 } else if ($shipToDifferent !== 'yes') {
352 // if not different shipping, copy billing to shipping
353 foreach ($datKeys as $key) {
354 $data['shipping_' . $key] = Arr::get($data, 'billing_' . $key, '');
355 }
356 $data['shipping_full_name'] = Arr::get($data, 'billing_full_name', '');
357 }
358
359 return $data;
360 }
361
362 private static function addLoggedUserData(array $data): array
363 {
364 if (is_user_logged_in()) {
365 $checkoutHelper = CartCheckoutHelper::make();
366 $data['billing_email'] = wp_get_current_user()->user_email;
367
368 if (CheckoutFieldsSchema::isFullNameRequired()) {
369 $userFullName = $checkoutHelper->getFullName();
370 if (!empty($userFullName) && empty($data['billing_full_name'])) {
371 $data['billing_full_name'] = $userFullName;
372 }
373 } else {
374 $firstName = $checkoutHelper->getFirstName();
375 if (!empty($firstName) && empty($data['billing_first_name'])) {
376 $data['billing_first_name'] = $firstName;
377 }
378
379 $lastName = $checkoutHelper->getLastName();
380 if (!empty($lastName) && empty($data['billing_last_name'])) {
381 $data['billing_last_name'] = $lastName;
382 }
383
384 }
385
386
387 }
388 return $data;
389 }
390
391 private static function finalizeOrder(Order $order, $args = [])
392 {
393 AddressHelper::insertOrderAddresses(
394 $order->id,
395 Arr::get($args, 'billing_address', []),
396 Arr::get($args, 'shipping_address', [])
397 );
398
399 static::syncCustomerNames($order, $args);
400 $cart = CartHelper::getCart();
401
402 $utmData = [];
403 if (!empty($cart) && is_array($cart->utm_data) && count($cart->utm_data) > 0) {
404 $utmData = $cart->utm_data;
405 }
406
407 $requestUtmData = UtmHelper::getUtmDataOfRequest();
408 $utmData = wp_parse_args($requestUtmData, $utmData);
409 UtmHelper::addUtmToOrder($order->id, $utmData);
410
411 $prevOrder = Arr::get($args, 'prev_order', null);
412
413 (new OrderCreated($order, $prevOrder, $order->customer, $order->getLatestTransaction()))->dispatch();
414
415 static::updateStock($order);
416
417 // we don't have to validate the payment method again, as it's already validated in placeOrder method
418 $gateway = App::gateway($order->payment_method);
419
420 if (!$gateway) {
421 wp_send_json([
422 'status' => 'failed',
423 'message' => __('Payment method not found!', 'fluent-cart'),
424 'data' => []
425 ], 404);
426 }
427
428 $paymentInstance = new PaymentInstance($order);
429
430 $data = $gateway->makePaymentFromPaymentInstance($paymentInstance);
431
432 if (is_wp_error($data)) {
433 wp_send_json([
434 'status' => 'failed',
435 'message' => $data->get_error_message(),
436 'data' => $data->get_error_data()
437 ], 422);
438 }
439
440 wp_send_json($data, 200);
441 }
442
443 private static function syncCustomerNames($order, $args)
444 {
445 $customer = $order->customer;
446
447 if (empty($customer)) {
448 return;
449 }
450
451 $firstName = Arr::get($args, 'billing_address.first_name');
452 $lastName = Arr::get($args, 'billing_address.last_name');
453
454 $customer->update([
455 'first_name' => $firstName,
456 'last_name' => $lastName,
457 ]);
458
459 $user = get_user_by('email', $customer->email);
460
461 if (empty($user)) {
462 return;
463 }
464
465 if (is_user_logged_in() && $user->ID === get_current_user_id()) {
466 update_user_meta($user->ID, 'first_name', $firstName);
467 update_user_meta($user->ID, 'last_name', $lastName);
468 }
469 }
470
471 public static function updateStock($order)
472 {
473 // pluck product ids to update product default variation as stock has changed
474 $productIds = OrderService::pluckProductIds($order);
475 if (!empty($productIds)) {
476 (new StockChanged($productIds))->dispatch();
477 }
478 }
479
480 public static function createCustomerWithAddress($customer, $orderData, $billingAddress, $shippingAddress)
481 {
482 if (empty($customer)) {
483 $customer = static::createNewCustomer($orderData, $billingAddress, $shippingAddress);
484 } else if ($customer instanceof Customer) {
485 static::updateExistingCustomer($customer, $orderData, $billingAddress, $shippingAddress);
486 }
487
488 return $customer;
489 }
490
491 private static function createNewCustomer($orderData, &$billingAddress, $shippingAddress)
492 {
493 global $current_user;
494 if ($current_user->ID) {
495 $billingAddress['email'] = $current_user->user_email;
496 $billingAddress['user_id'] = $current_user->ID;
497 } else {
498 static::handleUserCreation($orderData, $billingAddress);
499 }
500
501 $customer = CustomerResource::create($billingAddress);
502 $customer = Arr::get($customer, 'data', null);
503 $customerId = Arr::get($customer, 'id', null);
504 static::createCustomerAddress($billingAddress, $customerId);
505 static::createCustomerAddress($shippingAddress, $customerId);
506
507 return $customer;
508 }
509
510 private static function updateExistingCustomer($customer, $orderData, $billingAddress, $shippingAddress)
511 {
512 if (empty($customer->user_id)) {
513 $currentLoggedInUser = wp_get_current_user();
514 if ($currentLoggedInUser && $currentLoggedInUser->user_email === $customer->email) {
515 $userId = get_current_user_id();
516 $customer->update(['user_id' => $userId]);
517 $billingAddress['user_id'] = $userId;
518 }
519 }
520
521 $customer->load(['billing_address', 'shipping_address']);
522
523 if ($customer->billing_address->count() < 1) {
524 static::createCustomerAddress($billingAddress, $customer->id);
525 }
526 if ($customer->shipping_address->count() < 1) {
527 static::createCustomerAddress($shippingAddress, $customer->id);
528 }
529
530 static::handleUserCreation($orderData, $billingAddress, $customer);
531 }
532
533 private static function handleUserCreation($orderData, &$billingAddress, $customer = null)
534 {
535 $userEmail = Arr::get($billingAddress, 'email');
536 $user = get_user_by('email', $userEmail);
537
538 if ($user) {
539 $billingAddress['user_id'] = $user->ID;
540 if ($customer) {
541 $customer->update(['user_id' => $user->ID]);
542 }
543 }
544 }
545
546 private static function getCustomerEmail($billingAddress)
547 {
548 return is_user_logged_in() ? wp_get_current_user()->user_email : $billingAddress['email'];
549 }
550
551 public static function shouldCreateUser($data, $billingAddress): bool
552 {
553 $accountCreationMod = (new StoreSettings())->get('user_account_creation_mode');
554 $hasSubscription = (CartCheckoutHelper::make())->hasSubscription() === 'yes';
555 if ($accountCreationMod === 'all' || $hasSubscription) {
556 return true;
557 }
558
559 if (is_user_logged_in()) {
560 return false;
561 }
562
563 $allow_create_account = Arr::get($data, 'others.allow_create_account') === 'yes';
564
565 $userEmail = Arr::get($billingAddress, 'email');
566 $user = get_user_by('email', $userEmail);
567
568 return ($allow_create_account && $accountCreationMod === 'user_choice') || !empty($user);
569 }
570
571 private static function createCustomerAddress(array $address, $customerId)
572 {
573 CustomerAddressResource::create($address, ['id' => $customerId]);
574 }
575
576 /**
577 * Check if a customer is logged in and has the given address type.
578 *
579 * @param string $type Address type to check ('billing' or 'shipping').
580 * @return bool True if the customer is logged in and the address is available.
581 */
582 private static function hasCustomerWithAddress(string $type): bool
583 {
584 $addressType = $type . '_address';
585 $currentCustomer = \FluentCart\Api\Resource\CustomerResource::getCurrentCustomer();
586
587 return !empty($currentCustomer) && $currentCustomer->$addressType->count() === 1;
588 }
589
590
591 /**
592 * Validate the data against the provided rules.
593 * @return array
594 */
595
596 public static function billingRules($data = []): array
597 {
598 $baseRules = [
599 'billing_full_name' => 'required|sanitizeText|maxLength:255',
600 'billing_email' => 'required|sanitizeText|email|maxLength:255',
601 'order_notes' => 'nullable|sanitizeTextArea|maxLength:200',
602 ];
603
604 return static::generateAddressRules('billing', $data, $baseRules, 'getBillingAddressFields');
605 }
606
607 public static function shippingIdRules(): array
608 {
609 return [
610 'shipping_address' => 'exists:fct_customer_addresses,id',
611 ];
612 }
613
614 public static function shippingRules($data = []): array
615 {
616 $baseRules = [
617 'shipping_full_name' => 'required|sanitizeText|maxLength:255'
618 ];
619
620 return static::generateAddressRules('shipping', $data, $baseRules, 'getShippingAddressFields');
621
622 }
623
624 /**
625 * Validate the data against the provided rules.
626 *
627 * @return false|\WP_Error|string
628 */
629
630 public static function validateData($data, Cart $cart, CheckoutService $cartCheckoutService, $prevOrder)
631 {
632 $shippingRequired = $cart->requireShipping();
633 $isDifferentShipping = $shippingRequired && Arr::get($data, 'ship_to_different', 'no') === 'yes';
634 $fulfillmentType = $shippingRequired ? 'physical' : 'digital';
635
636 $billingValidations = array_filter(CheckoutFieldsSchema::getCheckoutFieldsRequirements('billing', $fulfillmentType, !$isDifferentShipping));
637
638 // Name fields are validated separately below (full_name/first_name/last_name)
639 // vat_number uses field name fct_billing_tax_id and is validated separately in the B2B block below
640 unset($billingValidations['full_name'], $billingValidations['first_name'], $billingValidations['last_name'], $billingValidations['vat_number']);
641
642 if (!isset($billingValidations['country']) && empty($data['billing_country'])) {
643 $data['billing_country'] = (new StoreSettings())->get('store_country');
644 }
645
646 $billingAddress = [];
647 foreach ($billingValidations as $key => $billingValidation) {
648 $billingAddress[$key] = Arr::get($data, 'billing_' . $key, '');
649 }
650 if (!isset($billingAddress['country'])) {
651 $billingAddress['country'] = !empty($data['billing_country'])
652 ? $data['billing_country']
653 : (new StoreSettings())->get('store_country');
654 }
655
656 $shippingAddress = [];
657 $shippingValidations = [];
658 if ($isDifferentShipping) {
659 $shippingValidations = array_filter(CheckoutFieldsSchema::getCheckoutFieldsRequirements('shipping', 'physical'));
660 // Name fields are validated via billing basic_info, not shipping address
661 unset($shippingValidations['full_name'], $shippingValidations['first_name'], $shippingValidations['last_name'], $shippingValidations['company_name']);
662 foreach ($shippingValidations as $key => $shippingValidation) {
663 $shippingAddress[$key] = Arr::get($data, 'shipping_' . $key, '');
664 }
665 }
666
667 if (Arr::get($data, 'ship_to_different', 'no') === 'yes') {
668 if (!isset($data['shipping_country'])) {
669 // get store country
670 $data['shipping_country'] = (new StoreSettings())->get('store_country');
671 }
672 if (!isset($shippingAddress['country'])) {
673 // get store country
674 $shippingAddress['country'] = (new StoreSettings())->get('store_country');
675 }
676
677 }
678
679 $errors = [];
680
681 $agreeTermsRequired = CheckoutFieldsSchema::isTermsRequired();
682
683 $customTitles = [
684 'address_1' => 'Street Address',
685 'address_2' => 'Apt, Suite, Unit',
686 ];
687
688 foreach ($billingValidations as $key => $rule) {
689 $value = Arr::get($billingAddress, $key, '');
690 $prefixedKey = 'billing_' . $key;
691 $titledKey = $customTitles[$key] ?? Str::headline($key);
692
693 if ($key === 'country') {
694 $countries = LocalizationManager::getInstance()->countries();
695
696 if ($rule === 'required' && empty($value)) {
697 if (!isset($errors[$key])) {
698 $errors[$prefixedKey] = [];
699 }
700 $errors[$prefixedKey]['required'] = sprintf(
701 /* translators: %s attribute name */
702 __('%s is required.', 'fluent-cart'),
703 $titledKey
704 );
705 continue;
706 }
707
708 if (!empty($value) && !Arr::has($countries, $value)) {
709 if (!isset($errors[$key])) {
710 $errors[$prefixedKey] = [];
711 }
712 $errors[$prefixedKey]['invalid'] = sprintf(
713 /* translators: %s attribute name */
714 __('%s is invalid.', 'fluent-cart'),
715 $titledKey
716 );
717 continue;
718 }
719
720 continue;
721 }
722
723 if ($key === 'state') {
724 $country = Arr::get($billingAddress, 'country', '');
725
726 $states = LocalizationManager::getInstance()->statesOptions($country);
727
728 if (empty($states)) {
729 continue;
730 }
731
732 if ($rule === 'required' && empty($value)) {
733 if (!isset($errors[$key])) {
734 $errors[$prefixedKey] = [];
735 }
736 $errors[$prefixedKey]['required'] = sprintf(
737 /* translators: %s attribute name */
738 __('%s is required.', 'fluent-cart'),
739 $titledKey
740 );
741 continue;
742 }
743
744 if (!empty($value) && !in_array($value, array_column($states, 'value'))) {
745 if (!isset($errors[$key])) {
746 $errors[$prefixedKey] = [];
747 }
748 $errors[$prefixedKey]['invalid'] = sprintf(
749 /* translators: %s attribute name */
750 __('%s is invalid.', 'fluent-cart'),
751 $titledKey
752 );
753 }
754
755 continue;
756 }
757
758 if ($rule === 'required' && empty($value)) {
759 if (!isset($errors[$key])) {
760 $errors[$prefixedKey] = [];
761 }
762 $errors[$prefixedKey]['required'] = sprintf(
763 /* translators: %s attribute name */
764 __('%s is required.', 'fluent-cart'),
765 $titledKey
766 );
767 }
768 }
769
770 foreach ($shippingValidations as $key => $rule) {
771 $value = Arr::get($shippingAddress, $key, '');
772 $prefixedKey = 'shipping_' . $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
788 continue;
789 }
790
791 if (!empty($value) && !Arr::has($countries, $value)) {
792 if (!isset($errors[$key])) {
793 $errors[$prefixedKey] = [];
794 }
795 $errors[$prefixedKey]['invalid'] = sprintf(
796 /* translators: %s attribute name */
797 __('%s is invalid.', 'fluent-cart'),
798 $titledKey
799 );
800 continue;
801 }
802
803 continue;
804 }
805
806 if ($key === 'state') {
807 $country = Arr::get($shippingAddress, 'country', '');
808
809 $states = LocalizationManager::getInstance()->statesOptions($country);
810
811 if (empty($states)) {
812 continue;
813 }
814
815 if ($rule === 'required' && empty($value)) {
816 if (!isset($errors[$key])) {
817 $errors[$prefixedKey] = [];
818 }
819 $errors[$prefixedKey]['required'] = sprintf(
820 /* translators: %s attribute name */
821 __('%s is required.', 'fluent-cart'),
822 $titledKey
823 );
824
825 continue;
826 }
827
828 if (!empty($value) && !in_array($value, array_column($states, 'value'))) {
829 if (!isset($errors[$key])) {
830 $errors[$prefixedKey] = [];
831 }
832 $errors[$prefixedKey]['invalid'] = sprintf(
833 /* translators: %s attribute name */
834 __('%s is invalid.', 'fluent-cart'),
835 $titledKey
836 );
837 }
838
839 continue;
840 }
841
842 if ($rule === 'required' && empty($value)) {
843 if (!isset($errors[$key])) {
844 $errors[$prefixedKey] = [];
845 }
846 $errors[$prefixedKey]['required'] = sprintf(
847 /* translators: %s attribute name */
848 __('%s is required.', 'fluent-cart'),
849 $titledKey
850 );
851 }
852 }
853
854 $basicInfoFields = (CheckoutFieldsSchema::getNameEmailFieldsSchema())['fields'];
855
856 foreach ($basicInfoFields as $field) {
857 $fieldName = (string)Arr::get($field, 'name', '');
858 $isRequired = Arr::get($field, 'required', 'no') === 'yes';
859 if ($fieldName && $isRequired) {
860 $value = Arr::get($data, $fieldName, '');
861 if (empty($value)) {
862 Arr::set($errors, $fieldName . '.required', sprintf(
863 /* translators: %s attribute name */
864 __('%s is required.', 'fluent-cart'),
865 Arr::get($field, 'aria-label')
866 ));
867 }
868 }
869 }
870
871 $isB2B = Arr::get($data, 'is_business', 'no') === 'yes' || CheckoutFieldsSchema::isB2BOnlyMode();
872
873 if ($isB2B && CheckoutFieldsSchema::isVatNumberRequired()) {
874 $vatNumber = Arr::get($data, 'fct_billing_tax_id', '');
875 if (empty($vatNumber)) {
876 $errors['fct_billing_tax_id']['required'] = __('VAT / Tax ID is required.', 'fluent-cart');
877 }
878 }
879
880 if ($isB2B && CheckoutFieldsSchema::isCompanyNameRequired()) {
881 $companyName = Arr::get($data, 'billing_company_name', '');
882 if (empty($companyName)) {
883 $errors['billing_company_name']['required'] = __('Company Name is required.', 'fluent-cart');
884 }
885 }
886
887 if ($isB2B && CheckoutFieldsSchema::isLegalRegistrationIdRequired()) {
888 $legalRegId = Arr::get($data, 'billing_legal_registration_id', '');
889 if (empty($legalRegId)) {
890 $errors['billing_legal_registration_id']['required'] = __('Legal Registration ID is required.', 'fluent-cart');
891 }
892 }
893
894 if (empty($data['agree_terms']) && $agreeTermsRequired) {
895 $errors['agree_terms']['required'] = __('You must agree to the terms and conditions.', 'fluent-cart');
896 }
897
898 if (empty($data['billing_email']) || !is_email($data['billing_email'])) {
899 $errors['billing_email']['invalid'] = __('Email must be a valid email address.', 'fluent-cart');
900 }
901
902 if (CheckoutFieldsSchema::isFullNameRequired() || !empty($data['billing_full_name'])) {
903 // Modal checkout always sends billing_full_name regardless of store name field settings
904 if (empty($data['billing_full_name'])) {
905 $errors['billing_full_name']['required'] = __('Full name is required.', 'fluent-cart');
906 }
907 } else {
908 if (empty($data['billing_first_name'])) {
909 $errors['billing_first_name']['required'] = __('First name is required.', 'fluent-cart');
910 }
911
912 if(CheckoutFieldsSchema::isLastNameRequired()) {
913 if (empty($data['billing_last_name'])) {
914 $errors['billing_last_name']['required'] = __('Last name is required.', 'fluent-cart');
915 }
916 }
917 }
918
919
920 if ($cart->requireShipping()) {
921 if (!empty($data['fc_selected_shipping_method'])) {
922 $selectedMethod = $data['fc_selected_shipping_method'];
923 $shippingCountry = Arr::get($data, 'billing_country', '');
924 $shippingState = Arr::get($data, 'billing_state', '');
925 $shipToDifferent = Arr::get($data, 'ship_to_different', 'no') === 'yes';
926
927 if ($shipToDifferent) {
928 $shippingCountry = Arr::get($data, 'shipping_country', '');
929 $shippingState = Arr::get($data, 'shipping_state', '');
930 }
931
932 $availableShippingMethods = AddressHelper::getShippingMethods($shippingCountry, $shippingState);
933
934
935 if (empty($availableShippingMethods) || is_wp_error($availableShippingMethods)) {
936 $errors['shipping_method']['unavailable'] = __('We don\'t ship to this address. Please select a different address.', 'fluent-cart');
937 } else {
938 $found = false;
939 foreach ($availableShippingMethods as $shippingMethod) {
940 if ($shippingMethod->id == $selectedMethod) {
941 $found = true;
942 break;
943 }
944 }
945
946 if (!$found) {
947 $errors['shipping_method']['invalid'] = __('The selected shipping method is not available.', 'fluent-cart');
948 }
949 }
950
951
952 } else {
953 $errors['shipping_method']['required'] = __('You must select a shipping method.', 'fluent-cart');
954 }
955 }
956
957 $errors = apply_filters('fluent_cart/checkout/validate_data', $errors, [
958 'data' => $data,
959 'cart' => $cart
960 ]);
961
962 if (count($errors) > 0) {
963 return new \Wp_Error('validation_error', 'Validation error', $errors);
964 }
965
966 return $data;
967 }
968
969 public static function validateShippingMethod(array $data, CheckoutService $cartCheckoutService): bool
970 {
971
972 if ($cartCheckoutService->isAllDigital()) {
973 return true;
974 }
975
976 $shipping_country = Arr::get($data, 'shipping_country');
977 $shipping_state = Arr::get($data, 'shipping_state');
978
979
980 $methods = ShippingMethod::getApplicableForCountry($shipping_country, $shipping_state)->keyBy('id');
981 if ($methods->count() === 0 || \FluentCart\App\App::isDevMode()) {
982 return true;
983 }
984
985 $shipping_method = Arr::get($data, 'fc_selected_shipping_method');
986
987 if (empty($shipping_method)) {
988 return false;
989 }
990
991
992 $exist = $methods->has($shipping_method);
993 if (!$exist) {
994 return false;
995 }
996
997 return true;
998 }
999
1000
1001 public static function messages(): array
1002 {
1003 return [
1004 'billing_full_name.required' => esc_html__('Full name field is required.', 'fluent-cart'),
1005 'billing_email.required' => esc_html__('Email field is required.', 'fluent-cart'),
1006 'billing_email.email' => esc_html__('Email must be a valid email address.', 'fluent-cart'),
1007 'billing_address.required' => esc_html__('Address field is required.', 'fluent-cart'),
1008 'billing_country.required' => esc_html__('Country field is required.', 'fluent-cart'),
1009 'billing_address_1.required' => esc_html__('Street Address field is required.', 'fluent-cart'),
1010 'billing_city.required' => esc_html__('City field is required.', 'fluent-cart'),
1011 'billing_postcode.required' => esc_html__('Postcode field is required.', 'fluent-cart'),
1012 'shipping_full_name.required' => esc_html__('Full name field is required.', 'fluent-cart'),
1013 // 'shipping_email.required' => esc_html__('Email field is required.', 'fluent-cart'),
1014 // 'shipping_email.email' => esc_html__('Email must be a valid email address.', 'fluent-cart'),
1015 'shipping_address.required' => esc_html__('Address field is required.', 'fluent-cart'),
1016 'shipping_city.required' => esc_html__('City field is required.', 'fluent-cart'),
1017 'shipping_postcode.required' => esc_html__('Postcode field is required.', 'fluent-cart'),
1018 ];
1019 }
1020
1021 private static function generateAddressRules($type, $data, $baseRules, $fieldGetter): array
1022 {
1023 $hasAddress = static::hasCustomerWithAddress($type);
1024 $rules = App::localization()->getValidationRule($data, $type);
1025
1026 if ($hasAddress) {
1027 $baseRules["{$type}_address"] = 'required|numeric';
1028 return $baseRules;
1029 }
1030
1031 $cartCheckoutHelper = CartCheckoutHelper::make();
1032 $fields = $cartCheckoutHelper->{$fieldGetter}();
1033 $addressFields = Arr::get($fields, 'address_section.schema', []);
1034 $addressFields = Arr::wrap($addressFields);
1035
1036 $validFields = array_keys($addressFields);
1037
1038 // Add prefix to each field (billing_ or shipping_)
1039 $validFields = array_map(function ($field) use ($type) {
1040 return $type . '_' . $field;
1041 }, $validFields);
1042
1043 // Filter only relevant rules
1044 $filteredRules = array_filter($rules, function ($key) use ($validFields) {
1045 return in_array($key, $validFields);
1046 }, ARRAY_FILTER_USE_KEY);
1047
1048 return $baseRules + $filteredRules;
1049 }
1050 }
1051