findOrFail($campaignId);
$clickStatus = $campaign->settings['click_tracker'] ?? '';
$openStatus = $campaign->settings['open_tracker'] ?? '';
if ($clickStatus === '') {
$clickStatus = fluentcrmTrackClicking();
}
if ($openStatus === '') {
$openStatus = fluentcrmTrackEmailOpen();
}
$links = array_values($campaignUrlMetric->getLinksReport($campaign));
return $this->sendSuccess([
'links' => $links,
'click_status' => $clickStatus,
'open_status' => $openStatus
]);
}
public function getRevenueReport(Request $request, $campaignId)
{
$limit = intval($request->get('per_page', 10));
$offset = (intval($request->get('page', 1)) - 1) * $limit;
$sources = $this->getActiveRevenueSources();
$multiSource = count($sources) > 1;
if (empty($sources)) {
return [
'orders' => [],
'labels' => $this->getRevenueLabels(false),
'total' => 0
];
}
// Build a single newest-first index across every active commerce source so
// pagination spans them all. Within each source, ids stay in DB-newest order.
$index = [];
foreach ($sources as $source) {
foreach ($this->getAttributedOrderIds($source, $campaignId) as $orderId) {
$index[] = ['source' => $source, 'order_id' => (int) $orderId];
}
}
$totalOrders = count($index);
$pageEntries = array_slice($index, $offset, $limit);
$orders = [];
foreach ($pageEntries as $entry) {
$row = $this->formatRevenueRow($entry['source'], $entry['order_id'], $multiSource);
if ($row) {
$orders[] = $row;
}
}
return [
'orders' => $orders,
'labels' => $this->getRevenueLabels($multiSource),
'total' => $totalOrders
];
}
public function getRevenueReSyncReport(Request $request, $campaignId)
{
$sources = $this->getActiveRevenueSources();
if (empty($sources)) {
return [
'message' => __('No revenue found for this campaign', 'fluent-crm')
];
}
$revenueData = ['orderIds' => []];
$primaryCurrency = null;
foreach ($sources as $source) {
$sourceData = $this->reSyncSourceRevenue($source, $campaignId);
foreach ($sourceData['orderIds'] as $oid) {
if (!in_array($oid, $revenueData['orderIds'])) {
$revenueData['orderIds'][] = $oid;
}
}
foreach ($sourceData['totals'] as $currency => $cents) {
if (!isset($revenueData[$currency])) {
$revenueData[$currency] = 0;
if ($primaryCurrency === null) {
$primaryCurrency = $currency;
}
}
$revenueData[$currency] += $cents;
}
}
if (empty($revenueData['orderIds'])) {
return [
'message' => __('No order found to re-sync', 'fluent-crm')
];
}
fluentcrm_update_campaign_meta($campaignId, '_campaign_revenue', $revenueData);
$primaryTotal = $primaryCurrency ? $revenueData[$primaryCurrency] : 0;
return [
'message' => __('Revenue has been re-synced successfully', 'fluent-crm'),
'total' => number_format($primaryTotal / 100, 2)
];
}
/**
* Active commerce sources that participate in campaign revenue attribution.
* Order matters: it determines display precedence within the merged report.
*/
protected function getActiveRevenueSources()
{
$sources = [];
if (defined('WC_PLUGIN_FILE')) {
$sources[] = 'woo';
}
if (Helper::isEdd3()) {
$sources[] = 'edd';
}
if (defined('FLUENTCART_VERSION')) {
$sources[] = 'fct';
}
return $sources;
}
/**
* Lightweight index query — returns just order IDs attributed to this campaign,
* newest-first per source. Used both for paginated report rendering and re-sync.
*/
protected function getAttributedOrderIds($source, $campaignId)
{
if ($source === 'woo') {
if (Helper::isWooHposEnabled()) {
return fluentCrmDb()->table('wc_orders_meta')
->where('meta_key', '_fc_cid')
->where('meta_value', $campaignId)
->orderBy('id', 'DESC')
->get()
->pluck('order_id')
->map(function ($orderId) {
return intval($orderId);
})
->all();
}
return fluentCrmDb()->table('postmeta')
->where('meta_key', '_fc_cid')
->where('meta_value', $campaignId)
->orderBy('meta_id', 'DESC')
->get()
->pluck('post_id')
->map(function ($orderId) {
return intval($orderId);
})
->all();
}
if ($source === 'edd') {
/*
* EDD 3 writes order attribution meta to edd_ordermeta via the
* order meta API. Do not read legacy postmeta/edd_payment records.
*/
return fluentCrmDb()->table('edd_ordermeta')
->where('meta_key', '_fc_cid')
->where('meta_value', $campaignId)
->orderBy('meta_id', 'DESC')
->get()
->pluck('edd_order_id')
->map(function ($orderId) {
return intval($orderId);
})
->all();
}
if ($source === 'fct') {
return fluentCrmDb()->table('fct_order_meta')
->where('meta_key', '_fc_cid')
->where('meta_value', $campaignId)
->orderBy('id', 'DESC')
->get()
->pluck('order_id')
->map(function ($orderId) {
return intval($orderId);
})
->all();
}
return [];
}
/**
* Sum NET revenue per currency for one source — i.e. only orders in a successful
* (paid/completed) status, with refunded amounts subtracted. Returns
* `['orderIds' => [int...], 'totals' => ['usd' => cents, ...]]`.
* Orders that net to zero or below (fully refunded, cancelled, pending) are skipped
* so they don't pollute the order list with non-revenue rows.
*/
protected function reSyncSourceRevenue($source, $campaignId)
{
$result = ['orderIds' => [], 'totals' => []];
$orderIds = $this->getAttributedOrderIds($source, $campaignId);
if (!$orderIds) {
return $result;
}
if ($source === 'woo') {
$paidStatuses = function_exists('wc_get_is_paid_statuses') ? wc_get_is_paid_statuses() : ['processing', 'completed'];
$currency = strtolower(get_woocommerce_currency());
foreach ($orderIds as $orderId) {
$order = wc_get_order($orderId);
if (!$order || !$order->get_id()) {
continue;
}
if (!in_array($order->get_status(), $paidStatuses, true)) {
continue;
}
$netCents = intval(((float) $order->get_total() - (float) $order->get_total_refunded()) * 100);
if ($netCents <= 0) {
continue;
}
$result['orderIds'][] = (int) $order->get_id();
$result['totals'][$currency] = ($result['totals'][$currency] ?? 0) + $netCents;
}
return $result;
}
if ($source === 'edd') {
// EDD 3 keeps canonical status and refund data in order tables.
$completeStatuses = ['complete', 'completed', 'partially_refunded'];
foreach ($orderIds as $orderId) {
$payment = new \EDD_Payment($orderId);
if (!$payment || !$payment->ID) {
continue;
}
if (!in_array($payment->status, $completeStatuses, true)) {
continue;
}
$netTotal = function_exists('edd_get_order_total')
? edd_get_order_total($payment->ID)
: $payment->total;
$netCents = intval(((float) $netTotal) * 100);
if ($netCents <= 0) {
continue;
}
$currency = strtolower(edd_get_payment_currency_code($payment->ID) ?: 'usd');
$result['orderIds'][] = (int) $payment->ID;
$result['totals'][$currency] = ($result['totals'][$currency] ?? 0) + $netCents;
}
return $result;
}
if ($source === 'fct') {
// Canonical "successful" set: paid, partially_paid, partially_refunded.
// Net revenue subtracts total_refund below so partial refunds still contribute.
$successStatuses = \FluentCart\App\Helpers\Status::getOrderPaymentSuccessStatuses();
$orders = \FluentCart\App\Models\Order::query()
->whereIn('id', $orderIds)
->whereIn('payment_status', $successStatuses)
->get();
foreach ($orders as $order) {
$netCents = (int) $order->total_amount - (int) ($order->total_refund ?? 0);
if ($netCents <= 0) {
continue;
}
$currency = strtolower($order->currency ?: 'usd');
$result['orderIds'][] = (int) $order->id;
$result['totals'][$currency] = ($result['totals'][$currency] ?? 0) + $netCents;
}
return $result;
}
return $result;
}
/**
* Render a single order row for the merged revenue table. The `source` key
* is added when more than one commerce platform is contributing data.
*/
protected function formatRevenueRow($source, $orderId, $multiSource)
{
$row = null;
if ($source === 'woo') {
$row = $this->formatWooOrderRow($orderId);
} else if ($source === 'edd') {
$row = $this->formatEddOrderRow($orderId);
} else if ($source === 'fct') {
$row = $this->formatFluentCartOrderRow($orderId);
}
if (!$row) {
return null;
}
if ($multiSource) {
$row = ['source' => $this->getSourceLabel($source)] + $row;
}
return $row;
}
protected function getSourceLabel($source)
{
$labels = [
'woo' => 'WooCommerce',
'edd' => 'EDD',
'fct' => 'FluentCart',
];
return $labels[$source] ?? $source;
}
protected function getRevenueLabels($multiSource)
{
$labels = [
'order' => '#',
'title' => __('Customer', 'fluent-crm'),
'status' => __('Status', 'fluent-crm'),
'date' => __('Date', 'fluent-crm'),
'total' => __('Total', 'fluent-crm'),
'action' => __('View', 'fluent-crm'),
];
if ($multiSource) {
$labels = ['source' => __('Source', 'fluent-crm')] + $labels;
}
return $labels;
}
protected function formatWooOrderRow($orderId)
{
$order = wc_get_order($orderId);
if (!$order || !$order->get_id()) {
return null;
}
/* translators: 1: billing first name, 2: billing last name */
$buyer = trim(sprintf(_x('%1$s %2$s', 'full name', 'fluent-crm'), $order->get_billing_first_name(), $order->get_billing_last_name()));
$order_timestamp = $order->get_date_created() ? $order->get_date_created()->getTimestamp() : '';
if (!$order_timestamp) {
$show_date = '–';
} else if ($order_timestamp > strtotime('-1 day', time()) && $order_timestamp <= time()) {
$show_date = sprintf(
/* translators: %s: human-readable time difference */
_x('%s ago', '%s = human-readable time difference', 'fluent-crm'),
human_time_diff($order->get_date_created()->getTimestamp(), time())
);
} else {
/**
* Determine the date format for displaying the order creation date in the WooCommerce admin in FluentCRM.
*
* @param string The date format to be used. Default is 'M j, Y'.
* @param string The context for the date format. Default is 'woocommerce'.
* @since 2.2.0
*/
$show_date = $order->get_date_created()->date_i18n(apply_filters('woocommerce_admin_order_date_format', __('M j, Y', 'fluent-crm')));
}
$editUrl = admin_url('post.php?post=' . absint($order->get_id()) . '&action=edit');
return [
'order' => '#' . esc_html($order->get_order_number()),
'title' => '' . esc_html($buyer) . '',
'status' => wc_get_order_status_name($order->get_status()),
'date' => $show_date,
'total' => $order->get_formatted_order_total(),
'action' => '' . esc_html__('View', 'fluent-crm') . '',
];
}
protected function formatEddOrderRow($orderId)
{
$payment = new \EDD_Payment($orderId);
if (!$payment || !$payment->ID) {
return null;
}
$orderActionHtml = '' . esc_html__('View', 'fluent-crm') . '';
$amount = !empty($payment->total) ? $payment->total : 0;
$customer_id = edd_get_payment_customer_id($payment->ID);
if (!empty($customer_id)) {
$customer = new \EDD_Customer($customer_id);
$customerName = '' . esc_html($customer->name) . '';
} else {
$email = edd_get_payment_user_email($payment->ID);
$customerName = '' . esc_html__('(customer missing)', 'fluent-crm') . '';
}
return [
'order' => '#' . $payment->number,
'title' => $customerName,
'status' => $payment->status_nicename,
'date' => date_i18n(get_option('date_format'), strtotime($payment->date)),
'total' => edd_currency_filter(edd_format_amount($amount), edd_get_payment_currency_code($payment->ID)),
'action' => $orderActionHtml,
];
}
protected function formatFluentCartOrderRow($orderId)
{
$order = \FluentCart\App\Models\Order::with('customer')->find($orderId);
if (!$order) {
return null;
}
$customerName = '';
if ($order->customer) {
$customerName = trim($order->customer->first_name . ' ' . $order->customer->last_name);
if (!$customerName) {
$customerName = $order->customer->email;
}
}
$orderUrl = admin_url('admin.php?page=fluent-cart#/orders/' . $order->id . '/view');
return [
'order' => '#' . ($order->invoice_no ?: $order->id),
'title' => '' . esc_html($customerName) . '',
'status' => esc_html(\FluentCrm\App\Services\Helper::getStatusText($order->status)),
'date' => date_i18n(get_option('date_format'), strtotime($order->created_at)),
'total' => \FluentCart\App\Helpers\Helper::toDecimal($order->total_amount, true, $order->currency),
'action' => '' . esc_html__('View', 'fluent-crm') . '',
];
}
public function getUnsubscribers(Request $request, $campaignId)
{
$unsubscribes = CampaignUrlMetric::with('subscriber')
->where('campaign_id', $campaignId)
->where('type', 'unsubscribe')
->paginate();
foreach ($unsubscribes as $unsubscribe) {
$unsubscribe->subscriber->reason = $unsubscribe->subscriber->unsubscribeReason();
}
return [
'unsubscribes' => $unsubscribes
];
}
public function getSegmentedContacts(Request $request, $campaignId)
{
$campaign = Campaign::findOrFail($campaignId);
$contactsModel = $campaign->getSubscribersModel();
$search = $request->getSafe('search', 'sanitize_text_field');
if ($search) {
$contactsModel->searchBy($search);
}
if ($orderBy = $request->getSafe('sort_by', 'sanitize_sql_orderby', 'id')) {
$orderType = $request->getSafe('sort_type', 'sanitize_sql_orderby', 'desc');
$contactsModel->orderBy($orderBy, $orderType);
}
$contacts = $contactsModel->with(['lists', 'tags'])->paginate();
return [
'subscribers' => $contacts
];
}
}