| @@ -211,16 +211,43 @@ | ||
| 211 | 211 | $customer = static::getOrCreateCustomer($cartCheckoutHelper, $orderData); |
| 212 | 212 | |
| 213 | 213 | $shouldCreateUser = static::shouldCreateUser($orderData, Arr::get($orderData, 'billing_address', [])); |
| 214 | 214 | |
| 215 | - // Ensure cart has the current payment method before recalculating fees | |
| 216 | - $checkoutData = $cart->checkout_data ?? []; | |
| 217 | - $checkoutData['payment_method'] = $paymentMethod; | |
| 218 | - $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; | |
| 219 | 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 | + | |
| 220 | 232 | $cart->clearFeeCache(); |
| 221 | 233 | $fees = $cart->getFees(); |
| 222 | 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 | + | |
| 223 | 250 | // Recompute cart tax data so fee_tax/fee_tax_lines reflect fees for the current |
| 224 | 251 | // payment method. The scope prevents ShippingModule from running unnecessarily. |
| 225 | 252 | do_action('fluent_cart/cart/cart_data_items_updated', ['cart' => $cart, 'scope' => 'payment_method_fee_recalculate']); |
| 226 | 253 | |
| @@ -235,12 +262,43 @@ | ||
| 235 | 262 | $exclusiveTaxTotal = (int)Arr::get($cart->checkout_data, 'tax_data.exclusive_tax_total', 0); |
| 236 | 263 | $feeTax = (int)Arr::get($cart->checkout_data, 'tax_data.fee_tax', 0); |
| 237 | 264 | $feeTaxLines = (array)Arr::get($cart->checkout_data, 'tax_data.fee_tax_lines', []); |
| 238 | 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 | + | |
| 239 | 275 | // For dynamic RC + inclusive pricing, reduce the shipping charge to net before storing. |
| 240 | 276 | // The fluent_cart/cart/shipping_total filter (registered by TaxModule) handles the logic. |
| 241 | 277 | $shippingCharge = apply_filters('fluent_cart/cart/shipping_total', $shippingCharge, ['cart' => $cart]); |
| 242 | 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 | + | |
| 243 | 301 | $checkoutProcessor = new CheckoutProcessor($cartCheckoutHelper->getItems(), [ |
| 244 | 302 | 'customer_id' => $customer->id, |
| 245 | 303 | 'user_tz' => $userTz, |
| 246 | 304 | 'create_account_after_paid' => $shouldCreateUser ? 'yes' : 'no', |
| @@ -246,8 +304,9 @@ | ||
| 246 | 304 | 'create_account_after_paid' => $shouldCreateUser ? 'yes' : 'no', |
| 247 | 305 | 'shipping_charge' => $shippingCharge, |
| 248 | 306 | 'shipping_method_id' => $shippingMethod ? (int)$shippingMethod->id : 0, |
| 249 | 307 | 'shipping_method_title' => $shippingMethod ? $shippingMethod->title : '', |
| 308 | + 'shipping_discount' => $shippingDiscount, | |
| 250 | 309 | 'tax_total' => $taxTotal, |
| 251 | 310 | 'tax_behavior' => $taxBehavior, |
| 252 | 311 | 'store_tax_behavior' => $storeTaxBehavior, |
| 253 | 312 | 'exclusive_tax_total' => $exclusiveTaxTotal, |
| @@ -302,8 +361,80 @@ | ||
| 302 | 361 | 'items' => $cartCheckoutHelper->getItems(), |
| 303 | 362 | 'from_checkout' => true, |
| 304 | 363 | 'prev_order' => $prevOrder |
| 305 | 364 | ]); |
| 365 | + } | |
| 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; | |
| 306 | 437 | } |
| 307 | 438 | |
| 308 | 439 | private static function getOrCreateCustomer(CartCheckoutHelper $cartCheckoutHelper, $orderData) |
| 309 | 440 | { |