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 +155 -16 1.7.0 → 1.8.0 View file →
@@ -73,8 +73,19 @@
73 73
74 74 $cart = $cart->reValidateCoupons();
75 75
76 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 + }
77 88 $prevOrder = $cart->order;
78 89 if ($prevOrder) {
79 90 $prevOrder->load('order_items');
80 91 }
@@ -120,8 +131,12 @@
120 131 'message' => $validation->get_error_message(),
121 132 ], 403);
122 133 }
123 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 +
124 139 if (empty($data['billing_address_id'])) {
125 140 if ($prevOrder instanceof Order) {
126 141 $oldCustomer = $prevOrder->customer;
127 142 if ($oldCustomer) {
@@ -150,18 +165,12 @@
150 165 ]);
151 166 }
152 167
153 168 if (!CheckoutFieldsSchema::isFullNameRequired()) {
154 - if (!empty($validatedData['billing_full_name']) && empty($validatedData['billing_first_name'])) {
155 - // Modal checkout sends billing_full_name — split into first/last name
156 - $nameParts = explode(' ', $validatedData['billing_full_name'], 2);
157 - $validatedData['billing_first_name'] = $nameParts[0];
158 - $validatedData['billing_last_name'] = $nameParts[1] ?? '';
159 - } else {
160 - $validatedData['billing_full_name'] = trim(
161 - Arr::get($validatedData, 'billing_first_name') . ' ' . Arr::get($validatedData, 'billing_last_name')
162 - );
163 - }
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 + );
164 173 }
165 174
166 175 $orderData = OrderService::groupSanitizedData($validatedData);
167 176
@@ -202,16 +211,43 @@
202 211 $customer = static::getOrCreateCustomer($cartCheckoutHelper, $orderData);
203 212
204 213 $shouldCreateUser = static::shouldCreateUser($orderData, Arr::get($orderData, 'billing_address', []));
205 214
206 - // Ensure cart has the current payment method before recalculating fees
207 - $checkoutData = $cart->checkout_data ?? [];
208 - $checkoutData['payment_method'] = $paymentMethod;
209 - $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;
210 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 +
211 232 $cart->clearFeeCache();
212 233 $fees = $cart->getFees();
213 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 +
214 250 // Recompute cart tax data so fee_tax/fee_tax_lines reflect fees for the current
215 251 // payment method. The scope prevents ShippingModule from running unnecessarily.
216 252 do_action('fluent_cart/cart/cart_data_items_updated', ['cart' => $cart, 'scope' => 'payment_method_fee_recalculate']);
217 253
@@ -226,12 +262,43 @@
226 262 $exclusiveTaxTotal = (int)Arr::get($cart->checkout_data, 'tax_data.exclusive_tax_total', 0);
227 263 $feeTax = (int)Arr::get($cart->checkout_data, 'tax_data.fee_tax', 0);
228 264 $feeTaxLines = (array)Arr::get($cart->checkout_data, 'tax_data.fee_tax_lines', []);
229 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 +
230 275 // For dynamic RC + inclusive pricing, reduce the shipping charge to net before storing.
231 276 // The fluent_cart/cart/shipping_total filter (registered by TaxModule) handles the logic.
232 277 $shippingCharge = apply_filters('fluent_cart/cart/shipping_total', $shippingCharge, ['cart' => $cart]);
233 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 +
234 301 $checkoutProcessor = new CheckoutProcessor($cartCheckoutHelper->getItems(), [
235 302 'customer_id' => $customer->id,
236 303 'user_tz' => $userTz,
237 304 'create_account_after_paid' => $shouldCreateUser ? 'yes' : 'no',
@@ -237,8 +304,9 @@
237 304 'create_account_after_paid' => $shouldCreateUser ? 'yes' : 'no',
238 305 'shipping_charge' => $shippingCharge,
239 306 'shipping_method_id' => $shippingMethod ? (int)$shippingMethod->id : 0,
240 307 'shipping_method_title' => $shippingMethod ? $shippingMethod->title : '',
308 + 'shipping_discount' => $shippingDiscount,
241 309 'tax_total' => $taxTotal,
242 310 'tax_behavior' => $taxBehavior,
243 311 'store_tax_behavior' => $storeTaxBehavior,
244 312 'exclusive_tax_total' => $exclusiveTaxTotal,
@@ -295,8 +363,80 @@
295 363 'prev_order' => $prevOrder
296 364 ]);
297 365 }
298 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 +
299 439 private static function getOrCreateCustomer(CartCheckoutHelper $cartCheckoutHelper, $orderData)
300 440 {
301 441 $customer = is_user_logged_in() ? ApiCustomerResource::getCurrentCustomer() : null;
302 442 $email = static::getCustomerEmail($orderData['billing_address']);
@@ -970,10 +1110,9 @@
970 1110 if (empty($data['billing_email']) || !is_email($data['billing_email'])) {
971 1111 $errors['billing_email']['invalid'] = __('Email must be a valid email address.', 'fluent-cart');
972 1112 }
973 1113
974 - if (CheckoutFieldsSchema::isFullNameRequired() || !empty($data['billing_full_name'])) {
975 - // Modal checkout always sends billing_full_name regardless of store name field settings
1114 + if (CheckoutFieldsSchema::isFullNameRequired()) {
976 1115 if (empty($data['billing_full_name'])) {
977 1116 $errors['billing_full_name']['required'] = __('Full name is required.', 'fluent-cart');
978 1117 }
979 1118 } else {