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