PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.8.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.8.0
1.8.0 1.7.1 1.7.0 1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 All 52 releases
← All changes | api/Checkout/CheckoutApi.php +371 -102 1.3.26 → 1.8.0 View file →
@@ -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,11 +55,37 @@
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;
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 + }
60 88 $prevOrder = $cart->order;
61 89 if ($prevOrder) {
62 90 $prevOrder->load('order_items');
63 91 }
@@ -62,19 +90,34 @@
62 90 $prevOrder->load('order_items');
63 91 }
64 92 $isLockedCart = $cart->isLocked();
65 93
66 - // todo: we should handle this logic as we have multiple options like PAYMENT_PARTIALLY_PAID....
67 94 if ($prevOrder &&
68 95 (
69 96 in_array($prevOrder->status, Status::getOrderSuccessStatuses()) ||
70 - $prevOrder->payment_status != Status::PAYMENT_PENDING
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,24 +165,24 @@
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
184 + $shippingMethod = null;
138 185 $shippingCharge = 0;
139 186 if (!$cartCheckoutService->isAllDigital()) {
140 187 $shippingMethod = ShippingMethod::query()->find($shippingMethodId);
141 188 if (!empty($shippingMethod)) {
@@ -164,28 +211,108 @@
164 211 $customer = static::getOrCreateCustomer($cartCheckoutHelper, $orderData);
165 212
166 213 $shouldCreateUser = static::shouldCreateUser($orderData, Arr::get($orderData, 'billing_address', []));
167 214
168 - $taxTotal = (int)Arr::get($cart->checkout_data, 'tax_data.tax_total', 0); // behavoir not applied here
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;
169 222
223 + // The helper's instance prices the order shipping charge below, so it
224 + // carries the same method, fields and email from here on.
225 + $helperCart = $cartCheckoutHelper->getCart();
226 + if ($helperCart && $helperCart !== $cart) {
227 + static::setPaymentMethod($helperCart, $paymentMethod);
228 + static::setFormFields($helperCart, $validatedData);
229 + $helperCart->email = $customer->email;
230 + }
231 +
232 + $cart->clearFeeCache();
233 + $fees = $cart->getFees();
234 +
235 + // A pricing rule can make this order's postage cheaper. The cart holds the
236 + // cheaper figure during the tax recalculation, then gets the real price back.
237 + // Shipping tax itself comes from each line's share, which TaxModule discounts
238 + // through this same filter. Its own hook keeps fluent_cart/cart/shipping_total,
239 + // and the Shipping row it draws, untouched.
240 + $preDiscountShippingCharge = (int)$shippingCharge;
241 + $shippingCharge = apply_filters('fluent_cart/checkout/order_shipping_charge', $shippingCharge, [
242 + 'cart' => $cartCheckoutHelper->getCart(),
243 + ]);
244 + $shippingDiscount = max(0, $preDiscountShippingCharge - (int)$shippingCharge);
245 +
246 + if ($shippingDiscount > 0) {
247 + static::setCartShippingCharge($cart, (int)$shippingCharge);
248 + }
249 +
250 + // Recompute cart tax data so fee_tax/fee_tax_lines reflect fees for the current
251 + // payment method. The scope prevents ShippingModule from running unnecessarily.
252 + do_action('fluent_cart/cart/cart_data_items_updated', ['cart' => $cart, 'scope' => 'payment_method_fee_recalculate']);
253 +
254 + // TaxModule::recalculateTax() refreshes checkout_data['fees'] (RC-adjusted amounts,
255 + // deduplication) — reload so persisted fee items match the recomputed fee tax metadata.
256 + $fees = (array) Arr::get($cart->checkout_data, 'fees', $fees);
257 +
258 + $taxBehavior = apply_filters('fluent_cart/cart/tax_behavior', 0, ['cart' => $cart]);
259 + $taxTotal = (int)Arr::get($cart->checkout_data, 'tax_data.tax_total', 0);
170 260 $shippingTax = (int)Arr::get($cart->checkout_data, 'tax_data.shipping_tax', 0);
171 - $taxBehavior = apply_filters('fluent_cart/cart/tax_behavior', 0, ['cart' => $cart]);
261 + $storeTaxBehavior = (int)Arr::get($cart->checkout_data, 'tax_data.store_tax_behavior', $taxBehavior);
262 + $exclusiveTaxTotal = (int)Arr::get($cart->checkout_data, 'tax_data.exclusive_tax_total', 0);
263 + $feeTax = (int)Arr::get($cart->checkout_data, 'tax_data.fee_tax', 0);
264 + $feeTaxLines = (array)Arr::get($cart->checkout_data, 'tax_data.fee_tax_lines', []);
172 265
173 - // Ensure cart has the current payment method before recalculating fees
174 - $checkoutData = $cart->checkout_data ?? [];
175 - $checkoutData['payment_method'] = $paymentMethod;
176 - $cart->checkout_data = $checkoutData;
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 + }
177 274
178 - $cart->clearFeeCache();
179 - $fees = $cart->getFees();
275 + // For dynamic RC + inclusive pricing, reduce the shipping charge to net before storing.
276 + // The fluent_cart/cart/shipping_total filter (registered by TaxModule) handles the logic.
277 + $shippingCharge = apply_filters('fluent_cart/cart/shipping_total', $shippingCharge, ['cart' => $cart]);
180 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 +
181 301 $checkoutProcessor = new CheckoutProcessor($cartCheckoutHelper->getItems(), [
182 302 'customer_id' => $customer->id,
183 303 'user_tz' => $userTz,
184 304 'create_account_after_paid' => $shouldCreateUser ? 'yes' : 'no',
185 305 'shipping_charge' => $shippingCharge,
306 + 'shipping_method_id' => $shippingMethod ? (int)$shippingMethod->id : 0,
307 + 'shipping_method_title' => $shippingMethod ? $shippingMethod->title : '',
308 + 'shipping_discount' => $shippingDiscount,
186 309 'tax_total' => $taxTotal,
187 310 'tax_behavior' => $taxBehavior,
311 + 'store_tax_behavior' => $storeTaxBehavior,
312 + 'exclusive_tax_total' => $exclusiveTaxTotal,
313 + 'fee_tax' => $feeTax,
314 + 'fee_tax_lines' => $feeTaxLines,
188 315 'shipping_tax' => $shippingTax,
189 316 'payment_method' => $paymentMethod,
190 317 'applied_coupons' => $cart->getDiscountLines(),
191 318 'billing_address' => Arr::get($orderData, 'billing_address', []),
@@ -192,11 +319,16 @@
192 319 'shipping_address' => Arr::get($orderData, 'shipping_address', []),
193 320 'cart_hash' => $cart->cart_hash,
194 321 'is_locked' => $isLockedCart,
195 322 'manual_discount_total' => $cartCheckoutHelper->getManualDiscountAmount(),
323 + 'prorate_credit' => (int) Arr::get($cart->checkout_data, 'prorate_credit.amount', 0),
324 + 'upgrade_discount' => (int) Arr::get($cart->checkout_data, 'upgrade_discount.amount', 0),
196 325 'ip_address' => AddressHelper::getIpAddress(),
197 326 'note' => Arr::get($orderData, 'others.order_notes', ''),
198 - 'tax_id' => Arr::get($validatedData, 'billing_tax_id', 0),
327 + 'tax_id' => sanitize_text_field(
328 + Arr::get($data, 'fct_billing_tax_id', '')
329 + ?: Arr::get($cart->checkout_data, 'tax_data.vat_number', '')
330 + ),
199 331 'fees' => $fees,
200 332 ]);
201 333
202 334 $createdOrder = $checkoutProcessor->createDraftOrder($prevOrder);
@@ -231,16 +363,90 @@
231 363 'prev_order' => $prevOrder
232 364 ]);
233 365 }
234 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 +
235 439 private static function getOrCreateCustomer(CartCheckoutHelper $cartCheckoutHelper, $orderData)
236 440 {
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);
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();
241 447 }
242 - $customer = $cartCheckoutHelper->getCustomer($customerEmail);
448 +
243 449 return static::createCustomerWithAddress(
244 450 $customer,
245 451 $orderData,
246 452 $orderData['billing_address'],
@@ -253,11 +459,13 @@
253 459 $shippingAddressId = Arr::get($data, 'shipping_address_id');
254 460 $billingAddressId = Arr::get($data, 'billing_address_id');
255 461 $orderId = Arr::get($data, 'order_id', null);
256 462
463 + $currentCustomer = is_user_logged_in() ? ApiCustomerResource::getCurrentCustomer() : null;
464 +
257 465 $shipToDifferent = Arr::get($data, 'ship_to_different', 'no');
258 466
259 - $datKeys = ['country', 'address_1', 'address_2', 'city', 'state', 'postcode', 'phone', 'label'];
467 + $datKeys = ['country', 'address_1', 'address_2', 'city', 'state', 'postcode', 'phone', 'label', 'company_name', 'vat_number', 'legal_registration_id'];
260 468
261 469 if ($billingAddressId) {
262 470 $prevOrder = Order::query()->find($orderId);
263 471 $prevBillingId = null;
@@ -272,27 +480,28 @@
272 480 ->where('id', $billingAddressId)
273 481 ->where('type', 'billing')
274 482 ->first();
275 483 }
276 - if (empty($billingAddress)) {
484 + if (empty($billingAddress) && $currentCustomer) {
277 485 $billingAddress = CustomerAddresses::query()
278 486 ->where('id', $billingAddressId)
279 487 ->where('type', 'billing')
488 + ->where('customer_id', $currentCustomer->id)
280 489 ->first();
281 490 }
282 491
283 492 if ($billingAddress) {
284 493 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 - }
494 + $data['billing_' . $key] = $billingAddress->{$key} ?: Arr::get($data, 'billing_' . $key, '');
291 495 }
292 496 }
293 497 }
294 498
499 + if (Arr::get($data, 'is_business', 'no') !== 'yes' && !CheckoutFieldsSchema::isB2BOnlyMode()) {
500 + $data['billing_company_name'] = '';
501 + $data['billing_legal_registration_id'] = '';
502 + }
503 +
295 504 if ($shippingAddressId && $shipToDifferent === 'yes') {
296 505 $prevShippingOrder = Order::query()->find($orderId);
297 506 $prevShippingAddress = $prevShippingOrder ? $prevShippingOrder->shipping_address : null;
298 507 $prevShippingId = Arr::get($prevShippingAddress, 'id', null);
@@ -302,24 +511,21 @@
302 511 ->where('id', $shippingAddressId)
303 512 ->where('type', 'shipping')
304 513 ->first();
305 514 }
306 - if (empty($shippingAddress)) {
515 + if (empty($shippingAddress) && $currentCustomer) {
307 516 $shippingAddress = CustomerAddresses::query()
308 517 ->where('id', $shippingAddressId)
309 518 ->where('type', 'shipping')
519 + ->where('customer_id', $currentCustomer->id)
310 520 ->first();
311 521 }
312 522
313 523 if ($shippingAddress) {
314 - $data['shipping_full_name'] = $shippingAddress->name;
524 + $formFullName = Arr::get($data, 'shipping_full_name', '');
525 + $data['shipping_full_name'] = $shippingAddress->name ?: $formFullName;
315 526 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 - }
527 + $data['shipping_' . $key] = $shippingAddress->{$key} ?: Arr::get($data, 'shipping_' . $key, '');
322 528 }
323 529 }
324 530 } else if ($shipToDifferent !== 'yes') {
325 531 // if not different shipping, copy billing to shipping
@@ -362,8 +568,10 @@
362 568 }
363 569
364 570 private static function finalizeOrder(Order $order, $args = [])
365 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.
366 574 AddressHelper::insertOrderAddresses(
367 575 $order->id,
368 576 Arr::get($args, 'billing_address', []),
369 577 Arr::get($args, 'shipping_address', [])
@@ -371,15 +579,12 @@
371 579
372 580 static::syncCustomerNames($order, $args);
373 581 $cart = CartHelper::getCart();
374 582
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);
583 + $utmData = UtmHelper::resolveUtmData(
584 + UtmHelper::getUtmDataOfRequest(),
585 + !empty($cart) ? $cart->utm_data : []
586 + );
382 587 UtmHelper::addUtmToOrder($order->id, $utmData);
383 588
384 589 $prevOrder = Arr::get($args, 'prev_order', null);
385 590
@@ -399,11 +604,27 @@
399 604 }
400 605
401 606 $paymentInstance = new PaymentInstance($order);
402 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 +
403 614 $data = $gateway->makePaymentFromPaymentInstance($paymentInstance);
404 615
405 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 +
406 627 wp_send_json([
407 628 'status' => 'failed',
408 629 'message' => $data->get_error_message(),
409 630 'data' => $data->get_error_data()
@@ -412,13 +633,48 @@
412 633
413 634 wp_send_json($data, 200);
414 635 }
415 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 +
416 671 private static function syncCustomerNames($order, $args)
417 672 {
418 673 $customer = $order->customer;
419 674
420 - 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())) {
421 677 return;
422 678 }
423 679
424 680 $firstName = Arr::get($args, 'billing_address.first_name');
@@ -428,18 +684,13 @@
428 684 'first_name' => $firstName,
429 685 'last_name' => $lastName,
430 686 ]);
431 687
432 - $user = get_user_by('email', $customer->email);
433 -
434 - if (empty($user)) {
435 - 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);
436 692 }
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 693 }
443 694
444 695 public static function updateStock($order)
445 696 {
@@ -467,16 +718,18 @@
467 718 if ($current_user->ID) {
468 719 $billingAddress['email'] = $current_user->user_email;
469 720 $billingAddress['user_id'] = $current_user->ID;
470 721 } else {
471 - static::handleUserCreation($orderData, $billingAddress);
722 + unset($billingAddress['user_id']);
472 723 }
473 724
474 725 $customer = CustomerResource::create($billingAddress);
475 726 $customer = Arr::get($customer, 'data', null);
476 727 $customerId = Arr::get($customer, 'id', null);
477 - static::createCustomerAddress($billingAddress, $customerId);
478 - static::createCustomerAddress($shippingAddress, $customerId);
728 + if ($customer && $customer->wasRecentlyCreated) {
729 + static::createCustomerAddress($billingAddress, $customerId);
730 + static::createCustomerAddress($shippingAddress, $customerId);
731 + }
479 732
480 733 return $customer;
481 734 }
482 735
@@ -481,17 +734,13 @@
481 734 }
482 735
483 736 private static function updateExistingCustomer($customer, $orderData, $billingAddress, $shippingAddress)
484 737 {
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 - }
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;
492 742 }
493 -
494 743 $customer->load(['billing_address', 'shipping_address']);
495 744
496 745 if ($customer->billing_address->count() < 1) {
497 746 static::createCustomerAddress($billingAddress, $customer->id);
@@ -498,25 +747,10 @@
498 747 }
499 748 if ($customer->shipping_address->count() < 1) {
500 749 static::createCustomerAddress($shippingAddress, $customer->id);
501 750 }
502 -
503 - static::handleUserCreation($orderData, $billingAddress, $customer);
504 751 }
505 752
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 753 private static function getCustomerEmail($billingAddress)
520 754 {
521 755 return is_user_logged_in() ? wp_get_current_user()->user_email : $billingAddress['email'];
522 756 }
@@ -608,12 +842,12 @@
608 842
609 843 $billingValidations = array_filter(CheckoutFieldsSchema::getCheckoutFieldsRequirements('billing', $fulfillmentType, !$isDifferentShipping));
610 844
611 845 // 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']);
846 + // vat_number uses field name fct_billing_tax_id and is validated separately in the B2B block below
847 + unset($billingValidations['full_name'], $billingValidations['first_name'], $billingValidations['last_name'], $billingValidations['vat_number']);
613 848
614 - if (!isset($billingValidations['country'])) {
615 - // get store country
849 + if (!isset($billingValidations['country']) && empty($data['billing_country'])) {
616 850 $data['billing_country'] = (new StoreSettings())->get('store_country');
617 851 }
618 852
619 853 $billingAddress = [];
@@ -620,10 +854,11 @@
620 854 foreach ($billingValidations as $key => $billingValidation) {
621 855 $billingAddress[$key] = Arr::get($data, 'billing_' . $key, '');
622 856 }
623 857 if (!isset($billingAddress['country'])) {
624 - // get store country
625 - $billingAddress['country'] = (new StoreSettings())->get('store_country');
858 + $billingAddress['country'] = !empty($data['billing_country'])
859 + ? $data['billing_country']
860 + : (new StoreSettings())->get('store_country');
626 861 }
627 862
628 863 $shippingAddress = [];
629 864 $shippingValidations = [];
@@ -652,10 +887,15 @@
652 887
653 888 $agreeTermsRequired = CheckoutFieldsSchema::isTermsRequired();
654 889
655 890 $customTitles = [
656 - 'address_1' => 'Street Address',
657 - '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'),
658 898 ];
659 899
660 900 foreach ($billingValidations as $key => $rule) {
661 901 $value = Arr::get($billingAddress, $key, '');
@@ -839,8 +1079,31 @@
839 1079 }
840 1080 }
841 1081 }
842 1082
1083 + $isB2B = Arr::get($data, 'is_business', 'no') === 'yes' || CheckoutFieldsSchema::isB2BOnlyMode();
1084 +
1085 + if ($isB2B && CheckoutFieldsSchema::isVatNumberRequired()) {
1086 + $vatNumber = Arr::get($data, 'fct_billing_tax_id', '');
1087 + if (empty($vatNumber)) {
1088 + $errors['fct_billing_tax_id']['required'] = __('VAT / Tax ID is required.', 'fluent-cart');
1089 + }
1090 + }
1091 +
1092 + if ($isB2B && CheckoutFieldsSchema::isCompanyNameRequired()) {
1093 + $companyName = Arr::get($data, 'billing_company_name', '');
1094 + if (empty($companyName)) {
1095 + $errors['billing_company_name']['required'] = __('Company Name is required.', 'fluent-cart');
1096 + }
1097 + }
1098 +
1099 + if ($isB2B && CheckoutFieldsSchema::isLegalRegistrationIdRequired()) {
1100 + $legalRegId = Arr::get($data, 'billing_legal_registration_id', '');
1101 + if (empty($legalRegId)) {
1102 + $errors['billing_legal_registration_id']['required'] = __('Legal Registration ID is required.', 'fluent-cart');
1103 + }
1104 + }
1105 +
843 1106 if (empty($data['agree_terms']) && $agreeTermsRequired) {
844 1107 $errors['agree_terms']['required'] = __('You must agree to the terms and conditions.', 'fluent-cart');
845 1108 }
846 1109
@@ -847,10 +1110,9 @@
847 1110 if (empty($data['billing_email']) || !is_email($data['billing_email'])) {
848 1111 $errors['billing_email']['invalid'] = __('Email must be a valid email address.', 'fluent-cart');
849 1112 }
850 1113
851 - if (CheckoutFieldsSchema::isFullNameRequired() || !empty($data['billing_full_name'])) {
852 - // Modal checkout always sends billing_full_name regardless of store name field settings
1114 + if (CheckoutFieldsSchema::isFullNameRequired()) {
853 1115 if (empty($data['billing_full_name'])) {
854 1116 $errors['billing_full_name']['required'] = __('Full name is required.', 'fluent-cart');
855 1117 }
856 1118 } else {
@@ -867,9 +1129,16 @@
867 1129
868 1130
869 1131 if ($cart->requireShipping()) {
870 1132 if (!empty($data['fc_selected_shipping_method'])) {
871 - $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 +
872 1141 $shippingCountry = Arr::get($data, 'billing_country', '');
873 1142 $shippingState = Arr::get($data, 'billing_state', '');
874 1143 $shipToDifferent = Arr::get($data, 'ship_to_different', 'no') === 'yes';
875 1144
@@ -885,9 +1154,9 @@
885 1154 $errors['shipping_method']['unavailable'] = __('We don\'t ship to this address. Please select a different address.', 'fluent-cart');
886 1155 } else {
887 1156 $found = false;
888 1157 foreach ($availableShippingMethods as $shippingMethod) {
889 - if ($shippingMethod->id == $selectedMethod) {
1158 + if ((int) $shippingMethod->id === $selectedMethod) {
890 1159 $found = true;
891 1160 break;
892 1161 }
893 1162 }
@@ -908,9 +1177,9 @@
908 1177 'cart' => $cart
909 1178 ]);
910 1179
911 1180 if (count($errors) > 0) {
912 - return new \Wp_Error('validation_error', 'Validation error', $errors);
1181 + return new \Wp_Error('validation_error', __('Validation error', 'fluent-cart'), $errors);
913 1182 }
914 1183
915 1184 return $data;
916 1185 }