Initial commit

This commit is contained in:
gustavooth
2026-07-23 22:56:30 -03:00
commit 22a1006598
1816 changed files with 410742 additions and 0 deletions
@@ -0,0 +1,247 @@
<?php
namespace FluentCrm\App\Modules\AbandonCart;
use FluentCrm\App\Modules\AbandonCart\Drivers\DriverManager;
use FluentCrm\App\Models\Funnel;
use FluentCrm\App\Services\Helper;
use FluentCrm\Framework\Support\Arr;
class AbCartHelper
{
public static function getSettings($useCache = true)
{
static $settings;
if ($useCache && $settings) {
return $settings;
}
$defaults = [
'enabled' => 'no',
'enabled_providers' => [],
'capture_after_minutes' => 30,
'lost_cart_days' => 10,
'cool_off_period_days' => 10,
'gdpr_consent' => 'no',
'gdpr_consent_text' => 'Your email and cart are saved so we can send you email reminders about this order. {{opt_out label="No Thanks"}}',
'disabled_user_roles' => [],
'track_add_to_cart' => 'no',
'add_to_cart_exclude_user_roles' => [],
'tags_on_cart_abandoned' => [],
'lists_on_cart_abandoned' => [],
'tags_on_cart_lost' => [],
'lists_on_cart_lost' => [],
'new_contact_status' => 'transactional',
];
// Merge provider-specific defaults from available drivers
foreach (DriverManager::getAvailable() as $driver) {
$providerDefaults = $driver->getProviderSettingsDefaults();
if ($providerDefaults) {
$defaults = array_merge($defaults, $providerDefaults);
}
}
$settings = get_option('_fc_ab_cart_settings', []);
if (is_array($settings) && $settings) {
if ($settings['enabled'] === 'yes' && !isset($settings['enabled_providers'])) {
// backwards compatibility: if enabled but no providers selected, enable woo only as we had that.
if (defined('WC_PLUGIN_FILE')) {
$settings['enabled_providers'] = ['woo'];
}
}
$settings = wp_parse_args($settings, $defaults);
} else {
$settings = $defaults;
}
// Let each driver process settings (e.g. merge WC paid statuses)
foreach (DriverManager::getAvailable() as $driver) {
$settings = $driver->processSettings($settings);
}
return $settings;
}
public static function getSetting($key, $default = '')
{
$setting = self::getSettings();
return Arr::get($setting, $key, $default);
}
public static function isActive()
{
return Helper::isExperimentalEnabled('abandoned_cart');
}
public static function willCartTrack()
{
if (!self::isActive()) {
return false;
}
$settings = self::getSettings();
if ($settings['enabled'] !== 'yes') {
return false;
}
$disableUserRoles = Arr::get($settings, 'disabled_user_roles', []);
if (!$disableUserRoles) {
return true;
}
$user = wp_get_current_user();
if (!$user) {
return true;
}
$userRoles = array_values($user->roles);
return !array_intersect($userRoles, $disableUserRoles);
}
public static function getGDPRMessage()
{
$settings = self::getSettings();
if (Arr::get($settings, 'gdpr_consent') !== 'yes' || empty($settings['gdpr_consent_text'])) {
return '';
}
$text = wp_kses_post($settings['gdpr_consent_text']);
// {{opt_out label="No Thanks"}}
return preg_replace('/{{opt_out label="([^"]+)"}}/', '<a style="text-decoration:underline;cursor: pointer;" id="fc_ab_opt_out" class="fc-ab-cart-opt-out">$1</a>', $text);
}
public static function getCountAndSumByStatus($status, $dateRange = [], $dateColumn = 'created_at')
{
$query = AbandonCartModel::where('status', $status);
if ($dateRange) {
$query = $query->whereBetween($dateColumn, $dateRange);
}
$count = $query->count();
$sum = 0;
if ($count) {
$sum = $query->sum('total');
}
return [$count, $sum];
}
public static function getSortedAutomations($provider = 'woo')
{
$triggerName = 'fc_ab_cart_simulation_' . $provider;
$funnels = Funnel::where('trigger_name', $triggerName)
->where('status', 'published')
->orderBy('id', 'DESC')
->get();
$formattedFunnels = [];
foreach ($funnels as $funnel) {
$priority = Arr::get($funnel->settings, 'priority', 1);
if (isset($formattedFunnels[$priority])) {
$priority++;
}
$formattedFunnels[$priority] = $funnel;
}
// reverse the array to get the latest funnels first
krsort($formattedFunnels);
return array_values($formattedFunnels);
}
public static function getAbCartByDataProps($props = [], $statuses = ['processing', 'draft'])
{
if (empty($props)) {
return null;
}
if ($token = Arr::get($props, 'checkout_key')) {
$record = AbandonCartModel::where('checkout_key', $token)
->when($statuses, function ($query) use ($statuses) {
return $query->whereIn('status', $statuses);
})
->first();
if ($record) {
return $record;
}
}
if ($billingEmail = Arr::get($props, 'email')) {
$record = AbandonCartModel::where('email', $billingEmail)
->when($statuses, function ($query, $statuses) {
return $query->whereIn('status', $statuses);
})
->orderBy('id', 'DESC')
->first();
if ($record) {
return $record;
}
}
if ($userId = Arr::get($props, 'user_id')) {
$record = AbandonCartModel::where('user_id', $userId)
->when($statuses, function ($query, $statuses) {
return $query->whereIn('status', $statuses);
})
->orderBy('id', 'DESC')
->first();
if ($record) {
return $record;
}
}
return null;
}
/**
* Check if the given order status is considered a "win" (i.e., completed) status for the specified driver.
* @param string $driver The driver slug (e.g., 'woo')
* @param string $orderStatus The order status to check (e.g., 'completed')
* @return bool True if it's a win status, false otherwise
*/
public static function isWinOrderStatus($driver, $orderStatus)
{
$driver = DriverManager::getDriver($driver);
if ($driver) {
return $driver->isWinOrderStatus($orderStatus);
}
return false;
}
/**
* @deprecated Use DriverManager::getDriver('woo')->isWithinCoolOffPeriod() instead
*/
public static function isWooWithinCoolOffPeriod($abCartModel)
{
$driver = DriverManager::getDriver('woo');
if ($driver) {
return $driver->isWithinCoolOffPeriod($abCartModel);
}
return false;
}
}
@@ -0,0 +1,195 @@
<?php
namespace FluentCrm\App\Modules\AbandonCart;
use FluentCrm\App\Modules\AbandonCart\Drivers\DriverManager;
use FluentCrm\App\Modules\AbandonCart\Drivers\FluentCart\FluentCartDriver;
use FluentCrm\App\Services\PermissionManager;
use FluentCrm\Framework\Support\Arr;
class AbandonCart
{
public function register()
{
add_action('init', function () {
// Register built-in FluentCart driver
DriverManager::register(new FluentCartDriver());
// Allow pro addon and third parties to register drivers
do_action('fluent_crm/abandon_cart_register_drivers');
if (!AbCartHelper::isActive()) {
return false;
}
$this->init();
}, 90);
}
protected function init()
{
$drivers = DriverManager::getEnabled();
if (!$drivers) {
return false;
}
// Boot only enabled drivers (available + toggled on in settings)
foreach ($drivers as $driver) {
$driver->register();
$driver->registerAutomationTrigger();
}
// Expose abandon cart availability to the frontend via fcAdmin vars
add_filter('fluent_crm/admin_vars', function ($vars) {
$vars['has_abandon_carts'] = true;
$vars['can_read_abandon_carts'] = PermissionManager::currentUserCan('fcrm_read_funnels');
return $vars;
});
// Add Abandoned Carts as a sub-item under the Reports dropdown
add_filter('fluent_crm/menu_items', function ($items) {
if (!PermissionManager::currentUserCan('fcrm_read_funnels')) {
return $items;
}
$urlBase = fluentcrm_menu_url_base();
$hasReportsMenu = false;
foreach ($items as &$item) {
if (!empty($item['key']) && $item['key'] === 'reports' && isset($item['sub_items'])) {
$item['sub_items'][] = [
'key' => 'reports_abandoned_carts',
'label' => __('Abandoned Carts', 'fluent-crm'),
'permalink' => $urlBase . 'reports?tab=abandoned_carts',
'icon' => '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none" class="fcrm_menu_icon"><path d="M5.5 15.75C5.76522 15.75 6.0195 15.8554 6.20703 16.043C6.39457 16.2305 6.5 16.4848 6.5 16.75C6.5 17.0152 6.39457 17.2695 6.20703 17.457C6.0195 17.6446 5.76522 17.75 5.5 17.75C5.23478 17.75 4.9805 17.6446 4.79297 17.457C4.60543 17.2695 4.5 17.0152 4.5 16.75C4.5 16.4848 4.60543 16.2305 4.79297 16.043C4.9805 15.8554 5.23478 15.75 5.5 15.75ZM14.5 15.75C14.7652 15.75 15.0195 15.8554 15.207 16.043C15.3946 16.2305 15.5 16.4848 15.5 16.75C15.5 17.0152 15.3946 17.2695 15.207 17.457C15.0195 17.6446 14.7652 17.75 14.5 17.75C14.2348 17.75 13.9805 17.6446 13.793 17.457C13.6054 17.2695 13.5 17.0152 13.5 16.75C13.5 16.4848 13.6054 16.2305 13.793 16.043C13.9805 15.8554 14.2348 15.75 14.5 15.75ZM4.75 3C4.8163 3 4.87987 3.02636 4.92676 3.07324C4.97364 3.12013 5 3.1837 5 3.25V12.75H15.2188L16.9688 5.75H7.5V5.25H17.29C17.328 5.25001 17.3653 5.25878 17.3994 5.27539C17.4336 5.29206 17.4639 5.31672 17.4873 5.34668C17.5105 5.37653 17.5263 5.41126 17.5342 5.44824C17.542 5.48538 17.5414 5.52373 17.5322 5.56055L15.6572 13.0605C15.6437 13.1146 15.6123 13.163 15.5684 13.1973C15.5245 13.2314 15.4706 13.25 15.415 13.25H4.75C4.68369 13.25 4.62012 13.2236 4.57324 13.1768C4.52636 13.1299 4.5 13.0663 4.5 13V3.5H3V3H4.75Z" stroke="var(--fc-secondary-text)"></path></svg>',
];
$hasReportsMenu = true;
break;
}
}
unset($item);
if (!$hasReportsMenu) {
$items[] = [
'key' => 'reports',
'label' => __('Reports', 'fluent-crm'),
'permalink' => $urlBase . 'reports?tab=abandoned_carts',
'layout_class' => 'fc_1_col_menu',
'sub_items' => [
[
'key' => 'reports_abandoned_carts',
'label' => __('Abandoned Carts', 'fluent-crm'),
'permalink' => $urlBase . 'reports?tab=abandoned_carts',
'icon' => '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none" class="fcrm_menu_icon"><path d="M5.5 15.75C5.76522 15.75 6.0195 15.8554 6.20703 16.043C6.39457 16.2305 6.5 16.4848 6.5 16.75C6.5 17.0152 6.39457 17.2695 6.20703 17.457C6.0195 17.6446 5.76522 17.75 5.5 17.75C5.23478 17.75 4.9805 17.6446 4.79297 17.457C4.60543 17.2695 4.5 17.0152 4.5 16.75C4.5 16.4848 4.60543 16.2305 4.79297 16.043C4.9805 15.8554 5.23478 15.75 5.5 15.75ZM14.5 15.75C14.7652 15.75 15.0195 15.8554 15.207 16.043C15.3946 16.2305 15.5 16.4848 15.5 16.75C15.5 17.0152 15.3946 17.2695 15.207 17.457C15.0195 17.6446 14.7652 17.75 14.5 17.75C14.2348 17.75 13.9805 17.6446 13.793 17.457C13.6054 17.2695 13.5 17.0152 13.5 16.75C13.5 16.4848 13.6054 16.2305 13.793 16.043C13.9805 15.8554 14.2348 15.75 14.5 15.75ZM4.75 3C4.8163 3 4.87987 3.02636 4.92676 3.07324C4.97364 3.12013 5 3.1837 5 3.25V12.75H15.2188L16.9688 5.75H7.5V5.25H17.29C17.328 5.25001 17.3653 5.25878 17.3994 5.27539C17.4336 5.29206 17.4639 5.31672 17.4873 5.34668C17.5105 5.37653 17.5263 5.41126 17.5342 5.44824C17.542 5.48538 17.5414 5.52373 17.5322 5.56055L15.6572 13.0605C15.6437 13.1146 15.6123 13.163 15.5684 13.1973C15.5245 13.2314 15.4706 13.25 15.415 13.25H4.75C4.68369 13.25 4.62012 13.2236 4.57324 13.1768C4.52636 13.1299 4.5 13.0663 4.5 13V3.5H3V3H4.75Z" stroke="var(--fc-secondary-text)"></path></svg>',
]
],
];
}
return $items;
});
// Run the runner
add_action('fluentcrm_scheduled_five_minute_tasks', [$this, 'maybeRunAbRunner'], 999);
add_action('fluentcrm_scheduled_daily_tasks', [$this, 'markOldCartsAsLost'], 10);
add_filter('fluent_crm/sales_stats', function ($stats) {
[$recoveredCount, $recoveredRevenue] = AbCartHelper::getCountAndSumByStatus('recovered', [], 'recovered_at');
if (!$recoveredRevenue) {
return $stats;
}
$dateRange = [
gmdate('Y-m-01 00:00:00', current_time('timestamp')),
gmdate('Y-m-t 23:59:59', current_time('timestamp'))
];
[$thisMonth, $thisMonthRevenue] = AbCartHelper::getCountAndSumByStatus('recovered', $dateRange, 'recovered_at');
$stats[] = [
'title' => __('Cart Recovered (This Month)', 'fluent-crm'),
'content' => DriverManager::formatPrice($thisMonthRevenue)
];
$stats[] = [
'title' => __('Cart Recovered (All Time)', 'fluent-crm'),
'content' => DriverManager::formatPrice($recoveredRevenue)
];
return $stats;
});
}
public function maybeRunAbRunner()
{
static $counter = 0;
if (!$counter) {
if (fluentCrmIsTimeOut(30)) {
return false;
}
// It's the first time. Check if there has any runner or not
$lastRunner = fluentCrmGetOptionCache('__fc_ab_runner');
if ($lastRunner) {
$timeElapsed = time() - $lastRunner;
if ($timeElapsed < 50) {
return false;
}
fluentCrmSetOptionCache('__fc_ab_runner', null, 50);
}
}
fluentCrmSetOptionCache('__fc_ab_runner', time(), 50);
$counter = $counter + 1;
// Get Draft Carts that need to be abandoned
$settings = AbCartHelper::getSettings();
$cutMinutes = Arr::get($settings, 'capture_after_minutes', 5);
$cutDateTime = gmdate('Y-m-d H:i:s', current_time('timestamp') - ($cutMinutes * 60));
$enabledSlugs = DriverManager::getEnabledSlugs();
if (!$enabledSlugs) {
fluentCrmSetOptionCache('__fc_ab_runner', null, 50);
return false;
}
$abCarts = AbandonCartModel::where('status', 'draft')
->whereIn('provider', $enabledSlugs)
->where('updated_at', '<=', $cutDateTime)
->orderBy('id', 'DESC')
->limit(10)
->get();
if ($abCarts->isEmpty()) {
fluentCrmSetOptionCache('__fc_ab_runner', null, 50);
return false;
}
foreach ($abCarts as $abCart) {
(new AbandonCartRunner())->runAbandonCart($abCart);
}
fluentCrmSetOptionCache('__fc_ab_runner', null, 50);
if (!fluentCrmIsTimeOut(40)) {
$this->maybeRunAbRunner();
}
return true;
}
public function markOldCartsAsLost()
{
$settings = AbCartHelper::getSettings();
$cutDays = Arr::get($settings, 'lost_cart_days', 15);
$cutDateTime = gmdate('Y-m-d H:i:s', current_time('timestamp') - ($cutDays * 86400));
AbandonCartModel::where('status', 'processing')
->where('created_at', '<=', $cutDateTime)
->update(['status' => 'lost']);
}
}
@@ -0,0 +1,236 @@
<?php
namespace FluentCrm\App\Modules\AbandonCart;
use FluentCrm\App\Modules\AbandonCart\Drivers\DriverManager;
use FluentCrm\App\Http\Controllers\Controller;
use FluentCrm\App\Models\Funnel;
use FluentCrm\App\Services\Helper;
use FluentCrm\Framework\Http\Request\Request;
use FluentCrm\Framework\Support\Arr;
class AbandonCartController extends Controller
{
public function getCarts(Request $request)
{
$query = $request->get('query', []);
$dateRangeInput = $request->get('date_range', []);
$dateRange = $this->getDateRange($dateRangeInput);
$enabledDrivers = DriverManager::getEnabled();
$missingAutomations = [];
$formattedDrivers = [];
$triggerNames = [];
foreach ($enabledDrivers as $driver) {
$triggerNames[$driver->getTriggerName()] = $driver;
}
$activeTriggers = Funnel::query()
->whereIn('trigger_name', array_keys($triggerNames))
->where('status', 'published')
->pluck('trigger_name')
->unique()
->toArray();
foreach ($enabledDrivers as $driver) {
if (!in_array($driver->getTriggerName(), $activeTriggers, true)) {
// Include the provider slug so the UI can scope the "no automation" notice
// (doc link + starter template differ per provider).
$missingAutomations[] = [
'provider' => $driver->getProviderSlug(),
'label' => $driver->getProviderLabel(),
];
}
$formattedDrivers[$driver->getProviderSlug()] = [
'label' => $driver->getProviderLabel(),
'logo' => $driver->getLogo()
];
}
$carts = AbandonCartModel::orderBy('id', 'DESC')
->with(['subscriber', 'automation']);
if ($dateRange) {
$carts = $carts->whereBetween('created_at', $dateRange);
}
$status = sanitize_text_field(Arr::get($query, 'status', ''));
$search = sanitize_text_field(Arr::get($query, 'search', ''));
$carts = $carts->statusBy($status)
->searchBy($search)
->paginate();
return [
'carts' => $this->mutateCartData($carts),
'haveAutomation' => empty($missingAutomations),
'missingAutomations' => $missingAutomations,
'drivers' => $formattedDrivers
];
}
public function mutateCartData($carts)
{
$updatedData = $carts->getCollection()->transform(function ($cart) {
if ($cart->status == 'processing') {
$cart->recovery_url = $cart->getRecoveryUrl();
}
$subscriber = $cart->subscriber;
// Customer Avatar
$cart->customer_avatar = $subscriber ? $subscriber->photo : fluentcrmGravatar($cart->email, $cart->full_name);
// Driver-specific enrichment (product images, order URL, etc.)
$driver = DriverManager::getDriver($cart->provider);
if ($driver) {
$cart = $driver->enrichCartForListing($cart);
}
// Remove subscriber to clean up output
unset($cart->subscriber);
return $cart;
});
$carts->setCollection(
$updatedData
);
return $carts;
}
public function handleBulkDeleteCart(Request $request)
{
$cartIds = $request->get('cart_ids', []);
if (!$cartIds || !is_array($cartIds)) {
return $this->sendError([
'message' => __('No carts selected to delete', 'fluent-crm')
]);
}
$cartIds = array_map('intval', $cartIds);
$carts = AbandonCartModel::whereIn('id', $cartIds)->get();
foreach ($carts as $cart) {
$cart->deleteCart();
}
return [
'message' => __('Selected carts have been deleted successfully', 'fluent-crm')
];
}
public function getReportSummary(Request $request)
{
$dateRangeInput = $request->get('date_range', []);
$dateRange = $this->getDateRange($dateRangeInput);
[$recoveredCount, $recoveredRevenue] = AbCartHelper::getCountAndSumByStatus('recovered', $dateRange, 'recovered_at');
[$processingCount, $processingRevenue] = AbCartHelper::getCountAndSumByStatus('processing', $dateRange);
[$lostCount, $lostRevenue] = AbCartHelper::getCountAndSumByStatus('lost', $dateRange);
[$draftCount, $draftRevenue] = AbCartHelper::getCountAndSumByStatus('draft', $dateRange);
[$optoutCount, $optoutRevenue] = AbCartHelper::getCountAndSumByStatus('opt_out', $dateRange);
$recoveryRate = '0%';
if ($lostCount) {
$recoveryRate = number_format(($recoveredCount / ($lostCount + $recoveredCount)) * 100, 2) . '%';
} else if ($recoveredCount) {
$recoveryRate = '100%';
}
return [
'widgets' => [
'recovered_revenue' => [
'title' => esc_html__('Recovered Revenue', 'fluent-crm'),
'value' => DriverManager::formatPrice($recoveredRevenue),
'count' => number_format($recoveredCount),
],
'processing_revenue' => [
'title' => esc_html__('Processing Revenue', 'fluent-crm'),
'value' => DriverManager::formatPrice($processingRevenue),
'count' => number_format($processingCount),
],
'lost_revenue' => [
'title' => esc_html__('Lost Revenue', 'fluent-crm'),
'value' => DriverManager::formatPrice($lostRevenue),
'count' => number_format($lostCount),
],
'draft_revenue' => [
'title' => esc_html__('Draft Revenue', 'fluent-crm'),
'value' => DriverManager::formatPrice($draftRevenue),
'count' => number_format($draftCount)
],
'optout_revenue' => [
'title' => esc_html__('Optout Revenue', 'fluent-crm'),
'value' => DriverManager::formatPrice($optoutRevenue),
'count' => number_format($optoutCount)
],
'recovery_rate' => [
'title' => esc_html__('Recovery Rate', 'fluent-crm'),
'value' => $recoveryRate,
'count' => ''
]
]
];
}
public function getDateRange($dateRangeInput)
{
if ($dateRangeInput) {
$dateRange = array_filter($dateRangeInput);
$startTime = isset($dateRange[0]) ? strtotime($dateRange[0]) : false;
$endTime = isset($dateRange[1]) ? strtotime($dateRange[1]) : false;
if (count($dateRange) != 2 || !$startTime || !$endTime || $startTime > $endTime) {
// Invalid date range, fallback to last 30 days
$startDate = gmdate('Y-m-d 00:00:01', strtotime('-30 days'));
$endDate = gmdate('Y-m-d 23:59:59');
$dateRange = [$startDate, $endDate];
} else {
$startDateString = $dateRange[0];
$endDateString = $dateRange[1];
// Remove timezone identifiers
$startDateString = preg_replace('/\(.*\)/', '', $startDateString);
$endDateString = preg_replace('/\(.*\)/', '', $endDateString);
try {
// Parse dates
$startDate = new \DateTime($startDateString);
$endDate = new \DateTime($endDateString);
// Adjust times for range
$startDate->setTime(0, 0, 1); // Set time to 00:00:01
$endDate->setTime(23, 59, 59); // Set time to 23:59:59
// Format for SQL or other usage
$dateRange = [
$startDate->format("Y-m-d H:i:s"),
$endDate->format("Y-m-d H:i:s")
];
} catch (\Exception $e) {
// Fallback to last 30 days
$dateRange = [
gmdate('Y-m-d 00:00:01', strtotime('-30 days')),
gmdate('Y-m-d 23:59:59')
];
}
}
} else {
// Default to last 30 days if no date range provided
$startDate = gmdate('Y-m-d 00:00:01', strtotime('-30 days'));
$endDate = gmdate('Y-m-d 23:59:59');
$dateRange = [$startDate, $endDate];
}
return $dateRange;
}
}
@@ -0,0 +1,56 @@
<?php
namespace FluentCrm\App\Modules\AbandonCart;
class AbandonCartMigrator
{
/**
* On-Demand Action Links Migrator.
*
* @param bool $isForced
* @return void
*/
public static function migrate($isForced = false)
{
global $wpdb;
$charsetCollate = $wpdb->get_charset_collate();
$table = $wpdb->prefix .'fc_abandoned_carts';
if ($wpdb->get_var("SHOW TABLES LIKE '$table'") != $table || $isForced) {
$sql = "CREATE TABLE $table (
`id` BIGINT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT,
`checkout_key` VARCHAR(192),
`cart_hash` VARCHAR(192),
`is_optout` TINYINT(1) DEFAULT 0,
`full_name` VARCHAR(192),
`email` VARCHAR(192),
`provider` VARCHAR(100) DEFAULT 'woo',
`user_id` BIGINT UNSIGNED NULL,
`click_counts` BIGINT UNSIGNED DEFAULT 0,
`contact_id` BIGINT UNSIGNED NULL,
`order_id` BIGINT UNSIGNED NULL,
`automation_id` BIGINT UNSIGNED NULL,
`checkout_page_id` BIGINT UNSIGNED NULL,
`status` VARCHAR(30) DEFAULT 'draft',
`subtotal` DECIMAL(10,2),
`shipping` DECIMAL(10,2),
`tax` DECIMAL(10,2),
`discounts` DECIMAL(10,2),
`fees` DECIMAL(10,2),
`total` DECIMAL(10,2),
`currency` VARCHAR(50),
`cart` LONGTEXT,
`note` TEXT,
`abandoned_at` TIMESTAMP NULL,
`recovered_at` TIMESTAMP NULL,
`created_at` TIMESTAMP NULL,
`updated_at` TIMESTAMP NULL,
KEY `status` (`status`),
KEY `checkout_key` (`checkout_key`)
) $charsetCollate;";
require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
dbDelta($sql);
}
}
}
@@ -0,0 +1,208 @@
<?php
namespace FluentCrm\App\Modules\AbandonCart;
use FluentCrm\App\Modules\AbandonCart\Drivers\DriverManager;
use FluentCrm\App\Models\Funnel;
use FluentCrm\App\Models\Model;
use FluentCrm\App\Models\Subscriber;
use FluentCrm\App\Services\Funnel\FunnelHelper;
use FluentCrm\Framework\Support\Arr;
class AbandonCartModel extends Model
{
protected $table = 'fc_abandoned_carts';
protected $fillable = [
'checkout_key',
'cart_hash',
'contact_id',
'is_optout',
'full_name',
'email',
'provider',
'user_id',
'order_id',
'automation_id',
'checkout_page_id',
'status',
'subtotal',
'shipping',
'discounts',
'fees',
'tax',
'total',
'currency',
'cart',
'note',
'recovered_at',
'abandoned_at',
'click_counts'
];
protected $searchable = ['full_name', 'email'];
public static function boot()
{
parent::boot();
static::creating(function ($model) {
$model->checkout_key = md5(time() . wp_generate_uuid4());
});
}
public function scopeProvider($query, $provider)
{
return $query->where('provider', $provider);
}
public function scopeStatusBy($query, $status)
{
if (!$status || $status == 'all') {
return $query;
}
return $query->where('status', $status);
}
public function scopeSearchBy($query, $search)
{
if (!$search) {
return $query;
}
return $query->where(function ($q) use ($search) {
$q->where('full_name', 'LIKE', '%' . $search . '%')
->orWhere('email', 'LIKE', '%' . $search . '%');
});
}
public function setCartAttribute($data)
{
$this->attributes['cart'] = \maybe_serialize($data);
}
public function getCartAttribute($data)
{
return \maybe_unserialize($data);
}
public function subscriber()
{
return $this->belongsTo(Subscriber::class, 'contact_id');
}
public function automation()
{
return $this->belongsTo(Funnel::class, 'automation_id');
}
public function getAddress($type = 'billing')
{
$customerData = Arr::get($this->cart, 'customer_data', []);
if (Arr::get($customerData, 'differentShipping') != 'yes') {
$type = 'billingAddress';
} else {
$type = 'shippingAddress';
}
return array_filter([
'address_1' => Arr::get($customerData, $type . '.address_1'),
'address_2' => Arr::get($customerData, $type . '.address_2'),
'city' => Arr::get($customerData, $type . '.city'),
'state' => Arr::get($customerData, $type . '.state'),
'postcode' => Arr::get($customerData, $type . '.postcode'),
'country' => Arr::get($customerData, $type . '.country'),
]);
}
private function getAddressLineByKey($type, $key)
{
$address = $this->getAddress($type);
return Arr::get($address, $key, '');
}
public function getInputProp($key, $default = '')
{
$customerData = Arr::get($this->cart, 'customer_data', []);
return Arr::get($customerData, $key, $default);
}
public function getAddressProp($key, $addressType = 'billingAddress', $default = '')
{
$address = Arr::get($this->cart, 'customer_data.'.$addressType, []);
return Arr::get($address, $key, $default);
}
/*
* Get the cart items as html
* This function is called by shortcodes/mergecodes/smartcode
* e.g. {{ab_cart_woo.cart_items_table}}
*/
public function getCartItemsHtml()
{
$driver = DriverManager::getDriver($this->provider);
if ($driver) {
return $driver->getCartItemsHtml($this);
}
return '';
}
public function getRecoveryUrl()
{
$driver = DriverManager::getDriver($this->provider);
if ($driver) {
return $driver->getRecoveryUrl($this);
}
if ($this->status != 'processing') {
return '';
}
return add_query_arg([
'fluentcrm' => 1,
'route' => 'general',
'handler' => 'fc_cart_' . $this->provider,
'fc_ab_hash' => $this->checkout_key
], home_url());
}
public function deleteCart()
{
if ($this->automation_id && $this->contact_id) {
FunnelHelper::removeSubscribersFromFunnel($this->automation_id, [$this->contact_id]);
}
$this->delete();
}
public function optOut()
{
if ($this->is_optout) {
return $this;
}
$originalStatus = $this->status;
$this->is_optout = 1;
$this->status = 'opt_out';
$this->save();
if (!$this->contact_id || !$this->automation_id) {
return $this;
}
if ($originalStatus == 'processing') {
FunnelHelper::removeSubscribersFromFunnel($this->automation_id, [$this->contact_id]);
$this->automation_id = null;
$this->save();
}
return $this;
}
}
@@ -0,0 +1,322 @@
<?php
namespace FluentCrm\App\Modules\AbandonCart;
use FluentCrm\App\Modules\AbandonCart\Drivers\DriverManager;
use FluentCrm\App\Models\FunnelMetric;
use FluentCrm\App\Models\FunnelSubscriber;
use FluentCrm\App\Services\Funnel\FunnelProcessor;
use FluentCrm\App\Services\Libs\ConditionAssessor;
use FluentCrm\Framework\Support\Arr;
class AbandonCartRunner
{
public function runAbandonCart(AbandonCartModel $abandonCart)
{
if ($abandonCart->status != 'draft') {
return false;
}
$driver = DriverManager::getDriver($abandonCart->provider);
if (!$driver) {
$abandonCart->status = 'skipped';
$abandonCart->note = 'No driver found for provider: ' . $abandonCart->provider;
$abandonCart->save();
return $abandonCart;
}
if ($driver->isWithinCoolOffPeriod($abandonCart)) {
$abandonCart->status = 'skipped';
$abandonCart->note = 'Under Cool Off Period';
$abandonCart->save();
return $abandonCart;
}
$automationData = $this->getEligibleAutomation($abandonCart);
if (!$automationData) {
$abandonCart->status = 'skipped';
$abandonCart->note = 'No automation found for this cart';
$abandonCart->save();
return $abandonCart;
}
if (is_wp_error($automationData)) {
$abandonCart->status = 'skipped';
$abandonCart->note = $automationData->get_error_message();
$abandonCart->save();
return $abandonCart;
}
$automation = $automationData['automation'];
$contact = $automationData['contact'];
if (!$contact) {
$contact = $this->createContactFromCart($abandonCart);
}
// Check if exit
$existingFunnelSub = FunnelSubscriber::where('funnel_id', $automation->id)
->where('subscriber_id', $contact->id)
->first();
if ($existingFunnelSub) {
FunnelMetric::where('funnel_id', $existingFunnelSub->funnel_id)
->where('subscriber_id', $contact->id)
->delete();
$existingFunnelSub->delete();
}
$settings = AbCartHelper::getSettings();
if ($attachLists = Arr::get($settings, 'lists_on_cart_abandoned', [])) {
$contact->attachLists($attachLists);
}
if ($attachTags = Arr::get($settings, 'tags_on_cart_abandoned', [])) {
$contact->attachTags($attachTags);
}
$abandonCart->status = 'processing';
$abandonCart->automation_id = $automation->id;
$abandonCart->contact_id = $contact->id;
$abandonCart->abandoned_at = current_time('mysql');
$abandonCart->save();
(new FunnelProcessor())->startFunnelSequence($automation, [], [
'source_trigger_name' => $driver->getTriggerName(),
'source_ref_id' => $abandonCart->id
], $contact);
return $abandonCart;
}
public function getEligibleAutomation(AbandonCartModel $abandonCart)
{
$automations = AbCartHelper::getSortedAutomations($abandonCart->provider);
if (!$automations) {
return new \WP_Error('no_automation', 'No automation found for this cart');
}
$existingContact = fluentCrmApi('contacts')->getContact($abandonCart->email);
$processableStatuses = ['subscribed', 'transactional'];
if ($existingContact && !in_array($existingContact->status, $processableStatuses)) {
return new \WP_Error('contact_unsubscribed', 'Contact status is not allowed to process this cart');
}
$items = Arr::get($abandonCart->cart, 'cart_contents', []);
// Use driver for provider-specific condition data extraction
$driver = DriverManager::getDriver($abandonCart->provider);
if ($driver) {
$conditionData = $driver->extractCartConditionData($abandonCart);
$productIds = $conditionData['product_ids'];
$categoryIds = $conditionData['category_ids'];
} else {
$productIds = [];
$categoryIds = [];
foreach ($items as $item) {
$productIds[] = $item['product_id'];
}
}
$cartData = [
'cart_total' => $abandonCart->total,
'cart_items_count' => count($items),
'cart_items' => $productIds,
'cart_items_categories' => $categoryIds,
];
$contact = $existingContact;
foreach ($automations as $automation) {
$conditions = (array)$automation->conditions;
if (Arr::get($conditions, 'require_subscribed') === 'yes' && (!$existingContact || $existingContact->status != 'subscribed')) {
continue;
}
$existingFunnelSub = null;
$checkActive = Arr::get($conditions, 'active_once') === 'yes';
if ($checkActive && $existingContact) {
// check if the contact is already has an automation for this one
$existingFunnelSub = FunnelSubscriber::where('funnel_id', $automation->id)
->where('subscriber_id', $existingContact->id)
->first();
if ($existingFunnelSub && $existingFunnelSub->status == 'active') {
continue;
}
}
$cartConditions = array_filter(Arr::get($conditions, 'cart_conditions', []));
if (!$cartConditions) {
return [
'automation' => $automation,
'contact' => $contact
];
}
if (!$contact && $this->hasContactConditions($cartConditions)) {
$contact = $this->createContactFromCart($abandonCart);
}
if ($this->assessConditionGroups($cartConditions, $contact, $cartData)) {
return [
'automation' => $automation,
'contact' => $contact
];
}
}
return new \WP_Error('no_automation', 'No automation found for this cart based on condition match');
}
protected function createContactFromCart(AbandonCartModel $abandonCart)
{
$customData = Arr::get($abandonCart->cart, 'customer_data', []);
$cartSettings = AbCartHelper::getSettings();
$contactData = array_filter([
'email' => $abandonCart->email,
'first_name' => Arr::get($customData, 'billingAddress.first_name'),
'last_name' => Arr::get($customData, 'billingAddress.last_name'),
'user_id' => $abandonCart->user_id,
'full_name' => $abandonCart->full_name,
'status' => Arr::get($cartSettings, 'new_contact_status', 'transactional'),
'address_line_1' => Arr::get($customData, 'billingAddress.address_1'),
'address_line_2' => Arr::get($customData, 'billingAddress.address_2'),
'city' => Arr::get($customData, 'billingAddress.city'),
'state' => Arr::get($customData, 'billingAddress.state'),
'postal_code' => Arr::get($customData, 'billingAddress.postcode'),
'country' => Arr::get($customData, 'billingAddress.country'),
'phone' => Arr::get($customData, 'billingAddress.phone'),
'tags' => Arr::get($cartSettings, 'tags_on_cart_abandoned', []),
'lists' => Arr::get($cartSettings, 'lists_on_cart_abandoned', [])
]);
return fluentCrmApi('contacts')->createOrUpdate($contactData);
}
protected function hasContactConditions($conditions)
{
$cartGroupKeys = DriverManager::getAllSmartCodeGroupKeys();
foreach ($conditions as $conditionGroup) {
foreach ($conditionGroup as $filterItem) {
if (count($filterItem['source']) != 2 || empty($filterItem['source'][0]) || empty($filterItem['source'][1]) || empty($filterItem['operator'])) {
continue;
}
$provider = $filterItem['source'][0];
if (!in_array($provider, $cartGroupKeys)) {
return true;
}
}
}
return false;
}
protected function assessConditionGroups($conditionGroups, $subscriber, $cartData = [])
{
foreach ($conditionGroups as $conditions) {
$result = $this->assessConditions($conditions, $subscriber, $cartData);
if ($result) {
return true;
}
}
return false;
}
protected function assessConditions($conditions, $subscriber, $cartData = [])
{
if (!defined('FLUENTCAMPAIGN_DIR_FILE')) {
return true;
}
$helperClass = 'FluentCampaign\App\Services\Funnel\Conditions\FunnelConditionHelper';
if (!class_exists($helperClass)) {
// Free-only: no advanced condition engine, accept all conditions
return true;
}
$formattedGroups = $helperClass::formatConditionGroups($conditions);
foreach ($formattedGroups as $groupName => $group) {
if ($groupName == 'subscriber') {
if (!$subscriber) {
return false;
}
$subscriberData = $subscriber->toArray();
if (!ConditionAssessor::matchAllConditions($group, $subscriberData)) {
return false;
}
} else if ($groupName == 'custom_fields') {
if (!$subscriber) {
return false;
}
$customData = $subscriber->custom_fields();
if (!ConditionAssessor::matchAllConditions($group, $customData)) {
return false;
}
} else if ($groupName == 'segment') {
if (!$subscriber) {
return false;
}
if (!$helperClass::assessSegmentConditions($group, $subscriber)) {
return false;
}
} else if ($groupName == 'activities') {
if (!$subscriber) {
return false;
}
if (!$helperClass::assessActivities($group, $subscriber)) {
return false;
}
} else if ($groupName == 'event_tracking') {
if (!$subscriber) {
return false;
}
if (!$helperClass::assessEventTrackingConditions($group, $subscriber)) {
return false;
}
} else if ($groupName == 'other') {
if (!$subscriber) {
return false;
}
foreach ($group as $condition) {
$prop = $condition['data_key'];
if (!apply_filters('fluentcrm_automation_custom_condition_assert_' . $prop, true, $condition, $subscriber, null, null)) {
return false;
}
}
} else if (DriverManager::getDriverByGroupKey($groupName)) {
if (!ConditionAssessor::matchAllConditions($group, $cartData)) {
return false;
}
} else {
if (!$subscriber) {
return false;
}
$result = apply_filters("fluentcrm_automation_conditions_assess_$groupName", true, $group, $subscriber, null, null);
if (!$result) {
return false;
}
}
}
return true;
}
}
@@ -0,0 +1,206 @@
<?php
namespace FluentCrm\App\Modules\AbandonCart\Drivers;
use FluentCrm\App\Modules\AbandonCart\AbandonCartModel;
abstract class AbstractCartDriver
{
public $logo = '';
/**
* Unique provider slug. Must match the `provider` column value in fc_abandoned_carts.
* Examples: 'woo', 'fluent_cart', 'edd'
*
* @return string
*/
abstract public function getProviderSlug();
/**
* Human-readable provider label for UI display.
*
* @return string
*/
abstract public function getProviderLabel();
/**
* Whether this driver's platform is currently available (plugin active).
*
* @return bool
*/
abstract public function isAvailable();
/**
* Register all hooks, filters, frontend scripts, ajax handlers,
* order lifecycle listeners, and cart recovery URL handlers.
*
* @return void
*/
abstract public function register();
/**
* Register the automation trigger class for this provider.
*
* @return void
*/
abstract public function registerAutomationTrigger();
/**
* Check if the given cart is within a cool-off period for this provider.
*
* @param AbandonCartModel $cart
* @return bool
*/
abstract public function isWithinCoolOffPeriod(AbandonCartModel $cart);
/**
* Render cart items as HTML for email templates.
*
* @param AbandonCartModel $cart
* @return string
*/
abstract public function getCartItemsHtml(AbandonCartModel $cart);
/**
* Format a monetary amount for display using this provider's currency formatting.
*
* @param float|string $amount
* @param string $currency
* @return string
*/
abstract public function formatPrice($amount, $currency = '');
/**
* Return the recovery URL for this cart.
*
* @param AbandonCartModel $cart
* @return string
*/
abstract public function getRecoveryUrl(AbandonCartModel $cart);
/**
* Extract product IDs and category IDs from the cart for condition matching.
*
* @param AbandonCartModel $cart
* @return array ['product_ids' => [...], 'category_ids' => [...]]
*/
abstract public function extractCartConditionData(AbandonCartModel $cart);
/**
* Enrich cart data for the admin listing API response.
* Adds product images, order URL, etc.
*
* @param AbandonCartModel $cart
* @return AbandonCartModel
*/
abstract public function enrichCartForListing(AbandonCartModel $cart);
/**
* Return provider-specific data for the settings API response.
* e.g. WooCommerce returns order statuses.
*
* @return array
*/
public function getProviderSettingsResponse()
{
return [];
}
/**
* Return provider-specific settings fields for the settings page.
*
* @return array
*/
public function getSettingsFields()
{
return [];
}
/**
* Return provider-specific default settings to merge into global defaults.
*
* @return array
*/
public function getProviderSettingsDefaults()
{
return [];
}
/**
* Apply provider-specific processing to settings after loading.
*
* @param array $settings
* @return array
*/
public function processSettings($settings)
{
return $settings;
}
/**
* Get the trigger name for this provider's automation.
*
* @return string
*/
public function getTriggerName()
{
return 'fc_ab_cart_simulation_' . $this->getProviderSlug();
}
/**
* Get the handler name for cart recovery URL routing.
*
* @return string
*/
public function getHandlerName()
{
return 'fc_cart_' . $this->getProviderSlug();
}
/**
* Get the smart code group key for this provider.
*
* @return string
*/
public function getSmartCodeGroupKey()
{
return 'ab_cart_' . $this->getProviderSlug();
}
/**
* Get the base path for view templates.
* Drivers should override this to point to their own plugin's Views directory.
*
* @return string
*/
protected function getViewsBasePath()
{
return '';
}
/**
* Load a view template from the driver's Views directory.
*
* @param string $templateName
* @param array $data
* @return string
*/
protected function loadView($templateName, $data)
{
$basePath = $this->getViewsBasePath();
if (!$basePath) {
return '';
}
extract($data, EXTR_SKIP);
ob_start();
include $basePath . $templateName . '.php';
return ltrim(ob_get_clean());
}
public function getLogo()
{
return $this->logo;
}
}
@@ -0,0 +1,168 @@
<?php
namespace FluentCrm\App\Modules\AbandonCart\Drivers;
use FluentCrm\App\Modules\AbandonCart\AbCartHelper;
class DriverManager
{
/** @var AbstractCartDriver[] keyed by provider slug */
private static $drivers = [];
public static function register(AbstractCartDriver $driver)
{
static::$drivers[$driver->getProviderSlug()] = $driver;
}
/**
* @param string $providerSlug
* @return AbstractCartDriver|null
*/
public static function getDriver($providerSlug)
{
return static::$drivers[$providerSlug] ?? null;
}
/**
* @return AbstractCartDriver[]
*/
public static function getAll()
{
return static::$drivers;
}
/**
* @return AbstractCartDriver[] Only drivers whose platform is currently active
*/
public static function getAvailable()
{
return array_filter(static::$drivers, function ($driver) {
return $driver->isAvailable();
});
}
/**
* @return string[]
*/
public static function getAvailableSlugs()
{
return array_keys(static::getAvailable());
}
/**
* @return bool
*/
public static function hasAvailableDrivers()
{
return count(static::getAvailable()) > 0;
}
/**
* Get drivers that are both available (plugin installed) and enabled in settings.
*
* @return AbstractCartDriver[]
*/
public static function getEnabled()
{
$available = static::getAvailable();
$settings = AbCartHelper::getSettings(true);
$enabledProviders = $settings['enabled_providers'] ?? [];
if (empty($enabledProviders)) {
return [];
}
return array_filter($available, function ($driver) use ($enabledProviders) {
return in_array($driver->getProviderSlug(), $enabledProviders);
});
}
/**
* @return string[]
*/
public static function getEnabledSlugs()
{
return array_keys(static::getEnabled());
}
/**
* Check if a specific driver is enabled
*
* @param string $providerSlug
* @return bool
*/
public static function isDriverEnabled($providerSlug)
{
return isset(static::getEnabled()[$providerSlug]);
}
/**
* Get trigger names from all enabled drivers
*
* @return string[]
*/
public static function getEnabledTriggerNames()
{
return array_map(function ($driver) {
return $driver->getTriggerName();
}, static::getEnabled());
}
/**
* Get smart code group keys from all registered drivers
*
* @return string[]
*/
public static function getAllSmartCodeGroupKeys()
{
return array_map(function ($driver) {
return $driver->getSmartCodeGroupKey();
}, static::getAll());
}
/**
* Find a driver by its smart code group key (e.g. 'ab_cart_woo')
*
* @param string $groupKey
* @return AbstractCartDriver|null
*/
public static function getDriverByGroupKey($groupKey)
{
foreach (static::$drivers as $driver) {
if ($driver->getSmartCodeGroupKey() === $groupKey) {
return $driver;
}
}
return null;
}
/**
* Format a price using the appropriate driver, with a generic fallback
*
* @param float|string $amount
* @param string $currency
* @param string|null $providerSlug
* @return string
*/
public static function formatPrice($amount, $currency = '', $providerSlug = null)
{
if ($providerSlug) {
$driver = static::getDriver($providerSlug);
if ($driver) {
return $driver->formatPrice($amount, $currency);
}
}
// Fall back to first available driver
$available = static::getAvailable();
if ($available) {
$driver = reset($available);
return $driver->formatPrice($amount, $currency);
}
return '$' . number_format((float)$amount, 2);
}
}
@@ -0,0 +1,270 @@
<?php
namespace FluentCrm\App\Modules\AbandonCart\Drivers\FluentCart;
use FluentCrm\App\Services\Funnel\BaseTrigger;
use FluentCrm\App\Services\Funnel\FunnelHelper;
class FluentCartAutomationTrigger extends BaseTrigger
{
public function __construct()
{
$this->triggerName = 'fc_ab_cart_simulation_fluent_cart';
$this->priority = 99;
$this->actionArgNum = 1;
parent::__construct();
}
public function getTrigger()
{
return [
'category' => __('FluentCart', 'fluent-crm'),
'label' => __('Cart Abandoned - FluentCart', 'fluent-crm'),
'description' => __('This Funnel will be initiated when a cart has been abandoned in FluentCart', 'fluent-crm'),
'svg' => '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24px" height="24px" viewBox="0 0 24 24" version="1.1"><g id="surface1"><path style=" stroke:none;fill-rule:nonzero;fill:rgb(100%,100%,100%);fill-opacity:1;" d="M 2.398438 0 L 21.601562 0 C 22.925781 0 24 1.074219 24 2.398438 L 24 21.601562 C 24 22.925781 22.925781 24 21.601562 24 L 2.398438 24 C 1.074219 24 0 22.925781 0 21.601562 L 0 2.398438 C 0 1.074219 1.074219 0 2.398438 0 Z M 2.398438 0 "/><path style=" stroke:none;fill-rule:nonzero;fill:rgb(0%,0%,62.352943%);fill-opacity:1;" d="M 10.925781 16.476562 L 3.769531 16.476562 L 4.894531 13.878906 C 5.222656 13.117188 5.972656 12.625 6.804688 12.625 L 15.328125 12.625 L 14.746094 13.964844 C 14.085938 15.488281 12.585938 16.476562 10.925781 16.476562 Z M 10.925781 16.476562 "/><path style=" stroke:none;fill-rule:nonzero;fill:rgb(0%,0%,62.352943%);fill-opacity:1;" d="M 16.851562 11.394531 L 6.789062 11.394531 L 7.367188 10.054688 C 8.027344 8.53125 9.53125 7.542969 11.191406 7.542969 L 19.886719 7.542969 L 18.761719 10.140625 C 18.433594 10.902344 17.683594 11.394531 16.851562 11.394531 Z M 16.851562 11.394531 "/></g></svg>'
];
}
public function getSettingsFields($funnel)
{
return [
'title' => __('Cart Abandoned - FluentCart', 'fluent-crm'),
'sub_title' => __('This Funnel will be initiated when a cart has been abandoned in FluentCart', 'fluent-crm'),
'fields' => [
'priority' => [
'label' => __('Priority of this abandon cart automation trigger', 'fluent-crm'),
'type' => 'input-number',
'placeholder' => __('Automation Priority', 'fluent-crm'),
'inline_help' => __('If you have multiple automations for abandoned cart, you can set the priority. The higher the priority means it will match earlier. Only one abandoned cart automation will run per abandonment depending on your conditional logic.', 'fluent-crm')
]
]
];
}
public function getFunnelSettingsDefaults()
{
return [
'priority' => 10
];
}
public function getFunnelConditionDefaults($funnel)
{
return [
'cart_conditions' => [[]],
'active_once' => 'no',
'require_subscribed' => 'no'
];
}
public function getConditionFields($funnel)
{
if (!defined('FLUENTCAMPAIGN_DIR_FILE')) {
$cartConditionField = [
'type' => 'html',
'label' => '',
'info' => '<h4 style="margin: 0; padding: 0;">Conditions by Cart Items</h4><div style="background-color: #FFF3DC !important;border-color: #FFF3DC; padding: 15px; line-height: 120%;">' . __('FluentCRM Pro plugin is required to use the conditional logic for this trigger. Please install and activate FluentCRM Pro to use this feature.', 'fluent-crm') . '</div>'
];
} else {
$cartConditionField = [
'type' => 'condition_block_groups',
'label' => __('Specify Matching Conditions', 'fluent-crm'),
'inline_help' => __('Specify which contact properties need to be matched. If the conditions match then the automation will run.', 'fluent-crm'),
'labels' => [
'match_type_all_label' => __('True if all conditions match', 'fluent-crm'),
'match_type_any_label' => __('True if any of the conditions match', 'fluent-crm'),
'data_key_label' => __('Contact Data', 'fluent-crm'),
'condition_label' => __('Condition', 'fluent-crm'),
'data_value_label' => __('Match Value', 'fluent-crm')
],
'groups' => $this->getConditionGroups($funnel),
'add_label' => __('Add Condition to check your contact\'s properties', 'fluent-crm'),
];
}
$fields = [
'cart_conditions' => $cartConditionField,
'active_once' => [
'type' => 'yes_no_check',
'label' => '',
'check_label' => __('Skip this automation if the contact is already in active state.', 'fluent-crm'),
'inline_help' => __('Enable this to prevent the automation from running multiple times for the same contact if it is currently active in this automation', 'fluent-crm')
],
'require_subscribed' => [
'type' => 'yes_no_check',
'label' => '',
'check_label' => __('Only run this automation for subscribed contacts', 'fluent-crm'),
'inline_help' => __('If you enable, then it will only run this automation for subscribed contacts', 'fluent-crm')
]
];
return $fields;
}
public function handle($funnel, $originalArgs)
{
// do nothing here - cart processing is handled by AbandonCartRunner
}
public function getConditionGroups($funnel)
{
$groups = [
'ab_cart_fluent_cart' => [
'label' => __('Cart Data', 'fluent-crm'),
'value' => 'ab_cart_fluent_cart',
'children' => [
[
'label' => __('Cart Total', 'fluent-crm'),
'value' => 'cart_total',
'type' => 'numeric'
],
[
'label' => __('Cart Items Count', 'fluent-crm'),
'value' => 'cart_items_count',
'type' => 'numeric'
],
[
'label' => __('Cart Items', 'fluent-crm'),
'value' => 'cart_items',
'type' => 'selections',
'component' => 'ajax_selector',
'option_key' => 'fluent_cart_products',
'is_multiple' => true,
'help' => __('Match the products on the cart', 'fluent-crm')
],
[
'label' => __('Cart Items Categories', 'fluent-crm'),
'value' => 'cart_items_categories',
'type' => 'selections',
'component' => 'tax_selector',
'taxonomy' => 'product-categories',
'is_multiple' => true,
'help' => __('Match the product categories on the cart', 'fluent-crm')
],
]
],
'subscriber' => [
'label' => __('Contact', 'fluent-crm'),
'value' => 'subscriber',
'children' => [
[
'label' => __('First Name', 'fluent-crm'),
'value' => 'first_name',
'type' => 'nullable_text'
],
[
'label' => __('Last Name', 'fluent-crm'),
'value' => 'last_name',
'type' => 'nullable_text'
],
[
'label' => __('Email', 'fluent-crm'),
'value' => 'email',
'type' => 'extended_text'
],
[
'label' => __('Country', 'fluent-crm'),
'value' => 'country',
'type' => 'selections',
'component' => 'options_selector',
'option_key' => 'countries',
'is_multiple' => true,
'is_singular_value' => true
],
[
'label' => __('Phone', 'fluent-crm'),
'value' => 'phone',
'type' => 'nullable_text'
],
[
'label' => __('Created At', 'fluent-crm'),
'value' => 'created_at',
'type' => 'dates',
]
],
],
'segment' => [
'label' => __('Contact Segment', 'fluent-crm'),
'value' => 'segment',
'children' => [
[
'label' => __('Tags', 'fluent-crm'),
'value' => 'tags',
'type' => 'selections',
'component' => 'options_selector',
'option_key' => 'tags',
'is_multiple' => true,
],
[
'label' => __('Lists', 'fluent-crm'),
'value' => 'lists',
'type' => 'selections',
'component' => 'options_selector',
'option_key' => 'lists',
'is_multiple' => true,
],
[
'label' => __('WP User Role', 'fluent-crm'),
'value' => 'user_role',
'type' => 'selections',
'is_singular_value' => true,
'options' => FunnelHelper::getUserRoles(true),
'is_multiple' => true,
]
],
],
];
if ($customFields = fluentcrm_get_custom_contact_fields()) {
$children = [];
foreach ($customFields as $field) {
$item = [
'label' => $field['label'],
'value' => $field['slug'],
'type' => $field['type'],
];
if ($item['type'] == 'number') {
$item['type'] = 'numeric';
} else if ($item['type'] == 'date') {
$item['type'] = 'dates';
$item['date_type'] = 'date';
$item['value_format'] = 'YYYY-MM-DD';
} else if ($item['type'] == 'date_time') {
$item['type'] = 'dates';
$item['has_time'] = 'yes';
$item['date_type'] = 'datetime';
$item['value_format'] = 'YYYY-MM-DD HH:mm:ss';
} else if (isset($field['options'])) {
$item['type'] = 'selections';
$options = $field['options'];
$formattedOptions = [];
foreach ($options as $option) {
$formattedOptions[$option] = $option;
}
$item['options'] = $formattedOptions;
$isMultiple = in_array($field['type'], ['checkbox', 'select-multi']);
$item['is_multiple'] = $isMultiple;
if ($isMultiple) {
$item['is_singular_value'] = true;
}
} else {
$item['type'] = 'extended_text';
}
$children[] = $item;
}
$groups['custom_fields'] = [
'label' => __('Custom Fields', 'fluent-crm'),
'value' => 'custom_fields',
'children' => $children
];
}
$groups = apply_filters('fluentcrm_automation_condition_groups', $groups, $funnel);
return array_values($groups);
}
}
@@ -0,0 +1,278 @@
<?php
namespace FluentCrm\App\Modules\AbandonCart\Drivers\FluentCart;
use FluentCrm\App\Modules\AbandonCart\AbandonCartModel;
use FluentCrm\App\Modules\AbandonCart\AbCartHelper;
use FluentCrm\App\Modules\AbandonCart\Drivers\AbstractCartDriver;
use FluentCrm\Framework\Support\Arr;
class FluentCartDriver extends AbstractCartDriver
{
public function getProviderSlug()
{
return 'fluent_cart';
}
public function getProviderLabel()
{
return __('FluentCart', 'fluent-crm');
}
public function isAvailable()
{
return defined('FLUENTCART_VERSION');
}
public function getLogo()
{
return FLUENTCRM_PLUGIN_URL . 'assets/images/fluent-cart-dark.svg';
}
public function register()
{
(new FluentCartTrackingInit())->register();
}
public function registerAutomationTrigger()
{
// Registered inside FluentCartTrackingInit::register()
}
protected function getViewsBasePath()
{
return __DIR__ . '/Views/';
}
public function isWithinCoolOffPeriod(AbandonCartModel $cart)
{
$coolOffPeriodDay = AbCartHelper::getSetting('cool_off_period_days', 0);
if (!$coolOffPeriodDay) {
return false;
}
$coolOffDateTime = gmdate('Y-m-d H:i:s', time() - ($coolOffPeriodDay * DAY_IN_SECONDS));
$winStatuses = $this->getWinOrderStatuses();
return fluentCrmDb()->table('fct_orders')
->where('created_at', '>=', $coolOffDateTime)
->whereIn('status', $winStatuses)
->where(function ($q) use ($cart) {
$q->whereIn('customer_id', function ($sub) use ($cart) {
$sub->select('id')
->from('fct_customers')
->where('email', $cart->email);
if ($cart->user_id) {
$sub->orWhere('user_id', $cart->user_id);
}
});
})
->exists();
}
public function getCartItemsHtml(AbandonCartModel $cart)
{
$cartItems = Arr::get($cart->cart, 'cart_data', []);
return $this->loadView('AbandonCartItems', [
'cartItems' => $cartItems,
'currency' => $cart->currency
]);
}
public function formatPrice($amount, $currency = '')
{
if (!class_exists('\FluentCart\Api\CurrencySettings')) {
return '$' . number_format((float)$amount, 2);
}
return \FluentCart\Api\CurrencySettings::getPriceHtml((int) round(((float) $amount) * 100), $currency ?: null);
}
public function getRecoveryUrl(AbandonCartModel $cart)
{
if ($cart->status != 'processing') {
return '';
}
return add_query_arg([
'fluentcrm' => 1,
'route' => 'general',
'handler' => $this->getHandlerName(),
'fc_ab_hash' => $cart->checkout_key
], home_url());
}
public function extractCartConditionData(AbandonCartModel $cart)
{
$items = Arr::get($cart->cart, 'cart_data', []);
$productIds = [];
$categoryIds = [];
foreach ($items as $item) {
$postId = Arr::get($item, 'post_id');
if ($postId) {
$productIds[] = $postId;
}
}
if ($productIds) {
$cats = fluentCrmDb()->table('term_relationships')
->join('term_taxonomy', 'term_relationships.term_taxonomy_id', '=', 'term_taxonomy.term_taxonomy_id')
->whereIn('term_relationships.object_id', $productIds)
->where('term_taxonomy.taxonomy', 'product-categories')
->select('term_taxonomy.term_id')
->get();
foreach ($cats as $cat) {
$categoryIds[] = $cat->term_id;
}
}
return [
'product_ids' => $productIds,
'category_ids' => $categoryIds
];
}
public function enrichCartForListing(AbandonCartModel $cart)
{
if ($cart->order_id) {
$cart->order_url = admin_url('admin.php?page=fluent-cart#/orders/' . $cart->order_id . '/view');
}
$newCart = $cart->cart ?: [];
$formData = Arr::get($newCart, 'checkout_data.form_data', []);
$billingFullName = trim(Arr::get($formData, 'billing_first_name', '') . ' ' . Arr::get($formData, 'billing_last_name', ''));
if (!$billingFullName) {
$billingFullName = $cart->full_name;
}
// Build customer_data with billingAddress/shippingAddress for the shared Vue modal
$newCart['customer_data'] = [
'billingAddress' => [
'first_name' => $billingFullName,
'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', ''),
'country' => Arr::get($formData, 'billing_country', ''),
],
'shippingAddress' => [
'first_name' => Arr::get($formData, 'shipping_full_name', ''),
'last_name' => '',
'address_1' => Arr::get($formData, 'shipping_address_1', ''),
'address_2' => Arr::get($formData, 'shipping_address_2', ''),
'postcode' => Arr::get($formData, 'shipping_postcode', ''),
'city' => Arr::get($formData, 'shipping_city', ''),
'country' => Arr::get($formData, 'shipping_country', ''),
],
'order_comments' => Arr::get($formData, 'order_comments', ''),
];
if (Arr::get($formData, 'ship_to_different') !== 'yes') {
$newCart['customer_data']['shippingAddress'] = $newCart['customer_data']['billingAddress'];
}
// Build cart_contents from cart_data for the shared Vue modal
$cartContents = [];
$cartItems = Arr::get($newCart, 'cart_data', []);
foreach ($cartItems as $cartItem) {
$imageUrl = Arr::get($cartItem, 'featured_media', '');
if (!$imageUrl) {
$postId = Arr::get($cartItem, 'post_id');
if ($postId) {
$imageUrl = get_the_post_thumbnail_url($postId, 'thumbnail');
}
}
$subtotal = (int)Arr::get($cartItem, 'subtotal', 0);
$title = Arr::get($cartItem, 'post_title', '');
$subTitle = Arr::get($cartItem, 'title', '');
if($title && $subTitle && $title != $subTitle) {
$title .= ' - ' . $subTitle;
}
$cartContents[] = [
'title' => $title,
'quantity' => (int)Arr::get($cartItem, 'quantity', 1),
'line_total' => number_format($subtotal / 100, 2, '.', ''),
'product_image' => $imageUrl ?: '',
];
}
$newCart['cart_contents'] = $cartContents;
$cart->cart = $newCart;
return $cart;
}
public function getProviderSettingsResponse()
{
if (!defined('FLUENTCART_VERSION')) {
return [];
}
return [
'fct_recovered_statuses' => $this->getWinOrderStatuses(),
];
}
public function getProviderSettingsDefaults()
{
return [
'fct_recovered_statuses' => ['completed', 'processing'],
];
}
public function getSettingsFields()
{
if (!$this->isAvailable()) {
return [];
}
$statuses = [
['id' => 'completed', 'label' => __('Completed', 'fluent-crm')],
['id' => 'processing', 'label' => __('Processing', 'fluent-crm')],
['id' => 'on-hold', 'label' => __('On Hold', 'fluent-crm')],
];
return [
'fct_recovered_statuses' => [
'name' => 'fct_recovered_statuses',
'label' => __('Mark Cart as Recovered when FluentCart Order Status Changes to:', 'fluent-crm'),
'type' => 'checkbox-group',
'options' => $statuses,
'inline_help' => __('Automatically mark a cart as recovered when the corresponding FluentCart order status changes to the selected status.', 'fluent-crm'),
]
];
}
/**
* Check if a FluentCart order status counts as a successful recovery.
*
* @param string $orderStatus
* @return bool
*/
public function isWinOrderStatus($orderStatus)
{
$recoveredStatuses = $this->getWinOrderStatuses();
$result = in_array($orderStatus, $recoveredStatuses, true);
return apply_filters('fluent_crm/ab_cart_is_win_status', $result, $orderStatus, $this);
}
private function getWinOrderStatuses()
{
$settings = AbCartHelper::getSettings();
return Arr::get($settings, 'fct_recovered_statuses', ['completed', 'processing']);
}
}
@@ -0,0 +1,752 @@
<?php
namespace FluentCrm\App\Modules\AbandonCart\Drivers\FluentCart;
use FluentCart\Api\StoreSettings;
use FluentCart\App\Helpers\AddressHelper;
use FluentCart\App\Models\Cart;
use FluentCrm\App\Modules\AbandonCart\AbandonCartModel;
use FluentCrm\App\Modules\AbandonCart\AbCartHelper;
use FluentCrm\App\Models\FunnelSubscriber;
use FluentCrm\App\Models\Subscriber;
use FluentCrm\Framework\Support\Arr;
class FluentCartTrackingInit
{
public function register()
{
// Funnel Automations
(new FluentCartAutomationTrigger())->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);
}
}
}
@@ -0,0 +1,136 @@
<?php
if (!defined('ABSPATH')) exit;
/**
* @var $cartItems array
* @var $currency string
*/
$formatPrice = function ($amount) use ($currency) {
if (class_exists('\FluentCart\Api\CurrencySettings')) {
return \FluentCart\Api\CurrencySettings::getPriceHtml((int)$amount, $currency ?: null);
}
// Fallback: amount is in cents
$symbol = $currency ?: '$';
return $symbol . number_format((float)$amount / 100, 2);
};
?>
<style>
.fc-abandoned-cart-table *,
.fc-abandoned-cart-table {
box-sizing: border-box;
}
@media (max-width: 767px) {
.fc-abandoned-cart-table table thead {
display: none;
}
.fc-abandoned-cart-table table {
display: block !important;
border: none !important;
}
.fc-abandoned-cart-table table tbody {
display: block !important;
width: 100%;
}
.fc-abandoned-cart-table table thead tr th:last-child,
.fc-abandoned-cart-table table thead tr th:nth-child(3),
.fc-abandoned-cart-table table thead tr td:first-child {
width: 100% !important;
}
.fc-abandoned-cart-table table tbody tr td:first-child img {
margin-top: 6px;
margin-bottom: 6px;
}
.fc-abandoned-cart-table table tbody tr {
display: block !important;
flex-direction: column !important;
margin-bottom: 10px;
border: 1px solid rgb(214, 218, 225);
border-radius: 4px;
}
.fc-abandoned-cart-table table tbody tr td:first-child {
border-top: none;
}
.fc-abandoned-cart-table table tbody tr td {
display: flex !important;
width: 100% !important;
border-right: none !important;
gap: 6px;
padding: 0 5px 0 0 !important;
}
.fc-abandoned-cart-table table tbody tr td .table-head {
display: inline-block !important;
margin-right: 6px;
flex: none !important;
}
}
</style>
<div class="fc-abandoned-cart-table">
<table
style="border-spacing: 0;border-collapse: separate;width: 100%;border: 1px solid #D6DAE1;border-radius: 8px;">
<thead>
<tr>
<th style="border-right:1px solid #e9ecf0;background: #EAECF0;padding: 8px 12px;color: #323232;line-height: 26px;font-weight: 700;font-size: 14px;width: 100px;border-top-left-radius: 6px;"><?php esc_html_e('Image', 'fluent-crm'); ?></th>
<th style="min-width: 140px;border-right:1px solid #e9ecf0;background: #EAECF0;padding: 8px 12px;color: #323232;line-height: 26px;font-weight: 700;font-size: 14px;"><?php esc_html_e('Item', 'fluent-crm'); ?></th>
<th style="border-right:1px solid #e9ecf0;background: #EAECF0;padding: 8px 12px;color: #323232;line-height: 26px;font-weight: 700;font-size: 14px;width: 60px;"><?php esc_html_e('Qty', 'fluent-crm'); ?></th>
<th style="border-right:1px solid #e9ecf0;background: #EAECF0;padding: 8px 12px;color: #323232;line-height: 26px;font-weight: 700;font-size: 14px;width: 100px;border-top-right-radius: 6px;"><?php esc_html_e('Price', 'fluent-crm'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($cartItems as $cartItem) {
$imageUrl = \FluentCrm\Framework\Support\Arr::get($cartItem, 'featured_media', '');
if (!$imageUrl) {
$postId = \FluentCrm\Framework\Support\Arr::get($cartItem, 'post_id');
if ($postId) {
$imageUrl = get_the_post_thumbnail_url($postId, 'thumbnail');
}
}
$title = \FluentCrm\Framework\Support\Arr::get($cartItem, 'post_title', '');
$subTitle = \FluentCrm\Framework\Support\Arr::get($cartItem, 'variation_title', '');
if ($subTitle && $title != $subTitle) {
$title .= ' - ' . $subTitle;
}
$quantity = (int)\FluentCrm\Framework\Support\Arr::get($cartItem, 'quantity', 1);
$subtotal = (int)\FluentCrm\Framework\Support\Arr::get($cartItem, 'subtotal', 0);
$discount = (int)\FluentCrm\Framework\Support\Arr::get($cartItem, 'discount_total', 0);
$lineTotal = $subtotal - $discount;
?>
<tr>
<td style="padding: 8px 12px;border-top: 1px solid #e9ecf0;border-right: 1px solid #e9ecf0;">
<div class="table-head"
style="display: none;width: 100px;min-width: 100px;flex:none;background: rgb(234, 236, 240);font-weight: 600;font-size: 14px;padding: 10px 12px;line-height: 1rem;"><?php esc_html_e('Image', 'fluent-crm'); ?></div>
<?php if ($imageUrl): ?>
<img
style="width: 50px;height: 50px;object-fit: contain;display: block;margin-top: 4px;margin-bottom: 4px;"
src="<?php echo esc_url($imageUrl); ?>" alt="<?php echo esc_attr($title); ?>">
<?php endif; ?>
</td>
<td style="padding: 8px 12px;overflow-wrap: break-word;border-top: 1px solid #e9ecf0;border-right: 1px solid #e9ecf0;">
<div class="table-head"
style="display: none;width: 100px;min-width: 100px;flex:none;background: rgb(234, 236, 240);font-weight: 600;font-size: 14px;padding: 10px 12px;line-height: 1rem;"><?php esc_html_e('Item', 'fluent-crm'); ?></div><?php echo esc_html($title); ?>
</td>
<td style="padding: 8px 12px;border-top: 1px solid #e9ecf0;border-right: 1px solid #e9ecf0;">
<div class="table-head"
style="display: none;width: 100px;min-width: 100px;flex:none;background: rgb(234, 236, 240);font-weight: 600;font-size: 14px;padding: 10px 12px;line-height: 1rem;"><?php esc_html_e('Qty', 'fluent-crm'); ?></div><?php echo esc_html($quantity); ?>
</td>
<td style="padding: 8px 12px;border-top: 1px solid #e9ecf0;">
<div class="table-head" style="display: none;width: 100px;min-width: 100px;flex:none;background: rgb(234, 236, 240);font-weight: 600;font-size: 14px;padding: 10px 12px;line-height: 1rem;"><?php esc_html_e('Price', 'fluent-crm'); ?></div><?php echo wp_kses_post($formatPrice($lineTotal)); ?>
</td>
</tr>
<?php } ?>
</tbody>
</table>
</div>
@@ -0,0 +1,76 @@
(function () {
'use strict';
if (typeof fc_ab_fct_cart === 'undefined') {
return;
}
var gdprShown = false;
function isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
function showGDPR() {
if (gdprShown || !fc_ab_fct_cart.__gdpr_message) {
return;
}
gdprShown = true;
var gdprDiv = document.createElement('div');
gdprDiv.id = 'fc_ab_cart_gdpr';
gdprDiv.style.cssText = 'padding: 10px; margin: 10px 0; font-size: 13px; color: #666; background: #f9f9f9; border-radius: 4px;';
gdprDiv.innerHTML = fc_ab_fct_cart.__gdpr_message;
var section = document.getElementById('billing_personal_information_section');
if (section) {
section.appendChild(gdprDiv);
}
document.addEventListener('click', function (e) {
if (!e.target.closest('#fc_ab_opt_out, .fc-ab-cart-opt-out')) {
return;
}
e.preventDefault();
var ajaxUrl = window.fluentcart_checkout_vars && window.fluentcart_checkout_vars.ajaxurl;
if (!ajaxUrl) {
return;
}
var params = new URLSearchParams();
params.append('action', 'fc_ab_fct_cart_skip');
params.append('_nonce', fc_ab_fct_cart.nonce);
var xhr = new XMLHttpRequest();
xhr.open('POST', ajaxUrl, true);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.onreadystatechange = function () {
if (xhr.readyState === 4 && xhr.status === 200) {
var el = document.getElementById('fc_ab_cart_gdpr');
if (el) {
el.style.display = 'none';
}
}
};
xhr.send(params.toString());
});
}
// Use capture phase since blur doesn't bubble
document.addEventListener('blur', function (e) {
if (e.target.id === 'billing_email' && e.target.value && isValidEmail(e.target.value.trim())) {
showGDPR();
}
}, true);
// Check if email is already filled (e.g., logged-in user)
setTimeout(function () {
var emailField = document.getElementById('billing_email');
if (emailField && emailField.value && isValidEmail(emailField.value.trim())) {
showGDPR();
}
}, 2000);
})();
@@ -0,0 +1,80 @@
<?php
namespace FluentCrm\App\Modules\AbandonCart;
use FluentCrm\App\Modules\AbandonCart\Drivers\DriverManager;
use FluentCrm\App\Http\Controllers\Controller;
use FluentCrm\App\Services\Helper;
use FluentCrm\Framework\Http\Request\Request;
use FluentCrm\Framework\Support\Arr;
class SettingsController extends Controller
{
public function getSettings(Request $request)
{
$settings = AbCartHelper::getSettings();
$returnData = [
'settings' => $settings
];
$availables = DriverManager::getAvailable();
// Collect provider-specific options from each available driver
foreach ($availables as $driver) {
$providerOptions = $driver->getProviderSettingsResponse();
if ($providerOptions) {
$returnData[$driver->getProviderSlug() . 'Options'] = $providerOptions;
}
}
$returnData['available_providers'] = array_map(function ($driver) {
return [
'slug' => $driver->getProviderSlug(),
'label' => $driver->getProviderLabel(),
'settings_fields' => $driver->getSettingsFields()
];
}, array_values($availables));
return $returnData;
}
public function saveSettings(Request $request)
{
$prevSettings = AbCartHelper::getSettings();
$settings = (array) $request->get('settings', []);
$settings = Arr::only($settings, array_keys($prevSettings));
do_action_ref_array('fluent_crm/abandon_cart_before_settings_save', [&$settings, $prevSettings]);
if (is_wp_error($settings)) {
return $this->sendError([
'message' => $settings->get_error_message()
], 422);
}
$isEnabled = Arr::get($settings, 'enabled') === 'yes';
if ($isEnabled) {
AbandonCartMigrator::migrate();
}
/*
* Adding this to experimental settings so we don't have to do extra query
*/
$experiments = Helper::getExperimentalSettings();
$experiments['abandoned_cart'] = $isEnabled ? 'yes' : 'no';
update_option('_fluentcrm_experimental_settings', $experiments, 'yes');
update_option('_fc_ab_cart_settings', $settings);
return [
'message' => __('Settings has been saved successfully', 'fluent-crm'),
'reload' => $prevSettings['enabled'] !== $settings['enabled'],
'settings' => $settings
];
}
}
@@ -0,0 +1,633 @@
<?php
namespace FluentCrm\App\Modules\MCP;
use FluentCrm\App\Modules\MCP\Tools\CampaignTools;
use FluentCrm\App\Modules\MCP\Tools\ContactTools;
use FluentCrm\App\Modules\MCP\Tools\ContextTools;
use FluentCrm\App\Modules\MCP\Tools\EmailTools;
use FluentCrm\App\Modules\MCP\Tools\FunnelTools;
use FluentCrm\App\Modules\MCP\Tools\SegmentTools;
use FluentCrm\App\Services\PermissionManager;
/**
* Single source of truth for every FluentCRM MCP ability.
*
* Per MCP_PLAN.md § 12 (token discipline) — descriptions are tight (≤30 tokens),
* input schemas omit redundant property descriptions, and the universal filter
* shape is referenced by pointer rather than inlined into each tool.
*
* Adding a tool: append an entry to `getDefinitions()`. Adding a Pro tool:
* push it from `fluentcampaign-pro` via the `fluent_crm/mcp_loaded` action.
*/
class AbilitiesRegistrar
{
public static function getDefinitions()
{
return [
'fluent-crm/get-crm-context' => [
'label' => __('Get CRM Context', 'fluent-crm'),
'description' => __('Discovery. Returns identity, permissions, stats, top tags/lists, available triggers/actions, all enums, custom fields schema, default sender. Call once per session.', 'fluent-crm'),
'input_schema' => [
'type' => 'object',
'properties' => new \stdClass(),
],
'execute_callback' => [ContextTools::class, 'getContext'],
'permission_callback' => function () {
return PermissionManager::currentUserCan('fcrm_view_dashboard')
|| PermissionManager::currentUserCan('fcrm_read_contacts');
},
'annotations' => ['readonly' => true],
],
'fluent-crm/list-contacts' => [
'label' => __('List Contacts', 'fluent-crm'),
'description' => __('List/filter contacts with tags + lists inline. `search` matches name/email/custom field values. Filter fields are strictly validated — see get-crm-context.enums for valid status values.', 'fluent-crm'),
'input_schema' => [
'type' => 'object',
'properties' => [
'search' => ['type' => 'string', 'description' => 'Full-text across first_name, last_name, email, and custom field values.'],
'tags' => ['type' => 'array', 'items' => ['type' => ['string', 'integer']], 'description' => 'Tag ids or slugs/titles. Mixed allowed.'],
'lists' => ['type' => 'array', 'items' => ['type' => ['string', 'integer']], 'description' => 'List ids or slugs/titles. Mixed allowed.'],
'statuses' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'See get-crm-context.enums.contact_statuses.'],
'sms_statuses' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'See get-crm-context.enums.sms_statuses.'],
'contact_type' => ['type' => 'string', 'enum' => ['lead', 'customer']],
'created_after' => ['type' => 'string', 'description' => 'YYYY-MM-DD or full ISO 8601. Site timezone.'],
'created_before' => ['type' => 'string', 'description' => 'YYYY-MM-DD or full ISO 8601. Site timezone.'],
'sort_by' => ['type' => 'string', 'enum' => ['id', 'email', 'first_name', 'last_name', 'created_at', 'last_activity'], 'default' => 'id'],
'sort_type' => ['type' => 'string', 'enum' => ['ASC', 'DESC'], 'default' => 'DESC'],
'page' => ['type' => 'integer', 'default' => 1],
'per_page' => ['type' => 'integer', 'default' => 15, 'description' => 'Max 100.'],
'include_custom_fields' => ['type' => 'boolean', 'default' => false, 'description' => 'Inline each contact\'s custom field values (heavier).'],
],
],
'execute_callback' => [ContactTools::class, 'listContacts'],
'permission_callback' => function () {
return PermissionManager::currentUserCan('fcrm_read_contacts');
},
'annotations' => ['readonly' => true],
],
'fluent-crm/get-contact' => [
'label' => __('Get Contact', 'fluent-crm'),
'description' => __('Full contact profile. Provide contact_id OR email. Default include: notes, email_history, automations. Optional: activity, purchase_history, support_tickets, ai_summary, info_widgets.', 'fluent-crm'),
'input_schema' => [
'type' => 'object',
'properties' => [
'contact_id' => ['type' => 'integer', 'description' => 'Provide this OR email.'],
'email' => ['type' => 'string', 'description' => 'Provide this OR contact_id.'],
'include' => [
'type' => 'array',
'description' => 'Adds optional sections to the response. The default 3 are always included.',
'items' => ['type' => 'string', 'enum' => ['notes', 'email_history', 'automations', 'activity', 'purchase_history', 'support_tickets', 'ai_summary', 'info_widgets']],
],
'generate_ai_summary' => ['type' => 'boolean', 'default' => false, 'description' => 'When true and ai_summary is in include, force a fresh AI call (costs provider tokens).'],
],
],
'execute_callback' => [ContactTools::class, 'getContact'],
'permission_callback' => function () {
return PermissionManager::currentUserCan('fcrm_read_contacts');
},
'annotations' => ['readonly' => true],
],
'fluent-crm/list-campaigns' => [
'label' => __('List Campaigns', 'fluent-crm'),
'description' => __('List campaigns with stats inline. Excludes one-off email-to-contact records by default — flip include_one_offs for a unified "what was sent recently" view.', 'fluent-crm'),
'input_schema' => [
'type' => 'object',
'properties' => [
'search' => ['type' => 'string', 'description' => 'Matches campaign title.'],
'statuses' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'See get-crm-context.enums.campaign_statuses.'],
'sort_by' => ['type' => 'string', 'enum' => ['id', 'created_at', 'updated_at', 'scheduled_at'], 'default' => 'created_at'],
'sort_type' => ['type' => 'string', 'enum' => ['ASC', 'DESC'], 'default' => 'DESC'],
'include_stats' => ['type' => 'boolean', 'default' => true, 'description' => 'When true, computes per-campaign stats inline (one extra query per row — turn off for cheap title scans).'],
'include_one_offs' => ['type' => 'boolean', 'default' => false, 'description' => 'Also include the per-recipient custom-email rows created by send-email-to-contact.'],
'page' => ['type' => 'integer', 'default' => 1],
'per_page' => ['type' => 'integer', 'default' => 15, 'description' => 'Max 100.'],
],
],
'execute_callback' => [CampaignTools::class, 'listCampaigns'],
'permission_callback' => function () {
return PermissionManager::currentUserCan('fcrm_read_emails');
},
'annotations' => ['readonly' => true],
],
'fluent-crm/get-campaign' => [
'label' => __('Get Campaign', 'fluent-crm'),
'description' => __('Campaign details. Default include: stats. Optional: subjects (A/B), link_report, recipients_estimate.', 'fluent-crm'),
'input_schema' => [
'type' => 'object',
'properties' => [
'campaign_id' => ['type' => 'integer'],
'include' => [
'type' => 'array',
'items' => ['type' => 'string', 'enum' => ['stats', 'subjects', 'link_report', 'recipients_estimate']],
],
],
'required' => ['campaign_id'],
],
'execute_callback' => [CampaignTools::class, 'getCampaign'],
'permission_callback' => function () {
return PermissionManager::currentUserCan('fcrm_read_emails');
},
'annotations' => ['readonly' => true],
],
'fluent-crm/list-automations' => [
'label' => __('List Automations', 'fluent-crm'),
'description' => __('List/filter automations (funnels) with subscriber counts inline.', 'fluent-crm'),
'input_schema' => [
'type' => 'object',
'properties' => [
'search' => ['type' => 'string'],
'statuses' => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['draft', 'published']]],
'sort_by' => ['type' => 'string', 'enum' => ['id', 'title', 'status', 'updated_at'], 'default' => 'id'],
'sort_type' => ['type' => 'string', 'enum' => ['ASC', 'DESC'], 'default' => 'DESC'],
'page' => ['type' => 'integer', 'default' => 1],
'per_page' => ['type' => 'integer', 'default' => 15],
],
],
'execute_callback' => [FunnelTools::class, 'listAutomations'],
'permission_callback' => function () {
return PermissionManager::currentUserCan('fcrm_read_funnels');
},
'annotations' => ['readonly' => true],
],
'fluent-crm/list-funnel-subscribers' => [
'label' => __('List Funnel Subscribers', 'fluent-crm'),
'description' => __('List contacts enrolled in a funnel by status. Use to find candidates for update-contact-automation-status when you only know the funnel.', 'fluent-crm'),
'input_schema' => [
'type' => 'object',
'properties' => [
'funnel_id' => ['type' => 'integer'],
'statuses' => [
'type' => 'array',
'items' => ['type' => 'string', 'enum' => ['active', 'waiting', 'completed', 'cancelled', 'skipped']],
'description' => 'Defaults to ["active"].',
],
'page' => ['type' => 'integer', 'default' => 1],
'per_page' => ['type' => 'integer', 'default' => 15],
],
'required' => ['funnel_id'],
],
'execute_callback' => [FunnelTools::class, 'listFunnelSubscribers'],
'permission_callback' => function () {
return PermissionManager::currentUserCan('fcrm_read_funnels');
},
'annotations' => ['readonly' => true],
],
'fluent-crm/get-automation' => [
'label' => __('Get Automation', 'fluent-crm'),
'description' => __('Funnel details with sequences and per-step report by default. Embedded email bodies in send_custom_email steps are stripped unless include_bodies=true (saves tokens).', 'fluent-crm'),
'input_schema' => [
'type' => 'object',
'properties' => [
'funnel_id' => ['type' => 'integer'],
'include' => [
'type' => 'array',
'description' => 'Defaults to ["sequences","report"]. Pass [] for metadata only.',
'items' => ['type' => 'string', 'enum' => ['sequences', 'report']],
],
'include_bodies' => ['type' => 'boolean', 'default' => false, 'description' => 'Return full email bodies inside send_custom_email step settings. Off by default — large funnels can blow agent context.'],
],
'required' => ['funnel_id'],
],
'execute_callback' => [FunnelTools::class, 'getAutomation'],
'permission_callback' => function () {
return PermissionManager::currentUserCan('fcrm_read_funnels');
},
'annotations' => ['readonly' => true],
],
// -----------------------------------------------------------------
// Phase 3 — write tools
// -----------------------------------------------------------------
'fluent-crm/upsert-contact' => [
'label' => __('Create or Update Contact', 'fluent-crm'),
'description' => __('Create or update a contact by id or email. status changes fire native hooks. Source stamps "mcp" only on create — preserved on update. new_email renames in place.', 'fluent-crm'),
'input_schema' => [
'type' => 'object',
'properties' => [
'contact_id' => ['type' => 'integer', 'description' => 'Provide this OR email for lookup.'],
'email' => ['type' => 'string', 'description' => 'Provide this OR contact_id for lookup. Required for create.'],
'new_email' => ['type' => 'string', 'description' => 'Renames an existing contact in place. Errors if another contact already uses this email.'],
'first_name' => ['type' => 'string'],
'last_name' => ['type' => 'string'],
'prefix' => ['type' => 'string'],
'phone' => ['type' => 'string'],
'status' => ['type' => 'string', 'description' => 'See get-crm-context.enums.contact_statuses.'],
'contact_type' => ['type' => 'string', 'enum' => ['lead', 'customer']],
'address' => ['type' => 'object', 'description' => 'Object: {line_1, line_2, city, state, postal_code, country (ISO-2)}. Empty fields are ignored.'],
'date_of_birth' => ['type' => 'string', 'description' => 'YYYY-MM-DD.'],
'timezone' => ['type' => 'string'],
'source' => ['type' => 'string', 'description' => 'Defaults to "mcp" on create. Omit on updates to preserve existing source.'],
'custom_fields' => ['type' => 'object', 'description' => 'Map of custom field slug → value. See get-crm-context.custom_fields_schema.'],
'add_tags' => ['type' => 'array', 'items' => ['type' => ['string', 'integer']]],
'remove_tags' => ['type' => 'array', 'items' => ['type' => ['string', 'integer']]],
'add_lists' => ['type' => 'array', 'items' => ['type' => ['string', 'integer']]],
'remove_lists' => ['type' => 'array', 'items' => ['type' => ['string', 'integer']]],
'auto_create_tags' => ['type' => 'boolean', 'default' => false, 'description' => 'Re-checks fcrm_manage_contact_cats. Off by default for safety.'],
'auto_create_lists' => ['type' => 'boolean', 'default' => false],
'double_optin' => ['type' => 'boolean', 'default' => false, 'description' => 'When status=pending, send opt-in email. No-op for other statuses.'],
'if_exists' => ['type' => 'string', 'enum' => ['merge', 'skip', 'error'], 'default' => 'merge', 'description' => 'merge: update existing fields. skip: leave row untouched. error: return contact_exists.'],
'status_change_reason' => ['type' => 'string', 'description' => 'When provided AND status changes, auto-creates an audit note ("Status changed via MCP").'],
],
],
'execute_callback' => [ContactTools::class, 'upsertContact'],
'permission_callback' => function () {
return PermissionManager::currentUserCan('fcrm_manage_contacts');
},
],
'fluent-crm/bulk-upsert-contacts' => [
'label' => __('Bulk Create or Update Contacts', 'fluent-crm'),
'description' => __('Batch create/update up to 500 contacts. Returns per-row {created, updated, skipped, invalid}. auto_create defaults to true here (matches CSV-import expectations) — opposite of upsert-contact.', 'fluent-crm'),
'input_schema' => [
'type' => 'object',
'properties' => [
'contacts' => [
'type' => 'array',
'description' => 'Array of contact objects. Each object accepts the same fields as upsert-contact but no add_tags/remove_tags — pass `tags` and `lists` directly.',
'items' => ['type' => 'object'],
],
'if_exists' => ['type' => 'string', 'enum' => ['merge', 'skip', 'error'], 'default' => 'merge'],
'double_optin' => ['type' => 'boolean', 'default' => false],
'auto_create_tags' => ['type' => 'boolean', 'default' => true, 'description' => 'Default true here (bulk-import context). Re-checks fcrm_manage_contact_cats.'],
'auto_create_lists' => ['type' => 'boolean', 'default' => true],
],
'required' => ['contacts'],
],
'execute_callback' => [ContactTools::class, 'bulkUpsertContacts'],
'permission_callback' => function () {
return PermissionManager::currentUserCan('fcrm_manage_contacts');
},
'annotations' => ['bulk' => true],
],
'fluent-crm/delete-contact' => [
'label' => __('Delete Contact', 'fluent-crm'),
'description' => __('Hard-delete a contact. Optional delete_emails wipes the email log too. Cannot be undone.', 'fluent-crm'),
'input_schema' => [
'type' => 'object',
'properties' => [
'contact_id' => ['type' => 'integer'],
'email' => ['type' => 'string'],
'delete_emails' => ['type' => 'boolean', 'default' => true],
],
],
'execute_callback' => [ContactTools::class, 'deleteContact'],
'permission_callback' => function () {
return PermissionManager::currentUserCan('fcrm_manage_contacts_delete');
},
'annotations' => ['destructive' => true],
],
'fluent-crm/apply-segments-to-contacts' => [
'label' => __('Apply Tags/Lists Across Contacts', 'fluent-crm'),
'description' => __('Add/remove tags and lists across many contacts. Provide contact_ids OR filter, not both. Always dry_run first for filter-based applies. Response includes applied_contact_ids for precise reversal. Cap 5000.', 'fluent-crm'),
'input_schema' => [
'type' => 'object',
'properties' => [
'contact_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Explicit ids. Use OR filter, not both.'],
'filter' => ['type' => 'object', 'description' => 'Universal filter — {tags, lists, statuses, contact_type, search, created_after, created_before}. See get-crm-context.guidelines.'],
'add_tags' => ['type' => 'array', 'items' => ['type' => ['string', 'integer']]],
'remove_tags' => ['type' => 'array', 'items' => ['type' => ['string', 'integer']]],
'add_lists' => ['type' => 'array', 'items' => ['type' => ['string', 'integer']]],
'remove_lists' => ['type' => 'array', 'items' => ['type' => ['string', 'integer']]],
'auto_create_tags' => ['type' => 'boolean', 'default' => false, 'description' => 'Re-checks fcrm_manage_contact_cats. Suppressed during dry_run so previews never leave orphans behind.'],
'auto_create_lists' => ['type' => 'boolean', 'default' => false],
'dry_run' => ['type' => 'boolean', 'default' => false, 'description' => 'Preview matched count, batches_required, and tags/lists_would_create without applying. Bypasses the cap (you see real matched_contacts even if > 5000).'],
],
],
'execute_callback' => [ContactTools::class, 'applySegmentsToContacts'],
'permission_callback' => function () {
return PermissionManager::currentUserCan('fcrm_manage_contacts');
},
'annotations' => ['bulk' => true],
],
'fluent-crm/manage-tag' => [
'label' => __('Manage Tag', 'fluent-crm'),
'description' => __('Create, update, delete, or merge tags. delete + merge are destructive (re-pivot or detach subscribers).', 'fluent-crm'),
'input_schema' => [
'type' => 'object',
'properties' => [
'action' => ['type' => 'string', 'enum' => ['create', 'update', 'delete', 'merge']],
'tag_id' => ['type' => 'integer', 'description' => 'Required for update/delete.'],
'title' => ['type' => 'string'],
'slug' => ['type' => 'string'],
'description' => ['type' => 'string'],
'force' => ['type' => 'boolean', 'default' => false, 'description' => 'delete only — allow deletion when subscribers are still attached.'],
'from_tag_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'merge only — source tags whose subscribers move to to_tag_id and which then get deleted.'],
'to_tag_id' => ['type' => 'integer', 'description' => 'merge only — destination tag.'],
],
'required' => ['action'],
],
'execute_callback' => [SegmentTools::class, 'manageTag'],
'permission_callback' => function () {
return PermissionManager::currentUserCan('fcrm_manage_contact_cats')
|| PermissionManager::currentUserCan('fcrm_manage_contact_cats_delete');
},
'annotations' => ['destructive' => true],
],
'fluent-crm/manage-list' => [
'label' => __('Manage List', 'fluent-crm'),
'description' => __('Create, update, delete, or merge lists. delete + merge are destructive (re-pivot or detach subscribers).', 'fluent-crm'),
'input_schema' => [
'type' => 'object',
'properties' => [
'action' => ['type' => 'string', 'enum' => ['create', 'update', 'delete', 'merge']],
'list_id' => ['type' => 'integer'],
'title' => ['type' => 'string'],
'slug' => ['type' => 'string'],
'description' => ['type' => 'string'],
'force' => ['type' => 'boolean', 'default' => false],
'from_list_ids' => ['type' => 'array', 'items' => ['type' => 'integer']],
'to_list_id' => ['type' => 'integer'],
],
'required' => ['action'],
],
'execute_callback' => [SegmentTools::class, 'manageList'],
'permission_callback' => function () {
return PermissionManager::currentUserCan('fcrm_manage_contact_cats')
|| PermissionManager::currentUserCan('fcrm_manage_contact_cats_delete');
},
'annotations' => ['destructive' => true],
],
'fluent-crm/delete-contact-note' => [
'label' => __('Delete Contact Note', 'fluent-crm'),
'description' => __('Delete a single subscriber note by id. Find the note id via get-contact include=["notes"]. Other notes and email history are untouched.', 'fluent-crm'),
'input_schema' => [
'type' => 'object',
'properties' => [
'note_id' => ['type' => 'integer'],
],
'required' => ['note_id'],
],
'execute_callback' => [ContactTools::class, 'deleteContactNote'],
'permission_callback' => function () {
return PermissionManager::currentUserCan('fcrm_manage_contacts');
},
'annotations' => ['destructive' => true],
],
'fluent-crm/add-contact-note' => [
'label' => __('Add Contact Note', 'fluent-crm'),
'description' => __('Add a note to a contact. Provide contact_id OR email plus title + description. Types: note, call, email, meeting, quote. Description supports HTML.', 'fluent-crm'),
'input_schema' => [
'type' => 'object',
'properties' => [
'contact_id' => ['type' => 'integer', 'description' => 'Provide this OR email.'],
'email' => ['type' => 'string', 'description' => 'Provide this OR contact_id.'],
'type' => ['type' => 'string', 'enum' => ['note', 'call', 'email', 'meeting', 'quote'], 'default' => 'note'],
'title' => ['type' => 'string', 'description' => 'Max 192 chars.'],
'description' => ['type' => 'string', 'description' => 'HTML or plain. SmartCodes resolve.'],
'created_at' => ['type' => 'string', 'description' => 'ISO 8601, defaults to now (site timezone).'],
],
'required' => ['title', 'description'],
],
'execute_callback' => [ContactTools::class, 'addContactNote'],
'permission_callback' => function () {
return PermissionManager::currentUserCan('fcrm_manage_contacts');
},
],
'fluent-crm/send-test-email' => [
'label' => __('Send Test Email', 'fluent-crm'),
'description' => __('Render and send a test copy of an email — does not enroll the recipient, does not create a campaign record, does not log to email_history. Subject is prefixed with "TEST:".', 'fluent-crm'),
'input_schema' => [
'type' => 'object',
'properties' => [
'to_email' => ['type' => 'string', 'description' => 'Where to send the test. Defaults to the current WP user\'s email.'],
'campaign_id' => ['type' => 'integer', 'description' => 'Send a test copy of this saved campaign\'s body / subject / settings.'],
'subject' => ['type' => 'string', 'description' => 'Override or supply a subject when not using campaign_id.'],
'body' => ['type' => 'string', 'description' => 'Override or supply a body when not using campaign_id.'],
'pre_header' => ['type' => 'string'],
'design_template' => [
'type' => 'string',
'enum' => array_keys(ContextTools::allowedDesignTemplates()),
],
'against_contact_id' => ['type' => 'integer', 'description' => 'Resolve smartcodes against this contact. Defaults to a contact matching to_email, then any subscribed contact.'],
'against_contact_email' => ['type' => 'string'],
],
],
'execute_callback' => [EmailTools::class, 'sendTestEmail'],
'permission_callback' => function () {
return PermissionManager::currentUserCan('fcrm_manage_emails');
},
],
'fluent-crm/send-email-to-contact' => [
'label' => __('Send Email to Contact', 'fluent-crm'),
'description' => __('Send a one-off email to a subscribed/transactional contact. Routes through normal queue + bounce + FluentSMTP. SmartCodes resolve. Persists a custom_email_campaign record (hidden from list-campaigns by default).', 'fluent-crm'),
'input_schema' => [
'type' => 'object',
'properties' => [
'contact_id' => ['type' => 'integer', 'description' => 'Provide this OR email.'],
'email' => ['type' => 'string', 'description' => 'Provide this OR contact_id.'],
'subject' => ['type' => 'string'],
'body' => ['type' => 'string', 'description' => 'HTML or plain. SmartCodes resolve.'],
'pre_header' => ['type' => 'string'],
'title' => ['type' => 'string', 'description' => 'Internal log title; defaults to "MCP one-off to {email}".'],
'design_template' => [
'type' => 'string',
'enum' => array_keys(ContextTools::allowedDesignTemplates()),
'default' => 'classic',
],
'from_name' => ['type' => 'string', 'description' => 'Defaults to site sender (get-crm-context.default_sender.from_name).'],
'from_email' => ['type' => 'string', 'description' => 'Defaults to site sender. Must be a configured/verified address.'],
'reply_to_name' => ['type' => 'string'],
'reply_to_email' => ['type' => 'string'],
'is_transactional' => ['type' => 'string', 'enum' => ['yes', 'no'], 'default' => 'no', 'description' => 'When "yes", also auto-disables the global marketing footer for transactional-mail compliance.'],
'disable_footer' => ['type' => 'string', 'enum' => ['yes', 'no'], 'description' => 'Explicit override of the auto-derived footer behavior.'],
'click_tracker' => ['type' => 'string', 'enum' => ['yes', 'no', 'anonymous']],
'open_tracker' => ['type' => 'string', 'enum' => ['yes', 'no', 'anonymous']],
'utm' => ['type' => 'object', 'description' => 'Optional {status:0|1, source, medium, campaign, term, content}. status defaults to 0.'],
'settings' => ['type' => 'object', 'description' => 'Free-form passthrough merged into campaign.settings (template_config, footer_settings). Caller keys override our defaults.'],
],
'required' => ['subject', 'body'],
],
'execute_callback' => [EmailTools::class, 'sendEmailToContact'],
'permission_callback' => function () {
return PermissionManager::currentUserCan('fcrm_manage_emails');
},
],
'fluent-crm/upsert-campaign' => [
'label' => __('Create or Update Campaign', 'fluent-crm'),
'description' => __('Create or update a draft campaign. Never sends — use change-campaign-status to schedule. recipients persists tags + lists ONLY (no statuses/contact_type — apply a temp tag first). Returns estimated_recipients + warnings inline.', 'fluent-crm'),
'input_schema' => [
'type' => 'object',
'properties' => [
'campaign_id' => ['type' => 'integer'],
'title' => ['type' => 'string'],
'email_subject' => ['type' => 'string'],
'email_pre_header' => ['type' => 'string'],
'email_body' => ['type' => 'string'],
'design_template' => [
'type' => 'string',
'enum' => array_keys(ContextTools::allowedDesignTemplates()),
'default' => 'classic',
],
'settings' => [
'type' => 'object',
'description' => 'Merged into campaign.settings. Shape: {mailer_settings:{from_name,from_email,reply_to_name,reply_to_email,is_custom:yes|no}, is_transactional:yes|no, click_tracker:yes|no|anonymous, open_tracker:yes|no|anonymous, footer_settings:{disable_footer:yes|no}, template_config}.',
],
'recipients' => [
'type' => 'object',
'description' => 'Recipient segment. Persists {tags:[id|slug|title], lists:[id|slug|title]} only. Pass other keys (statuses, contact_type, advanced_filters) and the call hard-errors with the temp-tag workaround.',
],
'exclude_recipients' => [
'type' => 'object',
'description' => 'Same shape + restriction as recipients.',
],
'subjects' => [
'type' => 'array',
'description' => 'A/B subjects. Each: {value: string [, key: string]}. Pass an array with 2+ items to enable A/B; the regular email_subject still acts as the primary line. Optional `key` is a stable identifier used internally — auto-generated if omitted.',
'items' => [
'type' => 'object',
'properties' => [
'value' => ['type' => 'string'],
'key' => ['type' => 'string'],
],
],
],
'label_ids' => ['type' => 'array', 'items' => ['type' => 'integer']],
'utm' => [
'type' => 'object',
'description' => 'Optional. {status: 0|1 to toggle, source, medium, campaign, term, content}. status defaults to 0 (off).',
],
'if_exists' => [
'type' => 'string',
'enum' => ['auto_suffix', 'error'],
'default' => 'auto_suffix',
'description' => 'On title conflict during create: auto_suffix (Title (2), Title (3)) or hard error.',
],
],
],
'execute_callback' => [CampaignTools::class, 'upsertCampaign'],
'permission_callback' => function () {
return PermissionManager::currentUserCan('fcrm_manage_emails');
},
],
'fluent-crm/change-campaign-status' => [
'label' => __('Change Campaign Status', 'fluent-crm'),
'description' => __('State transition. schedule + delete are destructive. pause/resume only valid mid-send (working↔paused). unschedule reverts to draft and clears scheduled_at.', 'fluent-crm'),
'input_schema' => [
'type' => 'object',
'properties' => [
'campaign_id' => ['type' => 'integer'],
'action' => ['type' => 'string', 'enum' => ['schedule', 'unschedule', 'pause', 'resume', 'duplicate', 'delete']],
'scheduled_at' => ['type' => 'string', 'description' => 'Required when action=schedule and sending_type≠instant. Site timezone (see get-crm-context.site.timezone). Must be in the future.'],
'schedule_range' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'Required when sending_type=range_schedule. [startISO, endISO].'],
'sending_type' => ['type' => 'string', 'enum' => ['instant', 'schedule', 'range_schedule'], 'description' => 'Defaults to "schedule" if scheduled_at is set, else "instant".'],
'new_title' => ['type' => 'string', 'description' => 'duplicate only — overrides the auto "[Duplicate] X" title.'],
],
'required' => ['campaign_id', 'action'],
],
'execute_callback' => [CampaignTools::class, 'changeCampaignStatus'],
'permission_callback' => function () {
return PermissionManager::currentUserCan('fcrm_manage_emails');
},
'annotations' => ['destructive' => true],
],
'fluent-crm/update-contact-automation-status' => [
'label' => __('Update Contact Automation Status', 'fluent-crm'),
'description' => __('Resume, cancel, or advance_now a contact in a funnel. cancel is destructive (reversible in UI but halts processing). advance_now requires advance_to_sequence_id and skips intermediate benchmarks.', 'fluent-crm'),
'input_schema' => [
'type' => 'object',
'properties' => [
'funnel_id' => ['type' => 'integer', 'description' => 'Use list-funnel-subscribers to find candidates.'],
'contact_id' => ['type' => 'integer', 'description' => 'Provide this OR email.'],
'email' => ['type' => 'string', 'description' => 'Provide this OR contact_id.'],
'action' => ['type' => 'string', 'enum' => ['resume', 'cancel', 'advance_now']],
'advance_to_sequence_id' => ['type' => 'integer', 'description' => 'Required when action=advance_now. The sequence id to jump to (find via get-automation include=["sequences"]).'],
],
'required' => ['funnel_id', 'action'],
],
'execute_callback' => [FunnelTools::class, 'updateContactAutomationStatus'],
'permission_callback' => function () {
return PermissionManager::currentUserCan('fcrm_write_funnels');
},
],
];
}
public static function register()
{
foreach (self::getDefinitions() as $name => $definition) {
$args = [
'label' => $definition['label'],
'description' => $definition['description'],
'category' => 'fluent-crm',
'execute_callback' => self::wrapExecuteCallback($name, $definition['execute_callback']),
'permission_callback' => $definition['permission_callback'],
'meta' => [
'show_in_rest' => true,
'mcp' => [
'public' => true,
],
],
];
if (!empty($definition['input_schema'])) {
$args['input_schema'] = $definition['input_schema'];
}
if (!empty($definition['annotations'])) {
$args['meta']['annotations'] = $definition['annotations'];
}
wp_register_ability($name, $args);
}
}
/**
* Wraps every tool's execute callback in a try/catch that converts
* unhandled exceptions (SQL errors, type errors, anything that escapes
* a tool's own validation) into a structured WP_Error with the actual
* exception message instead of the adapter's generic "Tool execution
* failed" surface. Without this, the agent has no signal about what
* went wrong, which leads to retries against tools that silently
* succeeded — see fluentcrm-mcp-review.md bug #1.
*/
private static function wrapExecuteCallback($toolName, $callback)
{
return function ($params) use ($toolName, $callback) {
try {
return call_user_func($callback, $params);
} catch (\Throwable $e) {
/**
* Allows logging or alerting on unhandled tool exceptions
* before the structured error is returned to the agent.
*
* @since 2.10.0
*
* @param \Throwable $e The exception.
* @param string $toolName Fully-qualified ability name.
* @param mixed $params The tool's input parameters.
*/
do_action('fluent_crm/mcp_tool_exception', $e, $toolName, $params);
$details = [
'tool' => $toolName,
'exception' => get_class($e),
];
if (defined('WP_DEBUG') && WP_DEBUG) {
$details['file'] = $e->getFile() . ':' . $e->getLine();
$details['trace'] = array_slice(explode("\n", $e->getTraceAsString()), 0, 5);
}
return new \WP_Error('failed', $e->getMessage(), $details);
}
};
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,140 @@
<?php
namespace FluentCrm\App\Modules\MCP;
/**
* Bootstrap for FluentCRM's Model Context Protocol (MCP) integration.
*
* Hooks the WordPress 6.9 Abilities API + WP MCP Adapter (separate plugin):
* - registers a `fluent-crm` ability category
* - hands AbilitiesRegistrar a chance to declare every CRM ability
* - fires `fluent_crm/mcp_loaded` so FluentCampaign Pro can register its own
* abilities under the same namespace
* - filters the adapter's default-server config to expose every CRM ability
* as a direct MCP tool (not just an adapter wrapper)
*
* All the heavy lifting is gated by the lazy-register guard in
* `app/Hooks/actions.php`, which checks `function_exists('wp_register_ability')`
* before instantiating this class — so this code never runs on WP < 6.9 or
* sites missing the adapter plugin.
*/
class MCPInit
{
public function init()
{
add_action('wp_abilities_api_categories_init', [$this, 'registerCategory']);
add_action('wp_abilities_api_init', [$this, 'registerAbilities']);
// Register a dedicated FluentCRM MCP server (separate from the
// adapter's default server). Endpoint:
// /wp-json/fluent-crm/mcp
// Tools live only here — agents that want CRM access connect to this
// URL specifically, and the adapter's default server is left to host
// whatever else the user has installed.
add_action('mcp_adapter_init', [$this, 'registerCustomServer']);
// Invalidate the cached `get-crm-context` payload when reference data
// an agent might have just learned changes — keeps stale enums or
// missing tags out of the next session.
$invalidate = [\FluentCrm\App\Modules\MCP\Tools\ContextTools::class, 'invalidateCache'];
foreach ([
'fluent_crm_tag_created',
'fluent_crm_tag_updated',
'fluent_crm_tag_deleted',
'fluent_crm_list_created',
'fluent_crm_list_updated',
'fluent_crm_list_deleted',
'fluent_crm/custom_field_added',
'fluent_crm/custom_field_updated',
'fluent_crm/custom_field_deleted',
'fluent_crm/global_email_settings_saved',
] as $hook) {
add_action($hook, $invalidate);
}
}
public function registerCategory()
{
wp_register_ability_category('fluent-crm', [
'label' => __('FluentCRM', 'fluent-crm'),
'description' => __('Contact, campaign, and automation abilities for FluentCRM.', 'fluent-crm'),
]);
}
public function registerAbilities()
{
AbilitiesRegistrar::register();
/**
* Fires after FluentCRM has registered its core MCP abilities.
*
* FluentCampaign Pro hooks this to register its 4 Pro abilities under
* the same `fluent-crm/` namespace — agents do not need to know which
* plugin owns which tool.
*
* @since 2.10.0
*/
do_action('fluent_crm/mcp_loaded');
}
/**
* Register the dedicated FluentCRM MCP server when the WP MCP Adapter
* fires `mcp_adapter_init`.
*
* @param \WP\MCP\Core\McpAdapter $adapter
*/
public function registerCustomServer($adapter)
{
if (!$adapter || !is_object($adapter) || !method_exists($adapter, 'create_server')) {
return;
}
$abilityNames = array_keys(AbilitiesRegistrar::getDefinitions());
/**
* Filter the list of FluentCRM ability names registered with the
* dedicated FluentCRM MCP server.
*
* FluentCampaign Pro hooks this filter (in its own MCPInit) to push
* its 4 Pro abilities into the same server. Other extensions can do
* the same to surface tools agents discover via `tools/list`.
*
* @since 2.10.0
*
* @param array $abilityNames Array of fully-qualified ability names.
*/
$abilityNames = apply_filters('fluent_crm/mcp_ability_names', $abilityNames);
// Allow operators to swap the route via filter. Default puts the
// server at /wp-json/fluent-crm/mcp — sibling to the existing
// FluentCRM REST namespace (fluent-crm/v2), but distinct so it does
// not get caught by the v2 policy stack.
$namespace = apply_filters('fluent_crm/mcp_server_namespace', 'fluent-crm');
$route = apply_filters('fluent_crm/mcp_server_route', 'mcp');
$adapter->create_server(
'fluent-crm',
$namespace,
$route,
__('FluentCRM MCP Server', 'fluent-crm'),
__('AI agent tools for FluentCRM contacts, campaigns, and automations.', 'fluent-crm'),
defined('FLUENTCRM_PLUGIN_VERSION') ? FLUENTCRM_PLUGIN_VERSION : '1.0.0',
['\WP\MCP\Transport\HttpTransport'],
'\WP\MCP\Infrastructure\ErrorHandling\ErrorLogMcpErrorHandler',
'\WP\MCP\Infrastructure\Observability\NullMcpObservabilityHandler',
array_values(array_unique(array_filter((array) $abilityNames)))
);
}
/**
* Public helper used by the Settings UI and the snippet generator to
* report a stable endpoint URL for the FluentCRM MCP server.
*/
public static function getEndpointUrl()
{
$namespace = apply_filters('fluent_crm/mcp_server_namespace', 'fluent-crm');
$route = apply_filters('fluent_crm/mcp_server_route', 'mcp');
return get_rest_url(null, trailingslashit($namespace) . $route);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,948 @@
<?php
namespace FluentCrm\App\Modules\MCP\Tools;
use FluentCrm\App\Models\Subscriber;
use FluentCrm\App\Modules\MCP\Helpers\MCPHelper;
use FluentCrm\App\Services\ContactsQuery;
/**
* Contact-centric MCP tools.
*
* Read tools (Phase 2): listContacts, getContact.
* Write tools (Phase 3): upsertContact, bulkUpsertContacts, deleteContact,
* applySegmentsToContacts, addContactNote.
*
* Each method delegates to existing FluentCRM services (ContactsQuery,
* Subscriber model, Helper::deleteContacts, etc.) — no business-logic
* duplication — and shapes the result through MCPHelper formatters.
*/
class ContactTools
{
// -----------------------------------------------------------------
// Read: list-contacts
// -----------------------------------------------------------------
public static function listContacts($params)
{
$params = (array) $params;
// Reject up front if the caller passed an unsupported advanced_filters
// shape — round-2 review #3.
$validation = MCPHelper::validateUniversalFilter($params);
if (is_wp_error($validation)) {
return $validation;
}
$pagination = MCPHelper::paginationFromInput($params);
$args = MCPHelper::buildContactsQueryArgs($params);
$args['with'] = ['tags', 'lists'];
if (!empty($params['include_custom_fields'])) {
$args['custom_fields'] = true;
}
$cq = new ContactsQuery($args);
MCPHelper::applyDateFilters($cq, $params);
$paginated = $cq->paginate();
return MCPHelper::formatContactList($paginated, !empty($params['include_custom_fields']));
}
// -----------------------------------------------------------------
// Read: get-contact
// -----------------------------------------------------------------
public static function getContact($params)
{
$params = (array) $params;
$defaultIncludes = ['notes', 'email_history', 'automations'];
$include = isset($params['include']) && is_array($params['include']) && $params['include']
? array_values(array_intersect(
$params['include'],
['notes', 'email_history', 'automations', 'activity', 'purchase_history', 'support_tickets', 'ai_summary', 'info_widgets']
))
: $defaultIncludes;
$contactId = isset($params['contact_id']) ? (int) $params['contact_id'] : 0;
$email = isset($params['email']) ? sanitize_email($params['email']) : '';
$with = ['tags', 'lists'];
$subscriber = null;
if ($contactId) {
$subscriber = Subscriber::with($with)->find($contactId);
} elseif ($email) {
$subscriber = Subscriber::with($with)->where('email', $email)->first();
}
if (!$subscriber) {
if (!$contactId && !$email) {
return MCPHelper::error('invalid_param', __('Provide contact_id or email', 'fluent-crm'));
}
return MCPHelper::error('not_found', __('Contact not found', 'fluent-crm'), array_filter([
'contact_id' => $contactId ?: null,
'email' => $email ?: null,
]));
}
$data = MCPHelper::formatContactForMCP($subscriber, ['include' => $include]);
// Defaults already inlined by formatContactForMCP — fill the optional ones.
if (in_array('activity', $include, true)) {
$data['activity'] = self::buildActivityTimeline($subscriber);
}
if (in_array('purchase_history', $include, true)) {
$data['purchase_history'] = self::buildPurchaseHistory($subscriber);
}
if (in_array('support_tickets', $include, true)) {
$data['support_tickets'] = self::buildSupportTickets($subscriber);
}
if (in_array('info_widgets', $include, true)) {
$data['info_widgets'] = self::buildInfoWidgets($subscriber);
}
if (in_array('ai_summary', $include, true)) {
$data['ai_summary'] = self::buildAiSummary($subscriber, !empty($params['generate_ai_summary']));
}
// Status-related context — surfaced inline so the agent can see why a
// contact is unsubscribed without an extra call.
if (in_array($subscriber->status, ['unsubscribed', 'bounced', 'complained', 'spammed'], true)) {
$data['unsubscribe_reason'] = method_exists($subscriber, 'unsubscribeReason')
? $subscriber->unsubscribeReason()
: null;
}
return $data;
}
/**
* Activity timeline = tracked events. The fc_event_tracking table is
* created by the free plugin's migrations but may not exist on legacy
* installs that never ran the migration. Probe with SHOW TABLES so we
* never trigger wpdb's print_error (which leaks HTML into the response
* body before the JSON envelope, even when the exception is caught).
*/
private static function buildActivityTimeline($subscriber)
{
global $wpdb;
$tableName = $wpdb->prefix . 'fc_event_tracking';
$exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $tableName)) === $tableName;
if (!$exists) {
return [];
}
try {
$events = $subscriber->trackingEvents()
->orderBy('id', 'DESC')
->limit(50)
->get();
} catch (\Throwable $e) {
return [];
}
$out = [];
foreach ($events as $event) {
$out[] = [
'id' => (int) $event->id,
'event_key' => $event->event_key,
'title' => $event->title,
'value' => $event->value,
'provider' => $event->provider ?? null,
'counter' => isset($event->counter) ? (int) $event->counter : null,
'created_at' => MCPHelper::toIso8601($event->created_at),
];
}
return $out;
}
private static function buildPurchaseHistory($subscriber)
{
/**
* Resolved per the existing FluentCRM commerce-provider filter chain.
*/
$provider = apply_filters('fluentcrm_commerce_provider', '');
if (!$provider) {
return [];
}
$stat = apply_filters('fluent_crm/contact_purchase_stat_' . $provider, [], $subscriber->id);
return is_array($stat) ? $stat : [];
}
private static function buildSupportTickets($subscriber)
{
// FluentSupport hooks this filter when active. Empty otherwise.
return apply_filters('fluentcrm_get_support_tickets', [], $subscriber);
}
private static function buildInfoWidgets($subscriber)
{
/**
* Filter that integrators (Pro, FluentSupport, FluentCart, etc.) push
* widget data into. Surface the raw filter result; ContextTools agents
* can interpret what's there.
*/
$widgets = apply_filters('fluent_crm/contact_info_widgets', [], $subscriber);
return is_array($widgets) ? $widgets : [];
}
private static function buildAiSummary($subscriber, $generate = false)
{
$cached = fluentcrm_get_subscriber_meta($subscriber->id, '_ai_summary');
if ($cached && !$generate) {
return [
'summary' => is_array($cached) ? ($cached['summary'] ?? '') : (string) $cached,
'generated_at' => is_array($cached) ? ($cached['generated_at'] ?? null) : null,
'cached' => true,
];
}
if (!$generate) {
return null;
}
// Honor existing AI controller; if it's missing or disabled, return
// a structured signal rather than throwing.
if (!class_exists('FluentCrm\\App\\Http\\Controllers\\AiController')) {
return ['summary' => null, 'cached' => false, 'error' => 'ai_unavailable'];
}
$aiSettings = fluentcrm_get_option('ai_settings', []);
if (empty($aiSettings['active_provider'])) {
return ['summary' => null, 'cached' => false, 'error' => 'ai_provider_not_configured'];
}
// Generation requires the existing controller's prompt + provider call;
// surface a dependency_missing-style signal so the agent can prompt the
// user to enable AI rather than blocking the read.
return [
'summary' => null,
'cached' => false,
'error' => 'generation_not_supported_in_mcp_v1',
'note' => 'Trigger AI summary from the contact profile UI; cached value will appear on subsequent get-contact calls.',
];
}
// -----------------------------------------------------------------
// Write: upsert-contact
// -----------------------------------------------------------------
public static function upsertContact($params)
{
$params = (array) $params;
$contactId = isset($params['contact_id']) ? (int) $params['contact_id'] : 0;
$email = isset($params['email']) ? sanitize_email($params['email']) : '';
$newEmail = isset($params['new_email']) ? sanitize_email($params['new_email']) : '';
if (!$contactId && !$email) {
return MCPHelper::error('invalid_param', __('Provide contact_id or email', 'fluent-crm'));
}
$existing = null;
if ($contactId) {
$existing = Subscriber::find($contactId);
if (!$existing) {
return MCPHelper::error('not_found', __('Contact not found', 'fluent-crm'), ['contact_id' => $contactId]);
}
// Lookup-by-id with email mismatch is fine — id wins.
$email = $existing->email;
} else {
$existing = Subscriber::where('email', $email)->first();
}
$ifExists = $params['if_exists'] ?? 'merge';
if ($existing && $ifExists === 'skip') {
return [
'ok' => true,
'action' => 'skipped',
'contact' => MCPHelper::formatContactForMCP($existing, ['include' => ['notes', 'email_history', 'automations']]),
'changes' => null,
];
}
if ($existing && $ifExists === 'error') {
return MCPHelper::error('contact_exists', __('A contact with this email already exists', 'fluent-crm'), [
'id' => (int) $existing->id,
]);
}
// Re-check the escalating capability if the agent asked us to create
// missing tags/lists — defense in depth, even though the
// permission_callback already enforced the base cap.
$autoCreateTags = !empty($params['auto_create_tags']);
$autoCreateLists = !empty($params['auto_create_lists']);
if (($autoCreateTags || $autoCreateLists)
&& !\FluentCrm\App\Services\PermissionManager::currentUserCan('fcrm_manage_contact_cats')) {
return MCPHelper::error('forbidden', __('Creating new tags/lists requires fcrm_manage_contact_cats', 'fluent-crm'));
}
// Resolve add/remove segment payloads up-front so we can mention
// resolution failures in the response without partially applying.
$addTags = MCPHelper::resolveTagIds($params['add_tags'] ?? [], $autoCreateTags);
$removeTags = MCPHelper::resolveTagIds($params['remove_tags'] ?? [], false);
$addLists = MCPHelper::resolveListIds($params['add_lists'] ?? [], $autoCreateLists);
$removeLists = MCPHelper::resolveListIds($params['remove_lists'] ?? [], false);
// Capture the pre-rename / pre-update snapshot fields BEFORE any
// mutation. The rename block below sets $existing->email to the new
// value, so reading $existing->email after that point would return
// the new email — operator-test report 2026-05-07 #9. The full
// snapshot also feeds diffFields() so fields_updated correctly
// reports 'email' on a rename.
$previousStatus = $existing ? $existing->status : null;
$previousEmail = $existing ? $existing->email : null;
$previousSnapshot = $existing ? self::snapshotCompareFields($existing) : null;
// Email rename: when an existing contact + new_email is provided, do
// the rename in-place on the existing row BEFORE delegating to
// createOrUpdate. createOrUpdate looks up by email — passing it the
// new_email would not find a row and would create a new contact
// (review B1 round 3). The save fires fluent_crm/contact_email_changed
// through Subscriber::updateOrCreate's normal path because we then
// call it with the new email as the lookup key.
if ($existing && $newEmail && $newEmail !== $existing->email) {
$oldEmail = $existing->email;
// Make sure the new email isn't already used by another contact.
$clash = Subscriber::where('email', $newEmail)->where('id', '!=', $existing->id)->first();
if ($clash) {
return MCPHelper::error('contact_exists', __('Another contact already uses the new_email — refusing to merge silently. Resolve manually or pick a different new_email.', 'fluent-crm'), [
'new_email' => $newEmail,
'conflict_id' => (int) $clash->id,
'subject_id' => (int) $existing->id,
]);
}
$existing->email = $newEmail;
$existing->save();
do_action('fluent_crm/contact_email_changed', $existing, $oldEmail);
}
// Build the upsert payload — only fields actually provided. Lookup
// email is the post-rename value (so createOrUpdate finds the same
// row we just renamed).
$payload = [
'email' => $existing && $newEmail ? $newEmail : ($email ?: ($existing->email ?? null)),
];
$passthru = ['first_name', 'last_name', 'prefix', 'phone', 'status', 'contact_type', 'date_of_birth', 'timezone', 'source'];
foreach ($passthru as $field) {
if (array_key_exists($field, $params) && $params[$field] !== null && $params[$field] !== '') {
$payload[$field] = $params[$field];
}
}
self::applyAddressShape($payload, $params['address'] ?? null);
if (!empty($params['custom_fields']) && is_array($params['custom_fields'])) {
// Validate against the registered schema. Unknown keys would
// otherwise be silently dropped (operator-test report
// 2026-05-07 #6) — fail closed so the agent can either
// correct the slug or call get-crm-context for the schema.
$diff = MCPHelper::diffCustomFields($params['custom_fields']);
if (!empty($diff['unknown'])) {
return MCPHelper::error('invalid_param', __('custom_fields contains slugs not in the contact custom-field schema. Refusing — silent-dropping makes the agent think the value persisted.', 'fluent-crm'), [
'unknown_custom_field_slugs' => $diff['unknown'],
'allowed_custom_field_slugs' => MCPHelper::knownContactCustomFieldSlugs(),
'tip' => 'Call get-crm-context and read enums.custom_fields_schema (or call options for the live registry) before retrying.',
]);
}
$payload['custom_values'] = $diff['known'];
}
// Only stamp source='mcp' on creation. On update, omit the field
// entirely so the model preserves whatever signup source the contact
// already has ("web", "checkout", "import", etc.). The agent can
// still pass an explicit `source` to override this when needed.
if (!$existing && (!isset($payload['source']) || $payload['source'] === '')) {
$payload['source'] = 'mcp';
} elseif ($existing && (!isset($payload['source']) || $payload['source'] === '')) {
unset($payload['source']);
}
// The `Subscriber::updateOrCreate` path forwards through
// FluentCrmApi('contacts')->createOrUpdate which fires the
// contact-created/updated and status-change hooks we need.
// ($previousStatus / $previousEmail were captured above, before
// the rename block — see operator-test report 2026-05-07 #9.)
$forceUpdate = true;
$contact = FluentCrmApi('contacts')->createOrUpdate($payload, $forceUpdate, false);
if (!$contact) {
return MCPHelper::error('failed', __('Could not create or update the contact', 'fluent-crm'));
}
$action = !empty($contact->wasRecentlyCreated) ? 'created' : 'updated';
// Apply delta segment changes.
$tagsAdded = [];
$tagsRemoved = [];
$listsAdded = [];
$listsRemoved = [];
if (!empty($addTags['ids'])) {
$contact->attachTags($addTags['ids']);
foreach ($addTags['ids'] as $id) {
$tagsAdded[] = ['id' => (int) $id];
}
}
if (!empty($removeTags['ids'])) {
$contact->detachTags($removeTags['ids']);
foreach ($removeTags['ids'] as $id) {
$tagsRemoved[] = ['id' => (int) $id];
}
}
if (!empty($addLists['ids'])) {
$contact->attachLists($addLists['ids']);
foreach ($addLists['ids'] as $id) {
$listsAdded[] = ['id' => (int) $id];
}
}
if (!empty($removeLists['ids'])) {
$contact->detachLists($removeLists['ids']);
foreach ($removeLists['ids'] as $id) {
$listsRemoved[] = ['id' => (int) $id];
}
}
// Optional double opt-in trigger for newly-pending contacts.
if ($contact->status === 'pending' && !empty($params['double_optin'])) {
$contact->sendDoubleOptinEmail();
}
// Status-change reason: drop a system-style note for audit.
if (!empty($params['status_change_reason']) && $previousStatus && $previousStatus !== $contact->status) {
\FluentCrm\App\Models\SubscriberNote::create([
'subscriber_id' => $contact->id,
'type' => 'note',
'title' => __('Status changed via MCP', 'fluent-crm'),
'description' => sanitize_text_field((string) $params['status_change_reason']),
]);
}
$contact = Subscriber::with(['tags', 'lists'])->find($contact->id);
return [
'ok' => true,
'action' => $action,
'contact' => MCPHelper::formatContactForMCP($contact, ['include' => ['notes', 'email_history', 'automations']]),
'changes' => [
'fields_updated' => self::diffFields($previousSnapshot, $contact),
'tags_added' => $tagsAdded,
'tags_removed' => $tagsRemoved,
'lists_added' => $listsAdded,
'lists_removed' => $listsRemoved,
'previous_status' => $previousStatus,
'current_status' => $contact->status,
'previous_email' => $previousEmail,
'current_email' => $contact->email,
'tags_created' => $addTags['created'],
'lists_created' => $addLists['created'],
],
];
}
// -----------------------------------------------------------------
// Write: delete-contact-note (round 3 review #11)
// -----------------------------------------------------------------
public static function deleteContactNote($params)
{
$params = (array) $params;
$noteId = (int) ($params['note_id'] ?? 0);
if (!$noteId) {
return MCPHelper::error('invalid_param', __('note_id is required', 'fluent-crm'));
}
$note = \FluentCrm\App\Models\SubscriberNote::find($noteId);
if (!$note) {
return MCPHelper::error('not_found', __('Note not found', 'fluent-crm'), ['note_id' => $noteId]);
}
$deletedId = (int) $note->id;
$subscriberId = (int) $note->subscriber_id;
$title = (string) $note->title;
$note->delete();
do_action('fluent_crm/note_deleted', $deletedId, $subscriberId);
return [
'ok' => true,
'action' => 'deleted',
'deleted_id' => $deletedId,
'subscriber_id' => $subscriberId,
'deleted_title' => $title,
'note' => __('Note row removed. The contact\'s other notes and email history are unaffected.', 'fluent-crm'),
];
}
/**
* Compute the would-create list for a dry-run preview. Skips numeric
* inputs (those are id lookups, not creation candidates — review B3
* round 3) and only flags string names that have no existing match.
*/
private static function wouldCreateNames($items, $kind = 'tag')
{
$out = [];
foreach ((array) $items as $item) {
if (is_numeric($item) || $item === '' || $item === null) {
continue;
}
$name = sanitize_text_field((string) $item);
$slug = sanitize_title($name);
if ($kind === 'list') {
$hit = \FluentCrm\App\Models\Lists::where('title', $name)->orWhere('slug', $slug)->first();
} else {
$hit = \FluentCrm\App\Models\Tag::where('title', $name)->orWhere('slug', $slug)->first();
}
if (!$hit) {
$out[] = $name;
}
}
return array_values(array_unique($out));
}
/**
* Map the agent-facing {line_1, line_2, city, state, postal_code,
* country} shape onto the column-named payload that Subscriber
* createOrUpdate consumes. Mutates $payload by reference. Shared
* between upsert-contact and bulk-upsert-contacts so both stay in
* lock-step (operator-test report 2026-05-07 #5).
*/
private static function applyAddressShape(array &$payload, $address)
{
if (empty($address) || !is_array($address)) {
return;
}
$map = [
'line_1' => 'address_line_1',
'line_2' => 'address_line_2',
'city' => 'city',
'state' => 'state',
'postal_code' => 'postal_code',
'country' => 'country',
];
foreach ($map as $key => $col) {
if (isset($address[$key]) && $address[$key] !== '') {
$payload[$col] = $address[$key];
}
}
}
/**
* Snapshot the diff-relevant columns of a Subscriber before any
* in-place mutation (rename, save). diffFields() compares against
* this snapshot so fields_updated stays correct even after the row
* has been written.
*
* @return array<string,string>
*/
private static function snapshotCompareFields($subscriber)
{
$snapshot = [];
foreach (self::compareFieldNames() as $field) {
$snapshot[$field] = (string) ($subscriber->{$field} ?? '');
}
return $snapshot;
}
private static function compareFieldNames()
{
return ['email', 'first_name', 'last_name', 'prefix', 'phone', 'status', 'contact_type', 'address_line_1', 'address_line_2', 'city', 'state', 'postal_code', 'country', 'date_of_birth', 'timezone', 'source'];
}
/**
* @param array<string,string>|null $before Snapshot from snapshotCompareFields()
* @param object $after Subscriber model post-save
*/
private static function diffFields($before, $after)
{
if (!$before) {
return ['*'];
}
$changed = [];
foreach (self::compareFieldNames() as $field) {
if (($before[$field] ?? '') !== (string) ($after->{$field} ?? '')) {
$changed[] = $field;
}
}
return $changed;
}
// -----------------------------------------------------------------
// Write: bulk-upsert-contacts
// -----------------------------------------------------------------
public static function bulkUpsertContacts($params)
{
$params = (array) $params;
$contacts = (array) ($params['contacts'] ?? []);
if (!$contacts) {
return MCPHelper::error('invalid_param', __('contacts is required', 'fluent-crm'));
}
$maxBatch = (int) apply_filters('fluent_crm/mcp_bulk_cap', 500, 'bulk-upsert-contacts');
if (count($contacts) > $maxBatch) {
return MCPHelper::error('cap_reached', __('Too many contacts in a single call', 'fluent-crm'), [
'max' => $maxBatch,
'matched' => count($contacts),
]);
}
$autoCreateTags = isset($params['auto_create_tags']) ? (bool) $params['auto_create_tags'] : true;
$autoCreateLists = isset($params['auto_create_lists']) ? (bool) $params['auto_create_lists'] : true;
$ifExists = $params['if_exists'] ?? 'merge';
$doubleOptin = !empty($params['double_optin']);
if (($autoCreateTags || $autoCreateLists)
&& !\FluentCrm\App\Services\PermissionManager::currentUserCan('fcrm_manage_contact_cats')) {
return MCPHelper::error('forbidden', __('Creating new tags/lists requires fcrm_manage_contact_cats', 'fluent-crm'));
}
$created = $updated = $skipped = $invalid = $warnings = [];
foreach ($contacts as $row) {
if (!is_array($row) || empty($row['email']) || !is_email($row['email'])) {
$invalid[] = ['email' => $row['email'] ?? null, 'reason' => 'invalid_email'];
continue;
}
$existing = Subscriber::where('email', sanitize_email($row['email']))->first();
if ($existing && $ifExists === 'skip') {
$skipped[] = ['id' => (int) $existing->id, 'email' => $existing->email];
continue;
}
if ($existing && $ifExists === 'error') {
$invalid[] = ['email' => $row['email'], 'reason' => 'contact_exists', 'id' => (int) $existing->id];
continue;
}
// Resolve segments per-row.
$tagIds = MCPHelper::resolveTagIds((array) ($row['tags'] ?? []), $autoCreateTags);
$listIds = MCPHelper::resolveListIds((array) ($row['lists'] ?? []), $autoCreateLists);
$payload = $row;
$payload['tags'] = $tagIds['ids'];
$payload['lists'] = $listIds['ids'];
// Same address-shape mapping as single upsert. Without this,
// bulk silently dropped the {line_1,...,country} object —
// operator-test report 2026-05-07 #5.
self::applyAddressShape($payload, $row['address'] ?? null);
// Same rule as upsert-contact: stamp source='mcp_bulk' only on
// creation. On update, preserve the original source unless the
// caller passed one explicitly.
if (!$existing && (!isset($payload['source']) || $payload['source'] === '')) {
$payload['source'] = 'mcp_bulk';
} elseif ($existing && (!isset($payload['source']) || $payload['source'] === '')) {
unset($payload['source']);
}
if (!empty($row['custom_fields']) && is_array($row['custom_fields'])) {
// Same diff-against-schema gate as single upsert, but
// surface unknown slugs as a per-row warning so one bad
// row doesn't fail the whole batch (operator-test report
// 2026-05-07 #6). Known keys still persist.
$diff = MCPHelper::diffCustomFields($row['custom_fields']);
if (!empty($diff['unknown'])) {
$warnings[] = [
'email' => $row['email'],
'reason' => 'unknown_custom_field_slugs',
'unknown_custom_field_slugs' => $diff['unknown'],
];
}
$payload['custom_values'] = $diff['known'];
}
$contact = FluentCrmApi('contacts')->createOrUpdate($payload, true, false);
if (!$contact) {
$invalid[] = ['email' => $row['email'], 'reason' => 'failed_to_save'];
continue;
}
if ($doubleOptin && $contact->status === 'pending') {
$contact->sendDoubleOptinEmail();
}
$entry = [
'id' => (int) $contact->id,
'email' => $contact->email,
'status' => $contact->status,
];
if (!empty($contact->wasRecentlyCreated)) {
$created[] = $entry;
} else {
$updated[] = $entry;
}
}
return [
'ok' => true,
'summary' => [
'created' => count($created),
'updated' => count($updated),
'skipped' => count($skipped),
'invalid' => count($invalid),
'warnings' => count($warnings),
],
'created' => $created,
'updated' => $updated,
'skipped' => $skipped,
'invalid' => $invalid,
'warnings' => $warnings,
];
}
// -----------------------------------------------------------------
// Write: delete-contact
// -----------------------------------------------------------------
public static function deleteContact($params)
{
$resolved = MCPHelper::resolveContact((array) $params);
if (is_wp_error($resolved)) {
return $resolved;
}
$contact = $resolved;
$deletedId = (int) $contact->id;
$deletedEmail = (string) $contact->email;
$deleteEmails = !isset($params['delete_emails']) ? true : (bool) $params['delete_emails'];
if ($deleteEmails) {
\FluentCrm\App\Models\CampaignEmail::where('subscriber_id', $deletedId)->delete();
}
$ok = \FluentCrm\App\Services\Helper::deleteContacts([$deletedId]);
if (!$ok) {
return MCPHelper::error('failed', __('Could not delete the contact', 'fluent-crm'));
}
return [
'ok' => true,
'deleted_id' => $deletedId,
'deleted_email' => $deletedEmail,
'emails_purged' => (bool) $deleteEmails,
];
}
// -----------------------------------------------------------------
// Write: apply-segments-to-contacts
// -----------------------------------------------------------------
public static function applySegmentsToContacts($params)
{
$params = (array) $params;
$autoCreateTags = !empty($params['auto_create_tags']);
$autoCreateLists = !empty($params['auto_create_lists']);
if (($autoCreateTags || $autoCreateLists)
&& !\FluentCrm\App\Services\PermissionManager::currentUserCan('fcrm_manage_contact_cats')) {
return MCPHelper::error('forbidden', __('Creating new tags/lists requires fcrm_manage_contact_cats', 'fluent-crm'));
}
$contactIds = isset($params['contact_ids']) ? array_filter(array_map('intval', (array) $params['contact_ids'])) : [];
$filter = $params['filter'] ?? null;
$dryRun = !empty($params['dry_run']);
if (!$contactIds && empty($filter)) {
return MCPHelper::error('invalid_param', __('Provide contact_ids or filter', 'fluent-crm'));
}
if ($contactIds && !empty($filter)) {
return MCPHelper::error('invalid_param', __('Provide contact_ids OR filter, not both', 'fluent-crm'));
}
$cap = (int) apply_filters('fluent_crm/mcp_bulk_cap', 5000, 'apply-segments-to-contacts');
if (!$contactIds) {
$validation = MCPHelper::validateUniversalFilter((array) $filter);
if (is_wp_error($validation)) {
return $validation;
}
$args = MCPHelper::buildContactsQueryArgs((array) $filter);
$args['with'] = []; // we just need ids
$cq = new ContactsQuery($args);
MCPHelper::applyDateFilters($cq, (array) $filter);
$query = $cq->getModel();
$matched = (int) $query->count();
// During a dry run, expose the matched count even when it
// exceeds the cap — knowing the size is the whole point of a
// preview. The agent can then batch.
if ($matched > $cap && !$dryRun) {
return MCPHelper::error('cap_reached', __('Too many contacts match the filter', 'fluent-crm'), [
'max' => $cap,
'matched' => $matched,
]);
}
$contactIds = array_map('intval', $query->limit($cap)->pluck('id')->toArray());
// Stash the true matched count so dry_run can echo it (the
// pluck call above only returns up to $cap rows).
$matchedTotal = $matched;
} else {
if (count($contactIds) > $cap && !$dryRun) {
return MCPHelper::error('cap_reached', __('Too many contact_ids in a single call', 'fluent-crm'), [
'max' => $cap,
'matched' => count($contactIds),
]);
}
$matchedTotal = count($contactIds);
}
// Resolve segment refs. Auto-create is suppressed during dry runs so
// a preview never leaves orphan tags/lists behind.
$addTags = MCPHelper::resolveTagIds((array) ($params['add_tags'] ?? []), $autoCreateTags && !$dryRun);
$removeTags = MCPHelper::resolveTagIds((array) ($params['remove_tags'] ?? []), false);
$addLists = MCPHelper::resolveListIds((array) ($params['add_lists'] ?? []), $autoCreateLists && !$dryRun);
$removeLists = MCPHelper::resolveListIds((array) ($params['remove_lists'] ?? []), false);
// Compute the would-create set: name strings the agent supplied that
// don't resolve to an existing tag/list. Numeric inputs are id
// lookups, never creation candidates (review B3 round 3).
$tagsWouldCreate = self::wouldCreateNames((array) ($params['add_tags'] ?? []), 'tag');
$listsWouldCreate = self::wouldCreateNames((array) ($params['add_lists'] ?? []), 'list');
// The "at least one" guard considers what would actually happen — if
// dry_run with names that would create, that IS work, so don't bail.
$hasAnyWork = $addTags['ids'] || $removeTags['ids']
|| $addLists['ids'] || $removeLists['ids']
|| ($dryRun && ($tagsWouldCreate || $listsWouldCreate));
if (!$hasAnyWork) {
return MCPHelper::error('invalid_param', __('Provide at least one of add_tags, remove_tags, add_lists, remove_lists', 'fluent-crm'));
}
if ($dryRun) {
$formatRefs = function ($ids) {
$out = [];
foreach ($ids as $id) {
$out[] = ['id' => (int) $id];
}
return $out;
};
$exceedsCap = $matchedTotal > $cap;
return [
'ok' => true,
'dry_run' => true,
'matched_contacts' => $matchedTotal,
'cap' => $cap,
'exceeds_cap' => $exceedsCap,
'batches_required' => $exceedsCap ? (int) ceil($matchedTotal / max(1, $cap)) : 1,
'applied_to_contacts' => 0,
'tags_added' => $formatRefs($addTags['ids']),
'tags_removed' => $formatRefs($removeTags['ids']),
'lists_added' => $formatRefs($addLists['ids']),
'lists_removed' => $formatRefs($removeLists['ids']),
'tags_would_create' => $tagsWouldCreate,
'lists_would_create' => $listsWouldCreate,
'note' => $exceedsCap
? __('Dry run — match exceeds the per-call cap. Apply by passing contact_ids in batches.', 'fluent-crm')
: __('Dry run — nothing was applied. Re-run without dry_run=true to commit.', 'fluent-crm'),
];
}
// Process in chunks so attach/detach don't load thousands of rows at
// once. Each Subscriber attach/detach already de-dupes internally.
// Track the actual touched ids (review P2 #10) so an agent can
// reverse precisely without re-running the original filter — which
// may match a different set after time passes.
$chunkSize = 200;
$applied = 0;
$appliedIds = [];
foreach (array_chunk($contactIds, $chunkSize) as $batchIds) {
$subscribers = Subscriber::whereIn('id', $batchIds)->get();
foreach ($subscribers as $sub) {
if ($addTags['ids']) {
$sub->attachTags($addTags['ids']);
}
if ($removeTags['ids']) {
$sub->detachTags($removeTags['ids']);
}
if ($addLists['ids']) {
$sub->attachLists($addLists['ids']);
}
if ($removeLists['ids']) {
$sub->detachLists($removeLists['ids']);
}
$applied++;
$appliedIds[] = (int) $sub->id;
}
}
$formatRefs = function ($ids) {
$out = [];
foreach ($ids as $id) {
$out[] = ['id' => (int) $id];
}
return $out;
};
return [
'ok' => true,
'matched_contacts' => count($contactIds),
'applied_to_contacts' => $applied,
'applied_contact_ids' => $appliedIds,
'tags_added' => $formatRefs($addTags['ids']),
'tags_removed' => $formatRefs($removeTags['ids']),
'lists_added' => $formatRefs($addLists['ids']),
'lists_removed' => $formatRefs($removeLists['ids']),
'tags_created' => $addTags['created'],
'lists_created' => $addLists['created'],
'reverse_with' => __('To reverse: re-call apply-segments-to-contacts with contact_ids=applied_contact_ids and add_*/remove_* swapped.', 'fluent-crm'),
];
}
// -----------------------------------------------------------------
// Write: add-contact-note
// -----------------------------------------------------------------
public static function addContactNote($params)
{
$params = (array) $params;
$resolved = MCPHelper::resolveContact($params);
if (is_wp_error($resolved)) {
return $resolved;
}
$subscriber = $resolved;
$title = trim((string) ($params['title'] ?? ''));
$description = (string) ($params['description'] ?? '');
$type = sanitize_key($params['type'] ?? 'note');
$allowedTypes = ['note', 'call', 'email', 'meeting', 'quote'];
if (!in_array($type, $allowedTypes, true)) {
$type = 'note';
}
if ($title === '' || $description === '') {
return MCPHelper::error('invalid_param', __('title and description are required', 'fluent-crm'));
}
$noteData = [
'subscriber_id' => $subscriber->id,
'type' => $type,
'title' => $title,
'description' => $description,
'created_at' => !empty($params['created_at']) ? sanitize_text_field($params['created_at']) : current_time('mysql'),
];
// Run through the same filter the controller does so smartcodes resolve.
$noteData['description'] = apply_filters('fluent_crm/parse_campaign_email_text', $noteData['description'], $subscriber);
$noteData = \FluentCrm\App\Services\Sanitize::contactNote($noteData);
$note = \FluentCrm\App\Models\SubscriberNote::create(wp_unslash($noteData));
do_action('fluent_crm/note_added', $note, $subscriber, $noteData);
return [
'ok' => true,
'note' => MCPHelper::formatNoteForMCP($note),
];
}
}
@@ -0,0 +1,451 @@
<?php
namespace FluentCrm\App\Modules\MCP\Tools;
use FluentCrm\App\Models\CustomContactField;
use FluentCrm\App\Models\Lists;
use FluentCrm\App\Models\Subscriber;
use FluentCrm\App\Models\Tag;
use FluentCrm\App\Modules\MCP\Helpers\MCPHelper;
use FluentCrm\App\Services\Helper;
use FluentCrm\App\Services\PermissionManager;
use FluentCrm\App\Services\Stats;
/**
* `get-crm-context` — discovery surface (MCP_PLAN.md § 5.1).
*
* The agent calls this once per session to learn:
* - who they are (current WP user, FluentCRM permissions)
* - what reference data is available (top tags, lists, custom fields)
* - which enums are valid (statuses, contact types, design templates, etc.)
* - the install's current sender configuration
* - guidelines that nudge the agent toward correct usage
*
* Cached for 60 seconds per WP user via transient. Invalidation hooks (set up
* in `MCPInit::init()` lifecycle) clear the cache when underlying reference
* data changes.
*/
class ContextTools
{
const CACHE_TTL = 60;
public static function getContext($params = [])
{
$userId = get_current_user_id();
$cacheKey = 'fluent_crm_mcp_context_' . $userId;
$cached = get_transient($cacheKey);
if (is_array($cached)) {
return $cached;
}
$context = self::buildContext($userId);
set_transient($cacheKey, $context, self::CACHE_TTL);
return $context;
}
private static function buildContext($userId)
{
$user = get_user_by('ID', $userId);
$isAdmin = $user && user_can($user, 'manage_options');
$you = [
'wp_user_id' => (int) $userId,
'name' => $user ? $user->display_name : null,
'email' => $user ? $user->user_email : null,
'is_admin' => (bool) $isAdmin,
'permissions' => array_values(PermissionManager::currentUserPermissions(false)),
];
$proActive = defined('FLUENTCAMPAIGN');
$aiState = self::detectAiProvider();
$site = [
'site_url' => site_url(),
'fluent_crm_version' => defined('FLUENTCRM_PLUGIN_VERSION') ? FLUENTCRM_PLUGIN_VERSION : null,
'fluent_campaign_active' => $proActive,
'ai_provider_configured' => $aiState['configured'],
'ai_provider' => $aiState['provider'],
'timezone' => fluentCrmGetTimezoneString(),
'current_time' => fluentCrmTimestamp(),
];
$stats = self::buildStats();
$tags = self::topTagsForContext();
$lists = self::topListsForContext();
$availableTriggers = self::formatRefList(apply_filters('fluentcrm_funnel_triggers', []), 'trigger_name');
$availableActions = self::formatRefList(apply_filters('fluentcrm_funnel_blocks', [], null), 'action_name');
$enums = [
'contact_statuses' => array_values(fluentcrm_subscriber_statuses()),
'sms_statuses' => array_values(fluentcrm_subscriber_sms_statuses()),
'contact_types' => array_values(fluentcrm_contact_types()),
'campaign_statuses' => ['draft', 'scheduled', 'pending-scheduled', 'processing', 'working', 'paused', 'archived'],
'design_templates' => array_keys(self::allowedDesignTemplates()),
'funnel_statuses' => ['draft', 'published'],
'funnel_subscriber_statuses' => ['active', 'waiting', 'completed', 'cancelled', 'skipped'],
'note_types' => ['note', 'call', 'email', 'meeting', 'quote'],
];
$defaultSender = self::buildDefaultSender();
$customFieldsSchema = ['contact' => self::buildCustomFieldSchema()];
return [
'you' => $you,
'site' => $site,
'stats' => $stats,
'tags' => $tags,
'lists' => $lists,
'available_triggers' => $availableTriggers,
'available_actions' => $availableActions,
'enums' => $enums,
'default_sender' => $defaultSender,
'custom_fields_schema' => $customFieldsSchema,
'smart_codes' => self::buildSmartCodes(),
'safety_levels' => self::buildSafetyLevels(),
'rate_hints' => self::buildRateHints(),
'mcp_capabilities' => self::buildCapabilities(),
'guidelines' => self::buildGuidelines(),
];
}
/**
* Per-tool safety classification — round-3 review R3.
*
* Lets agents branch on a stable code instead of parsing tool
* descriptions or relying on annotations alone (which only
* differentiate readonly / destructive in two coarse buckets).
*
* Levels:
* safe_render — no DB writes, no sends, no side effects
* readonly — DB reads only
* creates_or_mutates_draft — writes data the user can still review/cancel
* mutating_with_dry_run — writes data; dry_run preview available
* destructive_send — actually sends mail to a real recipient
* destructive_irrecoverable — deletion / cannot be undone
*/
private static function buildSafetyLevels()
{
return apply_filters('fluent_crm/mcp_safety_levels', [
'fluent-crm/get-crm-context' => 'readonly',
'fluent-crm/list-contacts' => 'readonly',
'fluent-crm/get-contact' => 'readonly',
'fluent-crm/list-campaigns' => 'readonly',
'fluent-crm/get-campaign' => 'readonly',
'fluent-crm/list-automations' => 'readonly',
'fluent-crm/list-funnel-subscribers' => 'readonly',
'fluent-crm/get-automation' => 'readonly',
'fluent-crm/list-sequences' => 'readonly',
'fluent-crm/get-sequence' => 'readonly',
'fluent-crm/estimate-dynamic-segment' => 'readonly',
'fluent-crm/upsert-contact' => 'creates_or_mutates_draft',
'fluent-crm/bulk-upsert-contacts' => 'creates_or_mutates_draft',
'fluent-crm/add-contact-note' => 'creates_or_mutates_draft',
'fluent-crm/upsert-campaign' => 'creates_or_mutates_draft',
'fluent-crm/apply-segments-to-contacts' => 'mutating_with_dry_run',
'fluent-crm/manage-sequence-subscribers' => 'mutating_with_dry_run',
'fluent-crm/update-contact-automation-status' => 'creates_or_mutates_draft',
'fluent-crm/manage-tag' => 'destructive_irrecoverable',
'fluent-crm/manage-list' => 'destructive_irrecoverable',
'fluent-crm/delete-contact' => 'destructive_irrecoverable',
'fluent-crm/delete-contact-note' => 'destructive_irrecoverable',
'fluent-crm/send-test-email' => 'safe_render',
'fluent-crm/send-email-to-contact' => 'destructive_send',
// change-campaign-status: per-action — schedule + delete are
// destructive in different ways. Annotation already flags it;
// the description spells out which actions are dangerous.
'fluent-crm/change-campaign-status' => 'destructive_send',
]);
}
/**
* Rate / cap hints — round-3 review R4.
*
* Surfaces the limits that are otherwise embedded only in tool
* descriptions ("Cap 5000 per call"). Lets agents pre-validate
* batch sizes deterministically.
*/
private static function buildRateHints()
{
$cap = (int) apply_filters('fluent_crm/mcp_bulk_cap', 5000, 'apply-segments-to-contacts');
return apply_filters('fluent_crm/mcp_rate_hints', [
'fluent-crm/bulk-upsert-contacts' => ['max_per_call' => 500, 'recommended_batch' => 100],
'fluent-crm/apply-segments-to-contacts' => ['max_per_call' => $cap],
'fluent-crm/manage-sequence-subscribers' => ['max_per_call' => $cap],
'fluent-crm/send-email-to-contact' => ['note' => 'Goes through the normal queue + bounce handling — site-level rate limits apply (see settings.email_settings.emails_per_second).'],
]);
}
/**
* Versioned capabilities map — round-3 review R9.
*
* Lets agents adapt their strategy across MCP versions without trial
* and error. Bump `version` whenever a capability is added/removed.
*/
private static function buildCapabilities()
{
return apply_filters('fluent_crm/mcp_capabilities', [
'version' => '1.4.0',
'supports' => [
'dry_run_apply_segments',
'send_test_email',
'smart_codes_discovery',
'safety_levels',
'rate_hints',
'manage_tags_lists',
'delete_contact_note',
'one_off_email_send',
'campaign_warnings',
'auto_suffix_title_conflict',
'list_funnel_subscribers',
'applied_contact_ids_return',
'tracking_mode_aware_stats',
'recipients_strict_validation',
'advanced_filters_provider_validation',
],
'deprecated' => [],
'breaking_changes_pending' => [],
]);
}
private static function buildStats()
{
$stats = (new Stats())->getCounts();
$todayStart = (new \DateTime('today', new \DateTimeZone(fluentCrmGetTimezoneString())))->format('Y-m-d H:i:s');
$sevenDaysAgo = gmdate('Y-m-d H:i:s', time() - (7 * DAY_IN_SECONDS));
return [
'contacts_total' => Subscriber::count(),
'contacts_subscribed' => (int) ($stats['total_subscribers']['count'] ?? Subscriber::where('status', 'subscribed')->count()),
'contacts_new_today' => Subscriber::where('created_at', '>=', $todayStart)->count(),
'campaigns_sent_last_7d' => \FluentCrm\App\Models\Campaign::where('status', 'archived')
->where('updated_at', '>=', $sevenDaysAgo)
->count(),
'automations_active' => \FluentCrm\App\Models\Funnel::where('status', 'published')->count(),
'automations_total' => \FluentCrm\App\Models\Funnel::count(),
];
}
private static function topTagsForContext($limit = 50)
{
$tags = Tag::withCount('subscribers')
->orderByDesc('subscribers_count')
->limit($limit)
->get();
$out = [];
foreach ($tags as $tag) {
$out[] = [
'id' => (int) $tag->id,
'title' => $tag->title,
'slug' => $tag->slug,
'subscribers_count' => (int) $tag->subscribers_count,
];
}
return $out;
}
private static function topListsForContext($limit = 50)
{
$lists = Lists::withCount('subscribers')
->orderByDesc('subscribers_count')
->limit($limit)
->get();
$out = [];
foreach ($lists as $list) {
$out[] = [
'id' => (int) $list->id,
'title' => $list->title,
'slug' => $list->slug,
'subscribers_count' => (int) $list->subscribers_count,
];
}
return $out;
}
private static function formatRefList($items, $keyField)
{
if (!is_array($items)) {
return [];
}
$out = [];
foreach ($items as $key => $item) {
$name = is_string($key) ? $key : ($item[$keyField] ?? null);
if (!$name) {
continue;
}
$out[] = [
'key' => $name,
'label' => $item['label'] ?? $item['title'] ?? $name,
'is_pro' => !empty($item['is_pro']),
];
}
return $out;
}
/**
* Design templates the MCP tools allow agents to select. The
* visual_builder template is intentionally excluded — it's an
* interactive Gutenberg editor experience, not something an agent
* should be authoring against. Use `mcp_allowed_design_templates`
* to publish it deliberately if a custom workflow needs it.
*/
public static function allowedDesignTemplates()
{
$defaults = [
'plain' => __('Plain', 'fluent-crm'),
'classic' => __('Classic', 'fluent-crm'),
'raw_html' => __('Raw HTML', 'fluent-crm'),
'raw_classic' => __('Raw Classic', 'fluent-crm'),
];
if (method_exists(Helper::class, 'getEmailDesignTemplates')) {
$all = Helper::getEmailDesignTemplates();
if (is_array($all) && $all) {
$excluded = ['visual_builder'];
$filtered = array_diff_key($all, array_flip($excluded));
if ($filtered) {
$defaults = $filtered;
}
}
}
/**
* Filter the design templates surfaced to MCP agents. Useful for
* adding custom templates a site has registered, or allow-listing
* visual_builder if the operator really wants agents to use it.
*
* @since 2.10.0
*
* @param array $templates Map of slug => label.
*/
return apply_filters('fluent_crm/mcp_allowed_design_templates', $defaults);
}
private static function buildDefaultSender()
{
$emailSettings = Helper::getGlobalEmailSettings();
return [
'from_name' => $emailSettings['from_name'] ?? '',
'from_email' => $emailSettings['from_email'] ?? '',
'reply_to_name' => $emailSettings['reply_to_name'] ?? '',
'reply_to_email' => $emailSettings['reply_to_email'] ?? '',
];
}
private static function buildCustomFieldSchema()
{
$model = new CustomContactField();
$global = $model->getGlobalFields();
$fields = is_array($global) ? ($global['fields'] ?? []) : [];
$out = [];
foreach ((array) $fields as $field) {
$entry = [
'key' => $field['slug'] ?? null,
'label' => $field['label'] ?? null,
'type' => $field['type'] ?? null,
];
if (!empty($field['options'])) {
$entry['options'] = array_values((array) $field['options']);
}
if ($entry['key']) {
$out[] = $entry;
}
}
return $out;
}
/**
* Flatten Helper::getGlobalSmartCodes() into a compact, agent-friendly
* shape — review #19. Preserves group structure so the agent can find
* codes by source (contact / custom fields / general / extensions).
*/
private static function buildSmartCodes()
{
if (!method_exists(Helper::class, 'getGlobalSmartCodes')) {
return [];
}
$groups = Helper::getGlobalSmartCodes();
if (!is_array($groups)) {
return [];
}
$out = [];
foreach ($groups as $group) {
$codes = [];
$shortcodes = $group['shortcodes'] ?? [];
if (is_array($shortcodes)) {
foreach ($shortcodes as $code => $label) {
$codes[] = ['code' => (string) $code, 'label' => (string) $label];
}
}
$out[] = [
'key' => $group['key'] ?? null,
'title' => $group['title'] ?? null,
'codes' => $codes,
];
}
return $out;
}
private static function detectAiProvider()
{
$aiSettings = fluentcrm_get_option('ai_settings', []);
$provider = '';
$configured = false;
if (!empty($aiSettings['active_provider'])) {
$provider = sanitize_key($aiSettings['active_provider']);
$providerCfg = $aiSettings[$provider] ?? [];
$configured = !empty($providerCfg['api_key']);
}
return ['provider' => $provider ?: null, 'configured' => $configured];
}
private static function buildGuidelines()
{
$default = "Be concise. When sending to a contact, confirm their status is 'subscribed'. " .
"Use add_tags/remove_tags for delta updates. Drafts are safe — only change-campaign-status " .
"with action=schedule causes sending. The site timezone is in site.timezone — use it when " .
"constructing scheduled_at. Use custom_fields_schema to construct valid custom_fields payloads " .
"on upsert-contact — never invent keys. Filter shape (universal): {search, tags[], lists[], " .
"statuses[], contact_type, created_after, created_before, sort_by, sort_type}.";
/**
* Filter the AI guidelines text returned in get-crm-context.
*
* Useful for shop-specific nudges (e.g. "always tag MCP-touched contacts
* with `mcp-edited`"). Keep terse — the text ships in every session's
* tool-discovery payload.
*
* @since 2.10.0
*
* @param string $default
*/
return apply_filters('fluent_crm/mcp_ai_guidelines', $default);
}
/**
* Invalidate cached context for every user. Hooked from MCPInit on the
* relevant FluentCRM events.
*/
public static function invalidateCache()
{
global $wpdb;
$like = $wpdb->esc_like('_transient_fluent_crm_mcp_context_') . '%';
$wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $like));
$like = $wpdb->esc_like('_transient_timeout_fluent_crm_mcp_context_') . '%';
$wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $like));
}
}
@@ -0,0 +1,436 @@
<?php
namespace FluentCrm\App\Modules\MCP\Tools;
use FluentCrm\App\Models\Campaign;
use FluentCrm\App\Models\CustomEmailCampaign;
use FluentCrm\App\Models\Subscriber;
use FluentCrm\App\Modules\MCP\Helpers\MCPHelper;
use FluentCrm\App\Modules\MCP\Tools\ContextTools;
use FluentCrm\App\Services\BlockParser;
use FluentCrm\App\Services\Helper;
use FluentCrm\App\Services\Libs\Mailer\Mailer;
use FluentCrm\App\Services\Sanitize;
use FluentCrm\Framework\Support\Arr;
/**
* One-off email tools — wraps SubscriberController::sendCustomEmail
* (MCP_PLAN.md § 5.9), exposing every option the contact-profile "Send
* Custom Email" UI surfaces *except* the interactive ones (visual_builder
* design template and template_id picker):
*
* - subject + preheader + body
* - design_template (plain | classic | raw_html | raw_classic)
* - mailer overrides: from_name/from_email/reply_to_name/reply_to_email
* - is_transactional (auto-disables the footer to match UI behavior)
* - explicit disable_footer override
* - click/open trackers (yes|no|anonymous)
* - UTM tagging
* - free-form settings passthrough for template_config / footer_settings
*
* Reuses the normal queue + bounce + FluentSMTP plumbing so MCP-sent emails
* behave identically to one-offs sent from the contact profile.
*/
class EmailTools
{
public static function sendEmailToContact($params)
{
$params = (array) $params;
$resolved = MCPHelper::resolveContact($params);
if (is_wp_error($resolved)) {
return $resolved;
}
$contact = $resolved;
$subject = trim((string) ($params['subject'] ?? ''));
$body = (string) ($params['body'] ?? '');
if ($subject === '' || $body === '') {
return MCPHelper::error('invalid_param', __('subject and body are required', 'fluent-crm'));
}
// Status check matches the controller's gate. Phrase the error so
// the agent doesn't think `is_transactional=yes` will bypass it
// (review #8 — that flag is the *message* type, not a status
// override).
$allowedStatuses = ['subscribed', 'transactional'];
if (!in_array($contact->status, $allowedStatuses, true)) {
return MCPHelper::error('invalid_param', sprintf(
/* translators: 1: current contact status, 2: comma-separated list of allowed statuses */
__("The contact's status is '%1\$s'. To send to this contact, the contact's own status must be one of: %2\$s. The is_transactional parameter controls the message type, not the contact gate.", 'fluent-crm'),
$contact->status,
implode(', ', $allowedStatuses)
), [
'current_status' => $contact->status,
'allowed_statuses' => $allowedStatuses,
]);
}
$defaults = Helper::getGlobalEmailSettings();
$designTemplate = sanitize_key((string) ($params['design_template'] ?? 'classic'));
if ($designTemplate === '') {
$designTemplate = 'classic';
}
// Defense in depth — even though the schema enum constrains this,
// a non-honoring agent (or a direct REST call) could still try to
// pass `visual_builder` or another disallowed value. Reject server
// side with a structured error.
$allowed = array_keys(ContextTools::allowedDesignTemplates());
if (!in_array($designTemplate, $allowed, true)) {
return MCPHelper::error('invalid_param', __('design_template not allowed via MCP', 'fluent-crm'), [
'design_template' => $designTemplate,
'allowed' => $allowed,
]);
}
$isTransactional = self::yesNo($params['is_transactional'] ?? null, 'no');
// Footer toggle — UI behavior: turning on transactional auto-disables
// the global footer because transactional mail must not include a
// marketing unsubscribe link. Honor that by default; let the caller
// override explicitly.
if (array_key_exists('disable_footer', $params)) {
$disableFooter = self::yesNo($params['disable_footer'], 'no');
} else {
$disableFooter = $isTransactional === 'yes' ? 'yes' : 'no';
}
$clickTracker = self::trackerValue($params['click_tracker'] ?? null);
$openTracker = self::trackerValue($params['open_tracker'] ?? null);
// Build the mailer override block.
$fromName = sanitize_text_field((string) ($params['from_name'] ?? $defaults['from_name']));
$fromEmail = sanitize_email((string) ($params['from_email'] ?? $defaults['from_email']));
$replyToName = sanitize_text_field((string) ($params['reply_to_name'] ?? ($defaults['reply_to_name'] ?? '')));
$replyToEmail = sanitize_email((string) ($params['reply_to_email'] ?? ($defaults['reply_to_email'] ?? '')));
$mailerSettings = [
'from_name' => $fromName,
'from_email' => $fromEmail,
'reply_to_name' => $replyToName,
'reply_to_email' => $replyToEmail,
'is_custom' => 'yes',
];
// Compose the settings object the way the UI does.
$settings = [
'mailer_settings' => $mailerSettings,
'is_transactional' => $isTransactional,
'footer_settings' => [
'disable_footer' => $disableFooter,
],
'template_config' => Helper::getTemplateConfig($designTemplate),
];
if ($clickTracker !== null) {
$settings['click_tracker'] = $clickTracker;
}
if ($openTracker !== null) {
$settings['open_tracker'] = $openTracker;
}
// Allow callers to pass an arbitrary `settings` object for things
// we haven't surfaced as top-level params (e.g. visual-builder style
// overrides). Caller-provided keys win on conflict.
if (!empty($params['settings']) && is_array($params['settings'])) {
$settings = array_replace_recursive($settings, $params['settings']);
}
// Custom title for audit / log; default keeps recipient email so the
// entry is searchable in the campaign list.
$title = isset($params['title']) && $params['title'] !== ''
? sanitize_text_field((string) $params['title'])
: sprintf(__('MCP one-off to %s', 'fluent-crm'), $contact->email);
$campaignData = [
'title' => $title,
'email_subject' => $subject,
'email_pre_header' => sanitize_text_field((string) ($params['pre_header'] ?? $params['preheader'] ?? '')),
'email_body' => $body,
'design_template' => $designTemplate,
'settings' => $settings,
'status' => 'draft',
];
// UTM tagging — flatten the optional `utm` object onto the
// campaign's utm_* columns.
if (!empty($params['utm']) && is_array($params['utm'])) {
$utm = $params['utm'];
$campaignData['utm_status'] = !empty($utm['status']) ? 1 : 0;
foreach (['source', 'medium', 'campaign', 'term', 'content'] as $key) {
if (isset($utm[$key])) {
$campaignData['utm_' . $key] = sanitize_text_field((string) $utm[$key]);
}
}
}
$campaignData = Sanitize::campaign($campaignData);
// Mirror the WP_Error surfacing behavior of the controller.
add_action('wp_mail_failed', function ($wpError) {
if (method_exists(Helper::class, 'debugLog')) {
Helper::debugLog('MCP send-email-to-contact failure', $wpError->get_error_message(), 'error');
}
}, 10, 1);
$campaign = CustomEmailCampaign::create($campaignData);
$campaign->subscribe([(int) $contact->id], [
'status' => 'scheduled',
'scheduled_at' => current_time('mysql'),
]);
do_action('fluentcrm_process_contact_jobs', $contact);
return [
'ok' => true,
'campaign_id' => (int) $campaign->id,
'message' => __('Email queued for delivery', 'fluent-crm'),
'contact' => [
'id' => (int) $contact->id,
'email' => $contact->email,
],
'applied' => [
'is_transactional' => $isTransactional,
'disable_footer' => $disableFooter,
'design_template' => $designTemplate,
'from' => self::formatAddress($fromName, $fromEmail),
'reply_to' => self::formatAddress($replyToName, $replyToEmail),
],
];
}
/**
* Render an RFC-5322 "Display Name <addr>" string. Previous version
* (`trim(... ' <>')`) ate the closing `>` from any "Name (with parens)"
* — review #15.
*/
private static function formatAddress($name, $email)
{
$email = trim((string) $email);
$name = trim((string) $name);
if ($email === '') return '';
if ($name === '') return $email;
return $name . ' <' . $email . '>';
}
/**
* `send-test-email` — render and send a one-off test copy of either:
* - a saved campaign (pass campaign_id), or
* - a draft body/subject the agent supplies inline.
*
* Differs from send-email-to-contact: NO campaign record is created,
* NO subscriber is enrolled, NO row is logged to fc_campaign_emails,
* and the recipient does NOT need to be subscribed. The subject is
* prefixed with "TEST:" to match what the contact-profile UI does.
* Mirrors CampaignController::sendTestEmail.
*/
public static function sendTestEmail($params)
{
$params = (array) $params;
// Resolve recipient address — defaults to the current WP user.
$toEmail = sanitize_email((string) ($params['to_email'] ?? ''));
if (!$toEmail) {
$user = wp_get_current_user();
$toEmail = $user ? $user->user_email : '';
}
if (!$toEmail || !is_email($toEmail)) {
return MCPHelper::error('invalid_param', __('A valid to_email is required.', 'fluent-crm'));
}
// Source the email content from a saved campaign or inline params.
$campaignId = isset($params['campaign_id']) ? (int) $params['campaign_id'] : 0;
$subject = $body = $preHeader = '';
$designTemplate = '';
$settings = [];
if ($campaignId) {
// Need to bypass the global type scope so test sends work for
// custom_email_campaign / sequence_mail / etc., not just
// type='campaign'.
$campaign = Campaign::withoutGlobalScope('type')->find($campaignId);
if (!$campaign) {
return MCPHelper::error('not_found', __('Campaign not found', 'fluent-crm'), ['campaign_id' => $campaignId]);
}
$subject = (string) $campaign->email_subject;
$body = (string) $campaign->email_body;
$preHeader = (string) $campaign->email_pre_header;
$designTemplate = (string) $campaign->design_template;
$settings = is_array($campaign->settings) ? $campaign->settings : (array) maybe_unserialize($campaign->settings);
}
// Inline params override campaign-derived values.
if (isset($params['subject']) && $params['subject'] !== '') {
$subject = (string) $params['subject'];
}
if (isset($params['body']) && $params['body'] !== '') {
$body = (string) $params['body'];
}
if (isset($params['pre_header'])) {
$preHeader = (string) $params['pre_header'];
}
if (isset($params['design_template']) && $params['design_template'] !== '') {
$designTemplate = sanitize_key((string) $params['design_template']);
}
if ($designTemplate === '') {
$designTemplate = 'classic';
}
// Apply the same MCP-safe enum guard as send-email-to-contact.
$allowedTemplates = array_keys(ContextTools::allowedDesignTemplates());
if (!in_array($designTemplate, $allowedTemplates, true)) {
return MCPHelper::error('invalid_param', __('design_template not allowed via MCP', 'fluent-crm'), [
'design_template' => $designTemplate,
'allowed' => $allowedTemplates,
]);
}
if ($subject === '' || $body === '') {
return MCPHelper::error('invalid_param', __('Provide either campaign_id, or subject + body.', 'fluent-crm'));
}
// Resolve the subscriber whose data smartcodes get filled with.
// Priority: explicit against_contact_*, then to_email, then any
// subscribed contact (mirrors CampaignController fallback).
$subscriber = null;
if (!empty($params['against_contact_id'])) {
$subscriber = Subscriber::find((int) $params['against_contact_id']);
}
if (!$subscriber && !empty($params['against_contact_email'])) {
$subscriber = Subscriber::where('email', sanitize_email($params['against_contact_email']))->first();
}
if (!$subscriber) {
$subscriber = Subscriber::where('email', $toEmail)->first();
}
if (!$subscriber) {
$subscriber = Subscriber::where('status', 'subscribed')->first();
}
if (!$subscriber) {
return MCPHelper::error('not_supported', __('No subscriber found to drive smartcode rendering. Add at least one subscribed contact.', 'fluent-crm'));
}
// Catch wp_mail errors for the response.
$mailErrors = [];
$mailErrorListener = function ($wpError) use (&$mailErrors) {
$mailErrors[] = $wpError->get_error_message();
};
add_action('wp_mail_failed', $mailErrorListener, 10, 1);
// Block-template rendering — same gate the controller uses.
$rawTemplates = ['raw_html', 'raw_classic'];
if (!in_array($designTemplate, $rawTemplates, true)) {
$body = (new BlockParser($subscriber))->parse($body);
}
// Footer config — pulled from a stand-in object so we can pass non-
// persisted draft data through Helper::getFooterConfig the same way
// the controller does.
$stub = (object) [
'design_template' => $designTemplate,
'settings' => $settings ?: ['template_config' => []],
'email_pre_header' => $preHeader,
'email_body' => $body,
'email_subject' => $subject,
];
$footerConfig = method_exists(Helper::class, 'getFooterConfig') ? Helper::getFooterConfig($stub) : ['footer_content' => ''];
$footerText = Arr::get($footerConfig, 'footer_content', '');
// Run the standard parse_campaign_email_text filter chain so
// smartcodes resolve.
$body = apply_filters('fluent_crm/parse_campaign_email_text', $body, $subscriber);
$footerText = apply_filters('fluent_crm/parse_campaign_email_text', $footerText, $subscriber);
$subject = apply_filters('fluent_crm/parse_campaign_email_text', $subject, $subscriber);
$preHeader = apply_filters('fluent_crm/parse_campaign_email_text', $preHeader, $subscriber);
$footerConfig['footer_content'] = $footerText;
$templateData = [
'preHeader' => $preHeader,
'email_body' => $body,
'footer_text' => $footerText,
'footer_config' => $footerConfig,
'config' => wp_parse_args(
Arr::get($settings, 'template_config', []),
Helper::getTemplateConfig($designTemplate)
),
];
$body = apply_filters(
'fluent_crm/email-design-template-' . $designTemplate,
$body,
$templateData,
$stub,
$subscriber
);
$body = str_replace('{{crm_global_email_footer}}', $footerText, $body);
$body = str_replace('{{crm_preheader_text}}', $preHeader, $body);
$data = [
'to' => [
'email' => $toEmail,
'name' => $subscriber->full_name ?: $toEmail,
],
'subject' => 'TEST: ' . $subject,
'body' => $body,
'headers' => Helper::getMailHeadersFromSettings(Arr::get($settings, 'mailer_settings', [])),
];
if (method_exists(Helper::class, 'maybeDisableEmojiOnEmail')) {
Helper::maybeDisableEmojiOnEmail();
}
$result = Mailer::send($data, $subscriber, null, true);
remove_action('wp_mail_failed', $mailErrorListener, 10);
$sent = $result !== false && empty($mailErrors);
return [
'ok' => $sent,
'sent' => $sent,
'to' => $toEmail,
'rendered_against' => [
'contact_id' => (int) $subscriber->id,
'email' => $subscriber->email,
],
'subject_preview' => 'TEST: ' . $subject,
'design_template' => $designTemplate,
'errors' => $mailErrors,
'note' => __('Test sends bypass the queue, do not enroll the recipient, and do not appear in email_history.', 'fluent-crm'),
];
}
private static function yesNo($value, $default = 'no')
{
if ($value === null) {
return $default;
}
if (is_bool($value)) {
return $value ? 'yes' : 'no';
}
$str = strtolower((string) $value);
if (in_array($str, ['yes', 'true', '1', 'on'], true)) {
return 'yes';
}
if (in_array($str, ['no', 'false', '0', 'off', ''], true)) {
return 'no';
}
return $default;
}
private static function trackerValue($value)
{
if ($value === null || $value === '') {
return null;
}
$str = strtolower((string) $value);
if (in_array($str, ['yes', 'no', 'anonymous'], true)) {
return $str;
}
if (is_bool($value)) {
return $value ? 'yes' : 'no';
}
return null;
}
}
@@ -0,0 +1,447 @@
<?php
namespace FluentCrm\App\Modules\MCP\Tools;
use FluentCrm\App\Models\Funnel;
use FluentCrm\App\Models\FunnelSubscriber;
use FluentCrm\App\Modules\MCP\Helpers\MCPHelper;
use FluentCrm\App\Services\Funnel\FunnelHelper;
/**
* Automation (funnel) MCP tools.
*
* Read tools (Phase 2): listAutomations, getAutomation.
* Write tools (Phase 3): updateContactAutomationStatus.
*/
class FunnelTools
{
// -----------------------------------------------------------------
// Read: list-automations
// -----------------------------------------------------------------
public static function listAutomations($params)
{
$params = (array) $params;
MCPHelper::paginationFromInput($params);
$search = sanitize_text_field((string) ($params['search'] ?? ''));
$statuses = (array) ($params['statuses'] ?? []);
$statuses = array_values(array_intersect(
array_map('sanitize_key', $statuses),
['draft', 'published']
));
// All fc_funnels columns. The framework rewrite made orderBy() throw
// LogicException on column names that don't match ^[a-zA-Z0-9_\.]+$
// — empty strings, "id ASC", "DROP TABLE", etc. — so an unguarded
// sort_by would 500 the tool. Schema is stable (migration only adds
// indexes), so hardcoding the column list avoids a per-request
// SHOW COLUMNS without restricting agents to the input_schema enum.
$allowedSortBy = [
'id', 'type', 'title', 'trigger_name', 'status', 'conditions',
'settings', 'created_by', 'created_at', 'updated_at',
];
$sortBy = sanitize_key((string) ($params['sort_by'] ?? 'id'));
if (!in_array($sortBy, $allowedSortBy, true)) {
$sortBy = 'id';
}
$sortType = strtoupper(sanitize_text_field((string) ($params['sort_type'] ?? 'DESC')));
$sortType = in_array($sortType, ['ASC', 'DESC'], true) ? $sortType : 'DESC';
$query = Funnel::withCount('subscribers')->orderBy($sortBy, $sortType);
if ($search !== '') {
global $wpdb;
$like = '%' . $wpdb->esc_like($search) . '%';
$query->where(function ($q) use ($like) {
$q->where('title', 'LIKE', $like)
->orWhere('trigger_name', 'LIKE', $like);
});
}
if (!empty($statuses)) {
$query->whereIn('status', $statuses);
}
$paginated = $query->paginate();
$items = [];
$triggerLabels = self::triggerLabelMap();
foreach ($paginated->items() as $funnel) {
$items[] = [
'id' => (int) $funnel->id,
'title' => $funnel->title,
'status' => $funnel->status,
'trigger_name' => $funnel->trigger_name,
'trigger_label' => $triggerLabels[$funnel->trigger_name] ?? $funnel->trigger_name,
'in_progress_subscribers_count' => self::inProgressCount((int) $funnel->id),
'completed_subscribers_count' => (int) ($funnel->subscribers_count ?? 0),
'created_at' => MCPHelper::toIso8601($funnel->created_at),
'updated_at' => MCPHelper::toIso8601($funnel->updated_at),
];
}
return [
'items' => $items,
'total' => (int) $paginated->total(),
'page' => (int) $paginated->currentPage(),
'per_page' => (int) $paginated->perPage(),
'pages' => (int) $paginated->lastPage(),
];
}
// -----------------------------------------------------------------
// Read: get-automation
// -----------------------------------------------------------------
public static function getAutomation($params)
{
$params = (array) $params;
$funnelId = (int) ($params['funnel_id'] ?? 0);
if (!$funnelId) {
return MCPHelper::error('invalid_param', __('funnel_id is required', 'fluent-crm'));
}
$funnel = Funnel::find($funnelId);
if (!$funnel) {
return MCPHelper::error('not_found', __('Automation not found', 'fluent-crm'), ['funnel_id' => $funnelId]);
}
$defaultIncludes = ['sequences', 'report'];
$include = isset($params['include']) && is_array($params['include']) && $params['include']
? array_values(array_intersect($params['include'], ['sequences', 'report']))
: $defaultIncludes;
$triggerLabels = self::triggerLabelMap();
$data = [
'id' => (int) $funnel->id,
'title' => $funnel->title,
'status' => $funnel->status,
'trigger_name' => $funnel->trigger_name,
'trigger_label' => $triggerLabels[$funnel->trigger_name] ?? $funnel->trigger_name,
'trigger_settings' => is_array($funnel->settings) ? $funnel->settings : [],
'conditions' => is_array($funnel->conditions) ? $funnel->conditions : [],
'in_progress_subscribers_count' => self::inProgressCount((int) $funnel->id),
'completed_subscribers_count' => (int) FunnelSubscriber::where('funnel_id', $funnel->id)
->where('status', 'completed')
->count(),
'created_at' => MCPHelper::toIso8601($funnel->created_at),
'updated_at' => MCPHelper::toIso8601($funnel->updated_at),
];
if (in_array('sequences', $include, true)) {
$sequences = FunnelHelper::getFunnelSequences($funnel, true);
$includeBodies = !empty($params['include_bodies']);
$data['sequences'] = self::formatSequences($sequences, $includeBodies);
}
if (in_array('report', $include, true)) {
$data['report'] = self::buildStepReport($funnel);
}
return $data;
}
/**
* Format funnel sequences for MCP. By default we strip large body
* fields out of `settings` (action_name=send_custom_email embeds an
* entire campaign payload including email_body). Pass include_bodies
* = true to get the full settings tree — review #7 (token bloat).
*/
private static function formatSequences($sequences, $includeBodies = false)
{
$out = [];
foreach ((array) $sequences as $seq) {
$row = is_object($seq) ? get_object_vars($seq) : (array) $seq;
$settings = $row['settings'] ?? [];
if (!$includeBodies) {
$settings = self::stripBodyFields($settings);
}
$out[] = [
'id' => isset($row['id']) ? (int) $row['id'] : null,
'type' => $row['type'] ?? null,
'action_name' => $row['action_name'] ?? null,
'settings' => $settings,
'delay' => $row['delay'] ?? 0,
'delay_unit' => $row['c_delay_unit'] ?? ($row['delay_unit'] ?? null),
'parent_id' => isset($row['parent_id']) ? (int) $row['parent_id'] : null,
];
}
return $out;
}
/**
* Recursively redact body / html fields. Replaces them with a marker so
* the agent knows the field exists and can re-fetch with include_bodies.
*
* Round-3 review B5: dropped the previous "only if >200 chars" gate —
* any stored body field gets stripped now, regardless of size, so the
* include_bodies=false contract is honored consistently. Rare to have a
* truly tiny body field anyway, and the marker is shorter than most
* email bodies.
*/
private static function stripBodyFields($value)
{
$bodyKeys = ['email_body', 'body', 'body_html', 'body_text'];
if (!is_array($value)) {
return $value;
}
foreach ($value as $k => $v) {
if (is_string($k) && in_array($k, $bodyKeys, true) && is_string($v)) {
$len = strlen($v);
$value[$k] = $len > 0
? '[truncated — re-fetch with include_bodies=true; ' . $len . ' chars]'
: '';
} elseif (is_array($v)) {
$value[$k] = self::stripBodyFields($v);
}
}
return $value;
}
private static function buildStepReport($funnel)
{
$stepCounts = FunnelSubscriber::where('funnel_id', $funnel->id)
->select(['last_sequence_id'])
->selectRaw('COUNT(id) as total')
->groupBy('last_sequence_id')
->get();
$steps = [];
foreach ($stepCounts as $row) {
if (!$row->last_sequence_id) {
continue;
}
$steps[] = [
'step_id' => (int) $row->last_sequence_id,
'total' => (int) $row->total,
];
}
return ['steps' => $steps];
}
private static function inProgressCount($funnelId)
{
return (int) FunnelSubscriber::where('funnel_id', $funnelId)
->whereIn('status', ['active', 'waiting'])
->count();
}
private static function triggerLabelMap()
{
$triggers = apply_filters('fluentcrm_funnel_triggers', []);
$map = [];
if (is_array($triggers)) {
foreach ($triggers as $key => $config) {
$map[$key] = $config['label'] ?? $key;
}
}
return $map;
}
// -----------------------------------------------------------------
// Read: list-funnel-subscribers (round-4 review P3 #11)
// -----------------------------------------------------------------
/**
* List the contacts currently in a funnel filtered by subscription
* status. Closes a real workflow gap: "this customer just upgraded —
* pull them out of trial-onboarding" requires knowing who's in the
* funnel first, and there was no way to find that without already
* knowing the contact id.
*/
public static function listFunnelSubscribers($params)
{
$params = (array) $params;
$funnelId = (int) ($params['funnel_id'] ?? 0);
if (!$funnelId) {
return MCPHelper::error('invalid_param', __('funnel_id is required', 'fluent-crm'));
}
$funnel = Funnel::find($funnelId);
if (!$funnel) {
return MCPHelper::error('not_found', __('Automation not found', 'fluent-crm'), ['funnel_id' => $funnelId]);
}
$allowedStatuses = ['active', 'waiting', 'completed', 'cancelled', 'skipped'];
$statuses = (array) ($params['statuses'] ?? ['active']);
$statuses = array_values(array_intersect(array_map('sanitize_key', $statuses), $allowedStatuses));
if (!$statuses) {
$statuses = ['active'];
}
MCPHelper::paginationFromInput($params);
$rows = FunnelSubscriber::with(['subscriber' => function ($q) {
$q->select(['id', 'email', 'first_name', 'last_name', 'status', 'contact_type']);
}])
->where('funnel_id', $funnelId)
->whereIn('status', $statuses)
->orderBy('id', 'DESC')
->paginate();
$items = [];
foreach ($rows->items() as $row) {
$sub = $row->subscriber;
if (!$sub) {
continue;
}
$items[] = [
'funnel_subscriber_id' => (int) $row->id,
'funnel_status' => $row->status,
'next_sequence_id' => $row->next_sequence_id ? (int) $row->next_sequence_id : null,
'last_executed_at' => MCPHelper::toIso8601($row->last_executed_time),
'next_execution_at' => MCPHelper::toIso8601($row->next_execution_time),
'enrolled_at' => MCPHelper::toIso8601($row->created_at),
'contact' => [
'id' => (int) $sub->id,
'email' => $sub->email,
'full_name' => trim((string) ($sub->first_name . ' ' . $sub->last_name)),
'status' => $sub->status,
'contact_type' => $sub->contact_type,
],
];
}
return [
'items' => $items,
'total' => (int) $rows->total(),
'page' => (int) $rows->currentPage(),
'per_page' => (int) $rows->perPage(),
'pages' => (int) $rows->lastPage(),
'funnel' => [
'id' => (int) $funnel->id,
'title' => $funnel->title,
],
'filtered_statuses' => $statuses,
];
}
// -----------------------------------------------------------------
// Write: update-contact-automation-status
// -----------------------------------------------------------------
public static function updateContactAutomationStatus($params)
{
$params = (array) $params;
$funnelId = (int) ($params['funnel_id'] ?? 0);
$action = sanitize_key((string) ($params['action'] ?? ''));
if (!$funnelId) {
return MCPHelper::error('invalid_param', __('funnel_id is required', 'fluent-crm'));
}
if (!in_array($action, ['resume', 'cancel', 'advance_now'], true)) {
// 'pause' was intentionally dropped — FluentCRM has no native
// paused funnel-subscriber state, and the previous mapping
// silently cancelled. Tell the agent what the alternative is.
if ($action === 'pause') {
return MCPHelper::error('not_supported', __('pause is not supported — FluentCRM has no paused state for funnel subscribers. Use cancel to stop processing (reversible from the UI), or wait for a real benchmark.', 'fluent-crm'), [
'allowed_actions' => ['resume', 'cancel', 'advance_now'],
]);
}
return MCPHelper::error('invalid_param', __('Invalid action', 'fluent-crm'), [
'allowed_actions' => ['resume', 'cancel', 'advance_now'],
]);
}
$contact = MCPHelper::resolveContact($params);
if (is_wp_error($contact)) {
return $contact;
}
$funnel = Funnel::find($funnelId);
if (!$funnel) {
return MCPHelper::error('not_found', __('Automation not found', 'fluent-crm'), ['funnel_id' => $funnelId]);
}
$row = FunnelSubscriber::where('funnel_id', $funnelId)
->where('subscriber_id', $contact->id)
->first();
if (!$row) {
return MCPHelper::error('not_found', __('Contact is not enrolled in this automation', 'fluent-crm'));
}
$previousStatus = $row->status;
if ($row->status === 'completed') {
return MCPHelper::error('not_supported', __('Automation is already completed for this contact', 'fluent-crm'), [
'status' => $row->status,
]);
}
if ($action === 'cancel') {
$row->status = 'cancelled';
$row->save();
} elseif ($action === 'resume') {
$row->status = 'active';
if (!$row->next_execution_time) {
$row->next_execution_time = gmdate('Y-m-d H:i:s', current_time('timestamp') + 60);
}
$row->save();
} elseif ($action === 'advance_now') {
$sequenceId = (int) ($params['advance_to_sequence_id'] ?? 0);
if (!$sequenceId) {
return MCPHelper::error('invalid_param', __('advance_to_sequence_id is required for advance_now', 'fluent-crm'));
}
$sequence = \FluentCrm\App\Models\FunnelSequence::where('id', $sequenceId)
->where('funnel_id', $funnelId)
->first();
if (!$sequence) {
return MCPHelper::error('not_found', __('Target sequence not found in this automation', 'fluent-crm'));
}
// If the contact is waiting on a benchmark, mark the benchmark as
// skipped so reports stay accurate (matches the controller's path).
if ($row->status === 'waiting') {
$benchmarkSeq = \FluentCrm\App\Models\FunnelSequence::find($row->next_sequence_id);
if ($benchmarkSeq) {
\FluentCrm\App\Models\FunnelMetric::updateOrCreate(
[
'funnel_id' => $funnelId,
'sequence_id' => $benchmarkSeq->id,
'subscriber_id' => $contact->id,
],
[
'benchmark_value' => 0,
'benchmark_currency' => 'USD',
'status' => 'skipped',
'notes' => __('Skipped via MCP advance_now', 'fluent-crm'),
]
);
\FluentCrm\App\Services\Funnel\FunnelHelper::changeFunnelSubSequenceStatus($row->id, $benchmarkSeq->id, 'skipped');
}
}
$prev = \FluentCrm\App\Models\FunnelSequence::where('funnel_id', $funnelId)
->where('sequence', '<', $sequence->sequence)
->orderBy('sequence', 'DESC')
->first();
$row->last_sequence_id = $prev ? $prev->id : 0;
$row->next_sequence_id = $sequence->id;
$row->next_sequence = $sequence->sequence;
$row->status = 'active';
$row->next_execution_time = current_time('mysql');
$row->save();
}
$row = FunnelSubscriber::find($row->id);
return [
'ok' => true,
'action' => $action,
'previous_status' => $previousStatus,
'current_status' => $row->status,
'funnel_subscriber' => [
'id' => (int) $row->id,
'funnel_id' => (int) $row->funnel_id,
'subscriber_id' => (int) $row->subscriber_id,
'status' => $row->status,
'next_sequence_id' => $row->next_sequence_id ? (int) $row->next_sequence_id : null,
'next_execution_time' => MCPHelper::toIso8601($row->next_execution_time),
],
];
}
}
@@ -0,0 +1,328 @@
<?php
namespace FluentCrm\App\Modules\MCP\Tools;
use FluentCrm\App\Models\Lists;
use FluentCrm\App\Models\Subscriber;
use FluentCrm\App\Models\Tag;
use FluentCrm\App\Modules\MCP\Helpers\MCPHelper;
use FluentCrm\App\Services\PermissionManager;
/**
* Tag/list management — round-3 review item #11.
*
* `manage-tag` and `manage-list` cover create, update, delete, and merge
* operations. Splitting create/update/delete into separate tools would
* triple the surface; the action enum keeps it compact while the
* `destructive` annotation tells MCP clients to confirm delete + merge.
*
* Merge semantics: re-pivot every subscriber attached to a `from` tag/list
* onto the `to` target, then delete the `from` rows. Idempotent — running
* the same merge twice no-ops on the second call (subscribers are already
* pivoted, sources already deleted).
*/
class SegmentTools
{
// -----------------------------------------------------------------
// manage-tag
// -----------------------------------------------------------------
public static function manageTag($params)
{
return self::manageSegment($params, 'tag');
}
// -----------------------------------------------------------------
// manage-list
// -----------------------------------------------------------------
public static function manageList($params)
{
return self::manageSegment($params, 'list');
}
private static function manageSegment($params, $kind)
{
$params = (array) $params;
$action = sanitize_key((string) ($params['action'] ?? ''));
if (!in_array($action, ['create', 'update', 'delete', 'merge'], true)) {
return MCPHelper::error('invalid_param', __('action must be one of: create, update, delete, merge', 'fluent-crm'));
}
// Permission gate. create/update need _cats; delete needs _cats_delete.
$needsDeleteCap = in_array($action, ['delete', 'merge'], true);
$cap = $needsDeleteCap ? 'fcrm_manage_contact_cats_delete' : 'fcrm_manage_contact_cats';
if (!PermissionManager::currentUserCan($cap)) {
return MCPHelper::error('forbidden', sprintf(
/* translators: %s: required capability */
__('This action requires the %s capability.', 'fluent-crm'),
$cap
), ['required' => $cap]);
}
switch ($action) {
case 'create':
return self::actionCreate($params, $kind);
case 'update':
return self::actionUpdate($params, $kind);
case 'delete':
return self::actionDelete($params, $kind);
case 'merge':
return self::actionMerge($params, $kind);
}
return MCPHelper::error('invalid_param', __('Unhandled action', 'fluent-crm'));
}
private static function actionCreate($params, $kind)
{
$title = trim((string) ($params['title'] ?? ''));
if ($title === '') {
return MCPHelper::error('invalid_param', __('title is required for create', 'fluent-crm'));
}
$slug = isset($params['slug']) && $params['slug'] !== ''
? sanitize_title((string) $params['slug'])
: sanitize_title($title);
$description = sanitize_textarea_field((string) ($params['description'] ?? ''));
$existing = self::lookupByTitleOrSlug($title, $slug, $kind);
if ($existing) {
return MCPHelper::error('contact_exists', sprintf(
/* translators: 1: kind (tag/list), 2: matched id */
__('A %1$s with that title or slug already exists (id %2$d). Use update or merge to change it.', 'fluent-crm'),
$kind,
(int) $existing->id
), ['existing_id' => (int) $existing->id]);
}
$modelClass = $kind === 'list' ? Lists::class : Tag::class;
$row = $modelClass::create([
'title' => sanitize_text_field($title),
'slug' => $slug,
'description' => $description,
]);
do_action(self::createdHook($kind), $row);
return [
'ok' => true,
'action' => 'create',
'kind' => $kind,
$kind => self::format($row),
'note' => sprintf(
/* translators: 1: kind, 2: title */
__('%1$s "%2$s" created. No subscribers are attached yet.', 'fluent-crm'),
ucfirst($kind),
$row->title
),
];
}
private static function actionUpdate($params, $kind)
{
$id = (int) ($params[$kind . '_id'] ?? 0);
$row = $id ? self::find($id, $kind) : null;
if (!$row) {
return MCPHelper::error('not_found', sprintf(__('%s not found', 'fluent-crm'), ucfirst($kind)), [$kind . '_id' => $id]);
}
$changes = [];
if (isset($params['title']) && $params['title'] !== '' && $params['title'] !== $row->title) {
$changes['title'] = ['from' => $row->title, 'to' => sanitize_text_field((string) $params['title'])];
$row->title = $changes['title']['to'];
}
if (isset($params['slug']) && $params['slug'] !== '') {
$newSlug = sanitize_title((string) $params['slug']);
if ($newSlug !== $row->slug) {
$changes['slug'] = ['from' => $row->slug, 'to' => $newSlug];
$row->slug = $newSlug;
}
}
if (array_key_exists('description', $params)) {
$newDesc = sanitize_textarea_field((string) $params['description']);
if ($newDesc !== $row->description) {
$changes['description'] = ['from' => $row->description, 'to' => $newDesc];
$row->description = $newDesc;
}
}
if (empty($changes)) {
return [
'ok' => true,
'action' => 'update',
'kind' => $kind,
$kind => self::format($row),
'note' => __('No changes — provided fields matched the current values.', 'fluent-crm'),
];
}
$row->save();
return [
'ok' => true,
'action' => 'update',
'kind' => $kind,
$kind => self::format($row),
'changes' => $changes,
];
}
private static function actionDelete($params, $kind)
{
$id = (int) ($params[$kind . '_id'] ?? 0);
$row = $id ? self::find($id, $kind) : null;
if (!$row) {
return MCPHelper::error('not_found', sprintf(__('%s not found', 'fluent-crm'), ucfirst($kind)), [$kind . '_id' => $id]);
}
$force = !empty($params['force']);
$attachedCount = self::attachedSubscriberCount($row, $kind);
if ($attachedCount > 0 && !$force) {
return MCPHelper::error('not_supported', sprintf(
/* translators: 1: kind, 2: count */
__('%1$s has %2$d subscribers attached. Pass force=true to delete anyway, or merge into another %1$s first.', 'fluent-crm'),
ucfirst($kind),
$attachedCount
), [
'attached_subscribers' => $attachedCount,
'force_required' => true,
]);
}
$deletedId = (int) $row->id;
$deletedTitle = (string) $row->title;
$row->delete();
do_action(self::deletedHook($kind), $deletedId);
return [
'ok' => true,
'action' => 'delete',
'kind' => $kind,
'deleted_id' => $deletedId,
'deleted_title' => $deletedTitle,
'detached_subscribers' => $attachedCount,
'note' => $attachedCount > 0
? __('Deleted with subscribers attached — pivot rows are orphaned and cleaned up by the cleanup hook.', 'fluent-crm')
: __('Deleted. No subscribers were attached.', 'fluent-crm'),
];
}
private static function actionMerge($params, $kind)
{
$fromIds = isset($params['from_' . $kind . '_ids']) ? (array) $params['from_' . $kind . '_ids'] : [];
$fromIds = array_values(array_unique(array_filter(array_map('intval', $fromIds))));
$toId = (int) ($params['to_' . $kind . '_id'] ?? 0);
if (!$toId) {
return MCPHelper::error('invalid_param', __('to_*_id is required for merge', 'fluent-crm'));
}
if (!$fromIds) {
return MCPHelper::error('invalid_param', __('from_*_ids must be a non-empty array of ids', 'fluent-crm'));
}
if (in_array($toId, $fromIds, true)) {
return MCPHelper::error('invalid_param', __('to_*_id cannot also be in from_*_ids', 'fluent-crm'));
}
$to = self::find($toId, $kind);
if (!$to) {
return MCPHelper::error('not_found', sprintf(__('Target %s not found', 'fluent-crm'), $kind), ['to_id' => $toId]);
}
$modelClass = $kind === 'list' ? Lists::class : Tag::class;
$fromRows = $modelClass::whereIn('id', $fromIds)->get();
$foundFromIds = $fromRows->pluck('id')->map('intval')->toArray();
$missingFromIds = array_values(array_diff($fromIds, $foundFromIds));
// Re-pivot subscribers attached to the `from` set onto the `to` target.
$attachedCount = 0;
foreach ($fromRows as $fromRow) {
$count = self::attachedSubscriberCount($fromRow, $kind);
$attachedCount += $count;
}
$repivoted = 0;
foreach ($fromRows as $fromRow) {
$subscribers = self::attachedSubscribers($fromRow, $kind);
foreach ($subscribers as $sub) {
if ($kind === 'list') {
$sub->attachLists([$to->id]);
$sub->detachLists([$fromRow->id]);
} else {
$sub->attachTags([$to->id]);
$sub->detachTags([$fromRow->id]);
}
$repivoted++;
}
}
// Delete the source rows.
foreach ($fromRows as $fromRow) {
$fromId = (int) $fromRow->id;
$fromRow->delete();
do_action(self::deletedHook($kind), $fromId);
}
return [
'ok' => true,
'action' => 'merge',
'kind' => $kind,
'merged_from' => $foundFromIds,
'merged_into' => self::format($to),
'subscribers_repivoted' => $repivoted,
'subscribers_seen' => $attachedCount,
'missing_from_ids' => $missingFromIds,
'note' => __('Each subscriber attached to a "from" target is now attached to "to" and the "from" rows are deleted. Re-running this merge with the same ids is a safe no-op.', 'fluent-crm'),
];
}
// -----------------------------------------------------------------
// helpers
// -----------------------------------------------------------------
private static function find($id, $kind)
{
return $kind === 'list' ? Lists::find($id) : Tag::find($id);
}
private static function lookupByTitleOrSlug($title, $slug, $kind)
{
$modelClass = $kind === 'list' ? Lists::class : Tag::class;
return $modelClass::where('title', $title)->orWhere('slug', $slug)->first();
}
private static function format($row)
{
return [
'id' => (int) $row->id,
'title' => $row->title,
'slug' => $row->slug,
'description' => $row->description ?? '',
];
}
private static function attachedSubscriberCount($row, $kind)
{
return (int) ($kind === 'list'
? $row->subscribers()->count()
: $row->subscribers()->count());
}
private static function attachedSubscribers($row, $kind)
{
return $kind === 'list'
? $row->subscribers()->get()
: $row->subscribers()->get();
}
private static function createdHook($kind)
{
return $kind === 'list' ? 'fluent_crm/list_created' : 'fluent_crm/tag_created';
}
private static function deletedHook($kind)
{
return $kind === 'list' ? 'fluent_crm/list_deleted' : 'fluent_crm/tag_deleted';
}
}