[ 'label' => __('Get Product Financials', 'fluent-cart'), 'description' => __('One product\'s complete financials in a call: one-time revenue, finite split-pay commitments (bill_times > 0), and perpetual run-rate (MRR/ARR) with a forward payment_schedule. Never sums across currencies. KEY: the time window (range/date_from/date_to + date_basis) applies ONLY to the one_time block — subscriptions.*, payment_schedule and totals are point-in-time as of as_of (lifetime-to-date or forward), so "revenue in the last 30 days" must read one_time.* only and never folds in contract value or MRR. For a windowed time series use get-sales-trend; for renewal cash in a period use query-products with order_type=renewal. totals.total_contracted is null while any perpetual subscription is active — read recurring.mrr/arr and payment_schedule instead, and meta.notes says why. Single currency: store default, other currencies for the product listed in meta.other_currencies. Money is {amount, amount_cents, currency, display}.', 'fluent-cart'), 'input_schema' => [ 'type' => 'object', 'properties' => [ 'product_id' => ['type' => 'integer', 'description' => 'Product to report on.'], 'variation_id' => ['type' => 'integer', 'description' => 'Restrict to one variation.'], 'currency' => ['type' => 'string', 'description' => 'ISO currency for the (single-currency) report. Defaults to store currency.'], 'range' => ['type' => 'string', 'enum' => ['today', 'yesterday', 'last_7_days', 'last_30_days', 'this_month', 'last_month', 'mtd', 'qtd', 'ytd', 'last_quarter', 'last_year', 'all_time', 'since_launch'], 'default' => 'all_time', 'description' => 'Window for the one_time block only. Resolved in UTC. all_time (alias: since_launch) spans all data.'], 'date_from' => ['type' => 'string', 'description' => 'YYYY-MM-DD or ISO 8601, UTC. Overrides range.'], 'date_to' => ['type' => 'string', 'description' => 'YYYY-MM-DD or ISO 8601, UTC. Overrides range.'], 'date_basis' => ['type' => 'string', 'enum' => ['created_at', 'paid_at'], 'default' => 'paid_at', 'description' => 'One-time window basis: paid_at = order completed_at (cash received); created_at = order placed. Does not affect forward fields.'], 'as_of' => ['type' => 'string', 'description' => 'ISO 8601, UTC. Point-in-time for forward metrics. Default now.'], 'horizon' => ['type' => 'string', 'enum' => ['3m', '6m', '12m'], 'default' => '12m', 'description' => 'How far to project payment_schedule.'], 'schedule_bucket' => ['type' => 'string', 'enum' => ['month', 'week'], 'default' => 'month', 'description' => 'Calendar granularity of payment_schedule.'], 'subscription_status' => [ 'type' => 'array', 'description' => 'Which statuses feed forward metrics (scheduled_remaining, mrr, next_*_scheduled, payment_schedule). Default [active]. Use [all] to include every status. status_breakdown is always the full picture regardless.', 'items' => ['type' => 'string', 'enum' => ['active', 'trialing', 'past_due', 'paused', 'canceled', 'all']], ], 'include' => [ 'type' => 'array', 'description' => 'Opt-in sections. schedule = the full payment_schedule[] (can be long; next_30d/next_90d scalars are always returned).', 'items' => ['type' => 'string', 'enum' => ['schedule']], ], ], 'required' => ['product_id'], ], 'execute_callback' => [self::class, 'getProductFinancials'], 'permission_callback' => function () { return PermissionGate::can('reports/view'); }, 'annotations' => ['readonly' => true], ], ]; } public static function getProductFinancials($params = []) { if (empty($params['product_id'])) { return MCPHelper::error('missing_identifier', __('product_id is required.', 'fluent-cart')); } $productId = (int) $params['product_id']; $product = Product::query()->where('ID', $productId)->first(); if (!$product) { return MCPHelper::error('product_not_found', __('No product found for the given product_id.', 'fluent-cart')); } $variationId = !empty($params['variation_id']) ? (int) $params['variation_id'] : null; $currency = !empty($params['currency']) ? strtoupper(sanitize_text_field($params['currency'])) : MCPHelper::currencyCode(); $window = self::resolveWindow($params); if (is_wp_error($window)) { return $window; } $asOf = self::resolveAsOf($params); $horizon = self::resolveHorizonMonths($params); $bucket = (isset($params['schedule_bucket']) && $params['schedule_bucket'] === 'week') ? 'week' : 'month'; $forward = self::resolveForwardStatuses($params); $include = isset($params['include']) ? (array) $params['include'] : []; // ---- One-time revenue (respects window + date_basis) ---- $oneTime = self::loadOneTime($productId, $variationId, $currency, $window); // ---- Subscriptions (all statuses so status_breakdown is complete) ---- $subLoad = self::loadSubscriptions($productId, $variationId); list($kept, $otherCurrencies) = Calc::filterByCurrency($subLoad['rows'], $currency); // ---- Pure computation ---- $result = Calc::compute($kept, [ 'as_of' => $asOf, 'horizon_months' => $horizon, 'bucket' => $bucket, 'forward_statuses' => $forward, 'include_schedule' => in_array('schedule', $include, true), 'one_time' => $oneTime, ]); $data = self::formatData($product, $currency, $asOf, $window, $result); $meta = [ 'currency' => $currency, 'other_currencies' => $otherCurrencies, 'field_semantics' => [ 'respect_window' => ['one_time'], 'point_in_time' => ['subscriptions', 'payment_schedule', 'totals'], ], 'notes' => $result['meta_notes'], ]; if ($subLoad['truncated']) { $meta['warnings'] = [sprintf( /* translators: %1$d: subscription load cap */ __('More than %1$d subscriptions exist for this product; figures use the first %1$d and may be incomplete.', 'fluent-cart'), self::MAX_SUBS )]; } return MCPHelper::envelope(self::summary($product, $currency, $result), $data, $meta); } // ----------------------------------------------------------------- // Loaders // ----------------------------------------------------------------- /** * One-time revenue: non-subscription line items on realized-revenue orders, * scoped to the report currency and the window (by date_basis column on the * parent order). Returns integer cents/counts for the calculator. */ private static function loadOneTime($productId, $variationId, $currency, $window) { $dateCol = $window['basis'] === 'created_at' ? 'created_at' : 'completed_at'; $from = $window['from']; $to = $window['to']; $query = OrderItem::query() ->where('post_id', $productId) ->where('payment_type', '!=', 'subscription') ->whereHas('order', function ($q) use ($currency, $dateCol, $from, $to) { $q->whereIn('payment_status', self::PAID) ->where('currency', $currency) ->where($dateCol, '>=', $from) ->where($dateCol, '<=', $to); }); if ($variationId !== null) { $query->where('object_id', $variationId); } $row = $query->selectRaw( 'COALESCE(SUM(quantity), 0) as units, ' . 'COALESCE(SUM(line_total), 0) as gross, ' . 'COALESCE(SUM(refund_total), 0) as refunds, ' . 'COUNT(DISTINCT order_id) as orders' )->first(); return [ 'units' => $row ? (int) $row->units : 0, 'gross' => $row ? (int) $row->gross : 0, 'refunds' => $row ? (int) $row->refunds : 0, 'orders' => $row ? (int) $row->orders : 0, ]; } /** * All subscriptions for the product (every status — the breakdown must be * complete). Currency is derived per row from config JSON (there is no * currency column) so the calculator can scope by it. Columns are limited * and the model is read attribute-by-attribute so the heavy $appends * accessors (url, billingInfo, overridden_status, …) never fire. */ private static function loadSubscriptions($productId, $variationId) { $store = MCPHelper::currencyCode(); $query = Subscription::query()->where('product_id', $productId); if ($variationId !== null) { $query->where('variation_id', $variationId); } /** @var \FluentCart\Framework\Database\Orm\Collection $subs */ $subs = $query ->orderBy('id', 'ASC') ->limit(self::MAX_SUBS + 1) ->get(['id', 'billing_interval', 'recurring_total', 'bill_count', 'bill_times', 'status', 'next_billing_date', 'variation_id', 'config']); $truncated = false; if (method_exists($subs, 'count') && $subs->count() > self::MAX_SUBS) { $truncated = true; $subs = $subs->slice(0, self::MAX_SUBS); } $rows = []; foreach ($subs as $sub) { $config = is_array($sub->config) ? $sub->config : []; $cur = isset($config['currency']) && $config['currency'] !== '' ? strtoupper((string) $config['currency']) : strtoupper($store); $rows[] = [ 'currency' => $cur, 'billing_interval' => $sub->billing_interval, 'recurring_total' => (int) $sub->recurring_total, 'bill_count' => (int) $sub->bill_count, 'bill_times' => (int) $sub->bill_times, 'status' => (string) $sub->status, 'next_billing_date' => $sub->next_billing_date, ]; } return ['rows' => $rows, 'truncated' => $truncated]; } // ----------------------------------------------------------------- // Formatting (cents -> money envelope) // ----------------------------------------------------------------- private static function formatData($product, $currency, $asOf, $window, $result) { $data = [ 'product_id' => (int) $product->ID, 'product_name' => $product->post_title, 'currency' => $currency, 'as_of' => MCPHelper::toIso8601($asOf), 'window' => [ 'from' => MCPHelper::toIso8601($window['from']), 'to' => MCPHelper::toIso8601($window['to']), 'range' => $window['range'], 'basis' => $window['basis'] === 'created_at' ? 'created_at' : 'paid_at', ], 'one_time' => self::formatOneTime($result['one_time'], $currency), 'subscriptions' => self::formatSubscriptions($result['subscriptions'], $currency), 'totals' => self::formatTotals($result['totals'], $currency), ]; if ($result['payment_schedule'] !== null) { $data['payment_schedule'] = self::formatSchedule($result['payment_schedule'], $currency); } return $data; } private static function formatOneTime($o, $currency) { if ($o === null) { return null; } return [ 'units' => $o['units'], 'orders' => $o['orders'], 'gross_collected' => MCPHelper::money($o['gross_collected'], $currency), 'refunds' => MCPHelper::money($o['refunds'], $currency), 'net_collected' => MCPHelper::money($o['net_collected'], $currency), 'aov' => MCPHelper::money($o['aov'], $currency), ]; } private static function formatSubscriptions($s, $currency) { $finite = $s['finite']; $finiteByInterval = []; foreach ($finite['by_interval'] as $iv => $b) { $finiteByInterval[$iv] = [ 'count' => $b['count'], 'scheduled_remaining' => MCPHelper::money($b['scheduled_remaining'], $currency), ]; } $recurring = $s['recurring']; $recurringByInterval = []; foreach ($recurring['by_interval'] as $iv => $b) { $recurringByInterval[$iv] = [ 'count' => $b['count'], 'recurring_total_sum' => MCPHelper::money($b['recurring_total_sum'], $currency), 'mrr' => MCPHelper::money($b['mrr'], $currency), 'next_30d_scheduled' => MCPHelper::money($b['next_30d_scheduled'], $currency), ]; } return [ 'finite' => [ 'count' => $finite['count'], 'collected_to_date' => MCPHelper::money($finite['collected_to_date'], $currency), 'scheduled_remaining' => MCPHelper::money($finite['scheduled_remaining'], $currency), 'remaining_installments' => $finite['remaining_installments'], 'total_contract_value' => MCPHelper::money($finite['total_contract_value'], $currency), 'avg_completion' => $finite['avg_completion'], 'by_interval' => $finiteByInterval, ], 'recurring' => [ 'count' => $recurring['count'], 'collected_to_date' => MCPHelper::money($recurring['collected_to_date'], $currency), 'mrr' => MCPHelper::money($recurring['mrr'], $currency), 'arr' => MCPHelper::money($recurring['arr'], $currency), 'by_interval' => $recurringByInterval, 'next_30d_scheduled' => MCPHelper::money($recurring['next_30d_scheduled'], $currency), 'next_90d_scheduled' => MCPHelper::money($recurring['next_90d_scheduled'], $currency), ], 'status_breakdown' => $s['status_breakdown'], ]; } private static function formatTotals($t, $currency) { return [ 'collected_to_date' => MCPHelper::money($t['collected_to_date'], $currency), 'committed_finite' => MCPHelper::money($t['committed_finite'], $currency), 'total_contracted' => $t['total_contracted'] === null ? null : MCPHelper::money($t['total_contracted'], $currency), 'mrr' => MCPHelper::money($t['mrr'], $currency), 'arr' => MCPHelper::money($t['arr'], $currency), ]; } private static function formatSchedule($schedule, $currency) { $out = []; foreach ($schedule as $b) { $out[] = [ 'period' => $b['period'], 'finite_installments' => MCPHelper::money($b['finite_installments'], $currency), 'recurring_renewals' => MCPHelper::money($b['recurring_renewals'], $currency), 'total_expected' => MCPHelper::money($b['total_expected'], $currency), ]; } return $out; } private static function summary($product, $currency, $result) { $collected = MCPHelper::displayAmount($result['totals']['collected_to_date'], $currency); if ($result['has_perpetual']) { return sprintf( /* translators: 1: product title, 2: collected to date, 3: MRR, 4: ARR */ __('%1$s — %2$s collected to date; MRR %3$s (ARR %4$s). No single total_contracted: this product has perpetual subscriptions.', 'fluent-cart'), $product->post_title, $collected, MCPHelper::displayAmount($result['totals']['mrr'], $currency), MCPHelper::displayAmount($result['totals']['arr'], $currency) ); } $contracted = $result['totals']['total_contracted'] === null ? MCPHelper::displayAmount(0, $currency) : MCPHelper::displayAmount($result['totals']['total_contracted'], $currency); return sprintf( /* translators: 1: product title, 2: collected to date, 3: total contracted */ __('%1$s — %2$s collected to date; total contracted %3$s (all one-time and/or finite split-pay).', 'fluent-cart'), $product->post_title, $collected, $contracted ); } // ----------------------------------------------------------------- // Param resolvers // ----------------------------------------------------------------- private static function resolveAsOf($params) { if (!empty($params['as_of'])) { try { return (new \DateTime((string) $params['as_of'], new \DateTimeZone('UTC')))->format('Y-m-d H:i:s'); } catch (\Exception $e) { // fall through to now } } return gmdate('Y-m-d H:i:s'); } private static function resolveHorizonMonths($params) { $map = ['3m' => 3, '6m' => 6, '12m' => 12]; $h = isset($params['horizon']) ? (string) $params['horizon'] : '12m'; return isset($map[$h]) ? $map[$h] : 12; } private static function resolveForwardStatuses($params) { $allowed = ['active', 'trialing', 'past_due', 'paused', 'canceled', 'all']; $in = isset($params['subscription_status']) ? (array) $params['subscription_status'] : ['active']; $out = []; foreach ($in as $s) { $s = strtolower(trim((string) $s)); if (in_array($s, $allowed, true) && !in_array($s, $out, true)) { $out[] = $s; } } if (empty($out)) { $out = ['active']; } if (in_array('all', $out, true)) { return ['all']; } return $out; } /** * Resolve range / date_from / date_to into a UTC window for the one_time * block. Mirrors the report tools: relative ranges resolve in UTC, custom * dates override. all_time spans epoch..now. * * @return array{from:string,to:string,range:string,basis:string}|\WP_Error */ private static function resolveWindow($params) { $tz = new \DateTimeZone('UTC'); $basis = (isset($params['date_basis']) && $params['date_basis'] === 'created_at') ? 'created_at' : 'paid_at'; // Explicit custom dates take precedence. if (!empty($params['date_from']) || !empty($params['date_to'])) { $from = self::dayBound(!empty($params['date_from']) ? $params['date_from'] : '1970-01-01', $tz, false); $to = self::dayBound(!empty($params['date_to']) ? $params['date_to'] : 'now', $tz, true); if ($from === null || $to === null) { return MCPHelper::error('invalid_date', __('date_from / date_to must be YYYY-MM-DD or ISO 8601.', 'fluent-cart'), ['fields' => ['date_from', 'date_to']]); } return ['from' => $from, 'to' => $to, 'range' => 'custom', 'basis' => $basis]; } $ranges = ['today', 'yesterday', 'last_7_days', 'last_30_days', 'this_month', 'last_month', 'mtd', 'qtd', 'ytd', 'last_quarter', 'last_year', 'all_time', 'since_launch']; $range = (isset($params['range']) && in_array($params['range'], $ranges, true)) ? $params['range'] : 'all_time'; // since_launch is an alias of all_time (epoch..now) so the range vocabulary // matches the report tools, which use since_launch. Echo whichever was asked. if ($range === 'all_time' || $range === 'since_launch') { return ['from' => '1970-01-01 00:00:00', 'to' => gmdate('Y-m-d H:i:s'), 'range' => $range, 'basis' => $basis]; } $now = new \DateTime('now', $tz); $startDt = clone $now; $endDt = clone $now; if ($range === 'yesterday') { $startDt->modify('-1 day'); $endDt->modify('-1 day'); } elseif ($range === 'last_7_days') { $startDt->modify('-6 days'); } elseif ($range === 'last_30_days') { $startDt->modify('-29 days'); } elseif ($range === 'this_month' || $range === 'mtd') { $startDt = new \DateTime($now->format('Y-m-01'), $tz); } elseif ($range === 'last_month') { $startDt = new \DateTime($now->format('Y-m-01'), $tz); $startDt->modify('-1 month'); $endDt = (clone $startDt)->modify('last day of this month'); } elseif ($range === 'qtd') { $startDt = self::quarterStart($now, $tz); } elseif ($range === 'last_quarter') { $qs = self::quarterStart($now, $tz); $startDt = (clone $qs)->modify('-3 months'); $endDt = (clone $qs)->modify('-1 day'); } elseif ($range === 'ytd') { $startDt = new \DateTime($now->format('Y-01-01'), $tz); } elseif ($range === 'last_year') { $year = (int) $now->format('Y') - 1; $startDt = new \DateTime($year . '-01-01', $tz); $endDt = new \DateTime($year . '-12-31', $tz); } return [ 'from' => (clone $startDt)->setTime(0, 0, 0)->format('Y-m-d H:i:s'), 'to' => (clone $endDt)->setTime(23, 59, 59)->format('Y-m-d H:i:s'), 'range' => $range, 'basis' => $basis, ]; } private static function quarterStart($now, $tz) { $month = (int) $now->format('n'); $qStartMonth = (int) (floor(($month - 1) / 3) * 3 + 1); return new \DateTime($now->format('Y') . '-' . str_pad($qStartMonth, 2, '0', STR_PAD_LEFT) . '-01', $tz); } /** Parse a date bound to a UTC 'Y-m-d H:i:s' at day start/end; null on failure. */ private static function dayBound($value, $tz, $endOfDay) { try { $dt = new \DateTime((string) $value, $tz); } catch (\Exception $e) { return null; } if ($endOfDay) { // Only pin to end-of-day for date-only input; keep explicit times intact. if (preg_match('/^\d{4}-\d{2}-\d{2}$/', trim((string) $value))) { $dt->setTime(23, 59, 59); } } else { if (preg_match('/^\d{4}-\d{2}-\d{2}$/', trim((string) $value))) { $dt->setTime(0, 0, 0); } } $dt->setTimezone(new \DateTimeZone('UTC')); return $dt->format('Y-m-d H:i:s'); } }