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.9.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 All 53 releases
← All changes | api/Checkout/CheckoutApi.php +374 -104 1.3.22 → 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,53 +480,52 @@
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 - $prevShippingAddress = Order::find($orderId)->shipping_address;
505 + $prevShippingOrder = Order::query()->find($orderId);
506 + $prevShippingAddress = $prevShippingOrder ? $prevShippingOrder->shipping_address : null;
297 507 $prevShippingId = Arr::get($prevShippingAddress, 'id', null);
298 508 $shippingAddress = null;
299 509 if ($orderId && $prevShippingId == $shippingAddressId) {
300 510 $shippingAddress = OrderAddress::query()
301 511 ->where('id', $shippingAddressId)
302 - ->where('type', 'billing')
512 + ->where('type', 'shipping')
303 513 ->first();
304 514 }
305 - if (empty($shippingAddress)) {
515 + if (empty($shippingAddress) && $currentCustomer) {
306 516 $shippingAddress = CustomerAddresses::query()
307 517 ->where('id', $shippingAddressId)
308 518 ->where('type', 'shipping')
519 + ->where('customer_id', $currentCustomer->id)
309 520 ->first();
310 521 }
311 522
312 523 if ($shippingAddress) {
313 - $data['shipping_full_name'] = $shippingAddress->name;
524 + $formFullName = Arr::get($data, 'shipping_full_name', '');
525 + $data['shipping_full_name'] = $shippingAddress->name ?: $formFullName;
314 526 foreach ($datKeys as $key) {
315 - // Guest user, take data from form
316 - if (!is_user_logged_in()) {
317 - $data['shipping_' . $key] = Arr::get($data, 'shipping_' . $key, '');
318 - } else {
319 - $data['shipping_' . $key] = $shippingAddress->{$key};
320 - }
527 + $data['shipping_' . $key] = $shippingAddress->{$key} ?: Arr::get($data, 'shipping_' . $key, '');
321 528 }
322 529 }
323 530 } else if ($shipToDifferent !== 'yes') {
324 531 // if not different shipping, copy billing to shipping
@@ -361,8 +568,10 @@
361 568 }
362 569
363 570 private static function finalizeOrder(Order $order, $args = [])
364 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.
365 574 AddressHelper::insertOrderAddresses(
366 575 $order->id,
367 576 Arr::get($args, 'billing_address', []),
368 577 Arr::get($args, 'shipping_address', [])
@@ -370,15 +579,12 @@
370 579
371 580 static::syncCustomerNames($order, $args);
372 581 $cart = CartHelper::getCart();
373 582
374 - $utmData = [];
375 - if (!empty($cart) && is_array($cart->utm_data) && count($cart->utm_data) > 0) {
376 - $utmData = $cart->utm_data;
377 - }
378 -
379 - $requestUtmData = UtmHelper::getUtmDataOfRequest();
380 - $utmData = wp_parse_args($requestUtmData, $utmData);
583 + $utmData = UtmHelper::resolveUtmData(
584 + UtmHelper::getUtmDataOfRequest(),
585 + !empty($cart) ? $cart->utm_data : []
586 + );
381 587 UtmHelper::addUtmToOrder($order->id, $utmData);
382 588
383 589 $prevOrder = Arr::get($args, 'prev_order', null);
384 590
@@ -398,11 +604,27 @@
398 604 }
399 605
400 606 $paymentInstance = new PaymentInstance($order);
401 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 +
402 614 $data = $gateway->makePaymentFromPaymentInstance($paymentInstance);
403 615
404 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 +
405 627 wp_send_json([
406 628 'status' => 'failed',
407 629 'message' => $data->get_error_message(),
408 630 'data' => $data->get_error_data()
@@ -411,13 +633,48 @@
411 633
412 634 wp_send_json($data, 200);
413 635 }
414 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 +
415 671 private static function syncCustomerNames($order, $args)
416 672 {
417 673 $customer = $order->customer;
418 674
419 - 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())) {
420 677 return;
421 678 }
422 679
423 680 $firstName = Arr::get($args, 'billing_address.first_name');
@@ -427,18 +684,13 @@
427 684 'first_name' => $firstName,
428 685 'last_name' => $lastName,
429 686 ]);
430 687
431 - $user = get_user_by('email', $customer->email);
432 -
433 - if (empty($user)) {
434 - 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);
435 692 }
436 -
437 - if (is_user_logged_in() && $user->ID === get_current_user_id()) {
438 - update_user_meta($user->ID, 'first_name', $firstName);
439 - update_user_meta($user->ID, 'last_name', $lastName);
440 - }
441 693 }
442 694
443 695 public static function updateStock($order)
444 696 {
@@ -466,16 +718,18 @@
466 718 if ($current_user->ID) {
467 719 $billingAddress['email'] = $current_user->user_email;
468 720 $billingAddress['user_id'] = $current_user->ID;
469 721 } else {
470 - static::handleUserCreation($orderData, $billingAddress);
722 + unset($billingAddress['user_id']);
471 723 }
472 724
473 725 $customer = CustomerResource::create($billingAddress);
474 726 $customer = Arr::get($customer, 'data', null);
475 727 $customerId = Arr::get($customer, 'id', null);
476 - static::createCustomerAddress($billingAddress, $customerId);
477 - static::createCustomerAddress($shippingAddress, $customerId);
728 + if ($customer && $customer->wasRecentlyCreated) {
729 + static::createCustomerAddress($billingAddress, $customerId);
730 + static::createCustomerAddress($shippingAddress, $customerId);
731 + }
478 732
479 733 return $customer;
480 734 }
481 735
@@ -480,17 +734,13 @@
480 734 }
481 735
482 736 private static function updateExistingCustomer($customer, $orderData, $billingAddress, $shippingAddress)
483 737 {
484 - if (empty($customer->user_id)) {
485 - $currentLoggedInUser = wp_get_current_user();
486 - if ($currentLoggedInUser && $currentLoggedInUser->user_email === $customer->email) {
487 - $userId = get_current_user_id();
488 - $customer->update(['user_id' => $userId]);
489 - $billingAddress['user_id'] = $userId;
490 - }
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;
491 742 }
492 -
493 743 $customer->load(['billing_address', 'shipping_address']);
494 744
495 745 if ($customer->billing_address->count() < 1) {
496 746 static::createCustomerAddress($billingAddress, $customer->id);
@@ -497,25 +747,10 @@
497 747 }
498 748 if ($customer->shipping_address->count() < 1) {
499 749 static::createCustomerAddress($shippingAddress, $customer->id);
500 750 }
501 -
502 - static::handleUserCreation($orderData, $billingAddress, $customer);
503 751 }
504 752
505 - private static function handleUserCreation($orderData, &$billingAddress, $customer = null)
506 - {
507 - $userEmail = Arr::get($billingAddress, 'email');
508 - $user = get_user_by('email', $userEmail);
509 -
510 - if ($user) {
511 - $billingAddress['user_id'] = $user->ID;
512 - if ($customer) {
513 - $customer->update(['user_id' => $user->ID]);
514 - }
515 - }
516 - }
517 -
518 753 private static function getCustomerEmail($billingAddress)
519 754 {
520 755 return is_user_logged_in() ? wp_get_current_user()->user_email : $billingAddress['email'];
521 756 }
@@ -607,12 +842,12 @@
607 842
608 843 $billingValidations = array_filter(CheckoutFieldsSchema::getCheckoutFieldsRequirements('billing', $fulfillmentType, !$isDifferentShipping));
609 844
610 845 // Name fields are validated separately below (full_name/first_name/last_name)
611 - 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']);
612 848
613 - if (!isset($billingValidations['country'])) {
614 - // get store country
849 + if (!isset($billingValidations['country']) && empty($data['billing_country'])) {
615 850 $data['billing_country'] = (new StoreSettings())->get('store_country');
616 851 }
617 852
618 853 $billingAddress = [];
@@ -619,10 +854,11 @@
619 854 foreach ($billingValidations as $key => $billingValidation) {
620 855 $billingAddress[$key] = Arr::get($data, 'billing_' . $key, '');
621 856 }
622 857 if (!isset($billingAddress['country'])) {
623 - // get store country
624 - $billingAddress['country'] = (new StoreSettings())->get('store_country');
858 + $billingAddress['country'] = !empty($data['billing_country'])
859 + ? $data['billing_country']
860 + : (new StoreSettings())->get('store_country');
625 861 }
626 862
627 863 $shippingAddress = [];
628 864 $shippingValidations = [];
@@ -651,10 +887,15 @@
651 887
652 888 $agreeTermsRequired = CheckoutFieldsSchema::isTermsRequired();
653 889
654 890 $customTitles = [
655 - 'address_1' => 'Street Address',
656 - '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'),
657 898 ];
658 899
659 900 foreach ($billingValidations as $key => $rule) {
660 901 $value = Arr::get($billingAddress, $key, '');
@@ -838,8 +1079,31 @@
838 1079 }
839 1080 }
840 1081 }
841 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 +
842 1106 if (empty($data['agree_terms']) && $agreeTermsRequired) {
843 1107 $errors['agree_terms']['required'] = __('You must agree to the terms and conditions.', 'fluent-cart');
844 1108 }
845 1109
@@ -846,10 +1110,9 @@
846 1110 if (empty($data['billing_email']) || !is_email($data['billing_email'])) {
847 1111 $errors['billing_email']['invalid'] = __('Email must be a valid email address.', 'fluent-cart');
848 1112 }
849 1113
850 - if (CheckoutFieldsSchema::isFullNameRequired() || !empty($data['billing_full_name'])) {
851 - // Modal checkout always sends billing_full_name regardless of store name field settings
1114 + if (CheckoutFieldsSchema::isFullNameRequired()) {
852 1115 if (empty($data['billing_full_name'])) {
853 1116 $errors['billing_full_name']['required'] = __('Full name is required.', 'fluent-cart');
854 1117 }
855 1118 } else {
@@ -866,9 +1129,16 @@
866 1129
867 1130
868 1131 if ($cart->requireShipping()) {
869 1132 if (!empty($data['fc_selected_shipping_method'])) {
870 - $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 +
871 1141 $shippingCountry = Arr::get($data, 'billing_country', '');
872 1142 $shippingState = Arr::get($data, 'billing_state', '');
873 1143 $shipToDifferent = Arr::get($data, 'ship_to_different', 'no') === 'yes';
874 1144
@@ -884,9 +1154,9 @@
884 1154 $errors['shipping_method']['unavailable'] = __('We don\'t ship to this address. Please select a different address.', 'fluent-cart');
885 1155 } else {
886 1156 $found = false;
887 1157 foreach ($availableShippingMethods as $shippingMethod) {
888 - if ($shippingMethod->id == $selectedMethod) {
1158 + if ((int) $shippingMethod->id === $selectedMethod) {
889 1159 $found = true;
890 1160 break;
891 1161 }
892 1162 }
@@ -907,9 +1177,9 @@
907 1177 'cart' => $cart
908 1178 ]);
909 1179
910 1180 if (count($errors) > 0) {
911 - return new \Wp_Error('validation_error', 'Validation error', $errors);
1181 + return new \Wp_Error('validation_error', __('Validation error', 'fluent-cart'), $errors);
912 1182 }
913 1183
914 1184 return $data;
915 1185 }