| @@ -1,8 +1,9 @@ | ||
| 1 | 1 | <?php |
| 2 | 2 | |
| 3 | 3 | namespace FluentCart\Api\Checkout; |
| 4 | 4 | |
| 5 | +use FluentCart\Api\Resource\CustomerResource as ApiCustomerResource; | |
| 5 | 6 | use FluentCart\Api\Resource\FrontendResource\CustomerAddressResource; |
| 6 | 7 | use FluentCart\Api\Resource\FrontendResource\CustomerResource; |
| 7 | 8 | use FluentCart\Api\StoreSettings; |
| 8 | 9 | use FluentCart\App\App; |
| @@ -20,8 +21,9 @@ | ||
| 20 | 21 | use FluentCart\App\Models\Order; |
| 21 | 22 | use FluentCart\App\Models\OrderAddress; |
| 22 | 23 | use FluentCart\App\Models\ShippingMethod; |
| 23 | 24 | use FluentCart\App\Services\CheckoutService; |
| 25 | +use FluentCart\App\Services\CustomerIdentity\EmailVerificationService; | |
| 24 | 26 | use FluentCart\App\Services\Localization\LocalizationManager; |
| 25 | 27 | use FluentCart\App\Services\OrderService; |
| 26 | 28 | use FluentCart\App\Services\Payments\PaymentHelper; |
| 27 | 29 | use FluentCart\App\Services\Payments\PaymentInstance; |
| @@ -53,8 +55,23 @@ | ||
| 53 | 55 | 'message' => __('Cart is empty or already completed', 'fluent-cart'), |
| 54 | 56 | ]); |
| 55 | 57 | } |
| 56 | 58 | |
| 59 | + // Serialize all submissions of one cart BEFORE the prevOrder read: locking later | |
| 60 | + // (or per-order) lets two concurrent first submissions both see prevOrder = null | |
| 61 | + // and create two orders -> two idempotency keys -> double charge. | |
| 62 | + static::acquireCartLock($cart->cart_hash); | |
| 63 | + | |
| 64 | + // Re-read under the lock (fresh(), not getCart() — that one is request-cached): | |
| 65 | + // a submission we waited on may have completed this cart meanwhile. | |
| 66 | + $cart = $cart->fresh(); | |
| 67 | + if (!$cart || !$cart->cart_data || $cart->stage === 'completed') { | |
| 68 | + wp_send_json([ | |
| 69 | + 'status' => 'failed', | |
| 70 | + 'message' => __('Cart is empty or already completed', 'fluent-cart'), | |
| 71 | + ]); | |
| 72 | + } | |
| 73 | + | |
| 57 | 74 | $cart = $cart->reValidateCoupons(); |
| 58 | 75 | |
| 59 | 76 | $cartData = $cart->cart_data; |
| 60 | 77 | $prevOrder = $cart->order; |
| @@ -62,19 +79,34 @@ | ||
| 62 | 79 | $prevOrder->load('order_items'); |
| 63 | 80 | } |
| 64 | 81 | $isLockedCart = $cart->isLocked(); |
| 65 | 82 | |
| 66 | - // todo: we should handle this logic as we have multiple options like PAYMENT_PARTIALLY_PAID.... | |
| 67 | 83 | if ($prevOrder && |
| 68 | 84 | ( |
| 69 | 85 | in_array($prevOrder->status, Status::getOrderSuccessStatuses()) || |
| 70 | - $prevOrder->payment_status != Status::PAYMENT_PENDING | |
| 86 | + !in_array($prevOrder->payment_status, Status::getPaymentRetryableStatuses()) | |
| 71 | 87 | ) |
| 72 | 88 | ) { |
| 73 | - wp_send_json([ | |
| 74 | - 'status' => 'failed', | |
| 75 | - 'message' => __('You have already completed this order.', 'fluent-cart'), | |
| 76 | - ]); | |
| 89 | + if ($isLockedCart) { | |
| 90 | + // Locked carts are bound to a specific order (e.g. pay-for-order links), | |
| 91 | + // so a finalized order really means there is nothing left to pay. | |
| 92 | + wp_send_json([ | |
| 93 | + 'status' => 'failed', | |
| 94 | + 'message' => __('You have already completed this order.', 'fluent-cart'), | |
| 95 | + ]); | |
| 96 | + } | |
| 97 | + | |
| 98 | + // The linked order is already finalized but the cart was never marked | |
| 99 | + // completed (e.g. a stale cart resurrected by the logged-in user lookup). | |
| 100 | + // Detach the dead order so the customer can check out again instead of | |
| 101 | + // being blocked on every future purchase. | |
| 102 | + $cart->order_id = null; | |
| 103 | + $checkoutData = $cart->checkout_data; | |
| 104 | + unset($checkoutData['is_locked']); | |
| 105 | + $cart->checkout_data = $checkoutData; | |
| 106 | + $cart->save(); | |
| 107 | + $prevOrder = null; | |
| 108 | + $isLockedCart = false; | |
| 77 | 109 | } |
| 78 | 110 | |
| 79 | 111 | $data = static::addLoggedUserData($data); |
| 80 | 112 | |
| @@ -132,10 +164,16 @@ | ||
| 132 | 164 | } |
| 133 | 165 | |
| 134 | 166 | $orderData = OrderService::groupSanitizedData($validatedData); |
| 135 | 167 | |
| 136 | - $shippingMethodId = Arr::get($orderData, 'others.fc_shipping_method'); | |
| 168 | + // The form posts the method twice: the checked radio (fc_shipping_method) and its | |
| 169 | + // hidden mirror (fc_selected_shipping_method). validateData() checks only the mirror | |
| 170 | + // against the address's zones, so pricing from the radio let a request pass with one | |
| 171 | + // method and be charged by another, from a zone the address is not in. Read from | |
| 172 | + // $validatedData, not the sanitized copy in others: that is the exact integer checked. | |
| 173 | + $shippingMethodId = (int) Arr::get($validatedData, 'fc_selected_shipping_method', 0); | |
| 137 | 174 | |
| 175 | + $shippingMethod = null; | |
| 138 | 176 | $shippingCharge = 0; |
| 139 | 177 | if (!$cartCheckoutService->isAllDigital()) { |
| 140 | 178 | $shippingMethod = ShippingMethod::query()->find($shippingMethodId); |
| 141 | 179 | if (!empty($shippingMethod)) { |
| @@ -164,13 +202,8 @@ | ||
| 164 | 202 | $customer = static::getOrCreateCustomer($cartCheckoutHelper, $orderData); |
| 165 | 203 | |
| 166 | 204 | $shouldCreateUser = static::shouldCreateUser($orderData, Arr::get($orderData, 'billing_address', [])); |
| 167 | 205 | |
| 168 | - $taxTotal = (int)Arr::get($cart->checkout_data, 'tax_data.tax_total', 0); // behavoir not applied here | |
| 169 | - | |
| 170 | - $shippingTax = (int)Arr::get($cart->checkout_data, 'tax_data.shipping_tax', 0); | |
| 171 | - $taxBehavior = apply_filters('fluent_cart/cart/tax_behavior', 0, ['cart' => $cart]); | |
| 172 | - | |
| 173 | 206 | // Ensure cart has the current payment method before recalculating fees |
| 174 | 207 | $checkoutData = $cart->checkout_data ?? []; |
| 175 | 208 | $checkoutData['payment_method'] = $paymentMethod; |
| 176 | 209 | $cart->checkout_data = $checkoutData; |
| @@ -177,15 +210,41 @@ | ||
| 177 | 210 | |
| 178 | 211 | $cart->clearFeeCache(); |
| 179 | 212 | $fees = $cart->getFees(); |
| 180 | 213 | |
| 214 | + // Recompute cart tax data so fee_tax/fee_tax_lines reflect fees for the current | |
| 215 | + // payment method. The scope prevents ShippingModule from running unnecessarily. | |
| 216 | + do_action('fluent_cart/cart/cart_data_items_updated', ['cart' => $cart, 'scope' => 'payment_method_fee_recalculate']); | |
| 217 | + | |
| 218 | + // TaxModule::recalculateTax() refreshes checkout_data['fees'] (RC-adjusted amounts, | |
| 219 | + // deduplication) — reload so persisted fee items match the recomputed fee tax metadata. | |
| 220 | + $fees = (array) Arr::get($cart->checkout_data, 'fees', $fees); | |
| 221 | + | |
| 222 | + $taxBehavior = apply_filters('fluent_cart/cart/tax_behavior', 0, ['cart' => $cart]); | |
| 223 | + $taxTotal = (int)Arr::get($cart->checkout_data, 'tax_data.tax_total', 0); | |
| 224 | + $shippingTax = (int)Arr::get($cart->checkout_data, 'tax_data.shipping_tax', 0); | |
| 225 | + $storeTaxBehavior = (int)Arr::get($cart->checkout_data, 'tax_data.store_tax_behavior', $taxBehavior); | |
| 226 | + $exclusiveTaxTotal = (int)Arr::get($cart->checkout_data, 'tax_data.exclusive_tax_total', 0); | |
| 227 | + $feeTax = (int)Arr::get($cart->checkout_data, 'tax_data.fee_tax', 0); | |
| 228 | + $feeTaxLines = (array)Arr::get($cart->checkout_data, 'tax_data.fee_tax_lines', []); | |
| 229 | + | |
| 230 | + // For dynamic RC + inclusive pricing, reduce the shipping charge to net before storing. | |
| 231 | + // The fluent_cart/cart/shipping_total filter (registered by TaxModule) handles the logic. | |
| 232 | + $shippingCharge = apply_filters('fluent_cart/cart/shipping_total', $shippingCharge, ['cart' => $cart]); | |
| 233 | + | |
| 181 | 234 | $checkoutProcessor = new CheckoutProcessor($cartCheckoutHelper->getItems(), [ |
| 182 | 235 | 'customer_id' => $customer->id, |
| 183 | 236 | 'user_tz' => $userTz, |
| 184 | 237 | 'create_account_after_paid' => $shouldCreateUser ? 'yes' : 'no', |
| 185 | 238 | 'shipping_charge' => $shippingCharge, |
| 239 | + 'shipping_method_id' => $shippingMethod ? (int)$shippingMethod->id : 0, | |
| 240 | + 'shipping_method_title' => $shippingMethod ? $shippingMethod->title : '', | |
| 186 | 241 | 'tax_total' => $taxTotal, |
| 187 | 242 | 'tax_behavior' => $taxBehavior, |
| 243 | + 'store_tax_behavior' => $storeTaxBehavior, | |
| 244 | + 'exclusive_tax_total' => $exclusiveTaxTotal, | |
| 245 | + 'fee_tax' => $feeTax, | |
| 246 | + 'fee_tax_lines' => $feeTaxLines, | |
| 188 | 247 | 'shipping_tax' => $shippingTax, |
| 189 | 248 | 'payment_method' => $paymentMethod, |
| 190 | 249 | 'applied_coupons' => $cart->getDiscountLines(), |
| 191 | 250 | 'billing_address' => Arr::get($orderData, 'billing_address', []), |
| @@ -192,11 +251,16 @@ | ||
| 192 | 251 | 'shipping_address' => Arr::get($orderData, 'shipping_address', []), |
| 193 | 252 | 'cart_hash' => $cart->cart_hash, |
| 194 | 253 | 'is_locked' => $isLockedCart, |
| 195 | 254 | 'manual_discount_total' => $cartCheckoutHelper->getManualDiscountAmount(), |
| 255 | + 'prorate_credit' => (int) Arr::get($cart->checkout_data, 'prorate_credit.amount', 0), | |
| 256 | + 'upgrade_discount' => (int) Arr::get($cart->checkout_data, 'upgrade_discount.amount', 0), | |
| 196 | 257 | 'ip_address' => AddressHelper::getIpAddress(), |
| 197 | 258 | 'note' => Arr::get($orderData, 'others.order_notes', ''), |
| 198 | - 'tax_id' => Arr::get($validatedData, 'billing_tax_id', 0), | |
| 259 | + 'tax_id' => sanitize_text_field( | |
| 260 | + Arr::get($data, 'fct_billing_tax_id', '') | |
| 261 | + ?: Arr::get($cart->checkout_data, 'tax_data.vat_number', '') | |
| 262 | + ), | |
| 199 | 263 | 'fees' => $fees, |
| 200 | 264 | ]); |
| 201 | 265 | |
| 202 | 266 | $createdOrder = $checkoutProcessor->createDraftOrder($prevOrder); |
| @@ -233,14 +297,16 @@ | ||
| 233 | 297 | } |
| 234 | 298 | |
| 235 | 299 | private static function getOrCreateCustomer(CartCheckoutHelper $cartCheckoutHelper, $orderData) |
| 236 | 300 | { |
| 237 | - $customerEmail = static::getCustomerEmail($orderData['billing_address']); | |
| 238 | - if (is_user_logged_in()) { | |
| 239 | - $customerEmail = wp_get_current_user()->user_email; | |
| 240 | - Arr::set($orderData, 'billing_address.email', $customerEmail); | |
| 301 | + $customer = is_user_logged_in() ? ApiCustomerResource::getCurrentCustomer() : null; | |
| 302 | + $email = static::getCustomerEmail($orderData['billing_address']); | |
| 303 | + Arr::set($orderData, 'billing_address.email', $email); | |
| 304 | + if (!$customer) { | |
| 305 | + // Reuse the email's customer for the purchase without granting ownership. | |
| 306 | + $customer = Customer::query()->where('email', $email)->orderBy('id')->first(); | |
| 241 | 307 | } |
| 242 | - $customer = $cartCheckoutHelper->getCustomer($customerEmail); | |
| 308 | + | |
| 243 | 309 | return static::createCustomerWithAddress( |
| 244 | 310 | $customer, |
| 245 | 311 | $orderData, |
| 246 | 312 | $orderData['billing_address'], |
| @@ -253,11 +319,13 @@ | ||
| 253 | 319 | $shippingAddressId = Arr::get($data, 'shipping_address_id'); |
| 254 | 320 | $billingAddressId = Arr::get($data, 'billing_address_id'); |
| 255 | 321 | $orderId = Arr::get($data, 'order_id', null); |
| 256 | 322 | |
| 323 | + $currentCustomer = is_user_logged_in() ? ApiCustomerResource::getCurrentCustomer() : null; | |
| 324 | + | |
| 257 | 325 | $shipToDifferent = Arr::get($data, 'ship_to_different', 'no'); |
| 258 | 326 | |
| 259 | - $datKeys = ['country', 'address_1', 'address_2', 'city', 'state', 'postcode', 'phone', 'label']; | |
| 327 | + $datKeys = ['country', 'address_1', 'address_2', 'city', 'state', 'postcode', 'phone', 'label', 'company_name', 'vat_number', 'legal_registration_id']; | |
| 260 | 328 | |
| 261 | 329 | if ($billingAddressId) { |
| 262 | 330 | $prevOrder = Order::query()->find($orderId); |
| 263 | 331 | $prevBillingId = null; |
| @@ -272,27 +340,28 @@ | ||
| 272 | 340 | ->where('id', $billingAddressId) |
| 273 | 341 | ->where('type', 'billing') |
| 274 | 342 | ->first(); |
| 275 | 343 | } |
| 276 | - if (empty($billingAddress)) { | |
| 344 | + if (empty($billingAddress) && $currentCustomer) { | |
| 277 | 345 | $billingAddress = CustomerAddresses::query() |
| 278 | 346 | ->where('id', $billingAddressId) |
| 279 | 347 | ->where('type', 'billing') |
| 348 | + ->where('customer_id', $currentCustomer->id) | |
| 280 | 349 | ->first(); |
| 281 | 350 | } |
| 282 | 351 | |
| 283 | 352 | if ($billingAddress) { |
| 284 | 353 | foreach ($datKeys as $key) { |
| 285 | - // Guest user, take data from form | |
| 286 | - if (!is_user_logged_in()) { | |
| 287 | - $data['billing_' . $key] = Arr::get($data, 'billing_' . $key, ''); | |
| 288 | - } else { | |
| 289 | - $data['billing_' . $key] = $billingAddress->{$key}; | |
| 290 | - } | |
| 354 | + $data['billing_' . $key] = $billingAddress->{$key} ?: Arr::get($data, 'billing_' . $key, ''); | |
| 291 | 355 | } |
| 292 | 356 | } |
| 293 | 357 | } |
| 294 | 358 | |
| 359 | + if (Arr::get($data, 'is_business', 'no') !== 'yes' && !CheckoutFieldsSchema::isB2BOnlyMode()) { | |
| 360 | + $data['billing_company_name'] = ''; | |
| 361 | + $data['billing_legal_registration_id'] = ''; | |
| 362 | + } | |
| 363 | + | |
| 295 | 364 | if ($shippingAddressId && $shipToDifferent === 'yes') { |
| 296 | 365 | $prevShippingOrder = Order::query()->find($orderId); |
| 297 | 366 | $prevShippingAddress = $prevShippingOrder ? $prevShippingOrder->shipping_address : null; |
| 298 | 367 | $prevShippingId = Arr::get($prevShippingAddress, 'id', null); |
| @@ -302,24 +371,21 @@ | ||
| 302 | 371 | ->where('id', $shippingAddressId) |
| 303 | 372 | ->where('type', 'shipping') |
| 304 | 373 | ->first(); |
| 305 | 374 | } |
| 306 | - if (empty($shippingAddress)) { | |
| 375 | + if (empty($shippingAddress) && $currentCustomer) { | |
| 307 | 376 | $shippingAddress = CustomerAddresses::query() |
| 308 | 377 | ->where('id', $shippingAddressId) |
| 309 | 378 | ->where('type', 'shipping') |
| 379 | + ->where('customer_id', $currentCustomer->id) | |
| 310 | 380 | ->first(); |
| 311 | 381 | } |
| 312 | 382 | |
| 313 | 383 | if ($shippingAddress) { |
| 314 | - $data['shipping_full_name'] = $shippingAddress->name; | |
| 384 | + $formFullName = Arr::get($data, 'shipping_full_name', ''); | |
| 385 | + $data['shipping_full_name'] = $shippingAddress->name ?: $formFullName; | |
| 315 | 386 | foreach ($datKeys as $key) { |
| 316 | - // Guest user, take data from form | |
| 317 | - if (!is_user_logged_in()) { | |
| 318 | - $data['shipping_' . $key] = Arr::get($data, 'shipping_' . $key, ''); | |
| 319 | - } else { | |
| 320 | - $data['shipping_' . $key] = $shippingAddress->{$key}; | |
| 321 | - } | |
| 387 | + $data['shipping_' . $key] = $shippingAddress->{$key} ?: Arr::get($data, 'shipping_' . $key, ''); | |
| 322 | 388 | } |
| 323 | 389 | } |
| 324 | 390 | } else if ($shipToDifferent !== 'yes') { |
| 325 | 391 | // if not different shipping, copy billing to shipping |
| @@ -362,8 +428,10 @@ | ||
| 362 | 428 | } |
| 363 | 429 | |
| 364 | 430 | private static function finalizeOrder(Order $order, $args = []) |
| 365 | 431 | { |
| 432 | + // Duplicate/concurrent submissions are already serialized by the cart-hash lock | |
| 433 | + // at the top of placeOrder() — no per-order lock needed here. | |
| 366 | 434 | AddressHelper::insertOrderAddresses( |
| 367 | 435 | $order->id, |
| 368 | 436 | Arr::get($args, 'billing_address', []), |
| 369 | 437 | Arr::get($args, 'shipping_address', []) |
| @@ -371,15 +439,12 @@ | ||
| 371 | 439 | |
| 372 | 440 | static::syncCustomerNames($order, $args); |
| 373 | 441 | $cart = CartHelper::getCart(); |
| 374 | 442 | |
| 375 | - $utmData = []; | |
| 376 | - if (!empty($cart) && is_array($cart->utm_data) && count($cart->utm_data) > 0) { | |
| 377 | - $utmData = $cart->utm_data; | |
| 378 | - } | |
| 379 | - | |
| 380 | - $requestUtmData = UtmHelper::getUtmDataOfRequest(); | |
| 381 | - $utmData = wp_parse_args($requestUtmData, $utmData); | |
| 443 | + $utmData = UtmHelper::resolveUtmData( | |
| 444 | + UtmHelper::getUtmDataOfRequest(), | |
| 445 | + !empty($cart) ? $cart->utm_data : [] | |
| 446 | + ); | |
| 382 | 447 | UtmHelper::addUtmToOrder($order->id, $utmData); |
| 383 | 448 | |
| 384 | 449 | $prevOrder = Arr::get($args, 'prev_order', null); |
| 385 | 450 | |
| @@ -399,11 +464,27 @@ | ||
| 399 | 464 | } |
| 400 | 465 | |
| 401 | 466 | $paymentInstance = new PaymentInstance($order); |
| 402 | 467 | |
| 468 | + // Transition subscription from pending → intended before submitting to the gateway | |
| 469 | + if ($paymentInstance->subscription && $paymentInstance->subscription->status === Status::SUBSCRIPTION_PENDING) { | |
| 470 | + $paymentInstance->subscription->status = Status::SUBSCRIPTION_INTENDED; | |
| 471 | + $paymentInstance->subscription->save(); | |
| 472 | + } | |
| 473 | + | |
| 403 | 474 | $data = $gateway->makePaymentFromPaymentInstance($paymentInstance); |
| 404 | 475 | |
| 405 | 476 | if (is_wp_error($data)) { |
| 477 | + // Server-observed create failure: mark the transaction FAILED so the next | |
| 478 | + // resubmit is a RETRY (payment_attempt bump -> fresh idempotency seed) — | |
| 479 | + // gateways cache error responses under the key, so keeping it pending would | |
| 480 | + // replay the same error on every resubmit. Client-side declines stay pending | |
| 481 | + // on purpose: there the same key resolving to the same gateway object IS the | |
| 482 | + // retry path. | |
| 483 | + if ($paymentInstance->transaction && $paymentInstance->transaction->status === Status::PAYMENT_PENDING) { | |
| 484 | + $paymentInstance->transaction->update(['status' => Status::PAYMENT_FAILED]); | |
| 485 | + } | |
| 486 | + | |
| 406 | 487 | wp_send_json([ |
| 407 | 488 | 'status' => 'failed', |
| 408 | 489 | 'message' => $data->get_error_message(), |
| 409 | 490 | 'data' => $data->get_error_data() |
| @@ -412,13 +493,48 @@ | ||
| 412 | 493 | |
| 413 | 494 | wp_send_json($data, 200); |
| 414 | 495 | } |
| 415 | 496 | |
| 497 | + /** | |
| 498 | + * Serialize checkout submissions per cart with a MySQL named lock. | |
| 499 | + * | |
| 500 | + * Keyed on cart_hash (not order id) so concurrent FIRST submissions — no draft | |
| 501 | + * order yet — contend on the same lock. Release goes through a shutdown function, | |
| 502 | + * not try/finally: wp_send_json() exits via die() (skips finally), and persistent | |
| 503 | + * DB connections don't drop the lock on request end. | |
| 504 | + */ | |
| 505 | + private static function acquireCartLock($cartHash) | |
| 506 | + { | |
| 507 | + global $wpdb; | |
| 508 | + | |
| 509 | + // md5 keeps the name inside MySQL's 64-char lock-name limit regardless of | |
| 510 | + // table-prefix length; the prefix scopes the lock per site on multisite. | |
| 511 | + $lockName = 'fct_checkout_' . md5($wpdb->prefix . $cartHash); | |
| 512 | + | |
| 513 | + $lockAcquired = (string) $wpdb->get_var( | |
| 514 | + $wpdb->prepare('SELECT GET_LOCK(%s, %d)', $lockName, 10) | |
| 515 | + ) === '1'; | |
| 516 | + | |
| 517 | + if (!$lockAcquired) { | |
| 518 | + wp_send_json([ | |
| 519 | + 'status' => 'failed', | |
| 520 | + 'message' => __('This order is already being processed. Please wait a moment — do not refresh or resubmit.', 'fluent-cart'), | |
| 521 | + 'data' => [] | |
| 522 | + ], 429); | |
| 523 | + } | |
| 524 | + | |
| 525 | + register_shutdown_function(function () use ($lockName) { | |
| 526 | + global $wpdb; | |
| 527 | + $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lockName)); | |
| 528 | + }); | |
| 529 | + } | |
| 530 | + | |
| 416 | 531 | private static function syncCustomerNames($order, $args) |
| 417 | 532 | { |
| 418 | 533 | $customer = $order->customer; |
| 419 | 534 | |
| 420 | - if (empty($customer)) { | |
| 535 | + if (empty($customer) || !is_user_logged_in() || (int) $customer->user_id !== get_current_user_id() | |
| 536 | + || EmailVerificationService::isRequired(get_current_user_id())) { | |
| 421 | 537 | return; |
| 422 | 538 | } |
| 423 | 539 | |
| 424 | 540 | $firstName = Arr::get($args, 'billing_address.first_name'); |
| @@ -428,18 +544,13 @@ | ||
| 428 | 544 | 'first_name' => $firstName, |
| 429 | 545 | 'last_name' => $lastName, |
| 430 | 546 | ]); |
| 431 | 547 | |
| 432 | - $user = get_user_by('email', $customer->email); | |
| 433 | - | |
| 434 | - if (empty($user)) { | |
| 435 | - return; | |
| 548 | + // Keep profile updates tied to the buyer's stored account link too. | |
| 549 | + if (is_user_logged_in() && (int) $customer->user_id === get_current_user_id()) { | |
| 550 | + update_user_meta(get_current_user_id(), 'first_name', $firstName); | |
| 551 | + update_user_meta(get_current_user_id(), 'last_name', $lastName); | |
| 436 | 552 | } |
| 437 | - | |
| 438 | - if (is_user_logged_in() && $user->ID === get_current_user_id()) { | |
| 439 | - update_user_meta($user->ID, 'first_name', $firstName); | |
| 440 | - update_user_meta($user->ID, 'last_name', $lastName); | |
| 441 | - } | |
| 442 | 553 | } |
| 443 | 554 | |
| 444 | 555 | public static function updateStock($order) |
| 445 | 556 | { |
| @@ -467,16 +578,18 @@ | ||
| 467 | 578 | if ($current_user->ID) { |
| 468 | 579 | $billingAddress['email'] = $current_user->user_email; |
| 469 | 580 | $billingAddress['user_id'] = $current_user->ID; |
| 470 | 581 | } else { |
| 471 | - static::handleUserCreation($orderData, $billingAddress); | |
| 582 | + unset($billingAddress['user_id']); | |
| 472 | 583 | } |
| 473 | 584 | |
| 474 | 585 | $customer = CustomerResource::create($billingAddress); |
| 475 | 586 | $customer = Arr::get($customer, 'data', null); |
| 476 | 587 | $customerId = Arr::get($customer, 'id', null); |
| 477 | - static::createCustomerAddress($billingAddress, $customerId); | |
| 478 | - static::createCustomerAddress($shippingAddress, $customerId); | |
| 588 | + if ($customer && $customer->wasRecentlyCreated) { | |
| 589 | + static::createCustomerAddress($billingAddress, $customerId); | |
| 590 | + static::createCustomerAddress($shippingAddress, $customerId); | |
| 591 | + } | |
| 479 | 592 | |
| 480 | 593 | return $customer; |
| 481 | 594 | } |
| 482 | 595 | |
| @@ -481,17 +594,13 @@ | ||
| 481 | 594 | } |
| 482 | 595 | |
| 483 | 596 | private static function updateExistingCustomer($customer, $orderData, $billingAddress, $shippingAddress) |
| 484 | 597 | { |
| 485 | - if (empty($customer->user_id)) { | |
| 486 | - $currentLoggedInUser = wp_get_current_user(); | |
| 487 | - if ($currentLoggedInUser && $currentLoggedInUser->user_email === $customer->email) { | |
| 488 | - $userId = get_current_user_id(); | |
| 489 | - $customer->update(['user_id' => $userId]); | |
| 490 | - $billingAddress['user_id'] = $userId; | |
| 491 | - } | |
| 598 | + // Order addresses come from this checkout; saved profile data needs proof. | |
| 599 | + if (!is_user_logged_in() || (int) $customer->user_id !== get_current_user_id() | |
| 600 | + || EmailVerificationService::isRequired(get_current_user_id())) { | |
| 601 | + return; | |
| 492 | 602 | } |
| 493 | - | |
| 494 | 603 | $customer->load(['billing_address', 'shipping_address']); |
| 495 | 604 | |
| 496 | 605 | if ($customer->billing_address->count() < 1) { |
| 497 | 606 | static::createCustomerAddress($billingAddress, $customer->id); |
| @@ -498,25 +607,10 @@ | ||
| 498 | 607 | } |
| 499 | 608 | if ($customer->shipping_address->count() < 1) { |
| 500 | 609 | static::createCustomerAddress($shippingAddress, $customer->id); |
| 501 | 610 | } |
| 502 | - | |
| 503 | - static::handleUserCreation($orderData, $billingAddress, $customer); | |
| 504 | 611 | } |
| 505 | 612 | |
| 506 | - private static function handleUserCreation($orderData, &$billingAddress, $customer = null) | |
| 507 | - { | |
| 508 | - $userEmail = Arr::get($billingAddress, 'email'); | |
| 509 | - $user = get_user_by('email', $userEmail); | |
| 510 | - | |
| 511 | - if ($user) { | |
| 512 | - $billingAddress['user_id'] = $user->ID; | |
| 513 | - if ($customer) { | |
| 514 | - $customer->update(['user_id' => $user->ID]); | |
| 515 | - } | |
| 516 | - } | |
| 517 | - } | |
| 518 | - | |
| 519 | 613 | private static function getCustomerEmail($billingAddress) |
| 520 | 614 | { |
| 521 | 615 | return is_user_logged_in() ? wp_get_current_user()->user_email : $billingAddress['email']; |
| 522 | 616 | } |
| @@ -608,12 +702,12 @@ | ||
| 608 | 702 | |
| 609 | 703 | $billingValidations = array_filter(CheckoutFieldsSchema::getCheckoutFieldsRequirements('billing', $fulfillmentType, !$isDifferentShipping)); |
| 610 | 704 | |
| 611 | 705 | // Name fields are validated separately below (full_name/first_name/last_name) |
| 612 | - unset($billingValidations['full_name'], $billingValidations['first_name'], $billingValidations['last_name'], $billingValidations['company_name']); | |
| 706 | + // vat_number uses field name fct_billing_tax_id and is validated separately in the B2B block below | |
| 707 | + unset($billingValidations['full_name'], $billingValidations['first_name'], $billingValidations['last_name'], $billingValidations['vat_number']); | |
| 613 | 708 | |
| 614 | - if (!isset($billingValidations['country'])) { | |
| 615 | - // get store country | |
| 709 | + if (!isset($billingValidations['country']) && empty($data['billing_country'])) { | |
| 616 | 710 | $data['billing_country'] = (new StoreSettings())->get('store_country'); |
| 617 | 711 | } |
| 618 | 712 | |
| 619 | 713 | $billingAddress = []; |
| @@ -620,10 +714,11 @@ | ||
| 620 | 714 | foreach ($billingValidations as $key => $billingValidation) { |
| 621 | 715 | $billingAddress[$key] = Arr::get($data, 'billing_' . $key, ''); |
| 622 | 716 | } |
| 623 | 717 | if (!isset($billingAddress['country'])) { |
| 624 | - // get store country | |
| 625 | - $billingAddress['country'] = (new StoreSettings())->get('store_country'); | |
| 718 | + $billingAddress['country'] = !empty($data['billing_country']) | |
| 719 | + ? $data['billing_country'] | |
| 720 | + : (new StoreSettings())->get('store_country'); | |
| 626 | 721 | } |
| 627 | 722 | |
| 628 | 723 | $shippingAddress = []; |
| 629 | 724 | $shippingValidations = []; |
| @@ -652,10 +747,15 @@ | ||
| 652 | 747 | |
| 653 | 748 | $agreeTermsRequired = CheckoutFieldsSchema::isTermsRequired(); |
| 654 | 749 | |
| 655 | 750 | $customTitles = [ |
| 656 | - 'address_1' => 'Street Address', | |
| 657 | - 'address_2' => 'Apt, Suite, Unit', | |
| 751 | + 'address_1' => __('Street Address', 'fluent-cart'), | |
| 752 | + 'address_2' => __('Apt, Suite, Unit', 'fluent-cart'), | |
| 753 | + 'country' => __('Country', 'fluent-cart'), | |
| 754 | + 'state' => __('State', 'fluent-cart'), | |
| 755 | + 'city' => __('City', 'fluent-cart'), | |
| 756 | + 'postcode' => __('Postcode', 'fluent-cart'), | |
| 757 | + 'phone' => __('Phone', 'fluent-cart'), | |
| 658 | 758 | ]; |
| 659 | 759 | |
| 660 | 760 | foreach ($billingValidations as $key => $rule) { |
| 661 | 761 | $value = Arr::get($billingAddress, $key, ''); |
| @@ -839,8 +939,31 @@ | ||
| 839 | 939 | } |
| 840 | 940 | } |
| 841 | 941 | } |
| 842 | 942 | |
| 943 | + $isB2B = Arr::get($data, 'is_business', 'no') === 'yes' || CheckoutFieldsSchema::isB2BOnlyMode(); | |
| 944 | + | |
| 945 | + if ($isB2B && CheckoutFieldsSchema::isVatNumberRequired()) { | |
| 946 | + $vatNumber = Arr::get($data, 'fct_billing_tax_id', ''); | |
| 947 | + if (empty($vatNumber)) { | |
| 948 | + $errors['fct_billing_tax_id']['required'] = __('VAT / Tax ID is required.', 'fluent-cart'); | |
| 949 | + } | |
| 950 | + } | |
| 951 | + | |
| 952 | + if ($isB2B && CheckoutFieldsSchema::isCompanyNameRequired()) { | |
| 953 | + $companyName = Arr::get($data, 'billing_company_name', ''); | |
| 954 | + if (empty($companyName)) { | |
| 955 | + $errors['billing_company_name']['required'] = __('Company Name is required.', 'fluent-cart'); | |
| 956 | + } | |
| 957 | + } | |
| 958 | + | |
| 959 | + if ($isB2B && CheckoutFieldsSchema::isLegalRegistrationIdRequired()) { | |
| 960 | + $legalRegId = Arr::get($data, 'billing_legal_registration_id', ''); | |
| 961 | + if (empty($legalRegId)) { | |
| 962 | + $errors['billing_legal_registration_id']['required'] = __('Legal Registration ID is required.', 'fluent-cart'); | |
| 963 | + } | |
| 964 | + } | |
| 965 | + | |
| 843 | 966 | if (empty($data['agree_terms']) && $agreeTermsRequired) { |
| 844 | 967 | $errors['agree_terms']['required'] = __('You must agree to the terms and conditions.', 'fluent-cart'); |
| 845 | 968 | } |
| 846 | 969 | |
| @@ -867,9 +990,16 @@ | ||
| 867 | 990 | |
| 868 | 991 | |
| 869 | 992 | if ($cart->requireShipping()) { |
| 870 | 993 | if (!empty($data['fc_selected_shipping_method'])) { |
| 871 | - $selectedMethod = $data['fc_selected_shipping_method']; | |
| 994 | + // One integer, decided here, is both what is checked and what placeOrder() prices. | |
| 995 | + // A loose compare let PHP 7.4 match "1<b>2" to method 1, and sanitize_text_field() | |
| 996 | + // then turned the same string into "12", so the order was priced by method 12. | |
| 997 | + $rawMethod = $data['fc_selected_shipping_method']; | |
| 998 | + $isPlainId = (is_string($rawMethod) || is_int($rawMethod)) && (string) absint($rawMethod) === (string) $rawMethod; | |
| 999 | + $selectedMethod = $isPlainId ? absint($rawMethod) : 0; | |
| 1000 | + $data['fc_selected_shipping_method'] = $selectedMethod; | |
| 1001 | + | |
| 872 | 1002 | $shippingCountry = Arr::get($data, 'billing_country', ''); |
| 873 | 1003 | $shippingState = Arr::get($data, 'billing_state', ''); |
| 874 | 1004 | $shipToDifferent = Arr::get($data, 'ship_to_different', 'no') === 'yes'; |
| 875 | 1005 | |
| @@ -885,9 +1015,9 @@ | ||
| 885 | 1015 | $errors['shipping_method']['unavailable'] = __('We don\'t ship to this address. Please select a different address.', 'fluent-cart'); |
| 886 | 1016 | } else { |
| 887 | 1017 | $found = false; |
| 888 | 1018 | foreach ($availableShippingMethods as $shippingMethod) { |
| 889 | - if ($shippingMethod->id == $selectedMethod) { | |
| 1019 | + if ((int) $shippingMethod->id === $selectedMethod) { | |
| 890 | 1020 | $found = true; |
| 891 | 1021 | break; |
| 892 | 1022 | } |
| 893 | 1023 | } |
| @@ -908,9 +1038,9 @@ | ||
| 908 | 1038 | 'cart' => $cart |
| 909 | 1039 | ]); |
| 910 | 1040 | |
| 911 | 1041 | if (count($errors) > 0) { |
| 912 | - return new \Wp_Error('validation_error', 'Validation error', $errors); | |
| 1042 | + return new \Wp_Error('validation_error', __('Validation error', 'fluent-cart'), $errors); | |
| 913 | 1043 | } |
| 914 | 1044 | |
| 915 | 1045 | return $data; |
| 916 | 1046 | } |