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 +203 -67 1.5.4 → 1.8.0 View file →
@@ -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;
@@ -72,8 +73,19 @@
72 73
73 74 $cart = $cart->reValidateCoupons();
74 75
75 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 + }
76 88 $prevOrder = $cart->order;
77 89 if ($prevOrder) {
78 90 $prevOrder->load('order_items');
79 91 }
@@ -119,8 +131,12 @@
119 131 'message' => $validation->get_error_message(),
120 132 ], 403);
121 133 }
122 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 +
123 139 if (empty($data['billing_address_id'])) {
124 140 if ($prevOrder instanceof Order) {
125 141 $oldCustomer = $prevOrder->customer;
126 142 if ($oldCustomer) {
@@ -149,23 +165,22 @@
149 165 ]);
150 166 }
151 167
152 168 if (!CheckoutFieldsSchema::isFullNameRequired()) {
153 - if (!empty($validatedData['billing_full_name']) && empty($validatedData['billing_first_name'])) {
154 - // Modal checkout sends billing_full_name — split into first/last name
155 - $nameParts = explode(' ', $validatedData['billing_full_name'], 2);
156 - $validatedData['billing_first_name'] = $nameParts[0];
157 - $validatedData['billing_last_name'] = $nameParts[1] ?? '';
158 - } else {
159 - $validatedData['billing_full_name'] = trim(
160 - Arr::get($validatedData, 'billing_first_name') . ' ' . Arr::get($validatedData, 'billing_last_name')
161 - );
162 - }
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 + );
163 173 }
164 174
165 175 $orderData = OrderService::groupSanitizedData($validatedData);
166 176
167 - $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);
168 183
169 184 $shippingMethod = null;
170 185 $shippingCharge = 0;
171 186 if (!$cartCheckoutService->isAllDigital()) {
@@ -196,16 +211,43 @@
196 211 $customer = static::getOrCreateCustomer($cartCheckoutHelper, $orderData);
197 212
198 213 $shouldCreateUser = static::shouldCreateUser($orderData, Arr::get($orderData, 'billing_address', []));
199 214
200 - // Ensure cart has the current payment method before recalculating fees
201 - $checkoutData = $cart->checkout_data ?? [];
202 - $checkoutData['payment_method'] = $paymentMethod;
203 - $cart->checkout_data = $checkoutData;
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;
204 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 +
205 232 $cart->clearFeeCache();
206 233 $fees = $cart->getFees();
207 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 +
208 250 // Recompute cart tax data so fee_tax/fee_tax_lines reflect fees for the current
209 251 // payment method. The scope prevents ShippingModule from running unnecessarily.
210 252 do_action('fluent_cart/cart/cart_data_items_updated', ['cart' => $cart, 'scope' => 'payment_method_fee_recalculate']);
211 253
@@ -220,12 +262,43 @@
220 262 $exclusiveTaxTotal = (int)Arr::get($cart->checkout_data, 'tax_data.exclusive_tax_total', 0);
221 263 $feeTax = (int)Arr::get($cart->checkout_data, 'tax_data.fee_tax', 0);
222 264 $feeTaxLines = (array)Arr::get($cart->checkout_data, 'tax_data.fee_tax_lines', []);
223 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 +
224 275 // For dynamic RC + inclusive pricing, reduce the shipping charge to net before storing.
225 276 // The fluent_cart/cart/shipping_total filter (registered by TaxModule) handles the logic.
226 277 $shippingCharge = apply_filters('fluent_cart/cart/shipping_total', $shippingCharge, ['cart' => $cart]);
227 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 +
228 301 $checkoutProcessor = new CheckoutProcessor($cartCheckoutHelper->getItems(), [
229 302 'customer_id' => $customer->id,
230 303 'user_tz' => $userTz,
231 304 'create_account_after_paid' => $shouldCreateUser ? 'yes' : 'no',
@@ -231,8 +304,9 @@
231 304 'create_account_after_paid' => $shouldCreateUser ? 'yes' : 'no',
232 305 'shipping_charge' => $shippingCharge,
233 306 'shipping_method_id' => $shippingMethod ? (int)$shippingMethod->id : 0,
234 307 'shipping_method_title' => $shippingMethod ? $shippingMethod->title : '',
308 + 'shipping_discount' => $shippingDiscount,
235 309 'tax_total' => $taxTotal,
236 310 'tax_behavior' => $taxBehavior,
237 311 'store_tax_behavior' => $storeTaxBehavior,
238 312 'exclusive_tax_total' => $exclusiveTaxTotal,
@@ -289,16 +363,90 @@
289 363 'prev_order' => $prevOrder
290 364 ]);
291 365 }
292 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 +
293 439 private static function getOrCreateCustomer(CartCheckoutHelper $cartCheckoutHelper, $orderData)
294 440 {
295 - $customerEmail = static::getCustomerEmail($orderData['billing_address']);
296 - if (is_user_logged_in()) {
297 - $customerEmail = wp_get_current_user()->user_email;
298 - Arr::set($orderData, 'billing_address.email', $customerEmail);
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();
299 447 }
300 - $customer = $cartCheckoutHelper->getCustomer($customerEmail);
448 +
301 449 return static::createCustomerWithAddress(
302 450 $customer,
303 451 $orderData,
304 452 $orderData['billing_address'],
@@ -431,15 +579,12 @@
431 579
432 580 static::syncCustomerNames($order, $args);
433 581 $cart = CartHelper::getCart();
434 582
435 - $utmData = [];
436 - if (!empty($cart) && is_array($cart->utm_data) && count($cart->utm_data) > 0) {
437 - $utmData = $cart->utm_data;
438 - }
439 -
440 - $requestUtmData = UtmHelper::getUtmDataOfRequest();
441 - $utmData = wp_parse_args($requestUtmData, $utmData);
583 + $utmData = UtmHelper::resolveUtmData(
584 + UtmHelper::getUtmDataOfRequest(),
585 + !empty($cart) ? $cart->utm_data : []
586 + );
442 587 UtmHelper::addUtmToOrder($order->id, $utmData);
443 588
444 589 $prevOrder = Arr::get($args, 'prev_order', null);
445 590
@@ -459,8 +604,14 @@
459 604 }
460 605
461 606 $paymentInstance = new PaymentInstance($order);
462 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 +
463 614 $data = $gateway->makePaymentFromPaymentInstance($paymentInstance);
464 615
465 616 if (is_wp_error($data)) {
466 617 // Server-observed create failure: mark the transaction FAILED so the next
@@ -520,9 +671,10 @@
520 671 private static function syncCustomerNames($order, $args)
521 672 {
522 673 $customer = $order->customer;
523 674
524 - 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())) {
525 677 return;
526 678 }
527 679
528 680 $firstName = Arr::get($args, 'billing_address.first_name');
@@ -532,18 +684,13 @@
532 684 'first_name' => $firstName,
533 685 'last_name' => $lastName,
534 686 ]);
535 687
536 - $user = get_user_by('email', $customer->email);
537 -
538 - if (empty($user)) {
539 - 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);
540 692 }
541 -
542 - if (is_user_logged_in() && $user->ID === get_current_user_id()) {
543 - update_user_meta($user->ID, 'first_name', $firstName);
544 - update_user_meta($user->ID, 'last_name', $lastName);
545 - }
546 693 }
547 694
548 695 public static function updateStock($order)
549 696 {
@@ -571,16 +718,18 @@
571 718 if ($current_user->ID) {
572 719 $billingAddress['email'] = $current_user->user_email;
573 720 $billingAddress['user_id'] = $current_user->ID;
574 721 } else {
575 - static::handleUserCreation($orderData, $billingAddress);
722 + unset($billingAddress['user_id']);
576 723 }
577 724
578 725 $customer = CustomerResource::create($billingAddress);
579 726 $customer = Arr::get($customer, 'data', null);
580 727 $customerId = Arr::get($customer, 'id', null);
581 - static::createCustomerAddress($billingAddress, $customerId);
582 - static::createCustomerAddress($shippingAddress, $customerId);
728 + if ($customer && $customer->wasRecentlyCreated) {
729 + static::createCustomerAddress($billingAddress, $customerId);
730 + static::createCustomerAddress($shippingAddress, $customerId);
731 + }
583 732
584 733 return $customer;
585 734 }
586 735
@@ -585,17 +734,13 @@
585 734 }
586 735
587 736 private static function updateExistingCustomer($customer, $orderData, $billingAddress, $shippingAddress)
588 737 {
589 - if (empty($customer->user_id)) {
590 - $currentLoggedInUser = wp_get_current_user();
591 - if ($currentLoggedInUser && $currentLoggedInUser->user_email === $customer->email) {
592 - $userId = get_current_user_id();
593 - $customer->update(['user_id' => $userId]);
594 - $billingAddress['user_id'] = $userId;
595 - }
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;
596 742 }
597 -
598 743 $customer->load(['billing_address', 'shipping_address']);
599 744
600 745 if ($customer->billing_address->count() < 1) {
601 746 static::createCustomerAddress($billingAddress, $customer->id);
@@ -602,25 +747,10 @@
602 747 }
603 748 if ($customer->shipping_address->count() < 1) {
604 749 static::createCustomerAddress($shippingAddress, $customer->id);
605 750 }
606 -
607 - static::handleUserCreation($orderData, $billingAddress, $customer);
608 751 }
609 752
610 - private static function handleUserCreation($orderData, &$billingAddress, $customer = null)
611 - {
612 - $userEmail = Arr::get($billingAddress, 'email');
613 - $user = get_user_by('email', $userEmail);
614 -
615 - if ($user) {
616 - $billingAddress['user_id'] = $user->ID;
617 - if ($customer) {
618 - $customer->update(['user_id' => $user->ID]);
619 - }
620 - }
621 - }
622 -
623 753 private static function getCustomerEmail($billingAddress)
624 754 {
625 755 return is_user_logged_in() ? wp_get_current_user()->user_email : $billingAddress['email'];
626 756 }
@@ -980,10 +1110,9 @@
980 1110 if (empty($data['billing_email']) || !is_email($data['billing_email'])) {
981 1111 $errors['billing_email']['invalid'] = __('Email must be a valid email address.', 'fluent-cart');
982 1112 }
983 1113
984 - if (CheckoutFieldsSchema::isFullNameRequired() || !empty($data['billing_full_name'])) {
985 - // Modal checkout always sends billing_full_name regardless of store name field settings
1114 + if (CheckoutFieldsSchema::isFullNameRequired()) {
986 1115 if (empty($data['billing_full_name'])) {
987 1116 $errors['billing_full_name']['required'] = __('Full name is required.', 'fluent-cart');
988 1117 }
989 1118 } else {
@@ -1000,9 +1129,16 @@
1000 1129
1001 1130
1002 1131 if ($cart->requireShipping()) {
1003 1132 if (!empty($data['fc_selected_shipping_method'])) {
1004 - $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 +
1005 1141 $shippingCountry = Arr::get($data, 'billing_country', '');
1006 1142 $shippingState = Arr::get($data, 'billing_state', '');
1007 1143 $shipToDifferent = Arr::get($data, 'ship_to_different', 'no') === 'yes';
1008 1144
@@ -1018,9 +1154,9 @@
1018 1154 $errors['shipping_method']['unavailable'] = __('We don\'t ship to this address. Please select a different address.', 'fluent-cart');
1019 1155 } else {
1020 1156 $found = false;
1021 1157 foreach ($availableShippingMethods as $shippingMethod) {
1022 - if ($shippingMethod->id == $selectedMethod) {
1158 + if ((int) $shippingMethod->id === $selectedMethod) {
1023 1159 $found = true;
1024 1160 break;
1025 1161 }
1026 1162 }