register(); // Checkout Frontend - inject tracking script add_action('fluent_cart/after_checkout_page', [$this, 'addAbandonScript']); // AJAX handler for GDPR opt-out add_action('wp_ajax_fc_ab_fct_cart_skip', [$this, 'handleAjaxOptOut']); add_action('wp_ajax_nopriv_fc_ab_fct_cart_skip', [$this, 'handleAjaxOptOut']); // Sync abandoned cart when FluentCart saves checkout data add_filter('fluent_cart/checkout/after_patch_checkout_data_fragments', function ($fragments, $data) { $this->maybeSyncCart(Arr::get($data, 'cart')); return $fragments; }, 99, 2); // Sync abandoned cart when cart amounts change (item add/remove, coupon) add_action('fluent_cart/checkout/cart_amount_updated', function ($data) { $this->maybeSyncCart(Arr::get($data, 'cart')); }, 99); // Sync abandoned cart on form data change (alternative save path) add_action('fluent_cart/checkout/form_data_changed', function ($data) { $this->maybeSyncCart(Arr::get($data, 'cart')); }, 99); // Cart recovery URL handler // URL: example.com/?fluentcrm=1&route=general&handler=fc_cart_fluent_cart&fc_ab_hash=xyz add_action('fluent_crm/handle_frontend_for_fc_cart_fluent_cart', function ($data) { add_action('template_redirect', function () use ($data) { $this->maybeRestoreCart($data); }, 1); }); // Order lifecycle - link cart to order when created add_action('fluent_cart/order_created', [$this, 'handleOrderCreated'], 1); // Order paid - mark cart as recovered add_action('fluent_cart/order_paid', [$this, 'handleOrderPaid'], 1); // Order status changes add_action('fluent_cart/order_status_changed', [$this, 'handleOrderStatusChanged'], 10); // Push contextual smart codes for this provider add_filter('fluent_crm_funnel_context_smart_codes', [$this, 'pushContextCodes'], 1, 2); // Parse the context codes add_filter('fluent_crm/smartcode_group_callback_ab_cart_fluent_cart', [$this, 'parseSmartCodes'], 10, 4); } public function addAbandonScript() { if (!AbCartHelper::willCartTrack()) { return; } if (isset($_COOKIE['fc_ab_cart_skip_track']) && $_COOKIE['fc_ab_cart_skip_track'] == 'yes') { return; } wp_enqueue_script( 'fluent_crm-abandon-cart-fct', FLUENTCRM_PLUGIN_URL . 'app/Modules/AbandonCart/Drivers/FluentCart/assets/fc-cart-abandon-fluent-cart.js', [], FLUENTCRM_PLUGIN_VERSION, true ); wp_localize_script('fluent_crm-abandon-cart-fct', 'fc_ab_fct_cart', [ 'nonce' => wp_create_nonce('fc_ab_fct_cart_nonce'), '__gdpr_message' => AbCartHelper::getGDPRMessage(), ]); } public function handleAjaxOptOut() { $nonce = Arr::get($_REQUEST, '_nonce'); if (!wp_verify_nonce($nonce, 'fc_ab_fct_cart_nonce')) { wp_send_json([ 'message' => __('Security check failed. Invalid nonce.', 'fluent-crm') ], 403); } $record = $this->getCurrentRecord(); if ($record) { $record->optOut(); } $cookieDays = (int)apply_filters('fluent_crm/ab_cart_opt_out_cookie_validity', 7); setcookie('fc_ab_cart_skip_track', 'yes', time() + (86400 * $cookieDays), COOKIEPATH, COOKIE_DOMAIN); wp_send_json([ 'message' => __('You have opted out from cart tracking', 'fluent-crm') ]); } public function maybeSyncCart($fctCart) { if (!$fctCart || !AbCartHelper::willCartTrack()) { return; } if (!$fctCart->email || empty($fctCart->cart_data)) { return; } $billingEmail = $fctCart->email; if (isset($_COOKIE['fc_ab_cart_skip_track']) && $_COOKIE['fc_ab_cart_skip_track'] == 'yes') { $record = $this->getCurrentRecord($billingEmail, $fctCart->cart_hash); if ($record && $record->status !== 'opt_out') { $record->status = 'opt_out'; $record->save(); } return; } $checkoutData = $fctCart->checkout_data ?: []; // Calculate totals for the table columns (FluentCart stores amounts in cents). // $subtotal is the items' price BEFORE any discount. $subtotal = $fctCart->getItemsSubtotal(); // Coupon and per-item manual discounts are stored on each cart item as // discount_total (manual_discount + coupon_discount). The old code only read // custom_checkout_data.discount_total, which is empty for normal frontend // coupons, so the coupon was never reflected in the cart total. Sum the per-item // discounts plus any checkout-level (manual/upgrade/prorate) discounts. $itemsDiscountTotal = array_sum(array_map(function ($item) { return (int)Arr::get($item, 'discount_total', 0); }, $fctCart->cart_data ?: [])); $discountTotal = $itemsDiscountTotal + (int)Arr::get($checkoutData, 'manual_discount.amount', 0) + (int)Arr::get($checkoutData, 'upgrade_discount.amount', 0) + (int)Arr::get($checkoutData, 'prorate_credit.amount', 0); $shippingTotal = (int)$fctCart->getShippingTotal(); $taxTotal = (int)Arr::get($checkoutData, 'tax_data.tax_total', 0); // Use FluentCart's authoritative total so the displayed Cart Total matches the // checkout exactly (coupons, fees, shipping and tax all included). $total = (int)$fctCart->getEstimatedTotal(); if ($total <= 0) { $record = $this->getCurrentRecord($billingEmail); if ($record) { $record->delete(); setcookie('fc_ab_fct_cart_token', '', time() - 3600, COOKIEPATH, COOKIE_DOMAIN); } return; } $currency = ''; if (class_exists('\FluentCart\Api\CurrencySettings')) { $currency = \FluentCart\Api\CurrencySettings::get('currency') ?: 'USD'; } $contact = FluentCrmApi('contacts')->getContact($billingEmail); $fullName = $fctCart->full_name ?? trim($fctCart->first_name . ' ' . $fctCart->last_name); if (!$fullName && $contact) { $fullName = trim($contact->first_name . ' ' . $contact->last_name); } // Build a per-coupon breakdown (code + discounted value) for the cart details view. // FluentCart stores the applied codes on $fctCart->coupons and the amount each code // saved (in cents) on checkout_data.__per_coupon_discounts, keyed by code. $perCouponDiscounts = Arr::get($checkoutData, '__per_coupon_discounts', []); $couponDetails = []; foreach (($fctCart->coupons ?: []) as $couponCode) { $couponAmount = (int)Arr::get($perCouponDiscounts, $couponCode, 0); $couponDetails[] = [ 'code' => $couponCode, 'discount' => number_format($couponAmount / 100, 2, '.', ''), ]; } // Snapshot the FluentCart data as-is for easy restore $data = [ 'cart_hash' => $fctCart->cart_hash, 'full_name' => $fullName, 'email' => $billingEmail, 'provider' => 'fluent_cart', 'user_id' => $fctCart->user_id, 'contact_id' => $contact ? $contact->id : null, 'order_id' => $fctCart->order_id, 'subtotal' => number_format($subtotal / 100, 2, '.', ''), 'shipping' => number_format($shippingTotal / 100, 2, '.', ''), 'discounts' => number_format($discountTotal / 100, 2, '.', ''), 'tax' => number_format($taxTotal / 100, 2, '.', ''), 'fees' => 0, 'total' => number_format($total / 100, 2, '.', ''), 'currency' => $currency, 'cart' => [ 'cart_data' => $fctCart->cart_data ?: [], 'checkout_data' => $checkoutData, 'coupons' => $fctCart->coupons ?: [], 'coupons_detail' => $couponDetails, 'utm_data' => $fctCart->utm_data ?: [], 'cart_group' => $fctCart->cart_group, 'customer_data' => $this->buildCustomerData($checkoutData), ], ]; $record = $this->getCurrentRecord($billingEmail, $fctCart->cart_hash); if (!$record) { $data['status'] = 'draft'; $record = AbandonCartModel::create($data); } else { $record->fill($data); $record->save(); } $cookieDays = (int)apply_filters('fluent_crm/ab_cart_cookie_validity', 30); setcookie('fc_ab_fct_cart_token', $record->checkout_key, time() + (86400 * $cookieDays), COOKIEPATH, COOKIE_DOMAIN); } private function buildCustomerData($checkoutData) { $formData = Arr::get($checkoutData, 'form_data', []); return [ 'billingAddress' => [ 'first_name' => Arr::get($formData, 'billing_first_name', ''), 'last_name' => Arr::get($formData, 'billing_last_name', ''), 'address_1' => Arr::get($formData, 'billing_address_1', ''), 'address_2' => Arr::get($formData, 'billing_address_2', ''), 'postcode' => Arr::get($formData, 'billing_postcode', ''), 'city' => Arr::get($formData, 'billing_city', ''), 'state' => Arr::get($formData, 'billing_state', ''), 'country' => Arr::get($formData, 'billing_country', ''), 'phone' => Arr::get($formData, 'billing_phone', ''), ], ]; } private function getCurrentRecord($billingEmail = null, $cartHash = null) { if ($cartHash) { // Try to find by cart hash first if available $record = AbandonCartModel::where('cart_hash', $cartHash) ->where('provider', 'fluent_cart') ->whereIn('status', ['pending', 'opt_out', 'draft', 'processing']) ->first(); if ($record) { return $record; } } // First try from the cookie $existingToken = Arr::get($_COOKIE, 'fc_ab_fct_cart_token'); if ($existingToken) { $record = AbandonCartModel::where('checkout_key', $existingToken) ->where('provider', 'fluent_cart') ->whereIn('status', ['pending', 'opt_out', 'draft', 'processing']) ->first(); if ($record) { return $record; } } // Try with billing email if ($billingEmail) { $record = AbandonCartModel::where('email', $billingEmail) ->where('provider', 'fluent_cart') ->whereIn('status', ['pending', 'opt_out', 'draft', 'processing']) ->first(); if ($record) { return $record; } } // If user logged in, try with user id $userId = get_current_user_id(); if ($userId) { $record = AbandonCartModel::where('user_id', $userId) ->where('provider', 'fluent_cart') ->whereIn('status', ['pending', 'opt_out', 'draft', 'processing']) ->first(); if ($record) { return $record; } } return null; } public function handleOrderCreated($eventData) { $order = Arr::get($eventData, 'order'); if (!$order) { return; } $token = sanitize_text_field(Arr::get($_COOKIE, 'fc_ab_fct_cart_token', '')); if (!$token) { return; } $abCart = AbCartHelper::getAbCartByDataProps([ 'checkout_key' => $token ], ['processing', 'draft']); if (!$abCart || $abCart->provider !== 'fluent_cart') { return; } $abCart->order_id = $order->id; $abCart->save(); } public function handleOrderPaid($eventData) { $order = Arr::get($eventData, 'order'); $customer = Arr::get($eventData, 'customer'); if (!$order || !$customer) { return; } $abCart = AbandonCartModel::query()->where('order_id', $order->id)->where('provider', 'fluent_cart')->first(); if (!$abCart) { $this->cancelAutomationsByCustomer($customer); return; } $this->markCartAsRecovered($abCart, $order); } public function handleOrderStatusChanged($eventData) { $order = Arr::get($eventData, 'order'); $newStatus = Arr::get($eventData, 'new_status'); if (!$order || !$newStatus) { return; } $abCartId = $order->getMeta('_fc_ab_cart_id'); if (!$abCartId) { return; } $abCart = AbandonCartModel::find($abCartId); if (!$abCart || $abCart->provider !== 'fluent_cart') { return; } $driver = new FluentCartDriver(); if ($driver->isWinOrderStatus($newStatus)) { if ($abCart->status !== 'recovered') { $this->markCartAsRecovered($abCart, $order); } return; } $lostStatuses = ['failed', 'canceled']; if (in_array($newStatus, $lostStatuses, true)) { $this->handleCartLost($abCart, $order); } } private function markCartAsRecovered($abCart, $order) { $deletableStatuses = ['draft', 'opt_out', 'pending']; if (in_array($abCart->status, $deletableStatuses, true)) { $abCart->deleteCart(); $this->deleteOtherCarts($abCart, $order); return; } $recoverableStatuses = ['processing', 'lost', 'cancelled']; if (!in_array($abCart->status, $recoverableStatuses, true)) { return; } $settings = AbCartHelper::getSettings(); $subscriber = $abCart->subscriber; if ($subscriber) { if ($attachLists = Arr::get($settings, 'lists_on_cart_abandoned', [])) { $subscriber->detachLists($attachLists); } if ($attachTags = Arr::get($settings, 'tags_on_cart_abandoned', [])) { $subscriber->detachTags($attachTags); } } $orderTotal = $order->total_amount ?? 0; $oldStatus = $abCart->status; $abCart->status = 'recovered'; $abCart->order_id = $order->id; $abCart->total = $orderTotal / 100; $abCart->recovered_at = current_time('mysql'); $abCart->save(); do_action('fluent_crm/ab_cart_fluent_cart_recovered', $abCart, $order, $oldStatus); $this->deleteOtherCarts($abCart, $order); $this->handleCartRecoveredAutomations($abCart); } private function handleCartLost($abCart, $order) { if ($abCart->status == 'lost') { return; } $oldStatus = $abCart->status; $abCart->status = 'lost'; $abCart->save(); do_action('fluent_crm/ab_cart_fluent_cart_lost', $abCart, $order, $oldStatus); if ($abCart->automation_id) { $subscriber = $abCart->subscriber; if ($subscriber) { $settings = AbCartHelper::getSettings(); if ($attachLists = Arr::get($settings, 'lists_on_cart_lost', [])) { $subscriber->attachLists($attachLists); } if ($attachTags = Arr::get($settings, 'tags_on_cart_lost', [])) { $subscriber->attachTags($attachTags); } FunnelSubscriber::where('subscriber_id', $subscriber->id) ->where('source_ref_id', $abCart->id) ->whereHas('funnel', function ($q) { $q->where('trigger_name', 'fc_ab_cart_simulation_fluent_cart'); }) ->where('funnel_id', $abCart->automation_id) ->update([ 'status' => 'cancelled', 'notes' => __('Automatically cancelled because the cart has been lost', 'fluent-crm') ]); } } } private function handleCartRecoveredAutomations($abCart) { if (!$abCart->automation_id) { return; } $contact = $abCart->subscriber; if (!$contact) { return; } $this->cancelAutomations($contact); } private function cancelAutomations($subscriber) { FunnelSubscriber::where('subscriber_id', $subscriber->id) ->whereHas('funnel', function ($q) { $q->where('trigger_name', 'fc_ab_cart_simulation_fluent_cart'); }) ->whereIn('status', ['active', 'pending', 'paused']) ->update([ 'status' => 'cancelled', 'notes' => __('Automatically cancelled because a cart has been recovered', 'fluent-crm') ]); } private function cancelAutomationsByCustomer($customer) { $subscriberIds = Subscriber::select(['id']) ->where('email', $customer->email) ->when($customer->user_id, function ($q) use ($customer) { return $q->orWhere('user_id', $customer->user_id); }) ->pluck('id') ->toArray(); if (!$subscriberIds) { return; } FunnelSubscriber::whereIn('subscriber_id', $subscriberIds) ->whereHas('funnel', function ($q) { $q->where('trigger_name', 'fc_ab_cart_simulation_fluent_cart'); }) ->whereIn('status', ['active', 'pending', 'paused']) ->update([ 'status' => 'cancelled', 'notes' => __('Automatically cancelled because a cart has been recovered', 'fluent-crm') ]); } private function deleteOtherCarts($abCart, $order) { $customerEmail = $abCart->email; $customerId = 0; if (method_exists($order, 'getAttribute')) { $customerId = $order->customer_id ?? 0; } $query = AbandonCartModel::where('provider', 'fluent_cart') ->where('id', '!=', $abCart->id) ->whereIn('status', ['processing', 'draft']); $query->where(function ($q) use ($customerEmail, $customerId) { $q->where('email', $customerEmail); if ($customerId) { // Look up user_id from the FluentCart customer $userId = fluentCrmDb()->table('fct_customers') ->where('id', $customerId) ->value('user_id'); if ($userId) { $q->orWhere('user_id', $userId); } } }); $otherCarts = $query->get(); foreach ($otherCarts as $cart) { $cart->deleteCart(); } } public function maybeRestoreCart($data) { $cartHash = sanitize_text_field(Arr::get($data, 'fc_ab_hash', '')); $abandonCart = null; if ($cartHash) { $abandonCart = AbandonCartModel::where('checkout_key', $cartHash)->first(); } if (!$abandonCart || $abandonCart->status != 'processing' || $abandonCart->provider != 'fluent_cart') { do_action('fluent_crm/ab_cart_restore_failed', $abandonCart); $checkoutUrl = home_url(); if (class_exists('\FluentCart\Api\StoreSettings')) { $checkoutUrl = (new \FluentCart\Api\StoreSettings())->getCheckoutPage() ?: $checkoutUrl; } wp_redirect($checkoutUrl); exit(); } // Set tracking cookie $cookieDays = (int)apply_filters('fluent_crm/ab_cart_cookie_validity', 30); setcookie('fc_ab_fct_cart_token', $abandonCart->checkout_key, time() + (86400 * $cookieDays), COOKIEPATH, COOKIE_DOMAIN); $abandonCart->click_counts = $abandonCart->click_counts + 1; $abandonCart->save(); // Restore the FluentCart cart from our snapshot $snapshot = $abandonCart->cart; $fctCartHash = $abandonCart->cart_hash; if ($fctCartHash) { $fctCart = \FluentCart\App\Models\Cart::where('cart_hash', $fctCartHash)->first(); if ($fctCart) { // just redirect to checkout if the cart still exists in FluentCart $checkoutUrl = add_query_arg([ 'fct_cart_hash' => $fctCart->cart_hash, ], (new StoreSettings())->getCheckoutPage()); wp_redirect($checkoutUrl); exit(); } } $newCart = new Cart(); $newCart->cart_hash = $abandonCart->cart_hash ?: Cart::generateCartHash(); // Write the snapshot back directly $newCart->cart_data = Arr::get($snapshot, 'cart_data', []); $newCart->checkout_data = Arr::get($snapshot, 'checkout_data', []); $newCart->coupons = Arr::get($snapshot, 'coupons', []); $newCart->email = $abandonCart->email; $newCart->utm_data = Arr::get($snapshot, 'utm_data', []); $newCart->cart_group = 'instant'; $newCart->ip_address = AddressHelper::getIpAddress(); $newCart->user_agent = AddressHelper::getUserAgent(); $fullName = $abandonCart->full_name; if ($fullName) { // Try to split full name into first and last name for better compatibility $nameParts = explode(' ', $fullName, 2); $newCart->first_name = $nameParts[0]; $newCart->last_name = isset($nameParts[1]) ? $nameParts[1] : ''; } $newCart->save(); \FluentCart\Api\Cookie\Cookie::setCartHash($newCart->cart_hash); $abandonCart->cart_hash = $newCart->cart_hash; $abandonCart->save(); wp_redirect(add_query_arg(['fct_cart_hash' => $newCart->cart_hash], (new \FluentCart\Api\StoreSettings())->getCheckoutPage())); exit(); } public function pushContextCodes($codes, $context) { if ($context != 'fc_ab_cart_simulation_fluent_cart') { return $codes; } $smartCodes = [ 'key' => 'ab_cart_fluent_cart', 'title' => 'Abandoned Cart - FluentCart', 'shortcodes' => [ '{{ab_cart_fluent_cart.billing_email}}' => __('Cart Billing Email', 'fluent-crm'), '{{ab_cart_fluent_cart.cart_items_table}}' => __('Cart Items', 'fluent-crm'), '##ab_cart_fluent_cart.recovery_url##' => __('Cart Recovery URL', 'fluent-crm'), '{{ab_cart_fluent_cart.cart_total}}' => __('Cart Total', 'fluent-crm'), '{{ab_cart_fluent_cart.subtotal}}' => __('Cart Subtotal (only products)', 'fluent-crm'), '{{ab_cart_fluent_cart.shipping_total}}' => __('Cart Shipping Total', 'fluent-crm'), '{{ab_cart_fluent_cart.discount_total}}' => __('Cart Discount Total', 'fluent-crm'), '{{ab_cart_fluent_cart.coupon_codes}}' => __('Applied Coupon Codes', 'fluent-crm'), '{{ab_cart_fluent_cart.tax_total}}' => __('Cart Tax Total', 'fluent-crm'), '{{ab_cart_fluent_cart.billing_full_name}}' => __('Billing Full Name', 'fluent-crm'), '{{ab_cart_fluent_cart.billing_address}}' => __('Billing Address', 'fluent-crm'), '{{ab_cart_fluent_cart.shipping_address}}' => __('Shipping Address', 'fluent-crm'), '{{ab_cart_fluent_cart.billing_city}}' => __('Billing City', 'fluent-crm'), '{{ab_cart_fluent_cart.billing_state}}' => __('Billing State', 'fluent-crm'), '{{ab_cart_fluent_cart.billing_postcode}}' => __('Billing Postcode', 'fluent-crm'), '{{ab_cart_fluent_cart.billing_country}}' => __('Billing Country', 'fluent-crm'), '{{ab_cart_fluent_cart.billing_phone}}' => __('Billing Phone', 'fluent-crm'), ] ]; $codes[] = $smartCodes; return $codes; } public function parseSmartCodes($code, $valueKey, $defaultValue, $subscriber) { $abCart = null; if ($subscriber->funnel_subscriber_id) { $funnelSub = FunnelSubscriber::find($subscriber->funnel_subscriber_id); if ($funnelSub) { $abCart = AbandonCartModel::find($funnelSub->source_ref_id); } } if (!$abCart) { $abCart = AbandonCartModel::where('email', $subscriber->email) ->where('provider', 'fluent_cart') ->whereIn('status', ['processing', 'opt_out', 'lost']) ->orderBy('id', 'DESC') ->first(); } if (!$abCart && defined('FLUENTCRM_PREVIEWING_EMAIL')) { $abCart = AbandonCartModel::where('provider', 'fluent_cart') ->orderBy('id', 'DESC') ->first(); } if (!$abCart) { if (defined('FLUENTCRM_PREVIEWING_EMAIL')) { return __('Dynamic Text will be available on real email', 'fluent-crm'); } return $defaultValue; } $formatPrice = function ($amount) use ($abCart) { $driver = new FluentCartDriver(); return $driver->formatPrice($amount, $abCart->currency); }; switch ($valueKey) { case 'billing_email': return $abCart->email; case 'cart_total': return $formatPrice($abCart->total); case 'subtotal': return $formatPrice($abCart->subtotal); case 'shipping_total': return $abCart->shipping ? $formatPrice($abCart->shipping) : $defaultValue; case 'discount_total': return ($abCart->discounts > 0) ? $formatPrice($abCart->discounts) : $defaultValue; case 'coupon_codes': $couponCodes = Arr::get($abCart->cart, 'coupons', []); return $couponCodes ? implode(', ', $couponCodes) : $defaultValue; case 'tax_total': return $abCart->tax ? $formatPrice($abCart->tax) : $defaultValue; case 'billing_full_name': return $abCart->full_name ?: $defaultValue; case 'billing_address': case 'shipping_address': $prefix = ($valueKey === 'shipping_address') ? 'shipping_' : 'billing_'; $formData = Arr::get($abCart->cart, 'checkout_data.form_data', []); return implode(', ', array_filter([ Arr::get($formData, $prefix . 'address_1'), Arr::get($formData, $prefix . 'address_2'), Arr::get($formData, $prefix . 'city'), Arr::get($formData, $prefix . 'state'), Arr::get($formData, $prefix . 'postcode'), ])) ?: $defaultValue; case 'billing_city': case 'billing_state': case 'billing_postcode': case 'billing_country': case 'billing_phone': return Arr::get($abCart->cart, 'checkout_data.form_data.' . $valueKey, $defaultValue); case 'recovery_url': return add_query_arg([ 'fluentcrm' => 1, 'route' => 'general', 'handler' => 'fc_cart_fluent_cart', 'fc_ab_hash' => $abCart->checkout_key ], home_url()); case 'cart_items_table': return $abCart->getCartItemsHtml(); default: return apply_filters('fluent_crm/ab_cart_smart_code_default_value', $defaultValue, $valueKey, $abCart); } } }