490 lines
18 KiB
PHP
490 lines
18 KiB
PHP
<?php
|
|
|
|
namespace FluentCrm\App\Http\Controllers;
|
|
|
|
use FluentCrm\App\Models\Campaign;
|
|
use FluentCrm\App\Models\CampaignUrlMetric;
|
|
use FluentCrm\App\Services\Helper;
|
|
use FluentCrm\Framework\Http\Request\Request;
|
|
|
|
/**
|
|
* CampaignAnalyticsController - REST API Handler Class
|
|
*
|
|
* REST API Handler
|
|
*
|
|
* @package FluentCrm\App\Http
|
|
*
|
|
* @version 1.0.0
|
|
*/
|
|
class CampaignAnalyticsController extends Controller
|
|
{
|
|
public function getLinksReport(CampaignUrlMetric $campaignUrlMetric, $campaignId)
|
|
{
|
|
$campaign = Campaign::withoutGlobalScopes()->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' => '<a href="' . esc_url($editUrl) . '" class="order-view"><strong>' . esc_html($buyer) . '</strong></a>',
|
|
'status' => wc_get_order_status_name($order->get_status()),
|
|
'date' => $show_date,
|
|
'total' => $order->get_formatted_order_total(),
|
|
'action' => '<a href="' . esc_url($editUrl) . '">' . esc_html__('View', 'fluent-crm') . '</a>',
|
|
];
|
|
}
|
|
|
|
protected function formatEddOrderRow($orderId)
|
|
{
|
|
$payment = new \EDD_Payment($orderId);
|
|
if (!$payment || !$payment->ID) {
|
|
return null;
|
|
}
|
|
|
|
$orderActionHtml = '<a href="' . add_query_arg('id', $payment->ID, admin_url('edit.php?post_type=download&page=edd-payment-history&view=view-order-details')) . '">' . esc_html__('View', 'fluent-crm') . '</a>';
|
|
$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 = '<a href="' . esc_url(admin_url("edit.php?post_type=download&page=edd-customers&view=overview&id=$customer_id")) . '">' . esc_html($customer->name) . '</a>';
|
|
} else {
|
|
$email = edd_get_payment_user_email($payment->ID);
|
|
$customerName = '<a href="' . esc_url(admin_url("edit.php?post_type=download&page=edd-payment-history&s=$email")) . '">' . esc_html__('(customer missing)', 'fluent-crm') . '</a>';
|
|
}
|
|
|
|
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' => '<a target="_blank" rel="noopener" href="' . esc_url($orderUrl) . '">' . esc_html($customerName) . '</a>',
|
|
'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' => '<a target="_blank" rel="noopener" href="' . esc_url($orderUrl) . '">' . esc_html__('View', 'fluent-crm') . '</a>',
|
|
];
|
|
}
|
|
|
|
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
|
|
];
|
|
}
|
|
}
|