| @@ -21,8 +21,9 @@ | ||
| 21 | 21 | use FluentCart\App\Models\Order; |
| 22 | 22 | use FluentCart\App\Models\OrderAddress; |
| 23 | 23 | use FluentCart\App\Models\ShippingMethod; |
| 24 | 24 | use FluentCart\App\Services\CheckoutService; |
| 25 | +use FluentCart\App\Services\CustomerIdentity\EmailVerificationService; | |
| 25 | 26 | use FluentCart\App\Services\Localization\LocalizationManager; |
| 26 | 27 | use FluentCart\App\Services\OrderService; |
| 27 | 28 | use FluentCart\App\Services\Payments\PaymentHelper; |
| 28 | 29 | use FluentCart\App\Services\Payments\PaymentInstance; |
| @@ -54,11 +55,37 @@ | ||
| 54 | 55 | 'message' => __('Cart is empty or already completed', 'fluent-cart'), |
| 55 | 56 | ]); |
| 56 | 57 | } |
| 57 | 58 | |
| 59 | + // Serialize all submissions of one cart BEFORE the prevOrder read: locking later | |
| 60 | + // (or per-order) lets two concurrent first submissions both see prevOrder = null | |
| 61 | + // and create two orders -> two idempotency keys -> double charge. | |
| 62 | + static::acquireCartLock($cart->cart_hash); | |
| 63 | + | |
| 64 | + // Re-read under the lock (fresh(), not getCart() — that one is request-cached): | |
| 65 | + // a submission we waited on may have completed this cart meanwhile. | |
| 66 | + $cart = $cart->fresh(); | |
| 67 | + if (!$cart || !$cart->cart_data || $cart->stage === 'completed') { | |
| 68 | + wp_send_json([ | |
| 69 | + 'status' => 'failed', | |
| 70 | + 'message' => __('Cart is empty or already completed', 'fluent-cart'), | |
| 71 | + ]); | |
| 72 | + } | |
| 73 | + | |
| 58 | 74 | $cart = $cart->reValidateCoupons(); |
| 59 | 75 | |
| 60 | 76 | $cartData = $cart->cart_data; |
| 77 | + | |
| 78 | + // Carts stored before the quantity ceiling existed can still hold an overflowing line. | |
| 79 | + foreach ($cartData as $cartItem) { | |
| 80 | + $quantityError = CartHelper::validateQuantity(Arr::get($cartItem, 'quantity', 1)); | |
| 81 | + if ($quantityError) { | |
| 82 | + wp_send_json([ | |
| 83 | + 'status' => 'failed', | |
| 84 | + 'message' => $quantityError->get_error_message(), | |
| 85 | + ], 422); | |
| 86 | + } | |
| 87 | + } | |
| 61 | 88 | $prevOrder = $cart->order; |
| 62 | 89 | if ($prevOrder) { |
| 63 | 90 | $prevOrder->load('order_items'); |
| 64 | 91 | } |
| @@ -69,12 +96,28 @@ | ||
| 69 | 96 | in_array($prevOrder->status, Status::getOrderSuccessStatuses()) || |
| 70 | 97 | !in_array($prevOrder->payment_status, Status::getPaymentRetryableStatuses()) |
| 71 | 98 | ) |
| 72 | 99 | ) { |
| 73 | - wp_send_json([ | |
| 74 | - 'status' => 'failed', | |
| 75 | - 'message' => __('You have already completed this order.', 'fluent-cart'), | |
| 76 | - ]); | |
| 100 | + if ($isLockedCart) { | |
| 101 | + // Locked carts are bound to a specific order (e.g. pay-for-order links), | |
| 102 | + // so a finalized order really means there is nothing left to pay. | |
| 103 | + wp_send_json([ | |
| 104 | + 'status' => 'failed', | |
| 105 | + 'message' => __('You have already completed this order.', 'fluent-cart'), | |
| 106 | + ]); | |
| 107 | + } | |
| 108 | + | |
| 109 | + // The linked order is already finalized but the cart was never marked | |
| 110 | + // completed (e.g. a stale cart resurrected by the logged-in user lookup). | |
| 111 | + // Detach the dead order so the customer can check out again instead of | |
| 112 | + // being blocked on every future purchase. | |
| 113 | + $cart->order_id = null; | |
| 114 | + $checkoutData = $cart->checkout_data; | |
| 115 | + unset($checkoutData['is_locked']); | |
| 116 | + $cart->checkout_data = $checkoutData; | |
| 117 | + $cart->save(); | |
| 118 | + $prevOrder = null; | |
| 119 | + $isLockedCart = false; | |
| 77 | 120 | } |
| 78 | 121 | |
| 79 | 122 | $data = static::addLoggedUserData($data); |
| 80 | 123 | |
| @@ -88,8 +131,12 @@ | ||
| 88 | 131 | 'message' => $validation->get_error_message(), |
| 89 | 132 | ], 403); |
| 90 | 133 | } |
| 91 | 134 | |
| 135 | + // order_id unlocks another order's addresses in prepareAddressData(), so it | |
| 136 | + // may only come from this cart's own order, never from the request. | |
| 137 | + unset($data['order_id']); | |
| 138 | + | |
| 92 | 139 | if (empty($data['billing_address_id'])) { |
| 93 | 140 | if ($prevOrder instanceof Order) { |
| 94 | 141 | $oldCustomer = $prevOrder->customer; |
| 95 | 142 | if ($oldCustomer) { |
| @@ -118,23 +165,22 @@ | ||
| 118 | 165 | ]); |
| 119 | 166 | } |
| 120 | 167 | |
| 121 | 168 | if (!CheckoutFieldsSchema::isFullNameRequired()) { |
| 122 | - if (!empty($validatedData['billing_full_name']) && empty($validatedData['billing_first_name'])) { | |
| 123 | - // Modal checkout sends billing_full_name — split into first/last name | |
| 124 | - $nameParts = explode(' ', $validatedData['billing_full_name'], 2); | |
| 125 | - $validatedData['billing_first_name'] = $nameParts[0]; | |
| 126 | - $validatedData['billing_last_name'] = $nameParts[1] ?? ''; | |
| 127 | - } else { | |
| 128 | - $validatedData['billing_full_name'] = trim( | |
| 129 | - Arr::get($validatedData, 'billing_first_name') . ' ' . Arr::get($validatedData, 'billing_last_name') | |
| 130 | - ); | |
| 131 | - } | |
| 169 | + // First/Last name mode: the form posts those fields; the full name is derived from them. | |
| 170 | + $validatedData['billing_full_name'] = trim( | |
| 171 | + Arr::get($validatedData, 'billing_first_name') . ' ' . Arr::get($validatedData, 'billing_last_name') | |
| 172 | + ); | |
| 132 | 173 | } |
| 133 | 174 | |
| 134 | 175 | $orderData = OrderService::groupSanitizedData($validatedData); |
| 135 | 176 | |
| 136 | - $shippingMethodId = Arr::get($orderData, 'others.fc_shipping_method'); | |
| 177 | + // The form posts the method twice: the checked radio (fc_shipping_method) and its | |
| 178 | + // hidden mirror (fc_selected_shipping_method). validateData() checks only the mirror | |
| 179 | + // against the address's zones, so pricing from the radio let a request pass with one | |
| 180 | + // method and be charged by another, from a zone the address is not in. Read from | |
| 181 | + // $validatedData, not the sanitized copy in others: that is the exact integer checked. | |
| 182 | + $shippingMethodId = (int) Arr::get($validatedData, 'fc_selected_shipping_method', 0); | |
| 137 | 183 | |
| 138 | 184 | $shippingMethod = null; |
| 139 | 185 | $shippingCharge = 0; |
| 140 | 186 | if (!$cartCheckoutService->isAllDigital()) { |
| @@ -165,16 +211,43 @@ | ||
| 165 | 211 | $customer = static::getOrCreateCustomer($cartCheckoutHelper, $orderData); |
| 166 | 212 | |
| 167 | 213 | $shouldCreateUser = static::shouldCreateUser($orderData, Arr::get($orderData, 'billing_address', [])); |
| 168 | 214 | |
| 169 | - // Ensure cart has the current payment method before recalculating fees | |
| 170 | - $checkoutData = $cart->checkout_data ?? []; | |
| 171 | - $checkoutData['payment_method'] = $paymentMethod; | |
| 172 | - $cart->checkout_data = $checkoutData; | |
| 215 | + // Ensure cart has the current payment method, the order's email and the | |
| 216 | + // submitted checkout fields before recalculating fees. The page saves | |
| 217 | + // each of them separately, and that save can lag or be sent on its own, | |
| 218 | + // so anything that prices this order reads what the order is placed under. | |
| 219 | + static::setPaymentMethod($cart, $paymentMethod); | |
| 220 | + static::setFormFields($cart, $validatedData); | |
| 221 | + $cart->email = $customer->email; | |
| 173 | 222 | |
| 223 | + // The helper's instance prices the order shipping charge below, so it | |
| 224 | + // carries the same method, fields and email from here on. | |
| 225 | + $helperCart = $cartCheckoutHelper->getCart(); | |
| 226 | + if ($helperCart && $helperCart !== $cart) { | |
| 227 | + static::setPaymentMethod($helperCart, $paymentMethod); | |
| 228 | + static::setFormFields($helperCart, $validatedData); | |
| 229 | + $helperCart->email = $customer->email; | |
| 230 | + } | |
| 231 | + | |
| 174 | 232 | $cart->clearFeeCache(); |
| 175 | 233 | $fees = $cart->getFees(); |
| 176 | 234 | |
| 235 | + // A pricing rule can make this order's postage cheaper. The cart holds the | |
| 236 | + // cheaper figure during the tax recalculation, then gets the real price back. | |
| 237 | + // Shipping tax itself comes from each line's share, which TaxModule discounts | |
| 238 | + // through this same filter. Its own hook keeps fluent_cart/cart/shipping_total, | |
| 239 | + // and the Shipping row it draws, untouched. | |
| 240 | + $preDiscountShippingCharge = (int)$shippingCharge; | |
| 241 | + $shippingCharge = apply_filters('fluent_cart/checkout/order_shipping_charge', $shippingCharge, [ | |
| 242 | + 'cart' => $cartCheckoutHelper->getCart(), | |
| 243 | + ]); | |
| 244 | + $shippingDiscount = max(0, $preDiscountShippingCharge - (int)$shippingCharge); | |
| 245 | + | |
| 246 | + if ($shippingDiscount > 0) { | |
| 247 | + static::setCartShippingCharge($cart, (int)$shippingCharge); | |
| 248 | + } | |
| 249 | + | |
| 177 | 250 | // Recompute cart tax data so fee_tax/fee_tax_lines reflect fees for the current |
| 178 | 251 | // payment method. The scope prevents ShippingModule from running unnecessarily. |
| 179 | 252 | do_action('fluent_cart/cart/cart_data_items_updated', ['cart' => $cart, 'scope' => 'payment_method_fee_recalculate']); |
| 180 | 253 | |
| @@ -189,12 +262,43 @@ | ||
| 189 | 262 | $exclusiveTaxTotal = (int)Arr::get($cart->checkout_data, 'tax_data.exclusive_tax_total', 0); |
| 190 | 263 | $feeTax = (int)Arr::get($cart->checkout_data, 'tax_data.fee_tax', 0); |
| 191 | 264 | $feeTaxLines = (array)Arr::get($cart->checkout_data, 'tax_data.fee_tax_lines', []); |
| 192 | 265 | |
| 266 | + // Tax is done, so the cart gets its real postage price back. The rule runs | |
| 267 | + // fresh on every place-order, and a customer returning after a failed payment | |
| 268 | + // must never be discounted from an already discounted charge. Saved here | |
| 269 | + // because nothing after this writes the cart. | |
| 270 | + if ($shippingDiscount > 0) { | |
| 271 | + static::setCartShippingCharge($cart, $preDiscountShippingCharge); | |
| 272 | + $cart->save(); | |
| 273 | + } | |
| 274 | + | |
| 193 | 275 | // For dynamic RC + inclusive pricing, reduce the shipping charge to net before storing. |
| 194 | 276 | // The fluent_cart/cart/shipping_total filter (registered by TaxModule) handles the logic. |
| 195 | 277 | $shippingCharge = apply_filters('fluent_cart/cart/shipping_total', $shippingCharge, ['cart' => $cart]); |
| 196 | 278 | |
| 279 | + // The discount has to be counted in the same money as the charge it is | |
| 280 | + // stored beside, or a receipt adding the two back together names a price | |
| 281 | + // the order never had. The filter above can turn both into net amounts, | |
| 282 | + // and it stops at zero, so the full price goes through it as well. | |
| 283 | + if ($shippingDiscount > 0) { | |
| 284 | + $preDiscountShippingCharge = (int)apply_filters( | |
| 285 | + 'fluent_cart/cart/shipping_total', | |
| 286 | + $preDiscountShippingCharge, | |
| 287 | + ['cart' => $cart] | |
| 288 | + ); | |
| 289 | + $shippingDiscount = max(0, $preDiscountShippingCharge - (int)$shippingCharge); | |
| 290 | + } | |
| 291 | + | |
| 292 | + // Last chance to write into cart_data before the order is built from it. | |
| 293 | + // The helper's copy was read before $cart was recalculated; build the order | |
| 294 | + // from $cart so a discount withdrawn on a retry can't be counted twice. | |
| 295 | + $cartCheckoutHelper->setCart($cart); | |
| 296 | + | |
| 297 | + do_action('fluent_cart/checkout/before_order_processing', [ | |
| 298 | + 'cart' => $cart, | |
| 299 | + ]); | |
| 300 | + | |
| 197 | 301 | $checkoutProcessor = new CheckoutProcessor($cartCheckoutHelper->getItems(), [ |
| 198 | 302 | 'customer_id' => $customer->id, |
| 199 | 303 | 'user_tz' => $userTz, |
| 200 | 304 | 'create_account_after_paid' => $shouldCreateUser ? 'yes' : 'no', |
| @@ -200,8 +304,9 @@ | ||
| 200 | 304 | 'create_account_after_paid' => $shouldCreateUser ? 'yes' : 'no', |
| 201 | 305 | 'shipping_charge' => $shippingCharge, |
| 202 | 306 | 'shipping_method_id' => $shippingMethod ? (int)$shippingMethod->id : 0, |
| 203 | 307 | 'shipping_method_title' => $shippingMethod ? $shippingMethod->title : '', |
| 308 | + 'shipping_discount' => $shippingDiscount, | |
| 204 | 309 | 'tax_total' => $taxTotal, |
| 205 | 310 | 'tax_behavior' => $taxBehavior, |
| 206 | 311 | 'store_tax_behavior' => $storeTaxBehavior, |
| 207 | 312 | 'exclusive_tax_total' => $exclusiveTaxTotal, |
| @@ -258,16 +363,90 @@ | ||
| 258 | 363 | 'prev_order' => $prevOrder |
| 259 | 364 | ]); |
| 260 | 365 | } |
| 261 | 366 | |
| 367 | + /** | |
| 368 | + * Set the postage the cart advertises. | |
| 369 | + * | |
| 370 | + * The tax pipeline reads the charge from here rather than from any local | |
| 371 | + * amount, so this is how a shipping discount is made visible to it. Does not | |
| 372 | + * persist — the caller decides, because during a tax recalculation the | |
| 373 | + * recalculation saves the cart itself. | |
| 374 | + * | |
| 375 | + * @param Cart $cart | |
| 376 | + * @param int $charge In cents. | |
| 377 | + * @return void | |
| 378 | + */ | |
| 379 | + private static function setCartShippingCharge(Cart $cart, $charge) | |
| 380 | + { | |
| 381 | + $checkoutData = $cart->checkout_data ?? []; | |
| 382 | + Arr::set($checkoutData, 'shipping_data.shipping_charge', (int)$charge); | |
| 383 | + $cart->checkout_data = $checkoutData; | |
| 384 | + } | |
| 385 | + | |
| 386 | + /** | |
| 387 | + * Record the method this order is paid with as both the cart's payment | |
| 388 | + * method and the form's choice, so nothing that prices the order reads an | |
| 389 | + * earlier click instead — the page saves the radio separately, and that | |
| 390 | + * save can lag, name a method no longer offered, or be sent on its own. | |
| 391 | + * | |
| 392 | + * @param Cart $cart | |
| 393 | + * @param string $paymentMethod | |
| 394 | + * @return void | |
| 395 | + */ | |
| 396 | + private static function setPaymentMethod(Cart $cart, $paymentMethod) | |
| 397 | + { | |
| 398 | + $checkoutData = $cart->checkout_data ?? []; | |
| 399 | + $checkoutData['payment_method'] = $paymentMethod; | |
| 400 | + Arr::set($checkoutData, 'form_data._fct_pay_method', $paymentMethod); | |
| 401 | + $cart->checkout_data = $checkoutData; | |
| 402 | + } | |
| 403 | + | |
| 404 | + /** | |
| 405 | + * Record the submitted checkout fields as the cart's form, so nothing that | |
| 406 | + * prices the order reads an earlier save instead. A field the form did not | |
| 407 | + * send keeps its saved value. | |
| 408 | + * | |
| 409 | + * Country, state, city, postcode, the ship-elsewhere and business toggles | |
| 410 | + * are left as saved: tax is worked out from them, and the page refreshes | |
| 411 | + * the summary each time one of them is saved, so the saved values are the | |
| 412 | + * ones the shopper saw the order priced for. | |
| 413 | + * | |
| 414 | + * @param Cart $cart | |
| 415 | + * @param array $data | |
| 416 | + * @return void | |
| 417 | + */ | |
| 418 | + private static function setFormFields(Cart $cart, array $data) | |
| 419 | + { | |
| 420 | + $checkoutData = $cart->checkout_data ?? []; | |
| 421 | + | |
| 422 | + $fields = [ | |
| 423 | + 'full_name', 'first_name', 'last_name', 'address_1', 'address_2', | |
| 424 | + 'phone', 'company_name', 'legal_registration_id' | |
| 425 | + ]; | |
| 426 | + | |
| 427 | + foreach ($fields as $field) { | |
| 428 | + foreach (['billing_', 'shipping_'] as $prefix) { | |
| 429 | + $value = Arr::get($data, $prefix . $field); | |
| 430 | + if (is_scalar($value)) { | |
| 431 | + Arr::set($checkoutData, 'form_data.' . $prefix . $field, sanitize_text_field((string)$value)); | |
| 432 | + } | |
| 433 | + } | |
| 434 | + } | |
| 435 | + | |
| 436 | + $cart->checkout_data = $checkoutData; | |
| 437 | + } | |
| 438 | + | |
| 262 | 439 | private static function getOrCreateCustomer(CartCheckoutHelper $cartCheckoutHelper, $orderData) |
| 263 | 440 | { |
| 264 | - $customerEmail = static::getCustomerEmail($orderData['billing_address']); | |
| 265 | - if (is_user_logged_in()) { | |
| 266 | - $customerEmail = wp_get_current_user()->user_email; | |
| 267 | - Arr::set($orderData, 'billing_address.email', $customerEmail); | |
| 441 | + $customer = is_user_logged_in() ? ApiCustomerResource::getCurrentCustomer() : null; | |
| 442 | + $email = static::getCustomerEmail($orderData['billing_address']); | |
| 443 | + Arr::set($orderData, 'billing_address.email', $email); | |
| 444 | + if (!$customer) { | |
| 445 | + // Reuse the email's customer for the purchase without granting ownership. | |
| 446 | + $customer = Customer::query()->where('email', $email)->orderBy('id')->first(); | |
| 268 | 447 | } |
| 269 | - $customer = $cartCheckoutHelper->getCustomer($customerEmail); | |
| 448 | + | |
| 270 | 449 | return static::createCustomerWithAddress( |
| 271 | 450 | $customer, |
| 272 | 451 | $orderData, |
| 273 | 452 | $orderData['billing_address'], |
| @@ -389,8 +568,10 @@ | ||
| 389 | 568 | } |
| 390 | 569 | |
| 391 | 570 | private static function finalizeOrder(Order $order, $args = []) |
| 392 | 571 | { |
| 572 | + // Duplicate/concurrent submissions are already serialized by the cart-hash lock | |
| 573 | + // at the top of placeOrder() — no per-order lock needed here. | |
| 393 | 574 | AddressHelper::insertOrderAddresses( |
| 394 | 575 | $order->id, |
| 395 | 576 | Arr::get($args, 'billing_address', []), |
| 396 | 577 | Arr::get($args, 'shipping_address', []) |
| @@ -398,15 +579,12 @@ | ||
| 398 | 579 | |
| 399 | 580 | static::syncCustomerNames($order, $args); |
| 400 | 581 | $cart = CartHelper::getCart(); |
| 401 | 582 | |
| 402 | - $utmData = []; | |
| 403 | - if (!empty($cart) && is_array($cart->utm_data) && count($cart->utm_data) > 0) { | |
| 404 | - $utmData = $cart->utm_data; | |
| 405 | - } | |
| 406 | - | |
| 407 | - $requestUtmData = UtmHelper::getUtmDataOfRequest(); | |
| 408 | - $utmData = wp_parse_args($requestUtmData, $utmData); | |
| 583 | + $utmData = UtmHelper::resolveUtmData( | |
| 584 | + UtmHelper::getUtmDataOfRequest(), | |
| 585 | + !empty($cart) ? $cart->utm_data : [] | |
| 586 | + ); | |
| 409 | 587 | UtmHelper::addUtmToOrder($order->id, $utmData); |
| 410 | 588 | |
| 411 | 589 | $prevOrder = Arr::get($args, 'prev_order', null); |
| 412 | 590 | |
| @@ -426,11 +604,27 @@ | ||
| 426 | 604 | } |
| 427 | 605 | |
| 428 | 606 | $paymentInstance = new PaymentInstance($order); |
| 429 | 607 | |
| 608 | + // Transition subscription from pending → intended before submitting to the gateway | |
| 609 | + if ($paymentInstance->subscription && $paymentInstance->subscription->status === Status::SUBSCRIPTION_PENDING) { | |
| 610 | + $paymentInstance->subscription->status = Status::SUBSCRIPTION_INTENDED; | |
| 611 | + $paymentInstance->subscription->save(); | |
| 612 | + } | |
| 613 | + | |
| 430 | 614 | $data = $gateway->makePaymentFromPaymentInstance($paymentInstance); |
| 431 | 615 | |
| 432 | 616 | if (is_wp_error($data)) { |
| 617 | + // Server-observed create failure: mark the transaction FAILED so the next | |
| 618 | + // resubmit is a RETRY (payment_attempt bump -> fresh idempotency seed) — | |
| 619 | + // gateways cache error responses under the key, so keeping it pending would | |
| 620 | + // replay the same error on every resubmit. Client-side declines stay pending | |
| 621 | + // on purpose: there the same key resolving to the same gateway object IS the | |
| 622 | + // retry path. | |
| 623 | + if ($paymentInstance->transaction && $paymentInstance->transaction->status === Status::PAYMENT_PENDING) { | |
| 624 | + $paymentInstance->transaction->update(['status' => Status::PAYMENT_FAILED]); | |
| 625 | + } | |
| 626 | + | |
| 433 | 627 | wp_send_json([ |
| 434 | 628 | 'status' => 'failed', |
| 435 | 629 | 'message' => $data->get_error_message(), |
| 436 | 630 | 'data' => $data->get_error_data() |
| @@ -439,13 +633,48 @@ | ||
| 439 | 633 | |
| 440 | 634 | wp_send_json($data, 200); |
| 441 | 635 | } |
| 442 | 636 | |
| 637 | + /** | |
| 638 | + * Serialize checkout submissions per cart with a MySQL named lock. | |
| 639 | + * | |
| 640 | + * Keyed on cart_hash (not order id) so concurrent FIRST submissions — no draft | |
| 641 | + * order yet — contend on the same lock. Release goes through a shutdown function, | |
| 642 | + * not try/finally: wp_send_json() exits via die() (skips finally), and persistent | |
| 643 | + * DB connections don't drop the lock on request end. | |
| 644 | + */ | |
| 645 | + private static function acquireCartLock($cartHash) | |
| 646 | + { | |
| 647 | + global $wpdb; | |
| 648 | + | |
| 649 | + // md5 keeps the name inside MySQL's 64-char lock-name limit regardless of | |
| 650 | + // table-prefix length; the prefix scopes the lock per site on multisite. | |
| 651 | + $lockName = 'fct_checkout_' . md5($wpdb->prefix . $cartHash); | |
| 652 | + | |
| 653 | + $lockAcquired = (string) $wpdb->get_var( | |
| 654 | + $wpdb->prepare('SELECT GET_LOCK(%s, %d)', $lockName, 10) | |
| 655 | + ) === '1'; | |
| 656 | + | |
| 657 | + if (!$lockAcquired) { | |
| 658 | + wp_send_json([ | |
| 659 | + 'status' => 'failed', | |
| 660 | + 'message' => __('This order is already being processed. Please wait a moment — do not refresh or resubmit.', 'fluent-cart'), | |
| 661 | + 'data' => [] | |
| 662 | + ], 429); | |
| 663 | + } | |
| 664 | + | |
| 665 | + register_shutdown_function(function () use ($lockName) { | |
| 666 | + global $wpdb; | |
| 667 | + $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lockName)); | |
| 668 | + }); | |
| 669 | + } | |
| 670 | + | |
| 443 | 671 | private static function syncCustomerNames($order, $args) |
| 444 | 672 | { |
| 445 | 673 | $customer = $order->customer; |
| 446 | 674 | |
| 447 | - if (empty($customer)) { | |
| 675 | + if (empty($customer) || !is_user_logged_in() || (int) $customer->user_id !== get_current_user_id() | |
| 676 | + || EmailVerificationService::isRequired(get_current_user_id())) { | |
| 448 | 677 | return; |
| 449 | 678 | } |
| 450 | 679 | |
| 451 | 680 | $firstName = Arr::get($args, 'billing_address.first_name'); |
| @@ -455,18 +684,13 @@ | ||
| 455 | 684 | 'first_name' => $firstName, |
| 456 | 685 | 'last_name' => $lastName, |
| 457 | 686 | ]); |
| 458 | 687 | |
| 459 | - $user = get_user_by('email', $customer->email); | |
| 460 | - | |
| 461 | - if (empty($user)) { | |
| 462 | - return; | |
| 688 | + // Keep profile updates tied to the buyer's stored account link too. | |
| 689 | + if (is_user_logged_in() && (int) $customer->user_id === get_current_user_id()) { | |
| 690 | + update_user_meta(get_current_user_id(), 'first_name', $firstName); | |
| 691 | + update_user_meta(get_current_user_id(), 'last_name', $lastName); | |
| 463 | 692 | } |
| 464 | - | |
| 465 | - if (is_user_logged_in() && $user->ID === get_current_user_id()) { | |
| 466 | - update_user_meta($user->ID, 'first_name', $firstName); | |
| 467 | - update_user_meta($user->ID, 'last_name', $lastName); | |
| 468 | - } | |
| 469 | 693 | } |
| 470 | 694 | |
| 471 | 695 | public static function updateStock($order) |
| 472 | 696 | { |
| @@ -494,16 +718,18 @@ | ||
| 494 | 718 | if ($current_user->ID) { |
| 495 | 719 | $billingAddress['email'] = $current_user->user_email; |
| 496 | 720 | $billingAddress['user_id'] = $current_user->ID; |
| 497 | 721 | } else { |
| 498 | - static::handleUserCreation($orderData, $billingAddress); | |
| 722 | + unset($billingAddress['user_id']); | |
| 499 | 723 | } |
| 500 | 724 | |
| 501 | 725 | $customer = CustomerResource::create($billingAddress); |
| 502 | 726 | $customer = Arr::get($customer, 'data', null); |
| 503 | 727 | $customerId = Arr::get($customer, 'id', null); |
| 504 | - static::createCustomerAddress($billingAddress, $customerId); | |
| 505 | - static::createCustomerAddress($shippingAddress, $customerId); | |
| 728 | + if ($customer && $customer->wasRecentlyCreated) { | |
| 729 | + static::createCustomerAddress($billingAddress, $customerId); | |
| 730 | + static::createCustomerAddress($shippingAddress, $customerId); | |
| 731 | + } | |
| 506 | 732 | |
| 507 | 733 | return $customer; |
| 508 | 734 | } |
| 509 | 735 | |
| @@ -508,17 +734,13 @@ | ||
| 508 | 734 | } |
| 509 | 735 | |
| 510 | 736 | private static function updateExistingCustomer($customer, $orderData, $billingAddress, $shippingAddress) |
| 511 | 737 | { |
| 512 | - if (empty($customer->user_id)) { | |
| 513 | - $currentLoggedInUser = wp_get_current_user(); | |
| 514 | - if ($currentLoggedInUser && $currentLoggedInUser->user_email === $customer->email) { | |
| 515 | - $userId = get_current_user_id(); | |
| 516 | - $customer->update(['user_id' => $userId]); | |
| 517 | - $billingAddress['user_id'] = $userId; | |
| 518 | - } | |
| 738 | + // Order addresses come from this checkout; saved profile data needs proof. | |
| 739 | + if (!is_user_logged_in() || (int) $customer->user_id !== get_current_user_id() | |
| 740 | + || EmailVerificationService::isRequired(get_current_user_id())) { | |
| 741 | + return; | |
| 519 | 742 | } |
| 520 | - | |
| 521 | 743 | $customer->load(['billing_address', 'shipping_address']); |
| 522 | 744 | |
| 523 | 745 | if ($customer->billing_address->count() < 1) { |
| 524 | 746 | static::createCustomerAddress($billingAddress, $customer->id); |
| @@ -525,25 +747,10 @@ | ||
| 525 | 747 | } |
| 526 | 748 | if ($customer->shipping_address->count() < 1) { |
| 527 | 749 | static::createCustomerAddress($shippingAddress, $customer->id); |
| 528 | 750 | } |
| 529 | - | |
| 530 | - static::handleUserCreation($orderData, $billingAddress, $customer); | |
| 531 | 751 | } |
| 532 | 752 | |
| 533 | - private static function handleUserCreation($orderData, &$billingAddress, $customer = null) | |
| 534 | - { | |
| 535 | - $userEmail = Arr::get($billingAddress, 'email'); | |
| 536 | - $user = get_user_by('email', $userEmail); | |
| 537 | - | |
| 538 | - if ($user) { | |
| 539 | - $billingAddress['user_id'] = $user->ID; | |
| 540 | - if ($customer) { | |
| 541 | - $customer->update(['user_id' => $user->ID]); | |
| 542 | - } | |
| 543 | - } | |
| 544 | - } | |
| 545 | - | |
| 546 | 753 | private static function getCustomerEmail($billingAddress) |
| 547 | 754 | { |
| 548 | 755 | return is_user_logged_in() ? wp_get_current_user()->user_email : $billingAddress['email']; |
| 549 | 756 | } |
| @@ -680,10 +887,15 @@ | ||
| 680 | 887 | |
| 681 | 888 | $agreeTermsRequired = CheckoutFieldsSchema::isTermsRequired(); |
| 682 | 889 | |
| 683 | 890 | $customTitles = [ |
| 684 | - 'address_1' => 'Street Address', | |
| 685 | - 'address_2' => 'Apt, Suite, Unit', | |
| 891 | + 'address_1' => __('Street Address', 'fluent-cart'), | |
| 892 | + 'address_2' => __('Apt, Suite, Unit', 'fluent-cart'), | |
| 893 | + 'country' => __('Country', 'fluent-cart'), | |
| 894 | + 'state' => __('State', 'fluent-cart'), | |
| 895 | + 'city' => __('City', 'fluent-cart'), | |
| 896 | + 'postcode' => __('Postcode', 'fluent-cart'), | |
| 897 | + 'phone' => __('Phone', 'fluent-cart'), | |
| 686 | 898 | ]; |
| 687 | 899 | |
| 688 | 900 | foreach ($billingValidations as $key => $rule) { |
| 689 | 901 | $value = Arr::get($billingAddress, $key, ''); |
| @@ -898,10 +1110,9 @@ | ||
| 898 | 1110 | if (empty($data['billing_email']) || !is_email($data['billing_email'])) { |
| 899 | 1111 | $errors['billing_email']['invalid'] = __('Email must be a valid email address.', 'fluent-cart'); |
| 900 | 1112 | } |
| 901 | 1113 | |
| 902 | - if (CheckoutFieldsSchema::isFullNameRequired() || !empty($data['billing_full_name'])) { | |
| 903 | - // Modal checkout always sends billing_full_name regardless of store name field settings | |
| 1114 | + if (CheckoutFieldsSchema::isFullNameRequired()) { | |
| 904 | 1115 | if (empty($data['billing_full_name'])) { |
| 905 | 1116 | $errors['billing_full_name']['required'] = __('Full name is required.', 'fluent-cart'); |
| 906 | 1117 | } |
| 907 | 1118 | } else { |
| @@ -918,9 +1129,16 @@ | ||
| 918 | 1129 | |
| 919 | 1130 | |
| 920 | 1131 | if ($cart->requireShipping()) { |
| 921 | 1132 | if (!empty($data['fc_selected_shipping_method'])) { |
| 922 | - $selectedMethod = $data['fc_selected_shipping_method']; | |
| 1133 | + // One integer, decided here, is both what is checked and what placeOrder() prices. | |
| 1134 | + // A loose compare let PHP 7.4 match "1<b>2" to method 1, and sanitize_text_field() | |
| 1135 | + // then turned the same string into "12", so the order was priced by method 12. | |
| 1136 | + $rawMethod = $data['fc_selected_shipping_method']; | |
| 1137 | + $isPlainId = (is_string($rawMethod) || is_int($rawMethod)) && (string) absint($rawMethod) === (string) $rawMethod; | |
| 1138 | + $selectedMethod = $isPlainId ? absint($rawMethod) : 0; | |
| 1139 | + $data['fc_selected_shipping_method'] = $selectedMethod; | |
| 1140 | + | |
| 923 | 1141 | $shippingCountry = Arr::get($data, 'billing_country', ''); |
| 924 | 1142 | $shippingState = Arr::get($data, 'billing_state', ''); |
| 925 | 1143 | $shipToDifferent = Arr::get($data, 'ship_to_different', 'no') === 'yes'; |
| 926 | 1144 | |
| @@ -936,9 +1154,9 @@ | ||
| 936 | 1154 | $errors['shipping_method']['unavailable'] = __('We don\'t ship to this address. Please select a different address.', 'fluent-cart'); |
| 937 | 1155 | } else { |
| 938 | 1156 | $found = false; |
| 939 | 1157 | foreach ($availableShippingMethods as $shippingMethod) { |
| 940 | - if ($shippingMethod->id == $selectedMethod) { | |
| 1158 | + if ((int) $shippingMethod->id === $selectedMethod) { | |
| 941 | 1159 | $found = true; |
| 942 | 1160 | break; |
| 943 | 1161 | } |
| 944 | 1162 | } |
| @@ -959,9 +1177,9 @@ | ||
| 959 | 1177 | 'cart' => $cart |
| 960 | 1178 | ]); |
| 961 | 1179 | |
| 962 | 1180 | if (count($errors) > 0) { |
| 963 | - return new \Wp_Error('validation_error', 'Validation error', $errors); | |
| 1181 | + return new \Wp_Error('validation_error', __('Validation error', 'fluent-cart'), $errors); | |
| 964 | 1182 | } |
| 965 | 1183 | |
| 966 | 1184 | return $data; |
| 967 | 1185 | } |