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

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