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