PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.4
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 1.2.0 All 47 releases
fluent-cart / api / Checkout / CheckoutApi.php

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

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