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.9.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 All 53 releases
fluent-cart / app / Hooks / Cart / WebCheckoutHandler.php

WebCheckoutHandler.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.8.0, at app/Hooks/Cart/WebCheckoutHandler.php

1,245 lines 47.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Hooks\Cart;
4
5 use FluentCart\Api\Checkout\CheckoutApi;
6 use FluentCart\Api\PaymentMethods;
7 use FluentCart\Api\Resource\FrontendResource\CartResource;
8 use FluentCart\Api\Resource\CustomerResource;
9 use FluentCart\Api\Resource\FrontendResource\CustomerAddressResource;
10 use FluentCart\App\App;
11 use FluentCart\App\Helpers\AddressHelper;
12 use FluentCart\App\Helpers\CartCheckoutHelper;
13 use FluentCart\App\Helpers\CartHelper;
14 use FluentCart\App\Helpers\Helper;
15 use FluentCart\App\Helpers\UtmHelper;
16 use FluentCart\App\Models\Product;
17 use FluentCart\App\Models\ProductVariation;
18 use FluentCart\App\Models\ShippingMethod;
19 use FluentCart\App\Modules\PaymentMethods\Core\GatewayManager;
20 use FluentCart\App\Modules\Tax\TaxModule;
21 use FluentCart\App\Services\Localization\LocalizationManager;
22 use FluentCart\App\Services\Renderer\AddressSelectRenderer;
23 use FluentCart\App\Services\Renderer\CartDrawerRenderer;
24 use FluentCart\App\Services\Renderer\CartRenderer;
25 use FluentCart\App\Services\Renderer\CartSummaryRender;
26 use FluentCart\App\Services\Renderer\CheckoutFieldsSchema;
27 use FluentCart\App\Services\Renderer\CheckoutRenderer;
28 use FluentCart\App\Services\Renderer\ProductModalRenderer;
29 use FluentCart\App\Services\Renderer\ShippingMethodsRender;
30 use FluentCart\Framework\Support\Arr;
31 use FluentCart\App\Services\Renderer\ModalCheckoutRenderer;
32
33 class WebCheckoutHandler
34 {
35 public function register()
36 {
37 add_action('wp_ajax_fluent_cart_place_order', [$this, 'handlePlaceOrderAjax']);
38 add_action('wp_ajax_nopriv_fluent_cart_place_order', [$this, 'handlePlaceOrderAjax']);
39
40 add_action('wp_ajax_fluent_cart_checkout_routes', [$this, 'globalCheckoutRouteHandler']);
41 add_action('wp_ajax_nopriv_fluent_cart_checkout_routes', [$this, 'globalCheckoutRouteHandler']);
42
43 add_action('fluent_cart/order_bump_succeed', function ($data) {
44 $cart = Arr::get($data, 'cart', null);
45 $order = Arr::get($data, 'order', null);
46
47 if (!$cart || !$order) {
48 return;
49 }
50
51 $order->addLog('Order Bump Succeeded', 'Order Bump done from variation ID: ' . Arr::get($cart->checkout_data, 'order_bump.upgraded_from', '') . ' to variation ID: ' . Arr::get($cart->checkout_data, 'order_bump.upgraded_to', ''));
52 $order->updateMeta('_order_bump', [
53 'upgraded_from' => Arr::get($cart->checkout_data, 'order_bump.upgraded_from', ''),
54 'upgraded_to' => Arr::get($cart->checkout_data, 'order_bump.upgraded_to', '')
55 ]);
56
57 });
58
59 }
60
61 public function globalCheckoutRouteHandler()
62 {
63 nocache_headers();
64
65 $nonce = isset($_SERVER['HTTP_X_WP_NONCE']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_X_WP_NONCE'])) : '';
66 if (!wp_verify_nonce($nonce, 'fluentcart') && !wp_verify_nonce($nonce, 'wp_rest')) {
67 wp_send_json(['message' => __('Invalid nonce', 'fluent-cart')], 403);
68 return;
69 }
70
71 $startedAt = microtime(true);
72
73 $action = App::request()->get('fc_checkout_action');
74 $result = [];
75 switch ($action) {
76 case 'apply_coupon':
77 $result = $this->handleApplyCouponAjax();
78 break;
79 case 'remove_coupon':
80 $result = $this->handleRemoveCouponAjax();
81 break;
82 case 'get_checkout_summary_view':
83 case 'reapply_coupon':
84 $result = $this->handleGetCheckoutSummaryViewAjax();
85 break;
86 case 'get_order_info':
87 $result = $this->handleGetOrderInfoAjax();
88 break;
89 case 'save_checkout_data':
90 $result = $this->patchCheckoutData();
91 break;
92 case 'get_country_info':
93 $result = $this->handleGetCountryInfoAjax();
94 break;
95 case 'update_address_select':
96 $result = $this->handleUpdateAddressSelectAjax();
97 break;
98
99 case 'get_shipping_methods_list_view':
100 $result = $this->handleGetShippingMethodsListViewAjax();
101 break;
102 case 'fluent_cart_cart_update':
103 $result = $this->handleCartUpdateAjax();
104 break;
105 case 'fluent_cart_cart_status':
106 $result = $this->handleCartStatusAjax();
107 break;
108 case 'apply_order_bump':
109 $result = $this->handleOrderBumpRequest();
110 break;
111 case 'get_product_modal_view':
112 $result = $this->getProductModalView();
113 break;
114 }
115
116 if (is_wp_error($result)) {
117 wp_send_json([
118 'message' => Arr::get($result, 'message') ?? $result->get_error_message()
119 ], 422);
120 }
121
122 if (is_array($result)) {
123 $result['_bench'] = microtime(true) - $startedAt;
124 }
125
126 wp_send_json($result, 200);
127 }
128
129 public function handlePlaceOrderAjax()
130 {
131 nocache_headers();
132 $data = App::request()->all();
133
134 CheckoutApi::placeOrder($data, true);
135 }
136
137 public function getShippingChargeData($cart): array
138 {
139 $shippingMethodId = App::request()->getSafe('shipping_method_id', 'sanitize_text_field');
140
141 if (!$shippingMethodId) {
142 $shippingMethodId = Arr::get($cart->checkout_data, 'shipping_data.shipping_method_id');
143 }
144
145 $shippingMethod = ShippingMethod::query()->find($shippingMethodId);
146
147 $charge = 0;
148 if (!empty($shippingMethod)) {
149 // The address the order ships to: the shipping fields only with ship-to-different on,
150 // otherwise billing, the same rule place-order validates the method against.
151 $shippingAddress = $cart->getShippingAddress();
152 $shippingCountry = Arr::get($shippingAddress, 'country');
153 $shippingState = Arr::get($shippingAddress, 'state');
154
155 $lists = AddressHelper::getAvailableShippingMethodLists(['country_code' => $shippingCountry, 'state' => $shippingState]);
156
157 if (isset($lists['available_shipping_methods'])) {
158 $availableShippingMethods = Arr::get($lists, 'available_shipping_methods', []);
159 $shippingMethodIds = Arr::pluck($availableShippingMethods, 'id');
160
161 if (in_array($shippingMethodId, $shippingMethodIds)) {
162 $shippingMethod = ShippingMethod::query()->find($shippingMethodId);
163 if (!empty($shippingMethod)) {
164 $charge = CartHelper::calculateShippingMethodCharge($shippingMethod);
165 }
166 }
167 }
168 // Read-only: gateways call this on load to size their payment amount. A method this
169 // address can't use adds no shipping, but the cart is left alone. Resetting it here
170 // wiped valid selections, and place-order then taxed shipping from the emptied cart.
171 }
172
173 return [
174 'charge' => $charge,
175 'formatted_charge' => Helper::toDecimal($charge),
176 'shippingMethodId' => $shippingMethodId
177 ];
178 }
179
180 public function handleGetOrderInfoAjax()
181 {
182 $method = App::request()->getSafe('method', 'sanitize_text_field');
183 $paymentManager = GatewayManager::getInstance()->get($method);
184 return $paymentManager->getOrderInfo(App::request()->all());
185 }
186
187 public function handleApplyCouponAjax()
188 {
189
190 $couponCode = App::request()->getSafe('coupon_code', 'sanitize_text_field');
191 $cart = CartHelper::getCart(null, false);
192
193 if (!$cart) {
194 return new \WP_Error('no_cart', __('No active cart found', 'fluent-cart'));
195 }
196
197 $result = $cart->applyCoupon($couponCode);
198
199 if (is_wp_error($result)) {
200 return $result;
201 }
202
203 $cartCouponCodes = is_array($cart->coupons) ? $cart->coupons : [];
204 $couponWasApplied = false;
205 foreach ($cartCouponCodes as $existingCode) {
206 if (strcasecmp((string) $existingCode, (string) $couponCode) === 0) {
207 $couponWasApplied = true;
208 break;
209 }
210 }
211
212 if (!$couponWasApplied) {
213 $perCouponResults = is_array($result) ? Arr::get($result, 'coupon_results', []) : [];
214 $errorMessage = '';
215 foreach ($perCouponResults as $resultCode => $couponResult) {
216 if (strcasecmp((string) $resultCode, (string) $couponCode) === 0) {
217 $errorMessage = Arr::get($couponResult, 'error', '');
218 if ($errorMessage !== '') {
219 break;
220 }
221 }
222 }
223 if ($errorMessage === '') {
224 $errorMessage = __('No matching coupon found for this code.', 'fluent-cart');
225 }
226 return new \WP_Error('coupon_not_applied', $errorMessage);
227 }
228
229 ob_start();
230 (new CartSummaryRender($cart))->render($withWrapper = false);
231 $summary = ob_get_clean();
232
233 $cartTotal = $cart->getEstimatedTotal();
234
235 $modalCheckoutRender = (new ModalCheckoutRenderer($cart));
236 $enableModalCheckout = App::request()->get('modal_checkout');
237
238 $fragments = [];
239
240 $fragments[] = [
241 'selector' => '[data-fluent-cart-checkout-page-cart-items-wrapper]',
242 'content' => $summary,
243 'type' => 'replace'
244 ];
245
246 if ($enableModalCheckout === 'yes') {
247 $fragments[] = [
248 'selector' => '[data-fluent-cart-checkout-payment-methods]',
249 'content' => $modalCheckoutRender->getFragment('payment_methods'),
250 'type' => 'replace'
251 ];
252 } else {
253 $fragments[] = [
254 'selector' => '[data-fluent-cart-checkout-payment-methods]',
255 'content' => (new CheckoutRenderer($cart))->getFragment('payment_methods'),
256 'type' => 'replace'
257 ];
258 }
259
260 $fragments[] = [
261 'selector' => '[data-fct-modal-checkout-summary-group]',
262 'content' => $modalCheckoutRender->getFragment('summary_group'),
263 'type' => 'replace'
264 ];
265
266 $fragments[] = [
267 'selector' => '[data-fct-modal-checkout-summary]',
268 'content' => $modalCheckoutRender->getFragment('checkout_summary'),
269 'type' => 'replace'
270 ];
271
272 return [
273 'fragments' => $fragments,
274 'cart' => $cart,
275 'total' => $cartTotal,
276 'formatted_total' => Helper::toDecimal($cartTotal),
277 'applied_coupons' => $cart->coupons,
278 'has_subscriptions' => $cart->hasSubscription()
279 ];
280 }
281
282 public function handleRemoveCouponAjax()
283 {
284 $couponCode = App::request()->getSafe('coupon_code', 'sanitize_text_field');
285 $cart = CartHelper::getCart(null, false);
286
287 if (!$cart) {
288 return new \WP_Error('no_cart', __('No active cart found', 'fluent-cart'));
289 }
290
291 $cart = $cart->removeCoupon($couponCode);
292
293 if (is_wp_error($cart)) {
294 return $cart;
295 }
296
297 ob_start();
298 (new CartSummaryRender($cart))->render($withWrapper = false);
299 $summary = ob_get_clean();
300
301 $cartTotal = $cart->getEstimatedTotal();
302
303 $modalCheckoutRender = (new ModalCheckoutRenderer($cart));
304 $enableModalCheckout = App::request()->get('modal_checkout');
305
306 $fragments = [];
307
308 $fragments[] = [
309 'selector' => '[data-fluent-cart-checkout-page-cart-items-wrapper]',
310 'content' => $summary,
311 'type' => 'replace'
312 ];
313
314 if ($enableModalCheckout === 'yes') {
315 $fragments[] = [
316 'selector' => '[data-fluent-cart-checkout-payment-methods]',
317 'content' => $modalCheckoutRender->getFragment('payment_methods'),
318 'type' => 'replace'
319 ];
320 } else {
321 $fragments[] = [
322 'selector' => '[data-fluent-cart-checkout-payment-methods]',
323 'content' => (new CheckoutRenderer($cart))->getFragment('payment_methods'),
324 'type' => 'replace'
325 ];
326 }
327
328 $fragments[] = [
329 'selector' => '[data-fct-modal-checkout-summary-group]',
330 'content' => $modalCheckoutRender->getFragment('summary_group'),
331 'type' => 'replace'
332 ];
333
334 $fragments[] = [
335 'selector' => '[data-fct-modal-checkout-summary]',
336 'content' => $modalCheckoutRender->getFragment('checkout_summary'),
337 'type' => 'replace'
338 ];
339
340
341 return [
342 'fragments' => $fragments,
343 'total' => $cartTotal,
344 'formatted_total' => Helper::toDecimal($cartTotal),
345 'applied_coupons' => $cart->coupons,
346 'has_subscriptions' => $cart->hasSubscription()
347 ];
348 }
349
350 public function handleGetCheckoutSummaryViewAjax()
351 {
352 $cart = CartHelper::getCart(null, false);
353 if (!$cart) {
354 return new \WP_Error('no_cart', __('No active cart found', 'fluent-cart'));
355 }
356
357 $cart = $cart->reValidateCoupons();
358
359 $checkOutHelper = CartCheckoutHelper::make();
360 $checkOutHelper->setCart($cart);
361
362 $shippingChargeData = $this->getShippingChargeData($cart);
363 $shippingCharge = Arr::get($shippingChargeData, 'charge');
364 $shippingMethodId = Arr::get($shippingChargeData, 'shippingMethodId');
365 $formattedShippingCharge = Arr::get($shippingChargeData, 'formatted_charge');
366 $subtotal = $checkOutHelper->getItemsAmountSubtotal(false);
367
368 $formattedSubtotal = $checkOutHelper->getItemsAmountSubtotal(true);
369
370 $totalPrice = $checkOutHelper->getItemsAmountTotal(false) + $shippingCharge;
371
372 // Include fee total
373 $feeTotal = $cart->getFeeTotal();
374 if ($feeTotal > 0) {
375 $totalPrice += $feeTotal;
376 }
377
378 if (!empty($cart->checkout_data['tax_data'])) {
379 $taxTotal = (int) Arr::get($cart->checkout_data, 'tax_data.tax_total', 0);
380 $totalPrice += $taxTotal;
381 }
382
383 $oldShippingCharge = Arr::get($cart->checkout_data, 'shipping_data.shipping_charge', 0);
384
385 if (!empty($shippingCharge)) {
386 $cart->checkout_data = array_merge($cart->checkout_data, [
387 'shipping_data' => [
388 'shipping_method_id' => $shippingMethodId,
389 'shipping_charge' => $shippingCharge
390 ]
391 ]);
392 } else {
393 $cart->checkout_data = array_merge($cart->checkout_data, [
394 'shipping_data' => [
395 'shipping_method_id' => null,
396 'shipping_charge' => 0
397 ]
398 ]);
399 }
400
401 $cart->save();
402
403 // shipping charge changed
404 if ($shippingCharge !== $oldShippingCharge) {
405 do_action('fluent_cart/checkout/shipping_data_changed', [
406 'cart' => $cart
407 ]);
408 }
409
410
411 $totalPrice = apply_filters('fluent_cart/cart/estimated_total', $totalPrice, [
412 'cart' => $cart
413 ]);
414
415 // Prorate credit and upgrade discount (plan upgrade) are post-tax adjustments on
416 // the payable total.
417 $totalPrice = max(0, $totalPrice
418 - (int) Arr::get($cart->checkout_data, 'prorate_credit.amount', 0)
419 - (int) Arr::get($cart->checkout_data, 'upgrade_discount.amount', 0));
420
421 $fragments = [];
422
423 $cartRender = (new CheckoutRenderer($cart));
424 $modalCheckoutRender = (new ModalCheckoutRenderer($cart));
425 $enableModalCheckout = App::request()->getSafe('modal_checkout', 'sanitize_text_field');
426
427 ob_start();
428 (new CartSummaryRender($cart))->render(false);
429 $cartSummaryInner = ob_get_clean();
430
431 $fragments[] = [
432 'selector' => '[data-fluent-cart-checkout-page-cart-items-wrapper]',
433 'content' => $cartSummaryInner,
434 'type' => 'replace'
435 ];
436
437 if ($enableModalCheckout === 'yes') {
438 $fragments[] = [
439 'selector' => '[data-fluent-cart-checkout-payment-methods]',
440 'content' => $modalCheckoutRender->getFragment('payment_methods'),
441 'type' => 'replace'
442 ];
443 } else {
444 $fragments[] = [
445 'selector' => '[data-fluent-cart-checkout-payment-methods]',
446 'content' => $cartRender->getFragment('payment_methods'),
447 'type' => 'replace'
448 ];
449 }
450
451 return [
452 'fragments' => $fragments,
453 'total' => $totalPrice,
454 'applied_coupons' => $cart->coupons,
455 'shipping_charge' => $shippingCharge,
456 'formatted_shipping_charge' => $formattedShippingCharge,
457 'has_subscriptions' => $cart->hasSubscription(),
458 'shipping_method_id' => $shippingMethodId
459 ];
460
461 }
462
463 public function handleUpdateAddressSelectAjax()
464 {
465 $customerAddressId = App::request()->get('customer_address_id');
466 $address = CustomerAddressResource::find($customerAddressId, ['with' => App::request()->get('with', [])]);
467
468 if (!$address) {
469 return [
470 'message' => __('Address not found', 'fluent-cart')
471 ];
472 }
473
474 //update address into cart
475 $addressId = Arr::get($address, 'address.id');
476 $country = Arr::get($address, 'address.country');
477 $state = Arr::get($address, 'address.state');
478 $type = Arr::get($address, 'address.type', 'billing');
479 $cart = CartHelper::getCart(App::request()->get('fct_cart_hash'));
480
481 $oldTaxTotal = Arr::get($cart->checkout_data, 'tax_data.tax_total', 0);
482 $oldShippingCharge = Arr::get($cart->checkout_data, 'shipping_data.shipping_charge', 0);
483
484 $previousFormData = Arr::get($cart->checkout_data, 'form_data', []);
485 $checkoutData = Arr::wrap($cart->checkout_data);
486 Arr::set($checkoutData, 'form_data.' . $type . '_address_id', $addressId);
487 Arr::set($checkoutData, 'form_data.' . $type . '_country', $country);
488 Arr::set($checkoutData, 'form_data.' . $type . '_state', $state);
489
490 if ($type === 'billing' && Arr::get($checkoutData, 'form_data.ship_to_different', 'no') === 'no') {
491 Arr::set($checkoutData, 'form_data.shipping_address_id', $addressId);
492 Arr::set($checkoutData, 'form_data.shipping_country', $country);
493 Arr::set($checkoutData, 'form_data.shipping_state', $state);
494 }
495
496 $checkoutData = (new TaxModule())->maybeInvalidateVatValidationForCountryChange($checkoutData, [
497 'ship_to_different' => Arr::get($previousFormData, 'ship_to_different', 'no'),
498 'billing_country' => Arr::get($previousFormData, 'billing_country', ''),
499 'shipping_country' => Arr::get($previousFormData, 'shipping_country', ''),
500 ]);
501
502 $cart->checkout_data = $checkoutData;
503
504 $cart->save();
505
506 $customerId = Arr::get($address, 'address.customer_id');
507
508 $customer = \FluentCart\Api\Resource\CustomerResource::getCurrentCustomer();
509 if (empty($customer) || $customer->id != $customerId) {
510 return [
511 'message' => __('You are not authorized to view this address', 'fluent-cart')
512 ];
513 }
514
515 $formattedAddress = Arr::get($address, 'address.formatted_address');
516
517 // Use output buffering to generate HTML
518 ob_start();
519
520 // Extract the address parts
521 $addressParts = [
522 trim(Arr::get($formattedAddress, 'address_1') ?? ''),
523 trim(Arr::get($formattedAddress, 'address_2') ?? ''),
524 trim(Arr::get($formattedAddress, 'city') ?? ''),
525 trim(Arr::get($formattedAddress, 'state') ?? ''),
526 trim(Arr::get($formattedAddress, 'country') ?? ''),
527 ];
528
529 // Filter out empty or null parts
530 $addressParts = array_filter($addressParts, function ($part) {
531 return $part !== '';
532 });
533
534 // Join parts with comma and space
535
536
537 do_action('fluent_cart/checkout/form_data_changed', [
538 'cart' => $cart
539 ]);
540
541 ob_start();
542 (new CartSummaryRender($cart))->render(false);
543 $cartSummaryInner = ob_get_clean();
544
545 $newTaxTotal = Arr::get($cart->checkout_data, 'tax_data.tax_total', 0);
546 $newShippingCharge = Arr::get($cart->checkout_data, 'shipping_data.shipping_charge', 0);
547
548 $checkoutData = [
549 'message' => __('Address Attached', 'fluent-cart'),
550 'fragments' => [
551 [
552 'selector' => '[data-fluent-cart-checkout-page-cart-items-wrapper]',
553 'content' => $cartSummaryInner,
554 'type' => 'replace'
555 ]
556 ],
557 'tax_total_changes' => $oldTaxTotal != $newTaxTotal,
558 'shipping_charge_changes' => $oldShippingCharge != $newShippingCharge
559 ];
560
561 return apply_filters('fluent_cart/checkout/checkout_data_changed', $checkoutData, ['cart' => $cart]);
562
563 }
564
565 public function handleGetCountryInfoAjax()
566 {
567 $timezone = App::request()->getSafe('timezone', 'sanitize_text_field');
568
569 $code = App::request()->getSafe('country_code', 'sanitize_text_field');
570 $countryInfo = LocalizationManager::getCountryInfoFromRequest($timezone, $code);
571
572 return [
573 'country_info' => $countryInfo
574 ];
575 }
576
577 public function getShippingMethodsListView(array $data)
578 {
579 $availableShippingMethods = AddressHelper::getAvailableShippingMethodLists($data);
580 $shippingMethods = Arr::get($availableShippingMethods, 'available_shipping_methods');
581 $countryCode = Arr::get($availableShippingMethods, 'country_code');
582 $status = Arr::get($availableShippingMethods, 'status');
583
584 if ($status === false) {
585 return false;
586 }
587
588 $cart = CartHelper::getCart();
589
590 $cartRender = (new CheckoutRenderer($cart));
591
592 ob_start();
593 $cartRender->getFragment('shipping_methods');
594 $content = ob_get_clean();
595
596 return [
597 'view' => $content,
598 'country_code' => $countryCode,
599 'shipping_methods' => $shippingMethods
600 ];
601 }
602
603 public function handleGetShippingMethodsListViewAjax()
604 {
605 $data = [
606 'country_code' => App::request()->get('country_code'),
607 'state' => App::request()->get('state'),
608 'timezone' => App::request()->get('timezone')
609 ];
610
611 $cart = CartHelper::getCart();
612
613 if (!$cart) {
614 return new \WP_Error('no_cart', __('No active cart found', 'fluent-cart'));
615 }
616
617 $availableShippingMethods = AddressHelper::getShippingMethods($data['country_code'], $data['state'], $data['timezone']);
618
619 ob_start();
620
621 $selectedId = Arr::get($cart->checkout_data, 'shipping_data.shipping_method_id', '');
622
623 $selectedId = CartHelper::resolveAutoSelectShippingMethod($cart, $availableShippingMethods ?: [], $selectedId);
624
625 if (!$availableShippingMethods || is_wp_error($availableShippingMethods)) {
626 (new ShippingMethodsRender($availableShippingMethods, $selectedId))->render();
627 } else {
628 foreach ($availableShippingMethods as $method) {
629 $method->charge_amount = CartHelper::calculateShippingMethodCharge($method, $cart->cart_data);
630 }
631
632 (new ShippingMethodsRender($availableShippingMethods, $selectedId))->render();
633 }
634
635 $shippingMethodsView = ob_get_clean();
636
637 return [
638 'status' => true,
639 'fragments' => [
640 [
641 'selector' => '[data-fluent-cart-checkout-page-shipping-methods-wrapper]',
642 'content' => $shippingMethodsView,
643 'type' => 'replace'
644 ]
645 ],
646 'country_code' => $data['country_code'],
647 'shipping_method_id' => $selectedId ?: null,
648 ];
649 }
650
651 public function handleCartStatusAjax()
652 {
653 return CartResource::getStatus();
654 }
655
656 public function handleCartUpdateAjax()
657 {
658 $requestData = App::request()->all();
659
660 $data = [
661 'item_id' => (int)Arr::get($requestData, 'item_id'),
662 'quantity' => (int)Arr::get($requestData, 'quantity', 0),
663 'by_input' => Arr::get($requestData, 'by_input', false),
664 'is_custom' => Arr::get($requestData, 'is_custom', false)
665 ];
666
667 $cart = CartResource::update($data, '', $requestData);
668
669 if (is_wp_error($cart)) {
670 return $cart;
671 }
672
673 do_action('fluent_cart/checkout/cart_amount_updated', [
674 'cart' => $cart
675 ]);
676
677 $itemCount = 0;
678
679 if ($cart) {
680 $itemCount = count($cart->cart_data ?? []);
681 }
682
683 $cartItems = Arr::get(CartResource::getStatus(), 'cart_data', []);
684
685 $defaultOpen = Arr::get($requestData, 'open_cart', false);
686 $isAdminBarEnabled = Arr::get($requestData, 'is_admin_bar_enabled', false);
687
688 $fragments = [];
689 if (!empty($cartItems)) {
690 ob_start();
691 (new CartDrawerRenderer($cartItems, [
692 'item_count' => $itemCount,
693 'open_cart' => $defaultOpen,
694 'is_admin_bar_enabled' => $isAdminBarEnabled
695 ]))->render();
696 $cartDrawerView = ob_get_clean();
697
698 ob_start();
699 (new CartRenderer($cartItems))->renderItems($cartItems);
700 $cartDrawerItemsView = ob_get_clean();
701
702 ob_start();
703 (new CartRenderer($cartItems))->renderTotal();
704 $cartDrawerItemsTotalView = ob_get_clean();
705
706 ob_start();
707 (new CartDrawerRenderer($cartItems, [
708 'item_count' => $itemCount,
709 'open_cart' => $defaultOpen,
710 'is_admin_bar_enabled' => $isAdminBarEnabled
711 ]))->renderItemCount();
712 $cartItemCount = ob_get_clean();
713
714 $fragments = [
715 [
716 'selector' => '[data-fluent-cart-cart-drawer-container]',
717 'content' => $cartDrawerView,
718 'type' => 'replace'
719 ],
720 [
721 'selector' => '[data-fluent-cart-cart-content-wrapper]',
722 'content' => $cartDrawerItemsView,
723 'type' => 'replace'
724 ],
725 [
726 'selector' => '[data-fluent-cart-cart-total-wrapper]',
727 'content' => $cartDrawerItemsTotalView,
728 'type' => 'replace'
729 ],
730 [
731 'selector' => '.fluent-cart-cart-badge-count',
732 'content' => $itemCount > 0 ? $cartItemCount : '',
733 'type' => 'replace'
734 ]
735 ];
736 }
737
738 if (empty($cartItems)) {
739 ob_start();
740 (new CartRenderer($cartItems))->renderEmpty();
741 $cartDrawerItemsView = ob_get_clean();
742 $fragments[] = [
743 'selector' => '[data-fluent-cart-cart-content-wrapper]',
744 'content' => $cartDrawerItemsView,
745 'type' => 'replace'
746 ];
747 }
748
749
750 return [
751 'message' => __('Cart updated successfully', 'fluent-cart'),
752 'data' => apply_filters('fluent_cart/checkout/cart_updated', [
753 'cart' => $cart,
754 ]),
755 'fragments' => $fragments
756 ];
757 }
758
759 public function patchCheckoutData()
760 {
761 $cart = CartHelper::getCart();
762 if (!$cart) {
763 return new \WP_Error('no_cart', __('No active cart found', 'fluent-cart'));
764 }
765
766 $allData = App::request()->all();
767 $dataKey = sanitize_text_field((string) Arr::get($allData, 'data_key'));
768 $dataValue = sanitize_text_field((string) Arr::get($allData, 'data_value'));
769
770 if ($dataKey) {
771 $allData[$dataKey] = $dataValue;
772 }
773
774 $allData = AddressHelper::maybePushAddressDataForCheckout($allData, 'billing');
775
776 if (Arr::get($allData, 'ship_to_different') === 'yes') {
777 $allData = AddressHelper::maybePushAddressDataForCheckout($allData, 'shipping');
778 } else {
779 $allData = AddressHelper::mergeBillingWithShipping($allData);
780 }
781
782 $validKeys = [
783 'ship_to_different' => 'form_data.ship_to_different',
784 'billing_email' => 'form_data.billing_email',
785 'billing_first_name' => 'form_data.billing_first_name',
786 'billing_last_name' => 'form_data.billing_last_name',
787 'billing_address_id' => 'form_data.billing_address_id',
788 'shipping_address_id' => 'form_data.shipping_address_id',
789 'billing_company' => 'form_data.billing_company',
790 'order_notes' => 'form_data.order_notes',
791 'shipping_method_id' => 'shipping_data.shipping_method_id',
792 '_fct_pay_method' => 'form_data._fct_pay_method',
793 'billing_company_name' => 'form_data.billing_company_name',
794 'billing_legal_registration_id' => 'form_data.billing_legal_registration_id',
795 'is_business' => 'form_data.is_business',
796 'fct_billing_tax_id' => 'tax_data.vat_number',
797 ];
798
799 $addressFieldKeys = [
800 'full_name',
801 'country',
802 'address_1',
803 'address_2',
804 'state',
805 'city',
806 'postcode',
807 'company_name',
808 'phone'
809 ];
810
811 foreach ($addressFieldKeys as $addressFieldKey) {
812 $validKeys['billing_' . $addressFieldKey] = 'form_data.billing_' . $addressFieldKey;
813 $validKeys['shipping_' . $addressFieldKey] = 'form_data.shipping_' . $addressFieldKey;
814 }
815
816 $prevFlatData = [];
817 $prevCheckoutData = $cart->checkout_data;
818 foreach ($validKeys as $dataName => $dataPath) {
819 $prevFlatData[$dataName] = Arr::get($prevCheckoutData, $dataPath, null);
820 }
821 // $prevFlatData = array_filter($prevFlatData);
822 $prevFlatData = array_filter($prevFlatData, function ($value) {
823 return $value !== null;
824 });
825 $validData = Arr::only($allData, array_keys($validKeys));
826
827
828 $normalizeData = [];
829
830 foreach ($validData as $key => $value) {
831 $prevValue = Arr::get($prevFlatData, $key, null);
832 if ($prevValue != $value) {
833 $normalizeData[$key] = $value;
834 }
835 }
836
837 // Force-check the explicit data_key field — bulk POST may have already stored it as a side-effect.
838 if ($dataKey && isset($validKeys[$dataKey]) && !isset($normalizeData[$dataKey])) {
839 $storedValue = (string) Arr::get($prevCheckoutData, $validKeys[$dataKey], '');
840 if ($storedValue !== $dataValue) {
841 $normalizeData[$dataKey] = $dataValue;
842 }
843 }
844
845 if (!$normalizeData) {
846 return [
847 'message' => __('No changes detected', 'fluent-cart')
848 ];
849 }
850
851 $normalizeData = $this->normalizeCheckoutChangeData($normalizeData, $allData);
852
853 // Validate these data and filter out invalid data points and also push the
854 // address data into this array.
855
856
857 $checkoutData = $cart->checkout_data;
858 foreach ($normalizeData as $normalizeKey => $normalizeValue) {
859 Arr::set($checkoutData, $validKeys[$normalizeKey], $normalizeValue);
860 }
861
862 $oldTaxTotal = (int) Arr::get($cart->checkout_data, 'tax_data.tax_total', 0);
863 $fillData = [
864 'checkout_data' => $checkoutData,
865 'cart_data' => $cart->cart_data,
866 'hook_changes' => [
867 'shipping' => false,
868 'tax' => false
869 ]
870 ];
871
872
873 $fillData = apply_filters('fluent_cart/checkout/before_patch_checkout_data', $fillData, [
874 'cart' => $cart,
875 'prev_data' => $prevFlatData,
876 'changes' => $normalizeData,
877 'all_data' => $allData
878 ]);
879
880 $hookChanges = Arr::get($fillData, 'hook_changes', []);
881 unset($fillData['hook_changes']);
882
883 if (isset($normalizeData['billing_email'])) {
884 $cart->email = $normalizeData['billing_email'];
885 }
886
887 if (isset($normalizeData['billing_full_name'])) {
888 $fullName = $normalizeData['billing_full_name'];
889 $nameParts = explode(' ', $fullName);
890 $cart->first_name = array_shift($nameParts);
891 $cart->last_name = implode(' ', $nameParts);
892 }
893
894 if (isset($normalizeData['billing_first_name'])) {
895 $cart->first_name = $normalizeData['billing_first_name'];
896 }
897
898 if (isset($normalizeData['billing_last_name'])) {
899 $cart->last_name = $normalizeData['billing_last_name'];
900 }
901
902 $cart->fill($fillData);
903 $newTaxTotal = (int) Arr::get($cart->checkout_data, 'tax_data.tax_total', 0);
904 $taxAmountChanged = $oldTaxTotal !== $newTaxTotal;
905 $cart->clearFeeCache();
906
907 $cart = CartHelper::getCart();
908 $sanitizedUtmData = UtmHelper::getUtmDataOfRequest();
909 $cart->utm_data = $sanitizedUtmData;
910
911 $cart->save();
912
913 $fragments = [];
914
915 $cartRender = (new CheckoutRenderer($cart));
916 $modalCheckoutRender = (new ModalCheckoutRenderer($cart));
917 $enableModalCheckout = App::request()->get('modal_checkout');
918
919 if (!empty($hookChanges['shipping'])) {
920 $fragments[] = [
921 'selector' => '[data-fluent-cart-checkout-page-shipping-methods-wrapper]',
922 'content' => $cartRender->getFragment('shipping_methods'),
923 'type' => 'replace'
924 ];
925 }
926
927 if (array_filter($hookChanges)) {
928 $fragments[] = [
929 'selector' => '[data-fluent-cart-checkout-page-cart-items-wrapper]',
930 'content' => $cartRender->getFragment('cart_summary_fragment'),
931 'type' => 'replace'
932 ];
933
934 if ($taxAmountChanged || !empty($hookChanges['shipping'])) {
935 // also update the payment methods
936 if ($enableModalCheckout === 'yes') {
937 $fragments[] = [
938 'selector' => '[data-fluent-cart-checkout-payment-methods]',
939 'content' => $modalCheckoutRender->getFragment('payment_methods'),
940 'type' => 'replace'
941 ];
942 } else {
943 $fragments[] = [
944 'selector' => '[data-fluent-cart-checkout-payment-methods]',
945 'content' => $cartRender->getFragment('payment_methods'),
946 'type' => 'replace'
947 ];
948 }
949
950 $fragments[] = [
951 'selector' => '[data-fct-modal-checkout-summary-group]',
952 'content' => $modalCheckoutRender->getFragment('summary_group'),
953 'type' => 'replace'
954 ];
955
956 $fragments[] = [
957 'selector' => '[data-fct-modal-checkout-summary]',
958 'content' => $modalCheckoutRender->getFragment('checkout_summary'),
959 'type' => 'replace'
960 ];
961 }
962 }
963
964 // Re-render cart summary on payment method change (fees may depend on payment method)
965 if (isset($normalizeData['_fct_pay_method'])) {
966 $hasSummaryFragment = false;
967 foreach ($fragments as $fragment) {
968 if (Arr::get($fragment, 'selector') === '[data-fluent-cart-checkout-page-cart-items-wrapper]') {
969 $hasSummaryFragment = true;
970 break;
971 }
972 }
973
974 if (!$hasSummaryFragment) {
975 $fragments[] = [
976 'selector' => '[data-fluent-cart-checkout-page-cart-items-wrapper]',
977 'content' => $cartRender->getFragment('cart_summary_fragment'),
978 'type' => 'replace'
979 ];
980
981 $fragments[] = [
982 'selector' => '[data-fct-modal-checkout-summary-group]',
983 'content' => $modalCheckoutRender->getFragment('summary_group'),
984 'type' => 'replace'
985 ];
986
987 $fragments[] = [
988 'selector' => '[data-fct-modal-checkout-summary]',
989 'content' => $modalCheckoutRender->getFragment('checkout_summary'),
990 'type' => 'replace'
991 ];
992 }
993 }
994
995 if (($dataKey === 'billing_address_id' || $dataKey === 'shipping_address_id') && $dataValue !== '') {
996 // get current customer
997
998 $customer = CustomerResource::getCurrentCustomer();
999
1000 $type = $dataKey === 'billing_address_id' ? 'billing' : 'shipping';
1001 $requiredShipping = $cart->requireShipping();
1002 $config = [
1003 'type' => $type,
1004 'product_type' => $requiredShipping ? 'physical' : 'digital',
1005 'with_shipping' => $requiredShipping
1006 ];
1007 if ($type === 'billing') {
1008 $config['with_shipping'] = Arr::get($allData, 'ship_to_different', 'no') !== 'yes';
1009 $config['billing_address_id'] = $dataValue;
1010 } else {
1011 $config['shipping_address_id'] = $dataValue;
1012 }
1013 $addresses = AddressHelper::getCustomerValidatedAddresses($config, $customer);
1014 $address = AddressHelper::getPrimaryAddress($addresses, $config, $customer, $type);
1015 $requirementsFields = CheckoutFieldsSchema::getCheckoutFieldsRequirements(
1016 $type,
1017 Arr::get($config, 'product_type'),
1018 Arr::get($config, 'with_shipping')
1019 );
1020 ob_start();
1021 (new AddressSelectRenderer($addresses, $address, $requirementsFields, $type))->renderAddressInfo();
1022 $addressRender = ob_get_clean();
1023
1024 $fragments[] = [
1025 'selector' => '#' . $type . '_address_wrapper [data-fluent-cart-checkout-page-form-address-info-wrapper]',
1026 'content' => $addressRender,
1027 'type' => 'replace'
1028 ];
1029 }
1030
1031 $fragments = apply_filters('fluent_cart/checkout/after_patch_checkout_data_fragments', $fragments, [
1032 'cart' => $cart,
1033 'changes' => $normalizeData
1034 ]);
1035
1036 return [
1037 'message' => __('Data saved successfully', 'fluent-cart'),
1038 'changes' => $normalizeData,
1039 'fragments' => $fragments,
1040 'cart' => $cart
1041 ];
1042 }
1043
1044 public function handleOrderBumpRequest()
1045 {
1046 $requestData = App::request()->all();
1047
1048 $cart = CartHelper::getCart();
1049 if (!$cart) {
1050 return new \WP_Error('no_cart', __('No active cart found', 'fluent-cart'));
1051 }
1052
1053 $checkoutData = $cart->checkout_data;
1054 if (!$cart->acceptsAdditionalItems()) {
1055 return new \WP_Error('invalid_request', __('This cart is locked or already has an upgrade applied.', 'fluent-cart'));
1056 }
1057
1058 $upgradeFromVariationId = (int) Arr::get($requestData, 'upgrade_form', 0);
1059 $targetVariationId = (int) Arr::get($requestData, 'upgrade_to', 0);
1060 $bumpId = (int) Arr::get($requestData, 'bump_id', 0);
1061
1062 if ($bumpId) {
1063 $response = new \WP_Error('invalid_bump', __('Could not apply item at this time.', 'fluent-cart'));
1064 return apply_filters('fluent_cart/apply_order_bump', $response, [
1065 'bump_id' => $bumpId,
1066 'cart' => $cart,
1067 'request_data' => $requestData
1068 ]);
1069 }
1070
1071 // A locked cart may still take bumps through the accepts_additional_items
1072 // filter, but must never let an item it pinned be swapped out.
1073 if (Arr::get($checkoutData, 'is_locked') === 'yes') {
1074 return new \WP_Error('invalid_request', __('This cart is locked and cannot be modified.', 'fluent-cart'));
1075 }
1076
1077 if (!$upgradeFromVariationId || !$targetVariationId) {
1078 return new \WP_Error('invalid_request', __('Invalid upgrade request.', 'fluent-cart'));
1079 }
1080
1081 $productVariation = ProductVariation::query()->find($targetVariationId);
1082
1083 if (!$productVariation || !$productVariation->canPurchase()) {
1084 return new \WP_Error('invalid_variation', __('The selected product variation is not available for purchase.', 'fluent-cart'));
1085 }
1086
1087 $cart->removeItem($upgradeFromVariationId);
1088 $cart = $cart->addByVariation($productVariation, [
1089 'quantity' => 1,
1090 'append' => false
1091 ]);
1092
1093 if (is_wp_error($cart)) {
1094 return $cart;
1095 }
1096
1097 $isUpgraded = Arr::get($requestData, 'is_upgraded') === 'yes';
1098 if ($isUpgraded) {
1099 $checkoutData['order_bump'] = [
1100 'upgraded_from' => $upgradeFromVariationId,
1101 'upgraded_to' => $targetVariationId
1102 ];
1103 $existingActions = Arr::get($checkoutData, '__on_success_actions__', []);
1104 if (!is_array($existingActions)) {
1105 $existingActions = [];
1106 }
1107 $existingActions[] = 'fluent_cart/order_bump_succeed';
1108 $checkoutData['__on_success_actions__'] = $existingActions;
1109 } else {
1110 unset($checkoutData['order_bump']);
1111 $existingActions = Arr::get($checkoutData, '__on_success_actions__', []);
1112 if (!is_array($existingActions)) {
1113 $existingActions = [];
1114 }
1115
1116 if (!empty($existingActions) && in_array('fluent_cart/order_bump_succeed', $existingActions)) {
1117 $existingActions = array_diff($existingActions, ['fluent_cart/order_bump_succeed']);
1118 $existingActions = array_values($existingActions);
1119 }
1120
1121 if (is_array($existingActions)) {
1122 $existingActions = array_filter($existingActions, function ($action) {
1123 return $action !== 'fluent_cart/order_bump_succeed';
1124 });
1125 }
1126
1127 $checkoutData['__on_success_actions__'] = $existingActions;
1128 }
1129
1130 $cart->checkout_data = $checkoutData;
1131 $cart->save();
1132
1133 do_action('fluent_cart/checkout/cart_amount_updated', [
1134 'cart' => $cart
1135 ]);
1136
1137 return [
1138 'message' => $isUpgraded ? __('Item has been applied successfully', 'fluent-cart') : __('Item has been reverted successfully', 'fluent-cart')
1139 ];
1140 }
1141
1142
1143 public function normalizeCheckoutChangeData($changedData, $allData): array
1144 {
1145
1146 $sanitizedData = [];
1147 $errors = [];
1148 foreach ($changedData as $dataKey => $dataValue) {
1149 if (in_array($dataKey, ['billing_full_name', 'shipping_full_name'])) {
1150 $sanitizedData[$dataKey] = sanitize_text_field($dataValue);
1151 } else if (in_array($dataKey, ['billing_email', 'shipping_email'])) {
1152 if (empty($dataValue)) {
1153 $sanitizedData[$dataKey] = '';
1154 } else {
1155 $sanitizedData[$dataKey] = sanitize_email($dataValue ?? '');
1156 }
1157 } else if (in_array($dataKey, ['billing_company_name', 'shipping_company_name', 'billing_legal_registration_id'])) {
1158 $sanitizedData[$dataKey] = sanitize_text_field($dataValue);
1159 } else if ($dataKey === 'is_business') {
1160 $sanitizedData[$dataKey] = $dataValue === 'yes' ? 'yes' : 'no';
1161 } else if ($dataKey === 'billing_state' && !empty($dataValue)) {
1162 $billingCountry = Arr::get($allData, 'billing_country');
1163
1164 $states = LocalizationManager::getInstance()->statesOptions($billingCountry);
1165 $countryStates = array_values(array_column($states, 'value'));
1166 if (!empty($states) && !in_array($dataValue, $countryStates)) {
1167 $errors[$dataKey] = __('Invalid state code.', 'fluent-cart');
1168 $sanitizedData[$dataKey] = null;
1169 } else {
1170 $sanitizedData[$dataKey] = $dataValue;
1171 }
1172 } else if ($dataKey === 'shipping_state' && !empty($dataValue)) {
1173 $shipToDifferent = ((Arr::get($allData, 'ship_to_different', 'no') === 'yes') == 'yes');
1174 $shippingCountry = $shipToDifferent ? Arr::get($allData, 'shipping_country') : Arr::get($allData, 'billing_country');
1175 $states = LocalizationManager::getInstance()->statesOptions($shippingCountry);
1176 if (!empty($states) && !in_array($dataValue, array_column($states, 'value'))) {
1177 $errors[$dataKey] = __('Invalid state code.', 'fluent-cart');
1178 $sanitizedData[$dataKey] = null;
1179 } else {
1180 $sanitizedData[$dataKey] = $dataValue;
1181 }
1182 } else if ($dataKey === '_fct_pay_method') {
1183 $value = sanitize_text_field($dataValue);
1184 $methods = PaymentMethods::getActiveMeta();
1185 $methods = array_column($methods, 'route');
1186 if (!in_array($value, $methods)) {
1187 $value = null;
1188 $errors[$dataKey] = __('Invalid payment method.', 'fluent-cart');
1189 }
1190 $sanitizedData[$dataKey] = $value;
1191 } else if ($dataKey === 'shipping_method_id') {
1192 $shipToDifferent = ((Arr::get($allData, 'ship_to_different', 'no') === 'yes') == 'yes');
1193 $countryKey = $shipToDifferent ? 'shipping_country' : 'billing_country';
1194 $stateKey = $shipToDifferent ? 'shipping_state' : 'billing_state';
1195 $shippingCountry = Arr::has($changedData, $countryKey) ? Arr::get($changedData, $countryKey) : Arr::get($allData, $countryKey);
1196 $shippingState = Arr::has($changedData, $stateKey) ? Arr::get($changedData, $stateKey) : Arr::get($allData, $stateKey);
1197 $availableShippingMethods = AddressHelper::getShippingMethods($shippingCountry, $shippingState);
1198
1199 $found = false;
1200 foreach ($availableShippingMethods as $method) {
1201 if ($method->id == $dataValue) {
1202 $found = true;
1203 break;
1204 }
1205 }
1206
1207 if (!$found) {
1208 $errors[$dataKey] = __('Invalid shipping method.', 'fluent-cart');
1209 $sanitizedData[$dataKey] = null;
1210 } else {
1211 $sanitizedData[$dataKey] = sanitize_text_field($dataValue);
1212 }
1213 } else {
1214 $sanitizedData[$dataKey] = sanitize_text_field($dataValue);
1215 }
1216 }
1217
1218 return $sanitizedData;
1219 }
1220
1221 private function pushAddressData($data, $type = 'billing')
1222 {
1223 return AddressHelper::maybePushAddressDataForCheckout($data, $type);
1224 }
1225
1226 public function getProductModalView()
1227 {
1228 $productId = App::request()->getSafe('product_id', 'intval');
1229 $product = Product::query()->find($productId);
1230
1231 if (!$product) {
1232 return $this->sendError([
1233 'message' => __('Product not found', 'fluent-cart')
1234 ]);
1235 }
1236 ob_start();
1237 (new ProductModalRenderer($product))->render();
1238 $view = ob_get_clean();
1239 return [
1240 'view' => $view
1241 ];
1242 }
1243
1244 }
1245