# fluent-cart/1.6.4/api/Checkout/CheckoutApi.php

FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler, version 1.6.4. 1,136 lines.

- Page: https://pluginprobe.com/plugins/fluent-cart/1.6.4/code/api/Checkout/CheckoutApi.php
- Raw: https://pluginprobe.com/plugins/fluent-cart/1.6.4/raw/api/Checkout/CheckoutApi.php
- Modified: 2026-08-20T12:34:14+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/fluent-cart/1.6.4/code/api/Checkout/CheckoutApi.php#L10-L20`.

```php
<?php

namespace FluentCart\Api\Checkout;

use FluentCart\Api\Resource\CustomerResource as ApiCustomerResource;
use FluentCart\Api\Resource\FrontendResource\CustomerAddressResource;
use FluentCart\Api\Resource\FrontendResource\CustomerResource;
use FluentCart\Api\StoreSettings;
use FluentCart\App\App;
use FluentCart\App\Events\Order\OrderCreated;
use FluentCart\App\Events\StockChanged;
use FluentCart\App\Helpers\AddressHelper;
use FluentCart\App\Helpers\CartCheckoutHelper;
use FluentCart\App\Helpers\CartHelper;
use FluentCart\App\Helpers\CheckoutProcessor;
use FluentCart\App\Helpers\Status;
use FluentCart\App\Helpers\UtmHelper;
use FluentCart\App\Models\Cart;
use FluentCart\App\Models\Customer;
use FluentCart\App\Models\CustomerAddresses;
use FluentCart\App\Models\Order;
use FluentCart\App\Models\OrderAddress;
use FluentCart\App\Models\ShippingMethod;
use FluentCart\App\Services\CheckoutService;
use FluentCart\App\Services\Localization\LocalizationManager;
use FluentCart\App\Services\OrderService;
use FluentCart\App\Services\Payments\PaymentHelper;
use FluentCart\App\Services\Payments\PaymentInstance;
use FluentCart\App\Services\Renderer\CheckoutFieldsSchema;
use FluentCart\Framework\Http\Response;
use FluentCart\App\Services\RateLimiter;
use FluentCart\Framework\Support\Arr;
use FluentCart\Framework\Support\Str;
use FluentCart\Framework\Validator\Validator;

class CheckoutApi
{
    /**
     * @throws \Exception
     */

    public static function placeOrder(array $data, $fromCheckout = false)
    {

        RateLimiter::isSpamming('place_order_attempt', 5, 60, true);

        $userTz = Arr::get($data, 'user_tz', 'UTC');

        $cart = CartHelper::getCart();

        if (!$cart || !$cart->cart_data || $cart->stage === 'completed') {
            wp_send_json([
                'status'  => 'failed',
                'message' => __('Cart is empty or already completed', 'fluent-cart'),
            ]);
        }

        // Serialize all submissions of one cart BEFORE the prevOrder read: locking later
        // (or per-order) lets two concurrent first submissions both see prevOrder = null
        // and create two orders -> two idempotency keys -> double charge.
        static::acquireCartLock($cart->cart_hash);

        // Re-read under the lock (fresh(), not getCart() — that one is request-cached):
        // a submission we waited on may have completed this cart meanwhile.
        $cart = $cart->fresh();
        if (!$cart || !$cart->cart_data || $cart->stage === 'completed') {
            wp_send_json([
                'status'  => 'failed',
                'message' => __('Cart is empty or already completed', 'fluent-cart'),
            ]);
        }

        $cart = $cart->reValidateCoupons();

        $cartData = $cart->cart_data;
        $prevOrder = $cart->order;
        if ($prevOrder) {
            $prevOrder->load('order_items');
        }
        $isLockedCart = $cart->isLocked();

        if ($prevOrder &&
            (
                in_array($prevOrder->status, Status::getOrderSuccessStatuses()) ||
                !in_array($prevOrder->payment_status, Status::getPaymentRetryableStatuses())
            )
        ) {
            if ($isLockedCart) {
                // Locked carts are bound to a specific order (e.g. pay-for-order links),
                // so a finalized order really means there is nothing left to pay.
                wp_send_json([
                    'status'  => 'failed',
                    'message' => __('You have already completed this order.', 'fluent-cart'),
                ]);
            }

            // The linked order is already finalized but the cart was never marked
            // completed (e.g. a stale cart resurrected by the logged-in user lookup).
            // Detach the dead order so the customer can check out again instead of
            // being blocked on every future purchase.
            $cart->order_id = null;
            $checkoutData = $cart->checkout_data;
            unset($checkoutData['is_locked']);
            $cart->checkout_data = $checkoutData;
            $cart->save();
            $prevOrder = null;
            $isLockedCart = false;
        }

        $data = static::addLoggedUserData($data);


        // Validate using filter hook (allows addons/modules to validate)
        $validation = apply_filters('fluent_cart/checkout/validate_before_process', true, $data);

        if (is_wp_error($validation)) {
            wp_send_json([
                'status'  => 'failed',
                'message' => $validation->get_error_message(),
            ], 403);
        }

        if (empty($data['billing_address_id'])) {
            if ($prevOrder instanceof Order) {
                $oldCustomer = $prevOrder->customer;
                if ($oldCustomer) {
                    $fallbackAddress = $oldCustomer->billing_address;
                    if ($fallbackAddress) {
                        $data['billing_address_id'] = $fallbackAddress->first()->id;
                    }
                }
            }
        } else if ($prevOrder) {
            $data['order_id'] = $prevOrder->id;
        }

        $data = static::prepareAddressData($data);

        $cartCheckoutService = new CheckoutService($cartData);
        $validatedData = static::validateData($data, $cart, $cartCheckoutService, $prevOrder);




        if (is_wp_error($validatedData)) {
            wp_send_json([
                'status' => 'failed',
                'errors' => $validatedData->get_error_data(),
            ]);
        }

        if (!CheckoutFieldsSchema::isFullNameRequired()) {
            if (!empty($validatedData['billing_full_name']) && empty($validatedData['billing_first_name'])) {
                // Modal checkout sends billing_full_name — split into first/last name
                $nameParts = explode(' ', $validatedData['billing_full_name'], 2);
                $validatedData['billing_first_name'] = $nameParts[0];
                $validatedData['billing_last_name'] = $nameParts[1] ?? '';
            } else {
                $validatedData['billing_full_name'] = trim(
                    Arr::get($validatedData, 'billing_first_name') . ' ' . Arr::get($validatedData, 'billing_last_name')
                );
            }
        }

        $orderData = OrderService::groupSanitizedData($validatedData);

        $shippingMethodId = Arr::get($orderData, 'others.fc_shipping_method');

        $shippingMethod = null;
        $shippingCharge = 0;
        if (!$cartCheckoutService->isAllDigital()) {
            $shippingMethod = ShippingMethod::query()->find($shippingMethodId);
            if (!empty($shippingMethod)) {
                $shippingCharge = CartHelper::calculateShippingMethodCharge($shippingMethod, $cartData);
            }
        }

        Arr::set($orderData, 'others.shipping_total', $shippingCharge);

        $cartCheckoutHelper = CartCheckoutHelper::make();

        try {
            OrderService::validateProducts($cartCheckoutHelper->getItems(), $prevOrder);
        } catch (\Exception $e) {
            wp_send_json([
                'message' => $e->getMessage(),
            ], 422);
        }

        $paymentMethod = PaymentHelper::validateAndGetPayMethod($cartCheckoutHelper, $orderData, $shippingCharge);

        Arr::set($orderData, 'others.payment_method', $paymentMethod);

        $orderData['user_tz'] = $userTz;

        $customer = static::getOrCreateCustomer($cartCheckoutHelper, $orderData);

        $shouldCreateUser = static::shouldCreateUser($orderData, Arr::get($orderData, 'billing_address', []));

        // Ensure cart has the current payment method before recalculating fees
        $checkoutData = $cart->checkout_data ?? [];
        $checkoutData['payment_method'] = $paymentMethod;
        $cart->checkout_data = $checkoutData;

        $cart->clearFeeCache();
        $fees = $cart->getFees();

        // Recompute cart tax data so fee_tax/fee_tax_lines reflect fees for the current
        // payment method. The scope prevents ShippingModule from running unnecessarily.
        do_action('fluent_cart/cart/cart_data_items_updated', ['cart' => $cart, 'scope' => 'payment_method_fee_recalculate']);

        // TaxModule::recalculateTax() refreshes checkout_data['fees'] (RC-adjusted amounts,
        // deduplication) — reload so persisted fee items match the recomputed fee tax metadata.
        $fees = (array) Arr::get($cart->checkout_data, 'fees', $fees);

        $taxBehavior = apply_filters('fluent_cart/cart/tax_behavior', 0, ['cart' => $cart]);
        $taxTotal = (int)Arr::get($cart->checkout_data, 'tax_data.tax_total', 0);
        $shippingTax = (int)Arr::get($cart->checkout_data, 'tax_data.shipping_tax', 0);
        $storeTaxBehavior = (int)Arr::get($cart->checkout_data, 'tax_data.store_tax_behavior', $taxBehavior);
        $exclusiveTaxTotal = (int)Arr::get($cart->checkout_data, 'tax_data.exclusive_tax_total', 0);
        $feeTax = (int)Arr::get($cart->checkout_data, 'tax_data.fee_tax', 0);
        $feeTaxLines = (array)Arr::get($cart->checkout_data, 'tax_data.fee_tax_lines', []);

        // For dynamic RC + inclusive pricing, reduce the shipping charge to net before storing.
        // The fluent_cart/cart/shipping_total filter (registered by TaxModule) handles the logic.
        $shippingCharge = apply_filters('fluent_cart/cart/shipping_total', $shippingCharge, ['cart' => $cart]);

        $checkoutProcessor = new CheckoutProcessor($cartCheckoutHelper->getItems(), [
            'customer_id'               => $customer->id,
            'user_tz'                   => $userTz,
            'create_account_after_paid' => $shouldCreateUser ? 'yes' : 'no',
            'shipping_charge'           => $shippingCharge,
            'shipping_method_id'        => $shippingMethod ? (int)$shippingMethod->id : 0,
            'shipping_method_title'     => $shippingMethod ? $shippingMethod->title : '',
            'tax_total'                 => $taxTotal,
            'tax_behavior'              => $taxBehavior,
            'store_tax_behavior'        => $storeTaxBehavior,
            'exclusive_tax_total'       => $exclusiveTaxTotal,
            'fee_tax'                   => $feeTax,
            'fee_tax_lines'             => $feeTaxLines,
            'shipping_tax'              => $shippingTax,
            'payment_method'            => $paymentMethod,
            'applied_coupons'           => $cart->getDiscountLines(),
            'billing_address'           => Arr::get($orderData, 'billing_address', []),
            'shipping_address'          => Arr::get($orderData, 'shipping_address', []),
            'cart_hash'                 => $cart->cart_hash,
            'is_locked'                 => $isLockedCart,
            'manual_discount_total'     => $cartCheckoutHelper->getManualDiscountAmount(),
            'prorate_credit'            => (int) Arr::get($cart->checkout_data, 'prorate_credit.amount', 0),
            'upgrade_discount'          => (int) Arr::get($cart->checkout_data, 'upgrade_discount.amount', 0),
            'ip_address'                => AddressHelper::getIpAddress(),
            'note'                      => Arr::get($orderData, 'others.order_notes', ''),
            'tax_id'                    => sanitize_text_field(
                Arr::get($data, 'fct_billing_tax_id', '')
                    ?: Arr::get($cart->checkout_data, 'tax_data.vat_number', '')
            ),
            'fees'                      => $fees,
        ]);

        $createdOrder = $checkoutProcessor->createDraftOrder($prevOrder);
        if (is_wp_error($createdOrder)) {
            wp_send_json([
                'status'  => 'failed',
                'message' => $createdOrder->get_error_message(),
                'data'    => $createdOrder->get_error_data()
            ], 423);
        }

        do_action('fluent_cart/order/after_items_calculated', [
            'order'      => $createdOrder,
            'cart'       => $cart,
            'cart_items' => $cartCheckoutHelper->getItems(),
        ]);

        // prepare other data if any module needs to add data to the order data
        do_action('fluent_cart/checkout/prepare_other_data', [
            'cart'           => $cart,
            'order'          => $createdOrder,
            'prev_order'     => $prevOrder,
            'request_data'   => $data,
            'validated_data' => $validatedData
        ]);

        static::finalizeOrder($createdOrder, [
            'billing_address'  => Arr::get($orderData, 'billing_address', []),
            'shipping_address' => Arr::get($orderData, 'shipping_address', []),
            'items'            => $cartCheckoutHelper->getItems(),
            'from_checkout'    => true,
            'prev_order'       => $prevOrder
        ]);
    }

    private static function getOrCreateCustomer(CartCheckoutHelper $cartCheckoutHelper, $orderData)
    {
        $customerEmail = static::getCustomerEmail($orderData['billing_address']);
        if (is_user_logged_in()) {
            $customerEmail = wp_get_current_user()->user_email;
            Arr::set($orderData, 'billing_address.email', $customerEmail);
        }
        $customer = $cartCheckoutHelper->getCustomer($customerEmail);
        return static::createCustomerWithAddress(
            $customer,
            $orderData,
            $orderData['billing_address'],
            $orderData['shipping_address']
        );
    }

    private static function prepareAddressData($data)
    {
        $shippingAddressId = Arr::get($data, 'shipping_address_id');
        $billingAddressId = Arr::get($data, 'billing_address_id');
        $orderId = Arr::get($data, 'order_id', null);

        $currentCustomer = is_user_logged_in() ? ApiCustomerResource::getCurrentCustomer() : null;

        $shipToDifferent = Arr::get($data, 'ship_to_different', 'no');

        $datKeys = ['country', 'address_1', 'address_2', 'city', 'state', 'postcode', 'phone', 'label', 'company_name', 'vat_number', 'legal_registration_id'];

        if ($billingAddressId) {
            $prevOrder = Order::query()->find($orderId);
            $prevBillingId = null;
            if ($prevOrder) {
                $prevBillingAddress = $prevOrder->billing_address;
                $prevBillingId = Arr::get($prevBillingAddress, 'id');
            }

            $billingAddress = null;
            if ($orderId && $prevBillingId == $billingAddressId) {
                $billingAddress = OrderAddress::query()
                    ->where('id', $billingAddressId)
                    ->where('type', 'billing')
                    ->first();
            }
            if (empty($billingAddress) && $currentCustomer) {
                $billingAddress = CustomerAddresses::query()
                    ->where('id', $billingAddressId)
                    ->where('type', 'billing')
                    ->where('customer_id', $currentCustomer->id)
                    ->first();
            }

            if ($billingAddress) {
                foreach ($datKeys as $key) {
                    $data['billing_' . $key] = $billingAddress->{$key} ?: Arr::get($data, 'billing_' . $key, '');
                }
            }
        }

        if (Arr::get($data, 'is_business', 'no') !== 'yes' && !CheckoutFieldsSchema::isB2BOnlyMode()) {
            $data['billing_company_name'] = '';
            $data['billing_legal_registration_id'] = '';
        }

        if ($shippingAddressId && $shipToDifferent === 'yes') {
            $prevShippingOrder = Order::query()->find($orderId);
            $prevShippingAddress = $prevShippingOrder ? $prevShippingOrder->shipping_address : null;
            $prevShippingId = Arr::get($prevShippingAddress, 'id', null);
            $shippingAddress = null;
            if ($orderId && $prevShippingId == $shippingAddressId) {
                $shippingAddress = OrderAddress::query()
                    ->where('id', $shippingAddressId)
                    ->where('type', 'shipping')
                    ->first();
            }
            if (empty($shippingAddress) && $currentCustomer) {
                $shippingAddress = CustomerAddresses::query()
                    ->where('id', $shippingAddressId)
                    ->where('type', 'shipping')
                    ->where('customer_id', $currentCustomer->id)
                    ->first();
            }

            if ($shippingAddress) {
                $formFullName = Arr::get($data, 'shipping_full_name', '');
                $data['shipping_full_name'] = $shippingAddress->name ?: $formFullName;
                foreach ($datKeys as $key) {
                    $data['shipping_' . $key] = $shippingAddress->{$key} ?: Arr::get($data, 'shipping_' . $key, '');
                }
            }
        } else if ($shipToDifferent !== 'yes') {
            // if not different shipping, copy billing to shipping
            foreach ($datKeys as $key) {
                $data['shipping_' . $key] = Arr::get($data, 'billing_' . $key, '');
            }
            $data['shipping_full_name'] = Arr::get($data, 'billing_full_name', '');
        }

        return $data;
    }

    private static function addLoggedUserData(array $data): array
    {
        if (is_user_logged_in()) {
            $checkoutHelper = CartCheckoutHelper::make();
            $data['billing_email'] = wp_get_current_user()->user_email;

            if (CheckoutFieldsSchema::isFullNameRequired()) {
                $userFullName = $checkoutHelper->getFullName();
                if (!empty($userFullName) && empty($data['billing_full_name'])) {
                    $data['billing_full_name'] = $userFullName;
                }
            } else {
                $firstName = $checkoutHelper->getFirstName();
                if (!empty($firstName) && empty($data['billing_first_name'])) {
                    $data['billing_first_name'] = $firstName;
                }

                $lastName = $checkoutHelper->getLastName();
                if (!empty($lastName) && empty($data['billing_last_name'])) {
                    $data['billing_last_name'] = $lastName;
                }

            }


        }
        return $data;
    }

    private static function finalizeOrder(Order $order, $args = [])
    {
        // Duplicate/concurrent submissions are already serialized by the cart-hash lock
        // at the top of placeOrder() — no per-order lock needed here.
        AddressHelper::insertOrderAddresses(
            $order->id,
            Arr::get($args, 'billing_address', []),
            Arr::get($args, 'shipping_address', [])
        );

        static::syncCustomerNames($order, $args);
        $cart = CartHelper::getCart();

        $utmData = UtmHelper::resolveUtmData(
            UtmHelper::getUtmDataOfRequest(),
            !empty($cart) ? $cart->utm_data : []
        );
        UtmHelper::addUtmToOrder($order->id, $utmData);

        $prevOrder = Arr::get($args, 'prev_order', null);

        (new OrderCreated($order, $prevOrder, $order->customer, $order->getLatestTransaction()))->dispatch();

        static::updateStock($order);

        // we don't have to validate the payment method again, as it's already validated in placeOrder method
        $gateway = App::gateway($order->payment_method);

        if (!$gateway) {
            wp_send_json([
                'status'  => 'failed',
                'message' => __('Payment method not found!', 'fluent-cart'),
                'data'    => []
            ], 404);
        }

        $paymentInstance = new PaymentInstance($order);

        // Transition subscription from pending → intended before submitting to the gateway
        if ($paymentInstance->subscription && $paymentInstance->subscription->status === Status::SUBSCRIPTION_PENDING) {
            $paymentInstance->subscription->status = Status::SUBSCRIPTION_INTENDED;
            $paymentInstance->subscription->save();
        }

        $data = $gateway->makePaymentFromPaymentInstance($paymentInstance);

        if (is_wp_error($data)) {
            // Server-observed create failure: mark the transaction FAILED so the next
            // resubmit is a RETRY (payment_attempt bump -> fresh idempotency seed) —
            // gateways cache error responses under the key, so keeping it pending would
            // replay the same error on every resubmit. Client-side declines stay pending
            // on purpose: there the same key resolving to the same gateway object IS the
            // retry path.
            if ($paymentInstance->transaction && $paymentInstance->transaction->status === Status::PAYMENT_PENDING) {
                $paymentInstance->transaction->update(['status' => Status::PAYMENT_FAILED]);
            }

            wp_send_json([
                'status'  => 'failed',
                'message' => $data->get_error_message(),
                'data'    => $data->get_error_data()
            ], 422);
        }

        wp_send_json($data, 200);
    }

    /**
     * Serialize checkout submissions per cart with a MySQL named lock.
     *
     * Keyed on cart_hash (not order id) so concurrent FIRST submissions — no draft
     * order yet — contend on the same lock. Release goes through a shutdown function,
     * not try/finally: wp_send_json() exits via die() (skips finally), and persistent
     * DB connections don't drop the lock on request end.
     */
    private static function acquireCartLock($cartHash)
    {
        global $wpdb;

        // md5 keeps the name inside MySQL's 64-char lock-name limit regardless of
        // table-prefix length; the prefix scopes the lock per site on multisite.
        $lockName = 'fct_checkout_' . md5($wpdb->prefix . $cartHash);

        $lockAcquired = (string) $wpdb->get_var(
            $wpdb->prepare('SELECT GET_LOCK(%s, %d)', $lockName, 10)
        ) === '1';

        if (!$lockAcquired) {
            wp_send_json([
                'status'  => 'failed',
                'message' => __('This order is already being processed. Please wait a moment — do not refresh or resubmit.', 'fluent-cart'),
                'data'    => []
            ], 429);
        }

        register_shutdown_function(function () use ($lockName) {
            global $wpdb;
            $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lockName));
        });
    }

    private static function syncCustomerNames($order, $args)
    {
        $customer = $order->customer;

        if (empty($customer)) {
            return;
        }

        $firstName = Arr::get($args, 'billing_address.first_name');
        $lastName = Arr::get($args, 'billing_address.last_name');

        $customer->update([
            'first_name' => $firstName,
            'last_name'  => $lastName,
        ]);

        $user = get_user_by('email', $customer->email);

        if (empty($user)) {
            return;
        }

        if (is_user_logged_in() && $user->ID === get_current_user_id()) {
            update_user_meta($user->ID, 'first_name', $firstName);
            update_user_meta($user->ID, 'last_name', $lastName);
        }
    }

    public static function updateStock($order)
    {
        // pluck product ids to update product default variation as stock has changed
        $productIds = OrderService::pluckProductIds($order);
        if (!empty($productIds)) {
            (new StockChanged($productIds))->dispatch();
        }
    }

    public static function createCustomerWithAddress($customer, $orderData, $billingAddress, $shippingAddress)
    {
        if (empty($customer)) {
            $customer = static::createNewCustomer($orderData, $billingAddress, $shippingAddress);
        } else if ($customer instanceof Customer) {
            static::updateExistingCustomer($customer, $orderData, $billingAddress, $shippingAddress);
        }

        return $customer;
    }

    private static function createNewCustomer($orderData, &$billingAddress, $shippingAddress)
    {
        global $current_user;
        if ($current_user->ID) {
            $billingAddress['email'] = $current_user->user_email;
            $billingAddress['user_id'] = $current_user->ID;
        } else {
            static::handleUserCreation($orderData, $billingAddress);
        }

        $customer = CustomerResource::create($billingAddress);
        $customer = Arr::get($customer, 'data', null);
        $customerId = Arr::get($customer, 'id', null);
        static::createCustomerAddress($billingAddress, $customerId);
        static::createCustomerAddress($shippingAddress, $customerId);

        return $customer;
    }

    private static function updateExistingCustomer($customer, $orderData, $billingAddress, $shippingAddress)
    {
        if (empty($customer->user_id)) {
            $currentLoggedInUser = wp_get_current_user();
            if ($currentLoggedInUser && $currentLoggedInUser->user_email === $customer->email) {
                $userId = get_current_user_id();
                $customer->update(['user_id' => $userId]);
                $billingAddress['user_id'] = $userId;
            }
        }

        $customer->load(['billing_address', 'shipping_address']);

        if ($customer->billing_address->count() < 1) {
            static::createCustomerAddress($billingAddress, $customer->id);
        }
        if ($customer->shipping_address->count() < 1) {
            static::createCustomerAddress($shippingAddress, $customer->id);
        }

        static::handleUserCreation($orderData, $billingAddress, $customer);
    }

    private static function handleUserCreation($orderData, &$billingAddress, $customer = null)
    {
        $userEmail = Arr::get($billingAddress, 'email');
        $user = get_user_by('email', $userEmail);

        if ($user) {
            $billingAddress['user_id'] = $user->ID;
            if ($customer) {
                $customer->update(['user_id' => $user->ID]);
            }
        }
    }

    private static function getCustomerEmail($billingAddress)
    {
        return is_user_logged_in() ? wp_get_current_user()->user_email : $billingAddress['email'];
    }

    public static function shouldCreateUser($data, $billingAddress): bool
    {
        $accountCreationMod = (new StoreSettings())->get('user_account_creation_mode');
        $hasSubscription = (CartCheckoutHelper::make())->hasSubscription() === 'yes';
        if ($accountCreationMod === 'all' || $hasSubscription) {
            return true;
        }

        if (is_user_logged_in()) {
            return false;
        }

        $allow_create_account = Arr::get($data, 'others.allow_create_account') === 'yes';

        $userEmail = Arr::get($billingAddress, 'email');
        $user = get_user_by('email', $userEmail);

        return ($allow_create_account && $accountCreationMod === 'user_choice') || !empty($user);
    }

    private static function createCustomerAddress(array $address, $customerId)
    {
        CustomerAddressResource::create($address, ['id' => $customerId]);
    }

    /**
     * Check if a customer is logged in and has the given address type.
     *
     * @param string $type Address type to check ('billing' or 'shipping').
     * @return bool True if the customer is logged in and the address is available.
     */
    private static function hasCustomerWithAddress(string $type): bool
    {
        $addressType = $type . '_address';
        $currentCustomer = \FluentCart\Api\Resource\CustomerResource::getCurrentCustomer();

        return !empty($currentCustomer) && $currentCustomer->$addressType->count() === 1;
    }


    /**
     * Validate the data against the provided rules.
     * @return array
     */

    public static function billingRules($data = []): array
    {
        $baseRules = [
            'billing_full_name' => 'required|sanitizeText|maxLength:255',
            'billing_email'     => 'required|sanitizeText|email|maxLength:255',
            'order_notes'       => 'nullable|sanitizeTextArea|maxLength:200',
        ];

        return static::generateAddressRules('billing', $data, $baseRules, 'getBillingAddressFields');
    }

    public static function shippingIdRules(): array
    {
        return [
            'shipping_address' => 'exists:fct_customer_addresses,id',
        ];
    }

    public static function shippingRules($data = []): array
    {
        $baseRules = [
            'shipping_full_name' => 'required|sanitizeText|maxLength:255'
        ];

        return static::generateAddressRules('shipping', $data, $baseRules, 'getShippingAddressFields');

    }

    /**
     * Validate the data against the provided rules.
     *
     * @return false|\WP_Error|string
     */

    public static function validateData($data, Cart $cart, CheckoutService $cartCheckoutService, $prevOrder)
    {
        $shippingRequired = $cart->requireShipping();
        $isDifferentShipping = $shippingRequired && Arr::get($data, 'ship_to_different', 'no') === 'yes';
        $fulfillmentType = $shippingRequired ? 'physical' : 'digital';

        $billingValidations = array_filter(CheckoutFieldsSchema::getCheckoutFieldsRequirements('billing', $fulfillmentType, !$isDifferentShipping));

        // Name fields are validated separately below (full_name/first_name/last_name)
        // vat_number uses field name fct_billing_tax_id and is validated separately in the B2B block below
        unset($billingValidations['full_name'], $billingValidations['first_name'], $billingValidations['last_name'], $billingValidations['vat_number']);

        if (!isset($billingValidations['country']) && empty($data['billing_country'])) {
            $data['billing_country'] = (new StoreSettings())->get('store_country');
        }

        $billingAddress = [];
        foreach ($billingValidations as $key => $billingValidation) {
            $billingAddress[$key] = Arr::get($data, 'billing_' . $key, '');
        }
        if (!isset($billingAddress['country'])) {
            $billingAddress['country'] = !empty($data['billing_country'])
                ? $data['billing_country']
                : (new StoreSettings())->get('store_country');
        }

        $shippingAddress = [];
        $shippingValidations = [];
        if ($isDifferentShipping) {
            $shippingValidations = array_filter(CheckoutFieldsSchema::getCheckoutFieldsRequirements('shipping', 'physical'));
            // Name fields are validated via billing basic_info, not shipping address
            unset($shippingValidations['full_name'], $shippingValidations['first_name'], $shippingValidations['last_name'], $shippingValidations['company_name']);
            foreach ($shippingValidations as $key => $shippingValidation) {
                $shippingAddress[$key] = Arr::get($data, 'shipping_' . $key, '');
            }
        }

        if (Arr::get($data, 'ship_to_different', 'no') === 'yes') {
            if (!isset($data['shipping_country'])) {
                // get store country
                $data['shipping_country'] = (new StoreSettings())->get('store_country');
            }
            if (!isset($shippingAddress['country'])) {
                // get store country
                $shippingAddress['country'] = (new StoreSettings())->get('store_country');
            }

        }

        $errors = [];

        $agreeTermsRequired = CheckoutFieldsSchema::isTermsRequired();

        $customTitles = [
            'address_1' => __('Street Address', 'fluent-cart'),
            'address_2' => __('Apt, Suite, Unit', 'fluent-cart'),
            'country'   => __('Country', 'fluent-cart'),
            'state'     => __('State', 'fluent-cart'),
            'city'      => __('City', 'fluent-cart'),
            'postcode'  => __('Postcode', 'fluent-cart'),
            'phone'     => __('Phone', 'fluent-cart'),
        ];

        foreach ($billingValidations as $key => $rule) {
            $value = Arr::get($billingAddress, $key, '');
            $prefixedKey = 'billing_' . $key;
            $titledKey = $customTitles[$key] ?? Str::headline($key);

            if ($key === 'country') {
                $countries = LocalizationManager::getInstance()->countries();

                if ($rule === 'required' && empty($value)) {
                    if (!isset($errors[$key])) {
                        $errors[$prefixedKey] = [];
                    }
                    $errors[$prefixedKey]['required'] = sprintf(
                    /* translators: %s attribute name */
                        __('%s is required.', 'fluent-cart'),
                        $titledKey
                    );
                    continue;
                }

                if (!empty($value) && !Arr::has($countries, $value)) {
                    if (!isset($errors[$key])) {
                        $errors[$prefixedKey] = [];
                    }
                    $errors[$prefixedKey]['invalid'] = sprintf(
                    /* translators: %s attribute name */
                        __('%s is invalid.', 'fluent-cart'),
                        $titledKey
                    );
                    continue;
                }

                continue;
            }

            if ($key === 'state') {
                $country = Arr::get($billingAddress, 'country', '');

                $states = LocalizationManager::getInstance()->statesOptions($country);

                if (empty($states)) {
                    continue;
                }

                if ($rule === 'required' && empty($value)) {
                    if (!isset($errors[$key])) {
                        $errors[$prefixedKey] = [];
                    }
                    $errors[$prefixedKey]['required'] = sprintf(
                    /* translators: %s attribute name */
                        __('%s is required.', 'fluent-cart'),
                        $titledKey
                    );
                    continue;
                }

                if (!empty($value) && !in_array($value, array_column($states, 'value'))) {
                    if (!isset($errors[$key])) {
                        $errors[$prefixedKey] = [];
                    }
                    $errors[$prefixedKey]['invalid'] = sprintf(
                    /* translators: %s attribute name */
                        __('%s is invalid.', 'fluent-cart'),
                        $titledKey
                    );
                }

                continue;
            }

            if ($rule === 'required' && empty($value)) {
                if (!isset($errors[$key])) {
                    $errors[$prefixedKey] = [];
                }
                $errors[$prefixedKey]['required'] = sprintf(
                /* translators: %s attribute name */
                    __('%s is required.', 'fluent-cart'),
                    $titledKey
                );
            }
        }

        foreach ($shippingValidations as $key => $rule) {
            $value = Arr::get($shippingAddress, $key, '');
            $prefixedKey = 'shipping_' . $key;
            $titledKey = $customTitles[$key] ?? Str::headline($key);

            if ($key === 'country') {
                $countries = LocalizationManager::getInstance()->countries();

                if ($rule === 'required' && empty($value)) {
                    if (!isset($errors[$key])) {
                        $errors[$prefixedKey] = [];
                    }
                    $errors[$prefixedKey]['required'] = sprintf(
                    /* translators: %s attribute name */
                        __('%s is required.', 'fluent-cart'),
                        $titledKey
                    );

                    continue;
                }

                if (!empty($value) && !Arr::has($countries, $value)) {
                    if (!isset($errors[$key])) {
                        $errors[$prefixedKey] = [];
                    }
                    $errors[$prefixedKey]['invalid'] = sprintf(
                    /* translators: %s attribute name */
                        __('%s is invalid.', 'fluent-cart'),
                        $titledKey
                    );
                    continue;
                }

                continue;
            }

            if ($key === 'state') {
                $country = Arr::get($shippingAddress, 'country', '');

                $states = LocalizationManager::getInstance()->statesOptions($country);

                if (empty($states)) {
                    continue;
                }

                if ($rule === 'required' && empty($value)) {
                    if (!isset($errors[$key])) {
                        $errors[$prefixedKey] = [];
                    }
                    $errors[$prefixedKey]['required'] = sprintf(
                    /* translators: %s attribute name */
                        __('%s is required.', 'fluent-cart'),
                        $titledKey
                    );

                    continue;
                }

                if (!empty($value) && !in_array($value, array_column($states, 'value'))) {
                    if (!isset($errors[$key])) {
                        $errors[$prefixedKey] = [];
                    }
                    $errors[$prefixedKey]['invalid'] = sprintf(
                    /* translators: %s attribute name */
                        __('%s is invalid.', 'fluent-cart'),
                        $titledKey
                    );
                }

                continue;
            }

            if ($rule === 'required' && empty($value)) {
                if (!isset($errors[$key])) {
                    $errors[$prefixedKey] = [];
                }
                $errors[$prefixedKey]['required'] = sprintf(
                /* translators: %s attribute name */
                    __('%s is required.', 'fluent-cart'),
                    $titledKey
                );
            }
        }

        $basicInfoFields = (CheckoutFieldsSchema::getNameEmailFieldsSchema())['fields'];

        foreach ($basicInfoFields as $field) {
            $fieldName = (string)Arr::get($field, 'name', '');
            $isRequired = Arr::get($field, 'required', 'no') === 'yes';
            if ($fieldName && $isRequired) {
                $value = Arr::get($data, $fieldName, '');
                if (empty($value)) {
                    Arr::set($errors, $fieldName . '.required', sprintf(
                    /* translators: %s attribute name */
                        __('%s is required.', 'fluent-cart'),
                        Arr::get($field, 'aria-label')
                    ));
                }
            }
        }

        $isB2B = Arr::get($data, 'is_business', 'no') === 'yes' || CheckoutFieldsSchema::isB2BOnlyMode();

        if ($isB2B && CheckoutFieldsSchema::isVatNumberRequired()) {
            $vatNumber = Arr::get($data, 'fct_billing_tax_id', '');
            if (empty($vatNumber)) {
                $errors['fct_billing_tax_id']['required'] = __('VAT / Tax ID is required.', 'fluent-cart');
            }
        }

        if ($isB2B && CheckoutFieldsSchema::isCompanyNameRequired()) {
            $companyName = Arr::get($data, 'billing_company_name', '');
            if (empty($companyName)) {
                $errors['billing_company_name']['required'] = __('Company Name is required.', 'fluent-cart');
            }
        }

        if ($isB2B && CheckoutFieldsSchema::isLegalRegistrationIdRequired()) {
            $legalRegId = Arr::get($data, 'billing_legal_registration_id', '');
            if (empty($legalRegId)) {
                $errors['billing_legal_registration_id']['required'] = __('Legal Registration ID is required.', 'fluent-cart');
            }
        }

        if (empty($data['agree_terms']) && $agreeTermsRequired) {
            $errors['agree_terms']['required'] = __('You must agree to the terms and conditions.', 'fluent-cart');
        }

        if (empty($data['billing_email']) || !is_email($data['billing_email'])) {
            $errors['billing_email']['invalid'] = __('Email must be a valid email address.', 'fluent-cart');
        }

        if (CheckoutFieldsSchema::isFullNameRequired() || !empty($data['billing_full_name'])) {
            // Modal checkout always sends billing_full_name regardless of store name field settings
            if (empty($data['billing_full_name'])) {
                $errors['billing_full_name']['required'] = __('Full name is required.', 'fluent-cart');
            }
        } else {
            if (empty($data['billing_first_name'])) {
                $errors['billing_first_name']['required'] = __('First name is required.', 'fluent-cart');
            }

            if(CheckoutFieldsSchema::isLastNameRequired()) {
                if (empty($data['billing_last_name'])) {
                    $errors['billing_last_name']['required'] = __('Last name is required.', 'fluent-cart');
                }
            }
        }


        if ($cart->requireShipping()) {
            if (!empty($data['fc_selected_shipping_method'])) {
                $selectedMethod = $data['fc_selected_shipping_method'];
                $shippingCountry = Arr::get($data, 'billing_country', '');
                $shippingState = Arr::get($data, 'billing_state', '');
                $shipToDifferent = Arr::get($data, 'ship_to_different', 'no') === 'yes';

                if ($shipToDifferent) {
                    $shippingCountry = Arr::get($data, 'shipping_country', '');
                    $shippingState = Arr::get($data, 'shipping_state', '');
                }

                $availableShippingMethods = AddressHelper::getShippingMethods($shippingCountry, $shippingState);


                if (empty($availableShippingMethods) || is_wp_error($availableShippingMethods)) {
                    $errors['shipping_method']['unavailable'] = __('We don\'t ship to this address. Please select a different address.', 'fluent-cart');
                } else {
                    $found = false;
                    foreach ($availableShippingMethods as $shippingMethod) {
                        if ($shippingMethod->id == $selectedMethod) {
                            $found = true;
                            break;
                        }
                    }

                    if (!$found) {
                        $errors['shipping_method']['invalid'] = __('The selected shipping method is not available.', 'fluent-cart');
                    }
                }


            } else {
                $errors['shipping_method']['required'] = __('You must select a shipping method.', 'fluent-cart');
            }
        }

        $errors = apply_filters('fluent_cart/checkout/validate_data', $errors, [
            'data' => $data,
            'cart' => $cart
        ]);

        if (count($errors) > 0) {
            return new \Wp_Error('validation_error', __('Validation error', 'fluent-cart'), $errors);
        }

        return $data;
    }

    public static function validateShippingMethod(array $data, CheckoutService $cartCheckoutService): bool
    {

        if ($cartCheckoutService->isAllDigital()) {
            return true;
        }

        $shipping_country = Arr::get($data, 'shipping_country');
        $shipping_state = Arr::get($data, 'shipping_state');


        $methods = ShippingMethod::getApplicableForCountry($shipping_country, $shipping_state)->keyBy('id');
        if ($methods->count() === 0 || \FluentCart\App\App::isDevMode()) {
            return true;
        }

        $shipping_method = Arr::get($data, 'fc_selected_shipping_method');

        if (empty($shipping_method)) {
            return false;
        }


        $exist = $methods->has($shipping_method);
        if (!$exist) {
            return false;
        }

        return true;
    }


    public static function messages(): array
    {
        return [
            'billing_full_name.required'  => esc_html__('Full name field is required.', 'fluent-cart'),
            'billing_email.required'      => esc_html__('Email field is required.', 'fluent-cart'),
            'billing_email.email'         => esc_html__('Email must be a valid email address.', 'fluent-cart'),
            'billing_address.required'    => esc_html__('Address field is required.', 'fluent-cart'),
            'billing_country.required'    => esc_html__('Country field is required.', 'fluent-cart'),
            'billing_address_1.required'  => esc_html__('Street Address field is required.', 'fluent-cart'),
            'billing_city.required'       => esc_html__('City field is required.', 'fluent-cart'),
            'billing_postcode.required'   => esc_html__('Postcode field is required.', 'fluent-cart'),
            'shipping_full_name.required' => esc_html__('Full name field is required.', 'fluent-cart'),
            // 'shipping_email.required' => esc_html__('Email field is required.', 'fluent-cart'),
            // 'shipping_email.email' => esc_html__('Email must be a valid email address.', 'fluent-cart'),
            'shipping_address.required'   => esc_html__('Address field is required.', 'fluent-cart'),
            'shipping_city.required'      => esc_html__('City field is required.', 'fluent-cart'),
            'shipping_postcode.required'  => esc_html__('Postcode field is required.', 'fluent-cart'),
        ];
    }

    private static function generateAddressRules($type, $data, $baseRules, $fieldGetter): array
    {
        $hasAddress = static::hasCustomerWithAddress($type);
        $rules = App::localization()->getValidationRule($data, $type);

        if ($hasAddress) {
            $baseRules["{$type}_address"] = 'required|numeric';
            return $baseRules;
        }

        $cartCheckoutHelper = CartCheckoutHelper::make();
        $fields = $cartCheckoutHelper->{$fieldGetter}();
        $addressFields = Arr::get($fields, 'address_section.schema', []);
        $addressFields = Arr::wrap($addressFields);

        $validFields = array_keys($addressFields);

        // Add prefix to each field (billing_ or shipping_)
        $validFields = array_map(function ($field) use ($type) {
            return $type . '_' . $field;
        }, $validFields);

        // Filter only relevant rules
        $filteredRules = array_filter($rules, function ($key) use ($validFields) {
            return in_array($key, $validFields);
        }, ARRAY_FILTER_USE_KEY);

        return $baseRules + $filteredRules;
    }
}

```
