PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.8.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.8.0
1.8.0 1.7.1 1.7.0 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 All 52 releases
fluent-cart / api / Checkout / CheckoutApi.php

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

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