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 +197 -67 1.6.0 → 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
@@ -526,9 +671,10 @@
526 671 private static function syncCustomerNames($order, $args)
527 672 {
528 673 $customer = $order->customer;
529 674
530 - 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())) {
531 677 return;
532 678 }
533 679
534 680 $firstName = Arr::get($args, 'billing_address.first_name');
@@ -538,18 +684,13 @@
538 684 'first_name' => $firstName,
539 685 'last_name' => $lastName,
540 686 ]);
541 687
542 - $user = get_user_by('email', $customer->email);
543 -
544 - if (empty($user)) {
545 - 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);
546 692 }
547 -
548 - if (is_user_logged_in() && $user->ID === get_current_user_id()) {
549 - update_user_meta($user->ID, 'first_name', $firstName);
550 - update_user_meta($user->ID, 'last_name', $lastName);
551 - }
552 693 }
553 694
554 695 public static function updateStock($order)
555 696 {
@@ -577,16 +718,18 @@
577 718 if ($current_user->ID) {
578 719 $billingAddress['email'] = $current_user->user_email;
579 720 $billingAddress['user_id'] = $current_user->ID;
580 721 } else {
581 - static::handleUserCreation($orderData, $billingAddress);
722 + unset($billingAddress['user_id']);
582 723 }
583 724
584 725 $customer = CustomerResource::create($billingAddress);
585 726 $customer = Arr::get($customer, 'data', null);
586 727 $customerId = Arr::get($customer, 'id', null);
587 - static::createCustomerAddress($billingAddress, $customerId);
588 - static::createCustomerAddress($shippingAddress, $customerId);
728 + if ($customer && $customer->wasRecentlyCreated) {
729 + static::createCustomerAddress($billingAddress, $customerId);
730 + static::createCustomerAddress($shippingAddress, $customerId);
731 + }
589 732
590 733 return $customer;
591 734 }
592 735
@@ -591,17 +734,13 @@
591 734 }
592 735
593 736 private static function updateExistingCustomer($customer, $orderData, $billingAddress, $shippingAddress)
594 737 {
595 - if (empty($customer->user_id)) {
596 - $currentLoggedInUser = wp_get_current_user();
597 - if ($currentLoggedInUser && $currentLoggedInUser->user_email === $customer->email) {
598 - $userId = get_current_user_id();
599 - $customer->update(['user_id' => $userId]);
600 - $billingAddress['user_id'] = $userId;
601 - }
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;
602 742 }
603 -
604 743 $customer->load(['billing_address', 'shipping_address']);
605 744
606 745 if ($customer->billing_address->count() < 1) {
607 746 static::createCustomerAddress($billingAddress, $customer->id);
@@ -608,25 +747,10 @@
608 747 }
609 748 if ($customer->shipping_address->count() < 1) {
610 749 static::createCustomerAddress($shippingAddress, $customer->id);
611 750 }
612 -
613 - static::handleUserCreation($orderData, $billingAddress, $customer);
614 751 }
615 752
616 - private static function handleUserCreation($orderData, &$billingAddress, $customer = null)
617 - {
618 - $userEmail = Arr::get($billingAddress, 'email');
619 - $user = get_user_by('email', $userEmail);
620 -
621 - if ($user) {
622 - $billingAddress['user_id'] = $user->ID;
623 - if ($customer) {
624 - $customer->update(['user_id' => $user->ID]);
625 - }
626 - }
627 - }
628 -
629 753 private static function getCustomerEmail($billingAddress)
630 754 {
631 755 return is_user_logged_in() ? wp_get_current_user()->user_email : $billingAddress['email'];
632 756 }
@@ -986,10 +1110,9 @@
986 1110 if (empty($data['billing_email']) || !is_email($data['billing_email'])) {
987 1111 $errors['billing_email']['invalid'] = __('Email must be a valid email address.', 'fluent-cart');
988 1112 }
989 1113
990 - if (CheckoutFieldsSchema::isFullNameRequired() || !empty($data['billing_full_name'])) {
991 - // Modal checkout always sends billing_full_name regardless of store name field settings
1114 + if (CheckoutFieldsSchema::isFullNameRequired()) {
992 1115 if (empty($data['billing_full_name'])) {
993 1116 $errors['billing_full_name']['required'] = __('Full name is required.', 'fluent-cart');
994 1117 }
995 1118 } else {
@@ -1006,9 +1129,16 @@
1006 1129
1007 1130
1008 1131 if ($cart->requireShipping()) {
1009 1132 if (!empty($data['fc_selected_shipping_method'])) {
1010 - $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 +
1011 1141 $shippingCountry = Arr::get($data, 'billing_country', '');
1012 1142 $shippingState = Arr::get($data, 'billing_state', '');
1013 1143 $shipToDifferent = Arr::get($data, 'ship_to_different', 'no') === 'yes';
1014 1144
@@ -1024,9 +1154,9 @@
1024 1154 $errors['shipping_method']['unavailable'] = __('We don\'t ship to this address. Please select a different address.', 'fluent-cart');
1025 1155 } else {
1026 1156 $found = false;
1027 1157 foreach ($availableShippingMethods as $shippingMethod) {
1028 - if ($shippingMethod->id == $selectedMethod) {
1158 + if ((int) $shippingMethod->id === $selectedMethod) {
1029 1159 $found = true;
1030 1160 break;
1031 1161 }
1032 1162 }