PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.1
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.1
1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 All 49 releases
fluent-cart / api / Checkout / CheckoutApi.php

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

1,139 lines 45.5 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 // Transition subscription from pending → intended before submitting to the gateway
464 if ($paymentInstance->subscription && $paymentInstance->subscription->status === Status::SUBSCRIPTION_PENDING) {
465 $paymentInstance->subscription->status = Status::SUBSCRIPTION_INTENDED;
466 $paymentInstance->subscription->save();
467 }
468
469 $data = $gateway->makePaymentFromPaymentInstance($paymentInstance);
470
471 if (is_wp_error($data)) {
472 // Server-observed create failure: mark the transaction FAILED so the next
473 // resubmit is a RETRY (payment_attempt bump -> fresh idempotency seed) —
474 // gateways cache error responses under the key, so keeping it pending would
475 // replay the same error on every resubmit. Client-side declines stay pending
476 // on purpose: there the same key resolving to the same gateway object IS the
477 // retry path.
478 if ($paymentInstance->transaction && $paymentInstance->transaction->status === Status::PAYMENT_PENDING) {
479 $paymentInstance->transaction->update(['status' => Status::PAYMENT_FAILED]);
480 }
481
482 wp_send_json([
483 'status' => 'failed',
484 'message' => $data->get_error_message(),
485 'data' => $data->get_error_data()
486 ], 422);
487 }
488
489 wp_send_json($data, 200);
490 }
491
492 /**
493 * Serialize checkout submissions per cart with a MySQL named lock.
494 *
495 * Keyed on cart_hash (not order id) so concurrent FIRST submissions — no draft
496 * order yet — contend on the same lock. Release goes through a shutdown function,
497 * not try/finally: wp_send_json() exits via die() (skips finally), and persistent
498 * DB connections don't drop the lock on request end.
499 */
500 private static function acquireCartLock($cartHash)
501 {
502 global $wpdb;
503
504 // md5 keeps the name inside MySQL's 64-char lock-name limit regardless of
505 // table-prefix length; the prefix scopes the lock per site on multisite.
506 $lockName = 'fct_checkout_' . md5($wpdb->prefix . $cartHash);
507
508 $lockAcquired = (string) $wpdb->get_var(
509 $wpdb->prepare('SELECT GET_LOCK(%s, %d)', $lockName, 10)
510 ) === '1';
511
512 if (!$lockAcquired) {
513 wp_send_json([
514 'status' => 'failed',
515 'message' => __('This order is already being processed. Please wait a moment — do not refresh or resubmit.', 'fluent-cart'),
516 'data' => []
517 ], 429);
518 }
519
520 register_shutdown_function(function () use ($lockName) {
521 global $wpdb;
522 $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lockName));
523 });
524 }
525
526 private static function syncCustomerNames($order, $args)
527 {
528 $customer = $order->customer;
529
530 if (empty($customer)) {
531 return;
532 }
533
534 $firstName = Arr::get($args, 'billing_address.first_name');
535 $lastName = Arr::get($args, 'billing_address.last_name');
536
537 $customer->update([
538 'first_name' => $firstName,
539 'last_name' => $lastName,
540 ]);
541
542 $user = get_user_by('email', $customer->email);
543
544 if (empty($user)) {
545 return;
546 }
547
548 if (is_user_logged_in() && $user->ID === get_current_user_id()) {
549 update_user_meta($user->ID, 'first_name', $firstName);
550 update_user_meta($user->ID, 'last_name', $lastName);
551 }
552 }
553
554 public static function updateStock($order)
555 {
556 // pluck product ids to update product default variation as stock has changed
557 $productIds = OrderService::pluckProductIds($order);
558 if (!empty($productIds)) {
559 (new StockChanged($productIds))->dispatch();
560 }
561 }
562
563 public static function createCustomerWithAddress($customer, $orderData, $billingAddress, $shippingAddress)
564 {
565 if (empty($customer)) {
566 $customer = static::createNewCustomer($orderData, $billingAddress, $shippingAddress);
567 } else if ($customer instanceof Customer) {
568 static::updateExistingCustomer($customer, $orderData, $billingAddress, $shippingAddress);
569 }
570
571 return $customer;
572 }
573
574 private static function createNewCustomer($orderData, &$billingAddress, $shippingAddress)
575 {
576 global $current_user;
577 if ($current_user->ID) {
578 $billingAddress['email'] = $current_user->user_email;
579 $billingAddress['user_id'] = $current_user->ID;
580 } else {
581 static::handleUserCreation($orderData, $billingAddress);
582 }
583
584 $customer = CustomerResource::create($billingAddress);
585 $customer = Arr::get($customer, 'data', null);
586 $customerId = Arr::get($customer, 'id', null);
587 static::createCustomerAddress($billingAddress, $customerId);
588 static::createCustomerAddress($shippingAddress, $customerId);
589
590 return $customer;
591 }
592
593 private static function updateExistingCustomer($customer, $orderData, $billingAddress, $shippingAddress)
594 {
595 if (empty($customer->user_id)) {
596 $currentLoggedInUser = wp_get_current_user();
597 if ($currentLoggedInUser && $currentLoggedInUser->user_email === $customer->email) {
598 $userId = get_current_user_id();
599 $customer->update(['user_id' => $userId]);
600 $billingAddress['user_id'] = $userId;
601 }
602 }
603
604 $customer->load(['billing_address', 'shipping_address']);
605
606 if ($customer->billing_address->count() < 1) {
607 static::createCustomerAddress($billingAddress, $customer->id);
608 }
609 if ($customer->shipping_address->count() < 1) {
610 static::createCustomerAddress($shippingAddress, $customer->id);
611 }
612
613 static::handleUserCreation($orderData, $billingAddress, $customer);
614 }
615
616 private static function handleUserCreation($orderData, &$billingAddress, $customer = null)
617 {
618 $userEmail = Arr::get($billingAddress, 'email');
619 $user = get_user_by('email', $userEmail);
620
621 if ($user) {
622 $billingAddress['user_id'] = $user->ID;
623 if ($customer) {
624 $customer->update(['user_id' => $user->ID]);
625 }
626 }
627 }
628
629 private static function getCustomerEmail($billingAddress)
630 {
631 return is_user_logged_in() ? wp_get_current_user()->user_email : $billingAddress['email'];
632 }
633
634 public static function shouldCreateUser($data, $billingAddress): bool
635 {
636 $accountCreationMod = (new StoreSettings())->get('user_account_creation_mode');
637 $hasSubscription = (CartCheckoutHelper::make())->hasSubscription() === 'yes';
638 if ($accountCreationMod === 'all' || $hasSubscription) {
639 return true;
640 }
641
642 if (is_user_logged_in()) {
643 return false;
644 }
645
646 $allow_create_account = Arr::get($data, 'others.allow_create_account') === 'yes';
647
648 $userEmail = Arr::get($billingAddress, 'email');
649 $user = get_user_by('email', $userEmail);
650
651 return ($allow_create_account && $accountCreationMod === 'user_choice') || !empty($user);
652 }
653
654 private static function createCustomerAddress(array $address, $customerId)
655 {
656 CustomerAddressResource::create($address, ['id' => $customerId]);
657 }
658
659 /**
660 * Check if a customer is logged in and has the given address type.
661 *
662 * @param string $type Address type to check ('billing' or 'shipping').
663 * @return bool True if the customer is logged in and the address is available.
664 */
665 private static function hasCustomerWithAddress(string $type): bool
666 {
667 $addressType = $type . '_address';
668 $currentCustomer = \FluentCart\Api\Resource\CustomerResource::getCurrentCustomer();
669
670 return !empty($currentCustomer) && $currentCustomer->$addressType->count() === 1;
671 }
672
673
674 /**
675 * Validate the data against the provided rules.
676 * @return array
677 */
678
679 public static function billingRules($data = []): array
680 {
681 $baseRules = [
682 'billing_full_name' => 'required|sanitizeText|maxLength:255',
683 'billing_email' => 'required|sanitizeText|email|maxLength:255',
684 'order_notes' => 'nullable|sanitizeTextArea|maxLength:200',
685 ];
686
687 return static::generateAddressRules('billing', $data, $baseRules, 'getBillingAddressFields');
688 }
689
690 public static function shippingIdRules(): array
691 {
692 return [
693 'shipping_address' => 'exists:fct_customer_addresses,id',
694 ];
695 }
696
697 public static function shippingRules($data = []): array
698 {
699 $baseRules = [
700 'shipping_full_name' => 'required|sanitizeText|maxLength:255'
701 ];
702
703 return static::generateAddressRules('shipping', $data, $baseRules, 'getShippingAddressFields');
704
705 }
706
707 /**
708 * Validate the data against the provided rules.
709 *
710 * @return false|\WP_Error|string
711 */
712
713 public static function validateData($data, Cart $cart, CheckoutService $cartCheckoutService, $prevOrder)
714 {
715 $shippingRequired = $cart->requireShipping();
716 $isDifferentShipping = $shippingRequired && Arr::get($data, 'ship_to_different', 'no') === 'yes';
717 $fulfillmentType = $shippingRequired ? 'physical' : 'digital';
718
719 $billingValidations = array_filter(CheckoutFieldsSchema::getCheckoutFieldsRequirements('billing', $fulfillmentType, !$isDifferentShipping));
720
721 // Name fields are validated separately below (full_name/first_name/last_name)
722 // vat_number uses field name fct_billing_tax_id and is validated separately in the B2B block below
723 unset($billingValidations['full_name'], $billingValidations['first_name'], $billingValidations['last_name'], $billingValidations['vat_number']);
724
725 if (!isset($billingValidations['country']) && empty($data['billing_country'])) {
726 $data['billing_country'] = (new StoreSettings())->get('store_country');
727 }
728
729 $billingAddress = [];
730 foreach ($billingValidations as $key => $billingValidation) {
731 $billingAddress[$key] = Arr::get($data, 'billing_' . $key, '');
732 }
733 if (!isset($billingAddress['country'])) {
734 $billingAddress['country'] = !empty($data['billing_country'])
735 ? $data['billing_country']
736 : (new StoreSettings())->get('store_country');
737 }
738
739 $shippingAddress = [];
740 $shippingValidations = [];
741 if ($isDifferentShipping) {
742 $shippingValidations = array_filter(CheckoutFieldsSchema::getCheckoutFieldsRequirements('shipping', 'physical'));
743 // Name fields are validated via billing basic_info, not shipping address
744 unset($shippingValidations['full_name'], $shippingValidations['first_name'], $shippingValidations['last_name'], $shippingValidations['company_name']);
745 foreach ($shippingValidations as $key => $shippingValidation) {
746 $shippingAddress[$key] = Arr::get($data, 'shipping_' . $key, '');
747 }
748 }
749
750 if (Arr::get($data, 'ship_to_different', 'no') === 'yes') {
751 if (!isset($data['shipping_country'])) {
752 // get store country
753 $data['shipping_country'] = (new StoreSettings())->get('store_country');
754 }
755 if (!isset($shippingAddress['country'])) {
756 // get store country
757 $shippingAddress['country'] = (new StoreSettings())->get('store_country');
758 }
759
760 }
761
762 $errors = [];
763
764 $agreeTermsRequired = CheckoutFieldsSchema::isTermsRequired();
765
766 $customTitles = [
767 'address_1' => __('Street Address', 'fluent-cart'),
768 'address_2' => __('Apt, Suite, Unit', 'fluent-cart'),
769 'country' => __('Country', 'fluent-cart'),
770 'state' => __('State', 'fluent-cart'),
771 'city' => __('City', 'fluent-cart'),
772 'postcode' => __('Postcode', 'fluent-cart'),
773 'phone' => __('Phone', 'fluent-cart'),
774 ];
775
776 foreach ($billingValidations as $key => $rule) {
777 $value = Arr::get($billingAddress, $key, '');
778 $prefixedKey = 'billing_' . $key;
779 $titledKey = $customTitles[$key] ?? Str::headline($key);
780
781 if ($key === 'country') {
782 $countries = LocalizationManager::getInstance()->countries();
783
784 if ($rule === 'required' && empty($value)) {
785 if (!isset($errors[$key])) {
786 $errors[$prefixedKey] = [];
787 }
788 $errors[$prefixedKey]['required'] = sprintf(
789 /* translators: %s attribute name */
790 __('%s is required.', 'fluent-cart'),
791 $titledKey
792 );
793 continue;
794 }
795
796 if (!empty($value) && !Arr::has($countries, $value)) {
797 if (!isset($errors[$key])) {
798 $errors[$prefixedKey] = [];
799 }
800 $errors[$prefixedKey]['invalid'] = sprintf(
801 /* translators: %s attribute name */
802 __('%s is invalid.', 'fluent-cart'),
803 $titledKey
804 );
805 continue;
806 }
807
808 continue;
809 }
810
811 if ($key === 'state') {
812 $country = Arr::get($billingAddress, 'country', '');
813
814 $states = LocalizationManager::getInstance()->statesOptions($country);
815
816 if (empty($states)) {
817 continue;
818 }
819
820 if ($rule === 'required' && empty($value)) {
821 if (!isset($errors[$key])) {
822 $errors[$prefixedKey] = [];
823 }
824 $errors[$prefixedKey]['required'] = sprintf(
825 /* translators: %s attribute name */
826 __('%s is required.', 'fluent-cart'),
827 $titledKey
828 );
829 continue;
830 }
831
832 if (!empty($value) && !in_array($value, array_column($states, 'value'))) {
833 if (!isset($errors[$key])) {
834 $errors[$prefixedKey] = [];
835 }
836 $errors[$prefixedKey]['invalid'] = sprintf(
837 /* translators: %s attribute name */
838 __('%s is invalid.', 'fluent-cart'),
839 $titledKey
840 );
841 }
842
843 continue;
844 }
845
846 if ($rule === 'required' && empty($value)) {
847 if (!isset($errors[$key])) {
848 $errors[$prefixedKey] = [];
849 }
850 $errors[$prefixedKey]['required'] = sprintf(
851 /* translators: %s attribute name */
852 __('%s is required.', 'fluent-cart'),
853 $titledKey
854 );
855 }
856 }
857
858 foreach ($shippingValidations as $key => $rule) {
859 $value = Arr::get($shippingAddress, $key, '');
860 $prefixedKey = 'shipping_' . $key;
861 $titledKey = $customTitles[$key] ?? Str::headline($key);
862
863 if ($key === 'country') {
864 $countries = LocalizationManager::getInstance()->countries();
865
866 if ($rule === 'required' && empty($value)) {
867 if (!isset($errors[$key])) {
868 $errors[$prefixedKey] = [];
869 }
870 $errors[$prefixedKey]['required'] = sprintf(
871 /* translators: %s attribute name */
872 __('%s is required.', 'fluent-cart'),
873 $titledKey
874 );
875
876 continue;
877 }
878
879 if (!empty($value) && !Arr::has($countries, $value)) {
880 if (!isset($errors[$key])) {
881 $errors[$prefixedKey] = [];
882 }
883 $errors[$prefixedKey]['invalid'] = sprintf(
884 /* translators: %s attribute name */
885 __('%s is invalid.', 'fluent-cart'),
886 $titledKey
887 );
888 continue;
889 }
890
891 continue;
892 }
893
894 if ($key === 'state') {
895 $country = Arr::get($shippingAddress, 'country', '');
896
897 $states = LocalizationManager::getInstance()->statesOptions($country);
898
899 if (empty($states)) {
900 continue;
901 }
902
903 if ($rule === 'required' && empty($value)) {
904 if (!isset($errors[$key])) {
905 $errors[$prefixedKey] = [];
906 }
907 $errors[$prefixedKey]['required'] = sprintf(
908 /* translators: %s attribute name */
909 __('%s is required.', 'fluent-cart'),
910 $titledKey
911 );
912
913 continue;
914 }
915
916 if (!empty($value) && !in_array($value, array_column($states, 'value'))) {
917 if (!isset($errors[$key])) {
918 $errors[$prefixedKey] = [];
919 }
920 $errors[$prefixedKey]['invalid'] = sprintf(
921 /* translators: %s attribute name */
922 __('%s is invalid.', 'fluent-cart'),
923 $titledKey
924 );
925 }
926
927 continue;
928 }
929
930 if ($rule === 'required' && empty($value)) {
931 if (!isset($errors[$key])) {
932 $errors[$prefixedKey] = [];
933 }
934 $errors[$prefixedKey]['required'] = sprintf(
935 /* translators: %s attribute name */
936 __('%s is required.', 'fluent-cart'),
937 $titledKey
938 );
939 }
940 }
941
942 $basicInfoFields = (CheckoutFieldsSchema::getNameEmailFieldsSchema())['fields'];
943
944 foreach ($basicInfoFields as $field) {
945 $fieldName = (string)Arr::get($field, 'name', '');
946 $isRequired = Arr::get($field, 'required', 'no') === 'yes';
947 if ($fieldName && $isRequired) {
948 $value = Arr::get($data, $fieldName, '');
949 if (empty($value)) {
950 Arr::set($errors, $fieldName . '.required', sprintf(
951 /* translators: %s attribute name */
952 __('%s is required.', 'fluent-cart'),
953 Arr::get($field, 'aria-label')
954 ));
955 }
956 }
957 }
958
959 $isB2B = Arr::get($data, 'is_business', 'no') === 'yes' || CheckoutFieldsSchema::isB2BOnlyMode();
960
961 if ($isB2B && CheckoutFieldsSchema::isVatNumberRequired()) {
962 $vatNumber = Arr::get($data, 'fct_billing_tax_id', '');
963 if (empty($vatNumber)) {
964 $errors['fct_billing_tax_id']['required'] = __('VAT / Tax ID is required.', 'fluent-cart');
965 }
966 }
967
968 if ($isB2B && CheckoutFieldsSchema::isCompanyNameRequired()) {
969 $companyName = Arr::get($data, 'billing_company_name', '');
970 if (empty($companyName)) {
971 $errors['billing_company_name']['required'] = __('Company Name is required.', 'fluent-cart');
972 }
973 }
974
975 if ($isB2B && CheckoutFieldsSchema::isLegalRegistrationIdRequired()) {
976 $legalRegId = Arr::get($data, 'billing_legal_registration_id', '');
977 if (empty($legalRegId)) {
978 $errors['billing_legal_registration_id']['required'] = __('Legal Registration ID is required.', 'fluent-cart');
979 }
980 }
981
982 if (empty($data['agree_terms']) && $agreeTermsRequired) {
983 $errors['agree_terms']['required'] = __('You must agree to the terms and conditions.', 'fluent-cart');
984 }
985
986 if (empty($data['billing_email']) || !is_email($data['billing_email'])) {
987 $errors['billing_email']['invalid'] = __('Email must be a valid email address.', 'fluent-cart');
988 }
989
990 if (CheckoutFieldsSchema::isFullNameRequired() || !empty($data['billing_full_name'])) {
991 // Modal checkout always sends billing_full_name regardless of store name field settings
992 if (empty($data['billing_full_name'])) {
993 $errors['billing_full_name']['required'] = __('Full name is required.', 'fluent-cart');
994 }
995 } else {
996 if (empty($data['billing_first_name'])) {
997 $errors['billing_first_name']['required'] = __('First name is required.', 'fluent-cart');
998 }
999
1000 if(CheckoutFieldsSchema::isLastNameRequired()) {
1001 if (empty($data['billing_last_name'])) {
1002 $errors['billing_last_name']['required'] = __('Last name is required.', 'fluent-cart');
1003 }
1004 }
1005 }
1006
1007
1008 if ($cart->requireShipping()) {
1009 if (!empty($data['fc_selected_shipping_method'])) {
1010 $selectedMethod = $data['fc_selected_shipping_method'];
1011 $shippingCountry = Arr::get($data, 'billing_country', '');
1012 $shippingState = Arr::get($data, 'billing_state', '');
1013 $shipToDifferent = Arr::get($data, 'ship_to_different', 'no') === 'yes';
1014
1015 if ($shipToDifferent) {
1016 $shippingCountry = Arr::get($data, 'shipping_country', '');
1017 $shippingState = Arr::get($data, 'shipping_state', '');
1018 }
1019
1020 $availableShippingMethods = AddressHelper::getShippingMethods($shippingCountry, $shippingState);
1021
1022
1023 if (empty($availableShippingMethods) || is_wp_error($availableShippingMethods)) {
1024 $errors['shipping_method']['unavailable'] = __('We don\'t ship to this address. Please select a different address.', 'fluent-cart');
1025 } else {
1026 $found = false;
1027 foreach ($availableShippingMethods as $shippingMethod) {
1028 if ($shippingMethod->id == $selectedMethod) {
1029 $found = true;
1030 break;
1031 }
1032 }
1033
1034 if (!$found) {
1035 $errors['shipping_method']['invalid'] = __('The selected shipping method is not available.', 'fluent-cart');
1036 }
1037 }
1038
1039
1040 } else {
1041 $errors['shipping_method']['required'] = __('You must select a shipping method.', 'fluent-cart');
1042 }
1043 }
1044
1045 $errors = apply_filters('fluent_cart/checkout/validate_data', $errors, [
1046 'data' => $data,
1047 'cart' => $cart
1048 ]);
1049
1050 if (count($errors) > 0) {
1051 return new \Wp_Error('validation_error', __('Validation error', 'fluent-cart'), $errors);
1052 }
1053
1054 return $data;
1055 }
1056
1057 public static function validateShippingMethod(array $data, CheckoutService $cartCheckoutService): bool
1058 {
1059
1060 if ($cartCheckoutService->isAllDigital()) {
1061 return true;
1062 }
1063
1064 $shipping_country = Arr::get($data, 'shipping_country');
1065 $shipping_state = Arr::get($data, 'shipping_state');
1066
1067
1068 $methods = ShippingMethod::getApplicableForCountry($shipping_country, $shipping_state)->keyBy('id');
1069 if ($methods->count() === 0 || \FluentCart\App\App::isDevMode()) {
1070 return true;
1071 }
1072
1073 $shipping_method = Arr::get($data, 'fc_selected_shipping_method');
1074
1075 if (empty($shipping_method)) {
1076 return false;
1077 }
1078
1079
1080 $exist = $methods->has($shipping_method);
1081 if (!$exist) {
1082 return false;
1083 }
1084
1085 return true;
1086 }
1087
1088
1089 public static function messages(): array
1090 {
1091 return [
1092 'billing_full_name.required' => esc_html__('Full name field is required.', 'fluent-cart'),
1093 'billing_email.required' => esc_html__('Email field is required.', 'fluent-cart'),
1094 'billing_email.email' => esc_html__('Email must be a valid email address.', 'fluent-cart'),
1095 'billing_address.required' => esc_html__('Address field is required.', 'fluent-cart'),
1096 'billing_country.required' => esc_html__('Country field is required.', 'fluent-cart'),
1097 'billing_address_1.required' => esc_html__('Street Address field is required.', 'fluent-cart'),
1098 'billing_city.required' => esc_html__('City field is required.', 'fluent-cart'),
1099 'billing_postcode.required' => esc_html__('Postcode field is required.', 'fluent-cart'),
1100 'shipping_full_name.required' => esc_html__('Full name field is required.', 'fluent-cart'),
1101 // 'shipping_email.required' => esc_html__('Email field is required.', 'fluent-cart'),
1102 // 'shipping_email.email' => esc_html__('Email must be a valid email address.', 'fluent-cart'),
1103 'shipping_address.required' => esc_html__('Address field is required.', 'fluent-cart'),
1104 'shipping_city.required' => esc_html__('City field is required.', 'fluent-cart'),
1105 'shipping_postcode.required' => esc_html__('Postcode field is required.', 'fluent-cart'),
1106 ];
1107 }
1108
1109 private static function generateAddressRules($type, $data, $baseRules, $fieldGetter): array
1110 {
1111 $hasAddress = static::hasCustomerWithAddress($type);
1112 $rules = App::localization()->getValidationRule($data, $type);
1113
1114 if ($hasAddress) {
1115 $baseRules["{$type}_address"] = 'required|numeric';
1116 return $baseRules;
1117 }
1118
1119 $cartCheckoutHelper = CartCheckoutHelper::make();
1120 $fields = $cartCheckoutHelper->{$fieldGetter}();
1121 $addressFields = Arr::get($fields, 'address_section.schema', []);
1122 $addressFields = Arr::wrap($addressFields);
1123
1124 $validFields = array_keys($addressFields);
1125
1126 // Add prefix to each field (billing_ or shipping_)
1127 $validFields = array_map(function ($field) use ($type) {
1128 return $type . '_' . $field;
1129 }, $validFields);
1130
1131 // Filter only relevant rules
1132 $filteredRules = array_filter($rules, function ($key) use ($validFields) {
1133 return in_array($key, $validFields);
1134 }, ARRAY_FILTER_USE_KEY);
1135
1136 return $baseRules + $filteredRules;
1137 }
1138 }
1139