PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.4
1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk 1.2.0 All 47 releases
fluent-cart / app / Modules / MCP / Tools / ReportTools.php

ReportTools.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.4, at app/Modules/MCP/Tools/ReportTools.php

1,971 lines 102.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Modules\MCP\Tools;
4
5 use FluentCart\App\Helpers\Helper;
6 use FluentCart\App\App;
7 use FluentCart\App\Models\Order;
8 use FluentCart\App\Models\OrderItem;
9 use FluentCart\App\Models\Customer;
10 use FluentCart\App\Models\Subscription;
11 use FluentCart\App\Modules\MCP\Support\MCPHelper;
12 use FluentCart\App\Modules\MCP\Support\PermissionGate;
13
14 /**
15 * Reports & analytics — the headline research surface.
16 *
17 * Design rules baked in:
18 * - Every report is CURRENCY-SCOPED: it filters to one currency (the store
19 * default unless `currency` is passed) so totals are never silently summed
20 * across currencies. The chosen currency is echoed in meta.currency.
21 * - "Revenue" definitions are explicit and consistent across tools (see
22 * metricDefs): gross = sum(total_amount) of paid orders; net = paid −
23 * refunded; paid orders = payment_status in the paid set.
24 * - Date basis is created_at (echoed as meta.date_basis) for determinism.
25 * - Server-side aggregation only — no raw row dumps. query-orders caps at 200
26 * grouped rows; trend caps its bucket count.
27 * - Each report returns an NL summary the agent can quote verbatim.
28 *
29 * Parameter design: a shared `range` enum (today … last_year) resolves to a
30 * UTC window server-side, with explicit start_date/end_date as an override —
31 * the agent never has to compute "last month" itself.
32 */
33 class ReportTools
34 {
35 // Payment statuses for orders that CAPTURED PAYMENT at some point — including
36 // one later fully refunded (payment_status 'refunded', order status 'canceled').
37 // A fully refunded order captured payment then returned it, so it belongs in the
38 // refund metrics (it is the whole point of a refund report) and in the paid
39 // denominator of the refund rate, and it nets to zero in net_revenue
40 // (total_paid - total_refund). Gating refund aggregation on the narrower paid set
41 // silently undercounted every fully-refunded order. Including 'refunded' here
42 // matches FluentCart's own admin reports, which compute refunds from
43 // total_refund > 0 with no current-status gate (see RevenueReportService /
44 // RefundReportService::applyFilters — the default has no payment_status filter).
45 const PAID = ['paid', 'partially_paid', 'partially_refunded', 'refunded'];
46
47 // all_time is a documented alias of since_launch (see resolveRange) so the
48 // range vocabulary matches get-product-financials, which uses all_time.
49 const RANGES = ['today', 'yesterday', 'last_7_days', 'last_30_days', 'this_month', 'last_month', 'mtd', 'qtd', 'ytd', 'last_quarter', 'last_year', 'since_launch', 'all_time'];
50
51 const MAX_BUCKETS = 180;
52
53 const MAX_ROWS = 200;
54
55 public static function definitions()
56 {
57 $rangeProp = ['type' => 'string', 'enum' => self::RANGES, 'description' => 'Relative window, resolved in UTC to match the store reports. today = midnight UTC to now; since_launch (alias: all_time) = the store\'s first paid order to now. Or pass start_date + end_date (dates), date_from + date_to (ISO 8601 datetimes), or since (delta).'];
58
59 // Shared custom-window params. All UTC — reports stay reconcilable with the
60 // admin dashboard, which buckets on the GMT-stored created_at.
61 $dateFrom = ['type' => 'string', 'description' => 'ISO 8601 datetime or YYYY-MM-DD, UTC. Time-precise custom window start; overrides range. A time of day is honored (e.g. launch hour).'];
62 $dateTo = ['type' => 'string', 'description' => 'ISO 8601 datetime or YYYY-MM-DD, UTC. Time-precise custom window end; overrides range.'];
63 $since = ['type' => 'string', 'description' => 'ISO 8601 datetime, UTC. Delta mode: only records after this instant, up to now — answers "what changed since my last check". Overrides range/date_from/date_to.'];
64
65 // Live/test order scoping. Reports historically counted BOTH, so 'all' is
66 // the non-breaking default; pass 'live' to exclude test-mode orders from
67 // revenue. The effective mode is always echoed as meta.mode so a number
68 // is never silently polluted by test orders.
69 $modeProp = ['type' => 'string', 'enum' => ['live', 'test', 'all'], 'default' => 'all', 'description' => 'Order mode. all (default) counts both live and test orders; pass live to exclude test-mode orders. Echoed as meta.mode.'];
70
71 // Pagination for the flexible query-* aggregates: when a grouping produces
72 // more than per_page groups the response sets meta.page.has_more; raise
73 // page to walk the rest instead of only being able to narrow the window.
74 $pageProp = ['type' => 'integer', 'default' => 1, 'description' => '1-based page over the grouped rows. Use with meta.page.has_more to page past the per_page cap.'];
75 $perPageProp = ['type' => 'integer', 'default' => 200, 'description' => 'Grouped rows per page. Max 200.'];
76
77 // The query-* aggregates share one response shape: metrics/dimensions echo
78 // + a rows array whose keys are dynamic (one per requested dimension and
79 // metric). Declared for the model up front so it needn't probe a call to
80 // learn the envelope. Money in rows is a compact decimal in meta.currency.
81 $queryOutputSchema = MCPHelper::envelopeSchema([
82 'type' => 'object',
83 'properties' => [
84 'metrics' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'The metrics that were computed.'],
85 'dimensions' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'The group-by dimensions.'],
86 'range' => ['type' => 'object', 'description' => 'Resolved UTC window (absent on query-customers, which is not date-scoped).'],
87 'rows' => [
88 'type' => 'array',
89 'description' => 'One object per group. Keys are the requested dimensions plus one key per metric; money metrics are compact decimals in meta.currency, counts are integers.',
90 'items' => ['type' => 'object'],
91 ],
92 ],
93 ], ['date_basis' => ['type' => 'string'], 'mode' => ['type' => 'string'], 'page' => ['type' => 'object'], 'truncated' => ['type' => 'boolean']]);
94
95 $subStatuses = ContextTools::enums()['subscription_statuses'];
96
97 $defs = [
98 'fluent-cart/get-sales-report' => [
99 'label' => __('Get Sales Report', 'fluent-cart'),
100 'description' => __('Revenue overview for a period with comparison to the prior equal period: gross, net, paid, refunded, tax, shipping, fees, order count, AOV, unique customers, and percent change. The refunded metric covers all refunds in the window including fully refunded orders (which net to zero in net_revenue). Scoped to one currency, the store default unless a currency is given.', 'fluent-cart'),
101 'input_schema' => [
102 'type' => 'object',
103 'properties' => [
104 'range' => $rangeProp,
105 'start_date' => ['type' => 'string', 'description' => 'YYYY-MM-DD, UTC. Overrides range.'],
106 'end_date' => ['type' => 'string', 'description' => 'YYYY-MM-DD, UTC. Overrides range.'],
107 'date_from' => $dateFrom,
108 'date_to' => $dateTo,
109 'since' => $since,
110 'currency' => ['type' => 'string', 'description' => 'ISO currency. Defaults to the store currency.'],
111 'compare' => ['type' => 'boolean', 'default' => true, 'description' => 'Include prior-period comparison.'],
112 ],
113 ],
114 'output_schema' => MCPHelper::envelopeSchema([
115 'type' => 'object',
116 'properties' => [
117 'range' => ['type' => 'object', 'description' => 'Resolved UTC window: start, end, label, currency.'],
118 'metrics' => [
119 'type' => 'object',
120 'description' => 'order_count and unique_customers are integers; every other key (gross_revenue, net_revenue, paid, refunded, tax, shipping, fees, aov) is a money object.',
121 'properties' => [
122 'order_count' => ['type' => 'integer'],
123 'unique_customers' => ['type' => 'integer'],
124 ],
125 // Declare the money shape ONCE for all the money metrics
126 // rather than inlining it per key (10x is real tokens).
127 'additionalProperties' => MCPHelper::moneyDef(),
128 ],
129 'definitions' => ['type' => 'object', 'description' => 'Human-readable metric definitions.'],
130 'comparison' => ['type' => 'object', 'description' => 'Prior-period metrics and percent change (present when compare=true).'],
131 ],
132 ], ['date_basis' => ['type' => 'string'], 'mode' => ['type' => 'string']]),
133 'execute_callback' => [self::class, 'getSalesReport'],
134 'permission_callback' => function () {
135 return PermissionGate::can('reports/view');
136 },
137 'annotations' => ['readonly' => true],
138 ],
139
140 'fluent-cart/get-sales-trend' => [
141 'label' => __('Get Sales Trend', 'fluent-cart'),
142 'description' => __('Time series of revenue and order count bucketed by day, week, or month over a period. Scoped to one currency. Use to see growth and seasonality.', 'fluent-cart'),
143 'input_schema' => [
144 'type' => 'object',
145 'properties' => [
146 'range' => $rangeProp,
147 'start_date' => ['type' => 'string', 'description' => 'YYYY-MM-DD, UTC.'],
148 'end_date' => ['type' => 'string', 'description' => 'YYYY-MM-DD, UTC.'],
149 'date_from' => $dateFrom,
150 'date_to' => $dateTo,
151 'since' => $since,
152 'interval' => ['type' => 'string', 'enum' => ['hour', 'day', 'week', 'month'], 'default' => 'day', 'description' => 'Bucket size. hour is for intraday launch monitoring (capped at 180 buckets per call). Alias: granularity.'],
153 'granularity' => ['type' => 'string', 'enum' => ['hour', 'day', 'week', 'month'], 'description' => 'Alias for interval.'],
154 'currency' => ['type' => 'string'],
155 ],
156 ],
157 'execute_callback' => [self::class, 'getSalesTrend'],
158 'permission_callback' => function () {
159 return PermissionGate::can('reports/view');
160 },
161 'annotations' => ['readonly' => true],
162 ],
163
164 'fluent-cart/get-top-products' => [
165 'label' => __('Get Top Products', 'fluent-cart'),
166 'description' => __('Best-selling products over a period, ranked by revenue or units sold. Scoped to one currency.', 'fluent-cart'),
167 'input_schema' => [
168 'type' => 'object',
169 'properties' => [
170 'range' => $rangeProp,
171 'start_date' => ['type' => 'string'],
172 'end_date' => ['type' => 'string'],
173 'date_from' => $dateFrom,
174 'date_to' => $dateTo,
175 'since' => $since,
176 'metric' => ['type' => 'string', 'enum' => ['revenue', 'units'], 'default' => 'revenue'],
177 'currency' => ['type' => 'string'],
178 'limit' => ['type' => 'integer', 'default' => 10, 'description' => 'Max 50.'],
179 ],
180 ],
181 'execute_callback' => [self::class, 'getTopProducts'],
182 'permission_callback' => function () {
183 return PermissionGate::can('reports/view');
184 },
185 'annotations' => ['readonly' => true],
186 ],
187
188 'fluent-cart/get-refund-report' => [
189 'label' => __('Get Refund Report', 'fluent-cart'),
190 'description' => __('Refund metrics for a period: refunded order count, refund rate as a share of paid orders, total and average refunded amount. Counts every order refunded in the window from its total_refund, including orders that were fully refunded and then canceled (not just partial refunds on still-paid orders). Scoped to one currency.', 'fluent-cart'),
191 'input_schema' => [
192 'type' => 'object',
193 'properties' => [
194 'range' => $rangeProp,
195 'start_date' => ['type' => 'string'],
196 'end_date' => ['type' => 'string'],
197 'date_from' => $dateFrom,
198 'date_to' => $dateTo,
199 'since' => $since,
200 'currency' => ['type' => 'string'],
201 ],
202 ],
203 'execute_callback' => [self::class, 'getRefundReport'],
204 'permission_callback' => function () {
205 return PermissionGate::can('reports/view');
206 },
207 'annotations' => ['readonly' => true],
208 ],
209
210 'fluent-cart/query-sources' => [
211 'label' => __('Query Sources (UTM attribution)', 'fluent-cart'),
212 'description' => __('Flexible UTM attribution: pick metrics and group by any UTM fields — source, medium, campaign, term, content, id — over a period, with optional source/medium/campaign filters to drill down. Pass product_id (or variation_id) to attribute only orders containing that product. Scoped to one currency, paid orders. Orders with no UTM fall under a none bucket. Returns up to 200 rows ranked by the first metric.', 'fluent-cart'),
213 'input_schema' => [
214 'type' => 'object',
215 'properties' => [
216 'metrics' => ['type' => 'array', 'description' => 'Defaults to orders and gross_revenue.', 'items' => ['type' => 'string', 'enum' => ['orders', 'gross_revenue', 'net_revenue', 'aov', 'unique_customers', 'refunded_amount']]],
217 'dimensions' => ['type' => 'array', 'description' => 'Group by these UTM fields. Defaults to utm_source, utm_medium, utm_campaign.', 'items' => ['type' => 'string', 'enum' => ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'utm_id']]],
218 'utm_source' => ['type' => 'string', 'description' => 'Filter to one source, exact match.'],
219 'utm_medium' => ['type' => 'string', 'description' => 'Filter to one medium, exact match.'],
220 'utm_campaign' => ['type' => 'string', 'description' => 'Filter to one campaign, exact match.'],
221 'product_id' => ['type' => 'integer', 'description' => 'Limit attribution to orders containing this product (e.g. one product on a multi-product store).'],
222 'variation_id' => ['type' => 'integer', 'description' => 'Limit attribution to orders containing this variation.'],
223 'range' => $rangeProp,
224 'start_date' => ['type' => 'string'],
225 'end_date' => ['type' => 'string'],
226 'date_from' => $dateFrom,
227 'date_to' => $dateTo,
228 'since' => $since,
229 'currency' => ['type' => 'string'],
230 'limit' => ['type' => 'integer', 'default' => 50, 'description' => 'Max 200.'],
231 ],
232 ],
233 'execute_callback' => [self::class, 'querySources'],
234 'permission_callback' => function () {
235 return PermissionGate::can('reports/view');
236 },
237 'annotations' => ['readonly' => true],
238 ],
239
240 'fluent-cart/query-orders' => [
241 'label' => __('Query Orders (flexible aggregate)', 'fluent-cart'),
242 'description' => __('Flexible order analytics: pick metrics, group by dimensions with filters, when a fixed report does not fit — e.g. revenue by payment_status, orders by month, revenue by order_type (one-time payment vs new subscription vs renewal), or revenue by country. product_id or variation_id limits to orders containing that product. This is the tool for revenue BY GEOGRAPHY: group by country/state, which read each order\'s own billing address, falling back to the buyer\'s primary billing address. One currency; window filters on created_at, echoed as meta.date_basis.', 'fluent-cart'),
243 'input_schema' => [
244 'type' => 'object',
245 'properties' => [
246 'metrics' => [
247 'type' => 'array',
248 'description' => 'One or more. Defaults to order_count and gross_revenue.',
249 'items' => ['type' => 'string', 'enum' => ['order_count', 'gross_revenue', 'paid_revenue', 'refunded_amount', 'aov', 'unique_customers']],
250 ],
251 'dimensions' => [
252 'type' => 'array',
253 'description' => 'Group by these. Empty means a single total row. order_type splits sales by payment (one-time purchase), subscription (first subscription order) and renewal (recurring charge); combine with a time dimension for e.g. order_type x month. country/state come from each order\'s own billing address, falling back to the buyer\'s primary billing address when the order has no address row of its own (common for digital goods) — this is the right basis for revenue by geography. Orders neither source can place land in an explicit "unknown" bucket so the rows still sum to total revenue; see meta.geo_source.',
254 'items' => ['type' => 'string', 'enum' => ['day', 'week', 'month', 'status', 'payment_status', 'order_type', 'country', 'state']],
255 ],
256 'country' => ['type' => 'string', 'description' => 'ISO-2 code. Limit to orders billed to this country, resolved the same way as the country dimension (order address, then the buyer\'s primary billing address).'],
257 'product_id' => ['type' => 'integer', 'description' => 'Limit to orders CONTAINING this product. Order-level metrics (revenue, count) reflect the whole order, not just this product\'s lines — for per-product line revenue use query-products.'],
258 'variation_id' => ['type' => 'integer', 'description' => 'Limit to orders containing this variation.'],
259 'range' => $rangeProp,
260 'start_date' => ['type' => 'string'],
261 'end_date' => ['type' => 'string'],
262 'date_from' => $dateFrom,
263 'date_to' => $dateTo,
264 'since' => $since,
265 'currency' => ['type' => 'string'],
266 'sort_desc' => ['type' => 'boolean', 'default' => true, 'description' => 'Sort by the first metric descending. When grouping by a time dimension (day/week/month), rows default to chronological order unless you set this explicitly.'],
267 ],
268 ],
269 'execute_callback' => [self::class, 'queryOrders'],
270 'permission_callback' => function () {
271 return PermissionGate::can('reports/view');
272 },
273 'annotations' => ['readonly' => true],
274 ],
275
276 'fluent-cart/query-products' => [
277 'label' => __('Query Products (flexible aggregate)', 'fluent-cart'),
278 'description' => __('Flexible product-line analytics over sold items: pick metrics, group by product or variation, optionally split by order_type (one-time payment vs new subscription vs renewal), within a period and one currency. Rows self-describe: product_name always, plus variation_label when grouped by variation, so no follow-up lookup. For a time series use get-sales-trend. Window filters on the parent order created_at, echoed as meta.date_basis.', 'fluent-cart'),
279 'input_schema' => [
280 'type' => 'object',
281 'properties' => [
282 'metrics' => ['type' => 'array', 'description' => 'Defaults to units_sold and line_revenue. list_price_sum = units x list price before any discount; discount_amount = list_price_sum minus line_revenue (coupon + manual discount) — pick both with line_revenue to see margin leakage directly; net_revenue = line_revenue minus refunds.', 'items' => ['type' => 'string', 'enum' => ['units_sold', 'line_revenue', 'list_price_sum', 'discount_amount', 'net_revenue', 'order_count', 'avg_unit_price', 'refund_amount']]],
283 'dimensions' => ['type' => 'array', 'description' => 'Group by these. order_type splits a product\'s sales by the parent order type: payment (one-time purchase), subscription (first subscription order) and renewal (recurring charge). Combine with product/variation, e.g. product x order_type.', 'items' => ['type' => 'string', 'enum' => ['product', 'variation', 'order_type']]],
284 'product_id' => ['type' => 'integer', 'description' => 'Limit to one product (by product/post id). Combine with dimensions=[variation] to break that single product down by variation.'],
285 'variation_id' => ['type' => 'integer', 'description' => 'Limit to one variation of the product.'],
286 'range' => $rangeProp,
287 'start_date' => ['type' => 'string'],
288 'end_date' => ['type' => 'string'],
289 'date_from' => $dateFrom,
290 'date_to' => $dateTo,
291 'since' => $since,
292 'currency' => ['type' => 'string'],
293 ],
294 ],
295 'execute_callback' => [self::class, 'queryProducts'],
296 'permission_callback' => function () {
297 return PermissionGate::can('reports/view');
298 },
299 'annotations' => ['readonly' => true],
300 ],
301
302 'fluent-cart/query-customers' => [
303 'label' => __('Query Customers (flexible aggregate)', 'fluent-cart'),
304 'description' => __('Flexible customer analytics: pick metrics and group by country, state, status, or first/last purchase month, with optional filters. LTV is in store currency. Returns up to 200 rows. country/state resolve from the customer\'s primary billing address, falling back to their profile location, then "unknown" — this is where a customer is REGISTERED, captured at their first purchase and not refreshed since. To attribute revenue to where each order was actually billed, group query-orders by country instead.', 'fluent-cart'),
305 'input_schema' => [
306 'type' => 'object',
307 'properties' => [
308 'metrics' => ['type' => 'array', 'description' => 'Defaults to customer_count and total_ltv.', 'items' => ['type' => 'string', 'enum' => ['customer_count', 'total_ltv', 'avg_ltv', 'avg_purchase_count', 'repeat_customers']]],
309 'dimensions' => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['country', 'state', 'status', 'first_purchase_month', 'last_purchase_month']]],
310 'country' => ['type' => 'string', 'description' => 'ISO-2 code, matched against the customer profile country (same caveat as the country dimension).'],
311 'status' => ['type' => 'string', 'enum' => ['active', 'archived'], 'description' => 'Not every customer has one set — customers with no status appear under the status dimension as "unknown" and are excluded by this filter. Omit it to count all customers.'],
312 'min_ltv' => ['type' => 'number', 'description' => 'Minimum LTV in store currency.'],
313 'min_purchase_count' => ['type' => 'integer'],
314 ],
315 ],
316 'execute_callback' => [self::class, 'queryCustomers'],
317 'permission_callback' => function () {
318 return PermissionGate::can('reports/view');
319 },
320 'annotations' => ['readonly' => true],
321 ],
322
323 'fluent-cart/query-subscriptions' => [
324 'label' => __('Query Subscriptions (flexible aggregate)', 'fluent-cart'),
325 'description' => __('Flexible subscription analytics: pick metrics, group by month/plan_type/status/billing_interval over a date window. plan_type splits fixed-term installment/split-pay from open-ended recurring plans. contract_value books installments at their full committed price, recurring_total x bill_times — a split-pay deal counts once at signup, not per charge. date_basis=created_at for booking cohorts, canceled_at for churn — a completed installment is paid-in-full, never churn. Not currency-scoped; money is store currency.', 'fluent-cart'),
326 'input_schema' => [
327 'type' => 'object',
328 'properties' => [
329 'metrics' => ['type' => 'array', 'description' => 'Defaults to subscription_count and contract_value. contract_value = full committed price of installments, recurring_total x bill_times, and 0 for open-ended plans; recurring_value = one-cycle total.', 'items' => ['type' => 'string', 'enum' => ['subscription_count', 'contract_value', 'recurring_value']]],
330 'dimensions' => ['type' => 'array', 'description' => 'Group by these. Empty means a single total row. plan_type splits installment vs recurring; status keeps completed installments separate from canceled/expired churn.', 'items' => ['type' => 'string', 'enum' => ['month', 'plan_type', 'status', 'billing_interval']]],
331 'date_basis' => ['type' => 'string', 'enum' => ['created_at', 'canceled_at', 'next_billing_date'], 'default' => 'created_at', 'description' => 'Which date the range and the month dimension use: created_at for booking cohorts, canceled_at for churn, next_billing_date for upcoming renewals.'],
332 'plan_type' => ['type' => 'string', 'enum' => ['installment', 'recurring', 'all'], 'default' => 'all', 'description' => 'Filter to installment (bill_times > 0) or recurring (bill_times = 0) plans.'],
333 'status' => ['type' => 'string', 'enum' => $subStatuses],
334 'product_id' => ['type' => 'integer', 'description' => 'Limit to subscriptions for one product.'],
335 'range' => $rangeProp,
336 'start_date' => ['type' => 'string'],
337 'end_date' => ['type' => 'string'],
338 'date_from' => $dateFrom,
339 'date_to' => $dateTo,
340 'since' => $since,
341 ],
342 ],
343 'execute_callback' => [self::class, 'querySubscriptions'],
344 'permission_callback' => function () {
345 return PermissionGate::can('reports/view');
346 },
347 'annotations' => ['readonly' => true],
348 ],
349 ];
350
351 // The live/test mode filter applies only to order-based reports —
352 // fct_orders has a mode column, but subscription/customer analytics do
353 // not. Injected here so the shared $modeProp stays a single definition.
354 foreach ([
355 'fluent-cart/get-sales-report',
356 'fluent-cart/get-sales-trend',
357 'fluent-cart/get-top-products',
358 'fluent-cart/get-refund-report',
359 'fluent-cart/query-sources',
360 'fluent-cart/query-orders',
361 'fluent-cart/query-products',
362 ] as $modeTool) {
363 $defs[$modeTool]['input_schema']['properties']['mode'] = $modeProp;
364 }
365
366 // page/per_page belong on the flexible aggregates, whose grouped output can
367 // exceed the 200-row cap. The fixed reports (sales/trend/top/refund) return
368 // a bounded shape and don't paginate. query-sources is excluded on purpose:
369 // it already exposes its own `limit` + peek + truncated, and adding a
370 // second page-size param would be ambiguous.
371 foreach ([
372 'fluent-cart/query-orders',
373 'fluent-cart/query-products',
374 'fluent-cart/query-customers',
375 'fluent-cart/query-subscriptions',
376 ] as $pagedTool) {
377 $defs[$pagedTool]['input_schema']['properties']['page'] = $pageProp;
378 $defs[$pagedTool]['input_schema']['properties']['per_page'] = $perPageProp;
379 }
380
381 // All five query-* aggregates share the same response envelope.
382 foreach ([
383 'fluent-cart/query-orders',
384 'fluent-cart/query-products',
385 'fluent-cart/query-customers',
386 'fluent-cart/query-subscriptions',
387 'fluent-cart/query-sources',
388 ] as $queryTool) {
389 $defs[$queryTool]['output_schema'] = $queryOutputSchema;
390 }
391
392 return $defs;
393 }
394
395 // -----------------------------------------------------------------
396 // get-sales-report
397 // -----------------------------------------------------------------
398
399 public static function getSalesReport($params = [])
400 {
401 $currency = self::currency($params);
402 $range = self::resolveRange($params);
403 $mode = self::orderMode($params);
404
405 $current = self::salesMetrics($range['start'], $range['end'], $currency, $mode);
406
407 $data = [
408 'range' => self::rangeBlock($range, $currency),
409 'metrics' => self::salesMetricsOut($current, $currency),
410 'definitions' => self::metricDefs(),
411 ];
412
413 $compare = !isset($params['compare']) || !empty($params['compare']);
414 if ($compare && $range['prev_start']) {
415 $prior = self::salesMetrics($range['prev_start'], $range['prev_end'], $currency, $mode);
416 $data['comparison'] = [
417 'prior_metrics' => self::salesMetricsOut($prior, $currency),
418 'change_percent' => [
419 'gross_revenue' => self::pct($current['gross'], $prior['gross']),
420 'net_revenue' => self::pct($current['net'], $prior['net']),
421 'order_count' => self::pct($current['orders'], $prior['orders']),
422 ],
423 ];
424 }
425
426 $summary = sprintf(
427 /* translators: 1: gross revenue, 2: order count, 3: average order value */
428 __('Revenue %1$s across %2$d paid orders, AOV %3$s.', 'fluent-cart'),
429 MCPHelper::displayAmount($current['gross'], $currency),
430 $current['orders'],
431 MCPHelper::displayAmount($current['aov'], $currency)
432 );
433
434 return MCPHelper::envelope($summary, $data, ['currency' => $currency, 'date_basis' => 'created_at', 'mode' => $mode]);
435 }
436
437 private static function salesMetrics($start, $end, $currency, $mode = 'all')
438 {
439 // One aggregate scan instead of eight (this is the headline report, and
440 // it runs twice when compare=true). Same filtered set, same numbers.
441 $q = Order::query()
442 ->whereIn('payment_status', self::PAID)
443 ->where('currency', $currency)
444 ->where('created_at', '>=', $start)
445 ->where('created_at', '<=', $end);
446 self::applyMode($q, $mode);
447 $row = $q->selectRaw(
448 'COUNT(*) as orders, '
449 . 'COALESCE(SUM(total_amount), 0) as gross, '
450 . 'COALESCE(SUM(total_paid), 0) as paid, '
451 . 'COALESCE(SUM(total_refund), 0) as refund, '
452 . 'COALESCE(SUM(tax_total), 0) as tax, '
453 . 'COALESCE(SUM(shipping_total), 0) as ship, '
454 . 'COALESCE(SUM(fee_total), 0) as fees, '
455 . 'COUNT(DISTINCT customer_id) as uniq'
456 )
457 ->first();
458
459 $orders = $row ? (int) $row->orders : 0;
460 $gross = $row ? (int) $row->gross : 0;
461 $paid = $row ? (int) $row->paid : 0;
462 $refund = $row ? (int) $row->refund : 0;
463 $tax = $row ? (int) $row->tax : 0;
464 $ship = $row ? (int) $row->ship : 0;
465 $fees = $row ? (int) $row->fees : 0;
466 $uniq = $row ? (int) $row->uniq : 0;
467
468 return [
469 'orders' => $orders,
470 'gross' => $gross,
471 'paid' => $paid,
472 'refund' => $refund,
473 'net' => $paid - $refund,
474 'tax' => $tax,
475 'shipping' => $ship,
476 'fees' => $fees,
477 'unique' => $uniq,
478 'aov' => $orders > 0 ? (int) round($gross / $orders) : 0,
479 ];
480 }
481
482 private static function salesMetricsOut($m, $currency)
483 {
484 return [
485 'order_count' => $m['orders'],
486 'unique_customers' => $m['unique'],
487 'gross_revenue' => MCPHelper::money($m['gross'], $currency),
488 'net_revenue' => MCPHelper::money($m['net'], $currency),
489 'paid' => MCPHelper::money($m['paid'], $currency),
490 'refunded' => MCPHelper::money($m['refund'], $currency),
491 'tax' => MCPHelper::money($m['tax'], $currency),
492 'shipping' => MCPHelper::money($m['shipping'], $currency),
493 'fees' => MCPHelper::money($m['fees'], $currency),
494 'aov' => MCPHelper::money($m['aov'], $currency),
495 ];
496 }
497
498 private static function metricDefs()
499 {
500 return [
501 'paid_orders' => 'Orders that captured payment at some point (payment_status in: ' . implode(', ', self::PAID) . '). A fully refunded order (payment_status "refunded") is included — it captured payment, so it counts toward gross/paid and the refunded total, and nets to zero in net_revenue.',
502 'gross_revenue' => 'Sum of order total_amount for paid orders (gross sales, before refunds).',
503 'net_revenue' => 'Sum of total_paid minus total_refund; a fully refunded order nets to zero.',
504 'refunded' => 'Sum of total_refund over paid orders — includes fully refunded orders, independent of current order status.',
505 'aov' => 'gross_revenue divided by paid order count.',
506 'date_basis' => 'created_at, within the given range.',
507 ];
508 }
509
510 // -----------------------------------------------------------------
511 // get-sales-trend
512 // -----------------------------------------------------------------
513
514 public static function getSalesTrend($params = [])
515 {
516 $currency = self::currency($params);
517 $range = self::resolveRange($params);
518 $mode = self::orderMode($params);
519 // `granularity` is an alias for `interval`; hour is for intraday launch
520 // monitoring (MAX_BUCKETS caps it at 180 hours ~ 7.5 days per call).
521 $intervalIn = isset($params['granularity']) ? $params['granularity'] : (isset($params['interval']) ? $params['interval'] : 'day');
522 $interval = in_array($intervalIn, ['hour', 'day', 'week', 'month'], true) ? $intervalIn : 'day';
523
524 $format = $interval === 'month'
525 ? '%Y-%m'
526 : ($interval === 'week' ? '%x-W%v' : ($interval === 'hour' ? '%Y-%m-%d %H:00' : '%Y-%m-%d'));
527
528 $q = Order::query()
529 ->whereIn('payment_status', self::PAID)
530 ->where('currency', $currency)
531 ->where('created_at', '>=', $range['start'])
532 ->where('created_at', '<=', $range['end']);
533 self::applyMode($q, $mode);
534 $rows = $q->selectRaw('DATE_FORMAT(created_at, ?) as bucket, COUNT(*) as order_count, SUM(total_amount) as gross', [$format])
535 ->groupBy('bucket')
536 ->orderBy('bucket', 'ASC')
537 ->limit(self::MAX_BUCKETS)
538 ->get();
539
540 $trend = [];
541 $sum = 0;
542 foreach ($rows as $row) {
543 $gross = (int) $row->gross;
544 $sum += $gross;
545 $trend[] = [
546 'bucket' => $row->bucket,
547 'order_count' => (int) $row->order_count,
548 'gross' => MCPHelper::moneyCompact($gross),
549 ];
550 }
551
552 $summary = sprintf(
553 /* translators: 1: number of buckets, 2: interval, 3: total revenue */
554 __('%1$d %2$s buckets, total revenue %3$s.', 'fluent-cart'),
555 count($trend),
556 $interval,
557 MCPHelper::displayAmount($sum, $currency)
558 );
559
560 return MCPHelper::envelope(
561 $summary,
562 ['interval' => $interval, 'range' => self::rangeBlock($range, $currency), 'trend' => $trend],
563 ['currency' => $currency, 'date_basis' => 'created_at', 'mode' => $mode, 'truncated' => count($rows) >= self::MAX_BUCKETS]
564 );
565 }
566
567 // -----------------------------------------------------------------
568 // get-top-products
569 // -----------------------------------------------------------------
570
571 public static function getTopProducts($params = [])
572 {
573 $currency = self::currency($params);
574 $range = self::resolveRange($params);
575 $mode = self::orderMode($params);
576 $metric = isset($params['metric']) && $params['metric'] === 'units' ? 'units' : 'revenue';
577 $limit = isset($params['limit']) ? min(max((int) $params['limit'], 1), 50) : 10;
578 $orderCol = $metric === 'units' ? 'units' : 'revenue';
579
580 $rows = OrderItem::query()
581 ->whereHas('order', function ($q) use ($range, $currency, $mode) {
582 $q->whereIn('payment_status', self::PAID)
583 ->where('currency', $currency)
584 ->where('created_at', '>=', $range['start'])
585 ->where('created_at', '<=', $range['end']);
586 self::applyMode($q, $mode);
587 })
588 ->selectRaw('post_id, MAX(post_title) as title, SUM(quantity) as units, SUM(line_total - refund_total) as revenue, COUNT(DISTINCT order_id) as order_count')
589 ->groupBy('post_id')
590 ->orderBy($orderCol, 'DESC')
591 ->limit($limit)
592 ->get();
593
594 $products = [];
595 foreach ($rows as $row) {
596 $products[] = [
597 'product_id' => (int) $row->post_id,
598 'title' => $row->title,
599 'units_sold' => (int) $row->units,
600 'revenue' => MCPHelper::moneyCompact((int) $row->revenue),
601 'order_count' => (int) $row->order_count,
602 ];
603 }
604
605 $summary = sprintf(
606 /* translators: 1: number of products, 2: ranking metric */
607 __('Top %1$d products by %2$s.', 'fluent-cart'),
608 count($products),
609 $metric
610 );
611
612 return MCPHelper::envelope($summary, ['metric' => $metric, 'range' => self::rangeBlock($range, $currency), 'products' => $products], ['currency' => $currency, 'date_basis' => 'created_at', 'mode' => $mode]);
613 }
614
615 // -----------------------------------------------------------------
616 // get-refund-report
617 // -----------------------------------------------------------------
618
619 public static function getRefundReport($params = [])
620 {
621 $currency = self::currency($params);
622 $range = self::resolveRange($params);
623 $mode = self::orderMode($params);
624
625 $paidBase = Order::query()
626 ->whereIn('payment_status', self::PAID)
627 ->where('currency', $currency)
628 ->where('created_at', '>=', $range['start'])
629 ->where('created_at', '<=', $range['end']);
630 self::applyMode($paidBase, $mode);
631
632 $paidCount = (clone $paidBase)->count();
633
634 $refundedBase = (clone $paidBase)->where('total_refund', '>', 0);
635 $refundedCount = (clone $refundedBase)->count();
636 $refundedAmount = (int) (clone $refundedBase)->sum('total_refund');
637
638 $rate = $paidCount > 0 ? round(($refundedCount / $paidCount) * 100, 2) : 0;
639 $avg = $refundedCount > 0 ? (int) round($refundedAmount / $refundedCount) : 0;
640
641 $summary = sprintf(
642 /* translators: 1: refunded order count, 2: refund rate percent, 3: total refunded */
643 __('%1$d orders refunded, %2$s%% of paid, totaling %3$s.', 'fluent-cart'),
644 $refundedCount,
645 $rate,
646 MCPHelper::displayAmount($refundedAmount, $currency)
647 );
648
649 return MCPHelper::envelope(
650 $summary,
651 [
652 'range' => self::rangeBlock($range, $currency),
653 'paid_order_count' => $paidCount,
654 'refunded_order_count' => $refundedCount,
655 'refund_rate_percent' => $rate,
656 'total_refunded' => MCPHelper::money($refundedAmount, $currency),
657 'average_refund' => MCPHelper::money($avg, $currency),
658 'definitions' => [
659 'refunded_order_count' => 'Orders with total_refund > 0 in the window, regardless of current status — a fully refunded (canceled) order still counts. Matches the admin refund report.',
660 'paid_order_count' => 'Orders that captured payment in the window, including those later fully refunded. This is the refund_rate denominator.',
661 'refund_rate_percent' => 'refunded_order_count / paid_order_count * 100. A fully refunded order was paid before being refunded, so it is counted in BOTH the numerator and the denominator.',
662 ],
663 ],
664 ['currency' => $currency, 'date_basis' => 'created_at', 'mode' => $mode]
665 );
666 }
667
668 // -----------------------------------------------------------------
669 // query-sources (flexible UTM attribution)
670 // -----------------------------------------------------------------
671
672 const UTM_DIMENSIONS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'utm_id'];
673
674 public static function querySources($params = [])
675 {
676 $currency = self::currency($params);
677 $range = self::resolveRange($params);
678 $mode = self::orderMode($params);
679 $limit = isset($params['limit']) ? min(max((int) $params['limit'], 1), self::MAX_ROWS) : 50;
680 $metrics = self::pickList($params, 'metrics', ['orders', 'gross_revenue', 'net_revenue', 'aov', 'unique_customers', 'refunded_amount'], ['orders', 'gross_revenue']);
681 $dimensions = self::pickList($params, 'dimensions', self::UTM_DIMENSIONS, ['utm_source', 'utm_medium', 'utm_campaign']);
682
683 // Build the aggregate directly (rather than via SourceReportService,
684 // which hard-codes its grouping) so the agent picks the UTM dimensions.
685 // Uses the raw query builder with the same aliases as the admin Source
686 // report to avoid Order model global scopes. Paid + one currency to stay
687 // consistent with the other reports.
688 // Dedupe operations to one row per order before joining: fct_order_operations
689 // has only an INDEX on order_id (not UNIQUE), so a raw leftJoin would fan out
690 // and make every SUM(o.<money>) double-count any order with >1 ops row.
691 // MAX() per UTM column is ONLY_FULL_GROUP_BY-safe and returns the single
692 // row's value in the normal one-row-per-order case.
693 $opSub = App::db()->table('fct_order_operations')
694 ->select('order_id')
695 ->selectRaw(
696 'MAX(utm_source) as utm_source, MAX(utm_medium) as utm_medium, '
697 . 'MAX(utm_campaign) as utm_campaign, MAX(utm_term) as utm_term, '
698 . 'MAX(utm_content) as utm_content, MAX(utm_id) as utm_id'
699 )
700 ->groupBy('order_id');
701
702 $query = App::db()->table('fct_orders as o')
703 ->leftJoinSub($opSub, 'oo', 'o.id', '=', 'oo.order_id')
704 ->whereIn('o.payment_status', self::PAID)
705 ->where('o.currency', $currency)
706 ->where('o.created_at', '>=', $range['start'])
707 ->where('o.created_at', '<=', $range['end']);
708 // Orders table is aliased 'o' here; qualify the mode column to match.
709 self::applyMode($query, $mode, 'o.mode');
710
711 // Optional drill-down filters on exact UTM values.
712 foreach (['utm_source', 'utm_medium', 'utm_campaign'] as $f) {
713 if (!empty($params[$f])) {
714 $query->where('oo.' . $f, sanitize_text_field($params[$f]));
715 }
716 }
717
718 // Optional entity filters: restrict attribution to orders containing a
719 // product/variation. whereExists on fct_order_items (never a join, so the
720 // per-order SUM()s don't fan out) — mirrors the admin SourceReport filter.
721 foreach (['product_id' => 'post_id', 'variation_id' => 'object_id'] as $param => $col) {
722 if (!empty($params[$param])) {
723 $val = (int) $params[$param];
724 $query->whereExists(function ($q) use ($col, $val) {
725 $q->selectRaw('1')
726 ->from('fct_order_items as oi')
727 ->whereRaw('oi.order_id = o.id')
728 ->where('oi.' . $col, $val);
729 });
730 }
731 }
732
733 $selects = [];
734 $groupExpr = [];
735 foreach ($dimensions as $dim) {
736 // Coalesce NULL/'' into a single 'none' bucket; group by the
737 // expression so the split values collapse together.
738 $expr = "COALESCE(NULLIF(oo." . $dim . ", ''), 'none')";
739 $selects[] = $expr . ' as ' . $dim;
740 $groupExpr[] = $expr;
741 }
742
743 // Definitions must match metricDefs() and the sales report so an agent's
744 // "revenue by source" ties out with "revenue this month":
745 // gross_revenue = SUM(total_amount), net_revenue = SUM(total_paid - total_refund).
746 $metricSql = [
747 'orders' => 'COUNT(DISTINCT o.id) as orders',
748 'gross_revenue' => 'SUM(o.total_amount) as gross_revenue',
749 'net_revenue' => 'SUM(o.total_paid - o.total_refund) as net_revenue',
750 'unique_customers' => 'COUNT(DISTINCT o.customer_id) as unique_customers',
751 'refunded_amount' => 'SUM(o.total_refund) as refunded_amount',
752 ];
753 foreach ($metrics as $m) {
754 if (isset($metricSql[$m])) {
755 $selects[] = $metricSql[$m];
756 }
757 }
758 if (in_array('aov', $metrics, true)) {
759 if (!in_array('gross_revenue', $metrics, true)) {
760 $selects[] = $metricSql['gross_revenue'];
761 }
762 if (!in_array('orders', $metrics, true)) {
763 $selects[] = $metricSql['orders'];
764 }
765 }
766
767 $query->selectRaw(implode(', ', $selects));
768 if ($groupExpr) {
769 $query->groupByRaw(implode(', ', $groupExpr));
770 }
771
772 $firstMetric = isset($metrics[0]) ? $metrics[0] : 'orders';
773 if ($firstMetric === 'aov') {
774 $firstMetric = 'gross_revenue';
775 }
776 if ($groupExpr && isset($metricSql[$firstMetric])) {
777 $query->orderBy($firstMetric, 'DESC');
778 }
779 // Fetch one extra row so we can tell "more exist beyond your limit" from
780 // "you hit the 200 hard cap". $limit is already clamped to <= MAX_ROWS.
781 $query->limit($limit + 1);
782
783 $rows = $query->get();
784 $moneyMetrics = ['gross_revenue', 'net_revenue', 'refunded_amount'];
785 $truncated = count($rows) > $limit;
786
787 $out = [];
788 foreach ($rows as $row) {
789 if (count($out) >= $limit) {
790 break;
791 }
792 $r = [];
793 foreach ($dimensions as $dim) {
794 $r[$dim] = $row->{$dim};
795 }
796 foreach ($metrics as $m) {
797 if ($m === 'aov') {
798 $g = (int) $row->gross_revenue;
799 $c = (int) $row->orders;
800 $r['aov'] = MCPHelper::moneyCompact($c > 0 ? (int) round($g / $c) : 0);
801 } elseif (in_array($m, $moneyMetrics, true)) {
802 $r[$m] = MCPHelper::moneyCompact((int) $row->{$m});
803 } else {
804 $r[$m] = (int) $row->{$m};
805 }
806 }
807 $out[] = $r;
808 }
809
810 $summary = sprintf(
811 /* translators: 1: row count, 2: metric list, 3: dimension list */
812 __('%1$d rows — metrics [%2$s] grouped by [%3$s].', 'fluent-cart'),
813 count($out),
814 implode(', ', $metrics),
815 $dimensions ? implode(', ', $dimensions) : __('total', 'fluent-cart')
816 );
817
818 return MCPHelper::envelope(
819 $summary,
820 ['metrics' => $metrics, 'dimensions' => $dimensions, 'range' => self::rangeBlock($range, $currency), 'rows' => $out],
821 [
822 'currency' => $currency,
823 'date_basis' => 'created_at',
824 'mode' => $mode,
825 'returned' => count($out),
826 'limit' => $limit,
827 'max_rows' => self::MAX_ROWS,
828 // true = more rows exist; raise `limit` (up to max_rows) to see them.
829 'truncated' => $truncated,
830 ]
831 );
832 }
833
834 // -----------------------------------------------------------------
835 // query-orders (flexible aggregate)
836 // -----------------------------------------------------------------
837
838 public static function queryOrders($params = [])
839 {
840 $currency = self::currency($params);
841 $range = self::resolveRange($params);
842 $mode = self::orderMode($params);
843 $metrics = self::pickList($params, 'metrics', ['order_count', 'gross_revenue', 'paid_revenue', 'refunded_amount', 'aov', 'unique_customers'], ['order_count', 'gross_revenue']);
844 $dimensions = self::pickList($params, 'dimensions', ['day', 'week', 'month', 'status', 'payment_status', 'order_type', 'country', 'state'], []);
845
846 $query = Order::query()
847 ->whereIn('payment_status', self::PAID)
848 ->where('currency', $currency)
849 ->where('created_at', '>=', $range['start'])
850 ->where('created_at', '<=', $range['end']);
851 self::applyMode($query, $mode);
852
853 // Optional entity filters: restrict to orders CONTAINING a product/variation.
854 // whereHas keeps the aggregate order-level (metrics still reflect the whole
855 // order); it never fans out rows the way a raw join would.
856 self::applyOrderItemFilter($query, $params);
857
858 // Geography comes from the order's OWN billing address first, so each
859 // order's revenue is attributed to where that order was actually billed —
860 // not to wherever the customer is registered now. Joined only when asked
861 // for, so every other dimension keeps its current cost.
862 //
863 // Grouped subqueries, not raw joins: neither fct_order_addresses nor
864 // fct_customer_addresses has a UNIQUE constraint on its (parent, type)
865 // pair — fct_order_addresses actually holds 208 duplicate billing groups on
866 // the reference store — and a duplicate would fan out and double-count
867 // that order's revenue in every SUM.
868 //
869 // Within a duplicate group the LATEST row wins (highest id), picked as one
870 // whole row. Taking MAX(country) and MAX(state) independently would let the
871 // two columns come from DIFFERENT rows and synthesize a place that does not
872 // exist: order 259 on the reference store has billing rows (BD, BD-60) and
873 // (AT, BD-60), so independent MAX()es resolve it to Bangladesh even though
874 // its latest address is Austrian. (That order is payment_status=pending and
875 // so outside self::PAID — it demonstrates the mechanism, not a revenue error
876 // this report was making.) Five duplicate groups there disagree on country or
877 // state. latestRowPick() does the one-row pick inside the GROUP BY.
878 //
879 // The customer's primary billing address is the SECOND source, and the
880 // choice between the two is made PER ORDER, not per column — see
881 // dimensionExpr(). Most orders here carry no address row of their own
882 // (digital goods skip the billing step), so order-address-only attribution
883 // left 976 of 4,905 paid orders — 19.9% of orders and 9.0% of revenue — in
884 // the 'unknown' bucket, which makes "revenue by country" unusable for the
885 // question it exists to answer. Falling back to the buyer's own primary
886 // billing address recovers 878 of those 976 and cuts 'unknown' to 2.0%;
887 // 98 orders are then genuinely unattributable. (Figures over all four
888 // self::PAID statuses, which is what this method actually queries.)
889 // geo_source in meta names the precedence so an agent never has to guess.
890 $geoDims = array_values(array_intersect(['country', 'state'], $dimensions));
891 $wantsGeo = $geoDims || !empty($params['country']);
892 if ($wantsGeo) {
893 // Columns aliased oaddr_*/caddr_* so raw select expressions stay
894 // unambiguous without table prefixes (same note as queryCustomers).
895 $addrSub = App::db()->table('fct_order_addresses')
896 ->select('order_id')
897 ->selectRaw(
898 self::latestRowPick('country') . ' as oaddr_country, '
899 . self::latestRowPick('state') . ' as oaddr_state'
900 )
901 ->where('type', 'billing')
902 ->groupBy('order_id');
903
904 $query->leftJoinSub($addrSub, 'fc_oaddr', 'fct_orders.id', '=', 'fc_oaddr.order_id');
905
906 // LEFT JOIN on customer_id, so a guest order simply has no fallback and
907 // still lands in 'unknown' rather than dropping: fct_orders.customer_id
908 // is nullable and NULL never matches a join predicate. The customer_id > 0
909 // guard covers the other shape of missing buyer — an order carrying 0
910 // instead of NULL would otherwise join to an orphaned address row filed
911 // under customer 0 and inherit a stranger's country.
912 //
913 // customer_id is aliased caddr_customer_id for the same reason the value
914 // columns are aliased: fct_orders has a customer_id too, and exposing a
915 // second one made the existing COUNT(DISTINCT customer_id) behind the
916 // unique_customers metric ambiguous — a hard SQL error, not a wrong
917 // number. Nothing joined here may share a name with an orders column.
918 $custAddrSub = App::db()->table('fct_customer_addresses')
919 ->selectRaw('customer_id as caddr_customer_id')
920 ->selectRaw(
921 self::latestRowPick('country') . ' as caddr_country, '
922 . self::latestRowPick('state') . ' as caddr_state'
923 )
924 ->where('type', 'billing')
925 ->where('is_primary', 1)
926 ->where('customer_id', '>', 0)
927 ->groupBy('customer_id');
928
929 $query->leftJoinSub($custAddrSub, 'fc_caddr', 'fct_orders.customer_id', '=', 'fc_caddr.caddr_customer_id');
930
931 if (!empty($params['country'])) {
932 // Built from dimensionExpr() itself, so the filter and the country
933 // dimension resolve a country identically BY CONSTRUCTION — including
934 // the 'unknown' bucket, which a hand-written COALESCE here omitted,
935 // making country=unknown return zero rows while the dimension
936 // reported 98 such orders.
937 $query->whereRaw(
938 self::dimensionExpr('country') . ' = ?',
939 [sanitize_text_field($params['country'])]
940 );
941 }
942 }
943
944 $selects = [];
945 $groupCols = [];
946 $groupRaws = [];
947 foreach ($dimensions as $dim) {
948 $expr = self::dimensionExpr($dim);
949 $selects[] = $expr . ' as ' . $dim;
950 if (in_array($dim, ['country', 'state'], true)) {
951 // Group by the EXPRESSION, not the alias: the alias collides with
952 // the real fc_oaddr.country column, and MySQL resolves a GROUP BY
953 // name to the column first — which would split NULL from '' and
954 // scatter the unknowns across two rows instead of one bucket.
955 $groupRaws[] = $expr;
956 } else {
957 $groupCols[] = $dim;
958 }
959 }
960
961 $metricSql = [
962 'order_count' => 'COUNT(*) as order_count',
963 'gross_revenue' => 'SUM(total_amount) as gross_revenue',
964 'paid_revenue' => 'SUM(total_paid) as paid_revenue',
965 'refunded_amount' => 'SUM(total_refund) as refunded_amount',
966 'unique_customers' => 'COUNT(DISTINCT customer_id) as unique_customers',
967 ];
968 foreach ($metrics as $m) {
969 if (isset($metricSql[$m])) {
970 $selects[] = $metricSql[$m];
971 }
972 }
973 if (in_array('aov', $metrics, true)) {
974 if (!in_array('gross_revenue', $metrics, true)) {
975 $selects[] = $metricSql['gross_revenue'];
976 }
977 if (!in_array('order_count', $metrics, true)) {
978 $selects[] = $metricSql['order_count'];
979 }
980 }
981
982 $query->selectRaw(implode(', ', $selects));
983 foreach ($groupCols as $g) {
984 $query->groupBy($g);
985 }
986 foreach ($groupRaws as $g) {
987 $query->groupByRaw($g);
988 }
989
990 $sortDesc = !isset($params['sort_desc']) || !empty($params['sort_desc']);
991 $firstMetric = isset($metrics[0]) ? $metrics[0] : 'order_count';
992 if ($firstMetric === 'aov') {
993 $firstMetric = 'gross_revenue';
994 }
995
996 // Find the first time dimension, if any.
997 $timeDim = null;
998 foreach ($dimensions as $dim) {
999 if (in_array($dim, ['day', 'week', 'month'], true)) {
1000 $timeDim = $dim;
1001 break;
1002 }
1003 }
1004
1005 $paging = self::queryPaging($params);
1006 if ($groupCols || $groupRaws) {
1007 if ($timeDim !== null && !isset($params['sort_desc'])) {
1008 // A time series reads chronologically by default; ranking a
1009 // calendar by metric is rarely what's wanted. An explicit
1010 // sort_desc still overrides this.
1011 $query->orderBy($timeDim, 'ASC');
1012 } else {
1013 $query->orderBy($firstMetric, $sortDesc ? 'DESC' : 'ASC');
1014 }
1015 // Deterministic tie-break on the group key so offset paging never
1016 // reshuffles equal-metric rows across pages.
1017 foreach ($groupCols as $g) {
1018 $query->orderBy($g, 'ASC');
1019 }
1020 foreach ($groupRaws as $g) {
1021 // Raw, for the same alias/column collision reason as the GROUP BY.
1022 $query->orderByRaw($g . ' ASC');
1023 }
1024 }
1025 // One extra row peeks past the page boundary → meta.page.has_more.
1026 $query->limit($paging['per_page'] + 1)->offset($paging['offset']);
1027
1028 $rows = $query->get();
1029 $fetched = count($rows);
1030 $moneyMetrics = ['gross_revenue', 'paid_revenue', 'refunded_amount'];
1031
1032 $out = [];
1033 foreach ($rows as $row) {
1034 if (count($out) >= $paging['per_page']) {
1035 break;
1036 }
1037 $r = [];
1038 foreach ($dimensions as $dim) {
1039 $r[$dim] = $row->{$dim};
1040 }
1041 foreach ($metrics as $m) {
1042 if ($m === 'aov') {
1043 $g = (int) $row->gross_revenue;
1044 $c = (int) $row->order_count;
1045 $r['aov'] = MCPHelper::moneyCompact($c > 0 ? (int) round($g / $c) : 0);
1046 } elseif (in_array($m, $moneyMetrics, true)) {
1047 $r[$m] = MCPHelper::moneyCompact((int) $row->{$m});
1048 } else {
1049 $r[$m] = (int) $row->{$m};
1050 }
1051 }
1052 $out[] = $r;
1053 }
1054
1055 $summary = sprintf(
1056 /* translators: 1: row count, 2: metric list, 3: dimension list */
1057 __('%1$d rows — metrics [%2$s] grouped by [%3$s].', 'fluent-cart'),
1058 count($out),
1059 implode(', ', $metrics),
1060 $dimensions ? implode(', ', $dimensions) : __('total', 'fluent-cart')
1061 );
1062
1063 $meta = ['currency' => $currency, 'date_basis' => 'created_at', 'mode' => $mode];
1064 // Only when geography was actually asked for — an agent grouping by month
1065 // should not have to read a note about addresses.
1066 if ($wantsGeo) {
1067 $meta['geo_source'] = 'the order\'s own billing address, falling back to the buyer\'s primary billing address, then "unknown" for orders neither source can place. Rows always sum to the period total, so the "unknown" bucket shows exactly how much revenue is unattributed.';
1068 }
1069
1070 return MCPHelper::envelope(
1071 $summary,
1072 ['metrics' => $metrics, 'dimensions' => $dimensions, 'range' => self::rangeBlock($range, $currency), 'rows' => $out],
1073 array_merge($meta, self::pageMeta($paging, $fetched))
1074 );
1075 }
1076
1077 // -----------------------------------------------------------------
1078 // query-products / query-customers (flexible aggregates)
1079 // -----------------------------------------------------------------
1080
1081 public static function queryProducts($params = [])
1082 {
1083 $currency = self::currency($params);
1084 $range = self::resolveRange($params);
1085 $mode = self::orderMode($params);
1086 $metrics = self::pickList($params, 'metrics', ['units_sold', 'line_revenue', 'list_price_sum', 'discount_amount', 'net_revenue', 'order_count', 'avg_unit_price', 'refund_amount'], ['units_sold', 'line_revenue']);
1087 $dimensions = self::pickList($params, 'dimensions', ['product', 'variation', 'order_type'], ['product']);
1088
1089 $query = OrderItem::query()->whereHas('order', function ($q) use ($range, $currency, $mode) {
1090 $q->whereIn('payment_status', self::PAID)
1091 ->where('currency', $currency)
1092 ->where('created_at', '>=', $range['start'])
1093 ->where('created_at', '<=', $range['end']);
1094 self::applyMode($q, $mode);
1095 });
1096
1097 // Entity filters on the line item itself: post_id is the product, object_id
1098 // the variation. These live on fct_order_items (the base model here), so a
1099 // plain where — no join — scopes the whole aggregate to one product/variation.
1100 // Without this, dimensions=[variation] grouped across the ENTIRE catalog even
1101 // when a product_id was passed (the param was silently dropped).
1102 if (!empty($params['product_id'])) {
1103 $query->where('post_id', (int) $params['product_id']);
1104 }
1105 if (!empty($params['variation_id'])) {
1106 $query->where('object_id', (int) $params['variation_id']);
1107 }
1108
1109 // order_type lives on the parent order (fct_orders.type), not the line
1110 // item. Join orders only when it's requested so existing calls are
1111 // unchanged. order_id -> orders.id is many-to-one, so the join never fans
1112 // out line rows and the SUM()s stay identical to the ungrouped query.
1113 $groupByOrderType = in_array('order_type', $dimensions, true);
1114 if ($groupByOrderType) {
1115 $query->join('fct_orders as fctord', 'fct_order_items.order_id', '=', 'fctord.id');
1116 }
1117
1118 $hasProduct = in_array('product', $dimensions, true);
1119 $hasVariation = in_array('variation', $dimensions, true);
1120
1121 $selects = [];
1122 $groupCols = [];
1123 if ($hasProduct) {
1124 $selects[] = 'post_id';
1125 $selects[] = 'MAX(post_title) as product_title';
1126 $groupCols[] = 'post_id';
1127 }
1128 if ($hasVariation) {
1129 $selects[] = 'object_id';
1130 $groupCols[] = 'object_id';
1131 // Make variation rows self-describing so the agent needs no follow-up
1132 // lookup: the variation's own stored title, plus the parent product's
1133 // id + name when we aren't already grouping by product. A variation
1134 // belongs to exactly one product, so MAX(post_id)/MAX(post_title) is
1135 // that single product's value per group — the join never fans out.
1136 $selects[] = 'MAX(title) as variation_label';
1137 if (!$hasProduct) {
1138 $selects[] = 'MAX(post_id) as vproduct_id';
1139 $selects[] = 'MAX(post_title) as product_title';
1140 }
1141 }
1142 if ($groupByOrderType) {
1143 // Reuse the order_type -> column mapping from query-orders rather than
1144 // hard-coding it again; qualify it with the join alias.
1145 $selects[] = 'fctord.' . self::dimensionExpr('order_type') . ' as order_type';
1146 $groupCols[] = 'order_type';
1147 }
1148
1149 // line_total = subtotal - discount_total on every item (see DiscountService/
1150 // CheckoutProcessor), so list_price_sum - line_revenue == discount_amount by
1151 // construction: margin leakage is the gap between what was listed and what
1152 // was charged, before refunds.
1153 //
1154 // Every item column below is qualified with the real (prefixed) items table.
1155 // When order_type is grouped, fct_orders is joined and columns that exist on
1156 // BOTH tables — subtotal is one — make a bare SUM(subtotal) throw SQL 1052
1157 // ("column is ambiguous"). selectRaw bypasses the grammar's table-prefixing,
1158 // so the literal prefixed name (not the bare `fct_order_items`) is required.
1159 // Qualifying all of them, not just subtotal, keeps a future column collision
1160 // (or a new metric) from silently reintroducing the crash.
1161 $itemsTable = App::db()->getTableName('fct_order_items');
1162 $metricSql = [
1163 'units_sold' => 'SUM(' . $itemsTable . '.quantity) as units_sold',
1164 'line_revenue' => 'SUM(' . $itemsTable . '.line_total) as line_revenue',
1165 'list_price_sum' => 'SUM(' . $itemsTable . '.subtotal) as list_price_sum',
1166 'discount_amount' => 'SUM(' . $itemsTable . '.discount_total) as discount_amount',
1167 'net_revenue' => 'SUM(' . $itemsTable . '.line_total - ' . $itemsTable . '.refund_total) as net_revenue',
1168 'order_count' => 'COUNT(DISTINCT ' . $itemsTable . '.order_id) as order_count',
1169 'refund_amount' => 'SUM(' . $itemsTable . '.refund_total) as refund_amount',
1170 ];
1171 foreach ($metrics as $m) {
1172 if (isset($metricSql[$m])) {
1173 $selects[] = $metricSql[$m];
1174 }
1175 }
1176 if (in_array('avg_unit_price', $metrics, true)) {
1177 if (!in_array('line_revenue', $metrics, true)) {
1178 $selects[] = $metricSql['line_revenue'];
1179 }
1180 if (!in_array('units_sold', $metrics, true)) {
1181 $selects[] = $metricSql['units_sold'];
1182 }
1183 }
1184
1185 $query->selectRaw(implode(', ', $selects));
1186 foreach ($groupCols as $g) {
1187 $query->groupBy($g);
1188 }
1189
1190 $firstMetric = isset($metrics[0]) ? $metrics[0] : 'line_revenue';
1191 if ($firstMetric === 'avg_unit_price') {
1192 $firstMetric = 'line_revenue';
1193 }
1194 $paging = self::queryPaging($params);
1195 if ($groupCols && isset($metricSql[$firstMetric])) {
1196 $query->orderBy($firstMetric, 'DESC');
1197 // Deterministic tie-break on the group key for stable offset paging.
1198 foreach ($groupCols as $g) {
1199 $query->orderBy($g, 'ASC');
1200 }
1201 }
1202 $query->limit($paging['per_page'] + 1)->offset($paging['offset']);
1203
1204 $rows = $query->get();
1205 $fetched = count($rows);
1206 $moneyMetrics = ['line_revenue', 'list_price_sum', 'discount_amount', 'net_revenue', 'refund_amount', 'avg_unit_price'];
1207
1208 $out = [];
1209 foreach ($rows as $row) {
1210 if (count($out) >= $paging['per_page']) {
1211 break;
1212 }
1213 $r = [];
1214 if ($hasProduct) {
1215 $r['product_id'] = (int) $row->post_id;
1216 $r['product_name'] = $row->product_title;
1217 // product_title kept as a backward-compatible alias of product_name.
1218 $r['product_title'] = $row->product_title;
1219 } elseif ($hasVariation) {
1220 $r['product_id'] = (int) $row->vproduct_id;
1221 $r['product_name'] = $row->product_title;
1222 }
1223 if ($hasVariation) {
1224 $r['variation_id'] = (int) $row->object_id;
1225 $r['variation_label'] = $row->variation_label;
1226 }
1227 if ($groupByOrderType) {
1228 $r['order_type'] = $row->order_type;
1229 }
1230 foreach ($metrics as $m) {
1231 if ($m === 'avg_unit_price') {
1232 $u = (int) $row->units_sold;
1233 $rev = (int) $row->line_revenue;
1234 $r['avg_unit_price'] = MCPHelper::moneyCompact($u > 0 ? (int) round($rev / $u) : 0);
1235 } elseif (in_array($m, $moneyMetrics, true)) {
1236 $r[$m] = MCPHelper::moneyCompact((int) $row->{$m});
1237 } else {
1238 $r[$m] = (int) $row->{$m};
1239 }
1240 }
1241 $out[] = $r;
1242 }
1243
1244 return MCPHelper::envelope(
1245 sprintf(
1246 /* translators: 1: row count, 2: metric list */
1247 __('%1$d rows — product metrics [%2$s].', 'fluent-cart'),
1248 count($out),
1249 implode(', ', $metrics)
1250 ),
1251 ['metrics' => $metrics, 'dimensions' => $dimensions, 'range' => self::rangeBlock($range, $currency), 'rows' => $out],
1252 array_merge(['currency' => $currency, 'date_basis' => 'created_at', 'mode' => $mode], self::pageMeta($paging, $fetched))
1253 );
1254 }
1255
1256 public static function queryCustomers($params = [])
1257 {
1258 $metrics = self::pickList($params, 'metrics', ['customer_count', 'total_ltv', 'avg_ltv', 'avg_purchase_count', 'repeat_customers'], ['customer_count', 'total_ltv']);
1259 $dimensions = self::pickList($params, 'dimensions', ['country', 'state', 'status', 'first_purchase_month', 'last_purchase_month'], []);
1260
1261 $query = Customer::query();
1262
1263 // Geography resolves through the customer's primary BILLING ADDRESS,
1264 // falling back to the fct_customers profile columns.
1265 //
1266 // Neither source is complete on its own, which is the whole reason for the
1267 // coalesce. Measured on the 2,320-customer reference store: 2,176 customers
1268 // have both and the two NEVER disagree (zero rows where both are set and
1269 // differ), 96 have only the profile column, 7 have only an address row, and
1270 // 41 have neither. So reading either source alone silently drops customers
1271 // the other could place — profile-only would lose 7, address-only would lose
1272 // 96 — while the coalesce leaves just the 41 genuinely unplaceable.
1273 //
1274 // Billing address is ordered first because it is the value the buyer actually
1275 // typed, whereas the profile column is written once when the customer row is
1276 // created and never refreshed (and at checkout the seeded value can be a
1277 // country the frontend guessed from the browser timezone). On this store that
1278 // ordering changes no result; it is the correct precedence for the case where
1279 // a profile snapshot has gone stale.
1280 //
1281 // LEFT JOIN, so a customer with no address row is still counted (on the
1282 // profile value, or 'unknown'); an INNER JOIN would silently drop them
1283 // and quietly shrink every total. The join rides the existing
1284 // (customer_id, is_primary) index — measured at no material cost.
1285 //
1286 // Grouped subquery rather than a raw leftJoin: fct_customer_addresses has
1287 // no UNIQUE constraint on (customer_id, type, is_primary), so a second
1288 // primary billing row would fan the join out and double-count that
1289 // customer in COUNT(*) and SUM(ltv). Same guard the source report applies
1290 // to fct_order_operations above. MAX() is ONLY_FULL_GROUP_BY-safe and
1291 // returns the row's own value in the normal one-row case.
1292 // The subquery's columns are aliased addr_* on purpose: raw SQL fragments
1293 // are not table-prefixed by the builder, so a bare `country` in a
1294 // selectRaw would be ambiguous across the two tables and a qualified
1295 // `fct_customers.country` would miss the wp_ prefix. Distinct names keep
1296 // every raw expression unambiguous with no prefix handling at all.
1297 // latestRowPick, not MAX() per column: with more than one primary billing row
1298 // independent MAX()es can take country from one row and state from another and
1299 // report a place that does not exist. See the note in queryOrders.
1300 $addrSub = App::db()->table('fct_customer_addresses')
1301 ->select('customer_id')
1302 ->selectRaw(
1303 self::latestRowPick('country') . ' as addr_country, '
1304 . self::latestRowPick('state') . ' as addr_state'
1305 )
1306 ->where('type', 'billing')
1307 ->where('is_primary', 1)
1308 ->groupBy('customer_id');
1309
1310 $query->leftJoinSub($addrSub, 'fc_addr', 'fct_customers.id', '=', 'fc_addr.customer_id');
1311
1312 if (!empty($params['country'])) {
1313 $country = sanitize_text_field($params['country']);
1314 // Match on the resolved value, not the raw column, so the filter and
1315 // the grouping can never disagree about which country a customer is in.
1316 $query->whereRaw(
1317 "COALESCE(NULLIF(fc_addr.addr_country, ''), NULLIF(country, '')) = ?",
1318 [$country]
1319 );
1320 }
1321 // Unambiguous without qualification: the joined subquery exposes only
1322 // customer_id / addr_country / addr_state.
1323 if (!empty($params['status'])) {
1324 $query->where('status', sanitize_text_field($params['status']));
1325 }
1326 if (isset($params['min_ltv'])) {
1327 $query->where('ltv', '>=', Helper::toCent($params['min_ltv']));
1328 }
1329 if (isset($params['min_purchase_count'])) {
1330 $query->where('purchase_count', '>=', (int) $params['min_purchase_count']);
1331 }
1332
1333 $selects = [];
1334 $groupCols = [];
1335 foreach ($dimensions as $dim) {
1336 if ($dim === 'country' || $dim === 'state') {
1337 // Billing address first, profile column second, 'unknown' last.
1338 // Coalesce NULL and '' into the single 'unknown' bucket. Group by
1339 // the expression (not the alias, which would resolve to the raw
1340 // column and keep null/'' split).
1341 $expr = "COALESCE(NULLIF(fc_addr.addr_$dim, ''), NULLIF($dim, ''), 'unknown')";
1342 $selects[] = "$expr as $dim";
1343 $groupCols[] = $expr;
1344 } elseif ($dim === 'status') {
1345 // Same 'unknown' coalescing as country/state, for the same reason.
1346 // fct_customers.status is nullable with no default and 77 of the
1347 // 2,320 customers on the reference store have NULL or '' — so a bare
1348 // GROUP BY status answered "how many customers per status" with two
1349 // buckets the agent was never told about (null AND '', split apart),
1350 // neither of them in the status enum. One labelled bucket keeps the
1351 // rows summing to the customer total and makes the gap legible.
1352 $expr = "COALESCE(NULLIF(status, ''), 'unknown')";
1353 $selects[] = "$expr as status";
1354 $groupCols[] = $expr;
1355 } elseif ($dim === 'first_purchase_month') {
1356 $selects[] = "DATE_FORMAT(first_purchase_date, '%Y-%m') as first_purchase_month";
1357 $groupCols[] = "DATE_FORMAT(first_purchase_date, '%Y-%m')";
1358 } elseif ($dim === 'last_purchase_month') {
1359 $selects[] = "DATE_FORMAT(last_purchase_date, '%Y-%m') as last_purchase_month";
1360 $groupCols[] = "DATE_FORMAT(last_purchase_date, '%Y-%m')";
1361 }
1362 }
1363
1364 $metricSql = [
1365 'customer_count' => 'COUNT(*) as customer_count',
1366 'total_ltv' => 'SUM(ltv) as total_ltv',
1367 'avg_ltv' => 'AVG(ltv) as avg_ltv',
1368 'avg_purchase_count' => 'AVG(purchase_count) as avg_purchase_count',
1369 'repeat_customers' => 'SUM(CASE WHEN purchase_count > 1 THEN 1 ELSE 0 END) as repeat_customers',
1370 ];
1371 foreach ($metrics as $m) {
1372 if (isset($metricSql[$m])) {
1373 $selects[] = $metricSql[$m];
1374 }
1375 }
1376
1377 $query->selectRaw(implode(', ', $selects));
1378 if ($groupCols) {
1379 $query->groupByRaw(implode(', ', $groupCols));
1380 }
1381
1382 $paging = self::queryPaging($params);
1383 $firstMetric = isset($metrics[0]) ? $metrics[0] : 'customer_count';
1384 if ($groupCols && isset($metricSql[$firstMetric])) {
1385 $query->orderBy($firstMetric, 'DESC');
1386 // Deterministic tie-break on the grouped dimensions (their aliases)
1387 // so offset paging is stable across pages.
1388 foreach ($dimensions as $d) {
1389 $query->orderBy($d, 'ASC');
1390 }
1391 }
1392 $query->limit($paging['per_page'] + 1)->offset($paging['offset']);
1393
1394 $rows = $query->get();
1395 $fetched = count($rows);
1396 $moneyMetrics = ['total_ltv', 'avg_ltv'];
1397
1398 $out = [];
1399 foreach ($rows as $row) {
1400 if (count($out) >= $paging['per_page']) {
1401 break;
1402 }
1403 $r = [];
1404 foreach ($dimensions as $dim) {
1405 $r[$dim] = $row->{$dim};
1406 }
1407 foreach ($metrics as $m) {
1408 if (in_array($m, $moneyMetrics, true)) {
1409 $r[$m] = MCPHelper::moneyCompact((int) round((float) $row->{$m}));
1410 } elseif ($m === 'avg_purchase_count') {
1411 $r[$m] = round((float) $row->{$m}, 2);
1412 } else {
1413 $r[$m] = (int) $row->{$m};
1414 }
1415 }
1416 $out[] = $r;
1417 }
1418
1419 return MCPHelper::envelope(
1420 sprintf(
1421 /* translators: 1: row count, 2: metric list */
1422 __('%1$d rows — customer metrics [%2$s].', 'fluent-cart'),
1423 count($out),
1424 implode(', ', $metrics)
1425 ),
1426 ['metrics' => $metrics, 'dimensions' => $dimensions, 'rows' => $out],
1427 array_merge(
1428 [
1429 'currency' => MCPHelper::currencyCode(),
1430 'note' => 'LTV is in store currency; customers are not currency-scoped.',
1431 'country_source' => 'primary billing address, falling back to the customer profile location, then "unknown". Registration geography, captured at first purchase — for per-order billing geography use query-orders grouped by country.',
1432 ],
1433 self::pageMeta($paging, $fetched)
1434 )
1435 );
1436 }
1437
1438 // -----------------------------------------------------------------
1439 // query-subscriptions (flexible aggregate)
1440 // -----------------------------------------------------------------
1441
1442 public static function querySubscriptions($params = [])
1443 {
1444 $range = self::resolveRange($params);
1445 $dateBasis = self::subDateBasis($params);
1446 $metrics = self::pickList($params, 'metrics', ['subscription_count', 'contract_value', 'recurring_value'], ['subscription_count', 'contract_value']);
1447 $dimensions = self::pickList($params, 'dimensions', ['month', 'plan_type', 'status', 'billing_interval'], []);
1448
1449 // Same UTC window resolution as every other report; the chosen date_basis
1450 // is the only thing that varies (signup cohort vs churn vs upcoming).
1451 $query = Subscription::query()
1452 ->where($dateBasis, '>=', $range['start'])
1453 ->where($dateBasis, '<=', $range['end']);
1454
1455 if (!empty($params['product_id'])) {
1456 $query->where('product_id', (int) $params['product_id']);
1457 }
1458 if (!empty($params['status'])) {
1459 $query->where('status', sanitize_text_field($params['status']));
1460 }
1461 $planType = isset($params['plan_type']) && in_array($params['plan_type'], ['installment', 'recurring'], true) ? $params['plan_type'] : 'all';
1462 if ($planType !== 'all') {
1463 $query->ofPlanType($planType);
1464 }
1465
1466 // plan_type is derived from bill_times with the SAME threshold as
1467 // Subscription::isInstallment(), so the SQL and PHP definitions agree.
1468 $planExpr = "CASE WHEN bill_times > 0 THEN 'installment' ELSE 'recurring' END";
1469
1470 $selects = [];
1471 $groupExpr = [];
1472 foreach ($dimensions as $dim) {
1473 if ($dim === 'month') {
1474 $expr = "DATE_FORMAT($dateBasis, '%Y-%m')";
1475 } elseif ($dim === 'plan_type') {
1476 $expr = $planExpr;
1477 } else {
1478 // status / billing_interval — plain columns.
1479 $expr = $dim;
1480 }
1481 $selects[] = $expr . ' as ' . $dim;
1482 $groupExpr[] = $expr;
1483 }
1484
1485 // contract_value is the SUM form of Subscription::totalContractValue()
1486 // (recurring_total * bill_times) — installments booked at full committed
1487 // value, 0 for open-ended plans. No parallel money math.
1488 $metricSql = [
1489 'subscription_count' => 'COUNT(*) as subscription_count',
1490 'contract_value' => 'SUM(recurring_total * bill_times) as contract_value',
1491 'recurring_value' => 'SUM(recurring_total) as recurring_value',
1492 ];
1493 foreach ($metrics as $m) {
1494 if (isset($metricSql[$m])) {
1495 $selects[] = $metricSql[$m];
1496 }
1497 }
1498
1499 $query->selectRaw(implode(', ', $selects));
1500 if ($groupExpr) {
1501 $query->groupByRaw(implode(', ', $groupExpr));
1502 }
1503
1504 $paging = self::queryPaging($params);
1505 $firstMetric = isset($metrics[0]) ? $metrics[0] : 'subscription_count';
1506 if ($groupExpr && isset($metricSql[$firstMetric])) {
1507 $query->orderBy($firstMetric, 'DESC');
1508 // Deterministic tie-break on the grouped dimensions (their aliases).
1509 foreach ($dimensions as $d) {
1510 $query->orderBy($d, 'ASC');
1511 }
1512 }
1513 $query->limit($paging['per_page'] + 1)->offset($paging['offset']);
1514
1515 $rows = $query->get();
1516 $fetched = count($rows);
1517 $moneyMetrics = ['contract_value', 'recurring_value'];
1518
1519 $out = [];
1520 foreach ($rows as $row) {
1521 if (count($out) >= $paging['per_page']) {
1522 break;
1523 }
1524 $r = [];
1525 foreach ($dimensions as $dim) {
1526 $r[$dim] = $row->{$dim};
1527 }
1528 foreach ($metrics as $m) {
1529 if (in_array($m, $moneyMetrics, true)) {
1530 $r[$m] = MCPHelper::moneyCompact((int) $row->{$m});
1531 } else {
1532 $r[$m] = (int) $row->{$m};
1533 }
1534 }
1535 $out[] = $r;
1536 }
1537
1538 return MCPHelper::envelope(
1539 sprintf(
1540 /* translators: 1: row count, 2: metric list, 3: dimension list */
1541 __('%1$d rows — subscription metrics [%2$s] grouped by [%3$s].', 'fluent-cart'),
1542 count($out),
1543 implode(', ', $metrics),
1544 $dimensions ? implode(', ', $dimensions) : __('total', 'fluent-cart')
1545 ),
1546 ['metrics' => $metrics, 'dimensions' => $dimensions, 'date_basis' => $dateBasis, 'range' => self::rangeBlock($range, MCPHelper::currencyCode()), 'rows' => $out],
1547 array_merge([
1548 'currency' => MCPHelper::currencyCode(),
1549 'date_basis' => $dateBasis,
1550 'note' => 'Money is in the store currency; subscriptions are not currency-scoped. contract_value books installments at recurring_total x bill_times; completed installments are paid-in-full, not churn.',
1551 ], self::pageMeta($paging, $fetched))
1552 );
1553 }
1554
1555 private static function subDateBasis($params)
1556 {
1557 $allowed = ['created_at', 'canceled_at', 'next_billing_date'];
1558
1559 return isset($params['date_basis']) && in_array($params['date_basis'], $allowed, true) ? $params['date_basis'] : 'created_at';
1560 }
1561
1562 /**
1563 * Restrict an Order-model aggregate to orders CONTAINING a given product /
1564 * variation. Uses whereHas on order_items (post_id = product, object_id =
1565 * variation) so the filter is a subquery, not a join — order-level metrics stay
1566 * per-order and never fan out. Both filters combine (AND) when both are given.
1567 */
1568 private static function applyOrderItemFilter($query, $params)
1569 {
1570 foreach (['product_id' => 'post_id', 'variation_id' => 'object_id'] as $param => $col) {
1571 if (!empty($params[$param])) {
1572 $val = (int) $params[$param];
1573 $query->whereHas('order_items', function ($q) use ($col, $val) {
1574 $q->where($col, $val);
1575 });
1576 }
1577 }
1578 }
1579
1580 /**
1581 * Aggregate expression returning $column from the HIGHEST-id row in the group.
1582 *
1583 * Address tables have no UNIQUE constraint on (parent, type), so a group can hold
1584 * several rows and the newest is the one the buyer last entered. Two columns each
1585 * aggregated with a bare MAX() can come from two different rows and describe a
1586 * place that never existed (a real case: country from one row, state from another).
1587 *
1588 * Prefixing each value with its zero-padded id makes lexicographic MAX() agree
1589 * with numeric id order, so every column built this way resolves to the SAME row;
1590 * SUBSTRING then drops the prefix. The width is 20 because that is exactly the
1591 * digit count of the largest BIGINT UNSIGNED (18446744073709551615) — no id can
1592 * overflow the padding, so all prefixes are equal-length and compare numerically.
1593 * Ids are unique within a group (id is the PK), so the prefix alone always decides
1594 * the winner and the collation of the value suffix can never influence it.
1595 *
1596 * COALESCE(...,'') matters — CONCAT with NULL is NULL and MAX() skips NULLs, which
1597 * would let a NULL column fall back to a different row and reintroduce the mixing
1598 * this exists to prevent.
1599 *
1600 * Portable to MySQL 5.6+ (no window functions) and needs no nested join, unlike
1601 * ORDER BY ... LIMIT 1 or ROW_NUMBER().
1602 *
1603 * @param string $column trusted column name — never interpolate caller input here
1604 * @return string
1605 */
1606 private static function latestRowPick($column)
1607 {
1608 return "SUBSTRING(MAX(CONCAT(LPAD(id, 20, '0'), COALESCE($column, ''))), 21)";
1609 }
1610
1611 private static function dimensionExpr($dim)
1612 {
1613 if ($dim === 'day') {
1614 return "DATE_FORMAT(created_at, '%Y-%m-%d')";
1615 }
1616 if ($dim === 'week') {
1617 return "DATE_FORMAT(created_at, '%x-W%v')";
1618 }
1619 if ($dim === 'month') {
1620 return "DATE_FORMAT(created_at, '%Y-%m')";
1621 }
1622 if ($dim === 'order_type') {
1623 // The order-type values (payment | renewal | subscription) live on the
1624 // fct_orders.type column; expose it under the order_type alias so the
1625 // dimension name and response key read naturally and don't collide
1626 // with the unrelated payment_type on line items.
1627 return 'type';
1628 }
1629 if ($dim === 'country' || $dim === 'state') {
1630 // ONE source per order, decided by whether the order has a billing
1631 // address row at all (fc_oaddr.order_id IS NULL) — never per column.
1632 //
1633 // Coalescing each column independently mixes provenance inside a single
1634 // order and invents places: order 16 on the reference store has its own
1635 // billing row (BG, '') while its buyer's address says (BG, BG-22), so a
1636 // per-column COALESCE reported that order's state as BG-22 — a value
1637 // from a mutable customer record, for an order that carries its own
1638 // address. 38 paid orders were affected. It is the same row-mixing
1639 // latestRowPick() prevents inside a table, reappearing across tables.
1640 //
1641 // Consequence, deliberately: an order whose own address has a country
1642 // but a blank state reports state 'unknown' rather than borrowing one.
1643 // That is the honest answer — that order's address genuinely has no
1644 // state — and it keeps historical attribution stable when a customer
1645 // later edits their address.
1646 //
1647 // Orders neither source can place get the 'unknown' bucket rather than
1648 // being dropped, so rows still sum to the period's total revenue and the
1649 // size of the gap stays visible.
1650 $pick = "CASE WHEN fc_oaddr.order_id IS NULL THEN fc_caddr.caddr_$dim ELSE fc_oaddr.oaddr_$dim END";
1651
1652 return "COALESCE(NULLIF($pick, ''), 'unknown')";
1653 }
1654 return $dim;
1655 }
1656
1657 // -----------------------------------------------------------------
1658 // shared helpers
1659 // -----------------------------------------------------------------
1660
1661 private static function currency($params)
1662 {
1663 if (!empty($params['currency'])) {
1664 return strtoupper(sanitize_text_field($params['currency']));
1665 }
1666 return MCPHelper::currencyCode();
1667 }
1668
1669 /**
1670 * Effective order-mode filter: 'live', 'test', or 'all'. Reports have always
1671 * counted BOTH live and test orders, so 'all' (the default) keeps existing
1672 * numbers unchanged; an agent opts into 'live' for clean revenue. Applied to
1673 * the fct_orders.mode column.
1674 */
1675 private static function orderMode($params)
1676 {
1677 $m = isset($params['mode']) ? strtolower(sanitize_text_field((string) $params['mode'])) : 'all';
1678 return in_array($m, ['live', 'test'], true) ? $m : 'all';
1679 }
1680
1681 /**
1682 * Apply the mode filter to an Order query (or an order-relation subquery /
1683 * whereHas closure). 'all' is a no-op so existing numbers are unchanged. The
1684 * column is fct_orders.mode; pass a qualified name via $column when the orders
1685 * table is aliased (e.g. query-sources uses 'o.mode').
1686 */
1687 private static function applyMode($query, $mode, $column = 'mode')
1688 {
1689 if ($mode !== 'all') {
1690 $query->where($column, $mode);
1691 }
1692 return $query;
1693 }
1694
1695 /**
1696 * Resolve range/start/end into a UTC window plus the prior equal-length
1697 * window. Relative ranges are computed in store timezone, expressed in UTC.
1698 *
1699 * Public so the single source of truth for MCP date-window resolution is
1700 * shared (e.g. list-transactions) instead of duplicated — every tool then
1701 * accepts the identical range vocabulary and UTC semantics.
1702 */
1703 public static function resolveRange($params)
1704 {
1705 // Resolve windows in UTC to match FluentCart's own admin reports, which
1706 // bucket on the GMT-stored created_at (DATE_FORMAT(created_at, ...)) with
1707 // no timezone conversion. Using store-local boundaries here would make a
1708 // local day straddle two UTC dates and emit an extra trailing bucket.
1709 $tz = new \DateTimeZone('UTC');
1710
1711 // Delta mode: everything strictly after an instant, up to now. Time-precise
1712 // (not snapped to a day) so "what changed since 14:05" works during a launch.
1713 if (!empty($params['since'])) {
1714 $start = self::instant($params['since'], $tz, false);
1715 if ($start !== null) {
1716 return self::withPrior($start, gmdate('Y-m-d H:i:s'), 'since');
1717 }
1718 }
1719
1720 // Time-precise custom window (ISO 8601). A time of day is kept; a date-only
1721 // value snaps to the day edge. Overrides range and the legacy start/end_date.
1722 if (!empty($params['date_from']) || !empty($params['date_to'])) {
1723 $start = self::instant(!empty($params['date_from']) ? $params['date_from'] : '-30 days', $tz, false);
1724 $end = self::instant(!empty($params['date_to']) ? $params['date_to'] : 'now', $tz, true);
1725 if ($start !== null && $end !== null) {
1726 return self::withPrior($start, $end, 'custom');
1727 }
1728 }
1729
1730 if (!empty($params['start_date']) || !empty($params['end_date'])) {
1731 $start = self::dayStart(!empty($params['start_date']) ? $params['start_date'] : '-30 days', $tz);
1732 $end = self::dayEnd(!empty($params['end_date']) ? $params['end_date'] : 'now', $tz);
1733 return self::withPrior($start, $end, !empty($params['start_date']) ? 'custom' : 'last_30_days');
1734 }
1735
1736 $range = isset($params['range']) && in_array($params['range'], self::RANGES, true) ? $params['range'] : 'last_30_days';
1737
1738 // since_launch (alias: all_time): the store's first paid order to now.
1739 // Needs a DB read, so it sits here rather than in the pure calendar math
1740 // below. Falls back to the last 30 days if the store has no paid orders
1741 // yet. Both names resolve identically — for a paid-order-scoped report the
1742 // first paid order IS the start of all data — so an agent that learned
1743 // all_time from get-product-financials succeeds here too. The label echoes
1744 // whichever name was requested.
1745 if ($range === 'since_launch' || $range === 'all_time') {
1746 $launch = self::storeLaunchDate();
1747 $start = $launch ? $launch : self::dayStart('-30 days', $tz);
1748 return self::withPrior($start, gmdate('Y-m-d H:i:s'), $range);
1749 }
1750
1751 $now = new \DateTime('now', $tz);
1752 $startDt = clone $now;
1753 $endDt = clone $now;
1754 // Set for calendar-bounded ranges to force a calendar-aligned prior period.
1755 $prevStartDt = null;
1756 $prevEndDt = null;
1757
1758 if ($range === 'yesterday') {
1759 $startDt->modify('-1 day');
1760 $endDt->modify('-1 day');
1761 } elseif ($range === 'last_7_days') {
1762 $startDt->modify('-6 days');
1763 } elseif ($range === 'last_30_days') {
1764 $startDt->modify('-29 days');
1765 } elseif ($range === 'this_month' || $range === 'mtd') {
1766 $startDt = new \DateTime($now->format('Y-m-01'), $tz);
1767 } elseif ($range === 'last_month') {
1768 $startDt = new \DateTime($now->format('Y-m-01'), $tz);
1769 $startDt->modify('-1 month');
1770 $endDt = (clone $startDt)->modify('last day of this month');
1771 // Prior = the full calendar month before last month.
1772 $prevStartDt = (clone $startDt)->modify('-1 month');
1773 $prevEndDt = (clone $prevStartDt)->modify('last day of this month');
1774 } elseif ($range === 'qtd') {
1775 $startDt = self::quarterStart($now, $tz);
1776 } elseif ($range === 'last_quarter') {
1777 $qs = self::quarterStart($now, $tz);
1778 $startDt = (clone $qs)->modify('-3 months');
1779 $endDt = (clone $qs)->modify('-1 day');
1780 // Prior = the full calendar quarter before last quarter.
1781 $prevStartDt = (clone $startDt)->modify('-3 months');
1782 $prevEndDt = (clone $startDt)->modify('-1 day');
1783 } elseif ($range === 'ytd') {
1784 $startDt = new \DateTime($now->format('Y-01-01'), $tz);
1785 } elseif ($range === 'last_year') {
1786 $year = (int) $now->format('Y') - 1;
1787 $startDt = new \DateTime($year . '-01-01', $tz);
1788 $endDt = new \DateTime($year . '-12-31', $tz);
1789 // Prior = the full calendar year before last year.
1790 $prevStartDt = new \DateTime(($year - 1) . '-01-01', $tz);
1791 $prevEndDt = new \DateTime(($year - 1) . '-12-31', $tz);
1792 }
1793
1794 $start = self::dayStart($startDt->format('Y-m-d'), $tz);
1795 $end = self::dayEnd($endDt->format('Y-m-d'), $tz);
1796
1797 if ($prevStartDt !== null && $prevEndDt !== null) {
1798 return self::withPrior(
1799 $start,
1800 $end,
1801 $range,
1802 self::dayStart($prevStartDt->format('Y-m-d'), $tz),
1803 self::dayEnd($prevEndDt->format('Y-m-d'), $tz)
1804 );
1805 }
1806
1807 return self::withPrior($start, $end, $range);
1808 }
1809
1810 private static function quarterStart($now, $tz)
1811 {
1812 $month = (int) $now->format('n');
1813 $qStartMonth = (int) (floor(($month - 1) / 3) * 3 + 1);
1814 return new \DateTime($now->format('Y') . '-' . str_pad($qStartMonth, 2, '0', STR_PAD_LEFT) . '-01', $tz);
1815 }
1816
1817 private static function withPrior($startUtc, $endUtc, $label, $prevStartUtc = null, $prevEndUtc = null)
1818 {
1819 // Calendar-bounded ranges (last_month/last_quarter/last_year) pass an
1820 // explicit prior *calendar* period so a 31-day month isn't compared to a
1821 // 28-day second-count window. Other ranges fall back to an equal-length
1822 // block ending 1s before start (exact for fixed-length, rolling, custom).
1823 if ($prevStartUtc !== null && $prevEndUtc !== null) {
1824 return [
1825 'start' => $startUtc,
1826 'end' => $endUtc,
1827 'prev_start' => $prevStartUtc,
1828 'prev_end' => $prevEndUtc,
1829 'label' => $label,
1830 ];
1831 }
1832
1833 $s = new \DateTime($startUtc, new \DateTimeZone('UTC'));
1834 $e = new \DateTime($endUtc, new \DateTimeZone('UTC'));
1835 $lengthSec = $e->getTimestamp() - $s->getTimestamp();
1836
1837 $prevEnd = (clone $s)->modify('-1 second');
1838 $prevStart = (clone $prevEnd)->modify('-' . ($lengthSec + 1) . ' seconds');
1839
1840 return [
1841 'start' => $startUtc,
1842 'end' => $endUtc,
1843 'prev_start' => $prevStart->format('Y-m-d H:i:s'),
1844 'prev_end' => $prevEnd->format('Y-m-d H:i:s'),
1845 'label' => $label,
1846 ];
1847 }
1848
1849 /**
1850 * Parse an ISO-8601 / relative value to a UTC 'Y-m-d H:i:s'. A date-only input
1851 * (YYYY-MM-DD) is snapped to the day start (or end when $isEnd); an explicit
1852 * time is preserved. Returns null on an unparseable value so the caller can
1853 * fall back to the next window source rather than silently matching all rows.
1854 */
1855 private static function instant($value, $tz, $isEnd = false)
1856 {
1857 try {
1858 $dt = new \DateTime((string) $value, $tz);
1859 } catch (\Exception $e) {
1860 return null;
1861 }
1862 if (preg_match('/^\d{4}-\d{2}-\d{2}$/', trim((string) $value))) {
1863 $dt->setTime($isEnd ? 23 : 0, $isEnd ? 59 : 0, $isEnd ? 59 : 0);
1864 }
1865 $dt->setTimezone(new \DateTimeZone('UTC'));
1866 return $dt->format('Y-m-d H:i:s');
1867 }
1868
1869 /**
1870 * The store's first paid order timestamp (UTC); null if the store has no paid
1871 * orders yet. Only hit on the since_launch path (one indexed min per call), so
1872 * it is not memoized — a static cache would leak across calls in a long-lived
1873 * process (e.g. the test runner) for no real per-request gain.
1874 */
1875 private static function storeLaunchDate()
1876 {
1877 $min = Order::query()->whereIn('payment_status', self::PAID)->min('created_at');
1878 return ($min && strpos((string) $min, '0000-00-00') !== 0) ? (string) $min : null;
1879 }
1880
1881 private static function dayStart($value, $tz)
1882 {
1883 try {
1884 $dt = new \DateTime((string) $value, $tz);
1885 } catch (\Exception $e) {
1886 $dt = new \DateTime('now', $tz);
1887 }
1888 $dt->setTime(0, 0, 0);
1889 $dt->setTimezone(new \DateTimeZone('UTC'));
1890 return $dt->format('Y-m-d H:i:s');
1891 }
1892
1893 private static function dayEnd($value, $tz)
1894 {
1895 try {
1896 $dt = new \DateTime((string) $value, $tz);
1897 } catch (\Exception $e) {
1898 $dt = new \DateTime('now', $tz);
1899 }
1900 $dt->setTime(23, 59, 59);
1901 $dt->setTimezone(new \DateTimeZone('UTC'));
1902 return $dt->format('Y-m-d H:i:s');
1903 }
1904
1905 private static function rangeBlock($range, $currency)
1906 {
1907 return [
1908 'start' => MCPHelper::toIso8601($range['start']),
1909 'end' => MCPHelper::toIso8601($range['end']),
1910 'label' => $range['label'],
1911 'currency' => $currency,
1912 ];
1913 }
1914
1915 private static function pickList($params, $key, array $allowed, array $default)
1916 {
1917 if (empty($params[$key]) || !is_array($params[$key])) {
1918 return $default;
1919 }
1920 $out = [];
1921 foreach ($params[$key] as $v) {
1922 if (in_array($v, $allowed, true) && !in_array($v, $out, true)) {
1923 $out[] = $v;
1924 }
1925 }
1926 return $out ? $out : $default;
1927 }
1928
1929 /**
1930 * Page/offset for the query-* aggregates. per_page defaults to and is clamped
1931 * at MAX_ROWS — grouped rows are compact but a single page still can't exceed
1932 * the context guardrail. Returns page, per_page and the row offset.
1933 */
1934 private static function queryPaging($params)
1935 {
1936 $page = isset($params['page']) ? max(1, (int) $params['page']) : 1;
1937 $perPage = isset($params['per_page']) ? (int) $params['per_page'] : self::MAX_ROWS;
1938 if ($perPage < 1 || $perPage > self::MAX_ROWS) {
1939 $perPage = self::MAX_ROWS;
1940 }
1941 return ['page' => $page, 'per_page' => $perPage, 'offset' => ($page - 1) * $perPage];
1942 }
1943
1944 /**
1945 * meta.page block for a query-* aggregate. The query fetches per_page + 1 rows
1946 * to peek past the page boundary; $fetchedCount is that raw count. `truncated`
1947 * is kept (never removed — additive API rule) and now means "more rows exist
1948 * beyond this page"; raise `page` to fetch them.
1949 */
1950 private static function pageMeta($paging, $fetchedCount)
1951 {
1952 $hasMore = $fetchedCount > $paging['per_page'];
1953 return [
1954 'page' => [
1955 'current' => $paging['page'],
1956 'per_page' => $paging['per_page'],
1957 'has_more' => $hasMore,
1958 ],
1959 'truncated' => $hasMore,
1960 ];
1961 }
1962
1963 private static function pct($current, $prior)
1964 {
1965 if ($prior == 0) {
1966 return $current == 0 ? 0 : null;
1967 }
1968 return round((($current - $prior) / abs($prior)) * 100, 2);
1969 }
1970 }
1971