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 / ProductFinancialsTools.php

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

515 lines 24.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\Models\Product;
6 use FluentCart\App\Models\OrderItem;
7 use FluentCart\App\Models\Subscription;
8 use FluentCart\App\Modules\MCP\Support\MCPHelper;
9 use FluentCart\App\Modules\MCP\Support\PermissionGate;
10 use FluentCart\App\Modules\MCP\Support\ProductFinancialsCalculator as Calc;
11
12 /**
13 * get-product-financials — the one honest financial picture for a single
14 * product: one-time revenue, finite split-pay commitments, and perpetual
15 * run-rate (MRR/ARR + forward schedule), with no cross-currency summing and no
16 * folding of forward commitments into "revenue this period".
17 *
18 * The subtle rule the whole tool is built around (see spec §6):
19 * - The `window` (range/date_from/date_to + date_basis) applies to the
20 * one_time block ONLY.
21 * - Everything under subscriptions.*, payment_schedule, and totals is
22 * point-in-time as of `as_of` (lifetime-to-date or forward).
23 * This is echoed back in meta.field_semantics so an agent cannot misread it.
24 *
25 * All math lives in the pure ProductFinancialsCalculator (unit-tested without a
26 * DB); this class only loads rows, scopes currency, and wraps money.
27 */
28 class ProductFinancialsTools
29 {
30 /** Payment statuses that count as realized revenue (matches ReportTools/ContextTools). */
31 const PAID = ['paid', 'partially_paid', 'partially_refunded'];
32
33 /** Safety ceiling on subscriptions loaded for one product. */
34 const MAX_SUBS = 50000;
35
36 public static function definitions()
37 {
38 return [
39 'fluent-cart/get-product-financials' => [
40 'label' => __('Get Product Financials', 'fluent-cart'),
41 'description' => __('One product\'s complete financials in a call: one-time revenue, finite split-pay commitments (bill_times > 0), and perpetual run-rate (MRR/ARR) with a forward payment_schedule. Never sums across currencies. KEY: the time window (range/date_from/date_to + date_basis) applies ONLY to the one_time block — subscriptions.*, payment_schedule and totals are point-in-time as of as_of (lifetime-to-date or forward), so "revenue in the last 30 days" must read one_time.* only and never folds in contract value or MRR. For a windowed time series use get-sales-trend; for renewal cash in a period use query-products with order_type=renewal. totals.total_contracted is null while any perpetual subscription is active — read recurring.mrr/arr and payment_schedule instead, and meta.notes says why. Single currency: store default, other currencies for the product listed in meta.other_currencies. Money is {amount, amount_cents, currency, display}.', 'fluent-cart'),
42 'input_schema' => [
43 'type' => 'object',
44 'properties' => [
45 'product_id' => ['type' => 'integer', 'description' => 'Product to report on.'],
46 'variation_id' => ['type' => 'integer', 'description' => 'Restrict to one variation.'],
47 'currency' => ['type' => 'string', 'description' => 'ISO currency for the (single-currency) report. Defaults to store currency.'],
48 'range' => ['type' => 'string', 'enum' => ['today', 'yesterday', 'last_7_days', 'last_30_days', 'this_month', 'last_month', 'mtd', 'qtd', 'ytd', 'last_quarter', 'last_year', 'all_time', 'since_launch'], 'default' => 'all_time', 'description' => 'Window for the one_time block only. Resolved in UTC. all_time (alias: since_launch) spans all data.'],
49 'date_from' => ['type' => 'string', 'description' => 'YYYY-MM-DD or ISO 8601, UTC. Overrides range.'],
50 'date_to' => ['type' => 'string', 'description' => 'YYYY-MM-DD or ISO 8601, UTC. Overrides range.'],
51 'date_basis' => ['type' => 'string', 'enum' => ['created_at', 'paid_at'], 'default' => 'paid_at', 'description' => 'One-time window basis: paid_at = order completed_at (cash received); created_at = order placed. Does not affect forward fields.'],
52 'as_of' => ['type' => 'string', 'description' => 'ISO 8601, UTC. Point-in-time for forward metrics. Default now.'],
53 'horizon' => ['type' => 'string', 'enum' => ['3m', '6m', '12m'], 'default' => '12m', 'description' => 'How far to project payment_schedule.'],
54 'schedule_bucket' => ['type' => 'string', 'enum' => ['month', 'week'], 'default' => 'month', 'description' => 'Calendar granularity of payment_schedule.'],
55 'subscription_status' => [
56 'type' => 'array',
57 'description' => 'Which statuses feed forward metrics (scheduled_remaining, mrr, next_*_scheduled, payment_schedule). Default [active]. Use [all] to include every status. status_breakdown is always the full picture regardless.',
58 'items' => ['type' => 'string', 'enum' => ['active', 'trialing', 'past_due', 'paused', 'canceled', 'all']],
59 ],
60 'include' => [
61 'type' => 'array',
62 'description' => 'Opt-in sections. schedule = the full payment_schedule[] (can be long; next_30d/next_90d scalars are always returned).',
63 'items' => ['type' => 'string', 'enum' => ['schedule']],
64 ],
65 ],
66 'required' => ['product_id'],
67 ],
68 'execute_callback' => [self::class, 'getProductFinancials'],
69 'permission_callback' => function () {
70 return PermissionGate::can('reports/view');
71 },
72 'annotations' => ['readonly' => true],
73 ],
74 ];
75 }
76
77 public static function getProductFinancials($params = [])
78 {
79 if (empty($params['product_id'])) {
80 return MCPHelper::error('missing_identifier', __('product_id is required.', 'fluent-cart'));
81 }
82 $productId = (int) $params['product_id'];
83
84 $product = Product::query()->where('ID', $productId)->first();
85 if (!$product) {
86 return MCPHelper::error('product_not_found', __('No product found for the given product_id.', 'fluent-cart'));
87 }
88
89 $variationId = !empty($params['variation_id']) ? (int) $params['variation_id'] : null;
90 $currency = !empty($params['currency']) ? strtoupper(sanitize_text_field($params['currency'])) : MCPHelper::currencyCode();
91
92 $window = self::resolveWindow($params);
93 if (is_wp_error($window)) {
94 return $window;
95 }
96 $asOf = self::resolveAsOf($params);
97 $horizon = self::resolveHorizonMonths($params);
98 $bucket = (isset($params['schedule_bucket']) && $params['schedule_bucket'] === 'week') ? 'week' : 'month';
99 $forward = self::resolveForwardStatuses($params);
100 $include = isset($params['include']) ? (array) $params['include'] : [];
101
102 // ---- One-time revenue (respects window + date_basis) ----
103 $oneTime = self::loadOneTime($productId, $variationId, $currency, $window);
104
105 // ---- Subscriptions (all statuses so status_breakdown is complete) ----
106 $subLoad = self::loadSubscriptions($productId, $variationId);
107 list($kept, $otherCurrencies) = Calc::filterByCurrency($subLoad['rows'], $currency);
108
109 // ---- Pure computation ----
110 $result = Calc::compute($kept, [
111 'as_of' => $asOf,
112 'horizon_months' => $horizon,
113 'bucket' => $bucket,
114 'forward_statuses' => $forward,
115 'include_schedule' => in_array('schedule', $include, true),
116 'one_time' => $oneTime,
117 ]);
118
119 $data = self::formatData($product, $currency, $asOf, $window, $result);
120
121 $meta = [
122 'currency' => $currency,
123 'other_currencies' => $otherCurrencies,
124 'field_semantics' => [
125 'respect_window' => ['one_time'],
126 'point_in_time' => ['subscriptions', 'payment_schedule', 'totals'],
127 ],
128 'notes' => $result['meta_notes'],
129 ];
130 if ($subLoad['truncated']) {
131 $meta['warnings'] = [sprintf(
132 /* translators: %1$d: subscription load cap */
133 __('More than %1$d subscriptions exist for this product; figures use the first %1$d and may be incomplete.', 'fluent-cart'),
134 self::MAX_SUBS
135 )];
136 }
137
138 return MCPHelper::envelope(self::summary($product, $currency, $result), $data, $meta);
139 }
140
141 // -----------------------------------------------------------------
142 // Loaders
143 // -----------------------------------------------------------------
144
145 /**
146 * One-time revenue: non-subscription line items on realized-revenue orders,
147 * scoped to the report currency and the window (by date_basis column on the
148 * parent order). Returns integer cents/counts for the calculator.
149 */
150 private static function loadOneTime($productId, $variationId, $currency, $window)
151 {
152 $dateCol = $window['basis'] === 'created_at' ? 'created_at' : 'completed_at';
153 $from = $window['from'];
154 $to = $window['to'];
155
156 $query = OrderItem::query()
157 ->where('post_id', $productId)
158 ->where('payment_type', '!=', 'subscription')
159 ->whereHas('order', function ($q) use ($currency, $dateCol, $from, $to) {
160 $q->whereIn('payment_status', self::PAID)
161 ->where('currency', $currency)
162 ->where($dateCol, '>=', $from)
163 ->where($dateCol, '<=', $to);
164 });
165
166 if ($variationId !== null) {
167 $query->where('object_id', $variationId);
168 }
169
170 $row = $query->selectRaw(
171 'COALESCE(SUM(quantity), 0) as units, '
172 . 'COALESCE(SUM(line_total), 0) as gross, '
173 . 'COALESCE(SUM(refund_total), 0) as refunds, '
174 . 'COUNT(DISTINCT order_id) as orders'
175 )->first();
176
177 return [
178 'units' => $row ? (int) $row->units : 0,
179 'gross' => $row ? (int) $row->gross : 0,
180 'refunds' => $row ? (int) $row->refunds : 0,
181 'orders' => $row ? (int) $row->orders : 0,
182 ];
183 }
184
185 /**
186 * All subscriptions for the product (every status — the breakdown must be
187 * complete). Currency is derived per row from config JSON (there is no
188 * currency column) so the calculator can scope by it. Columns are limited
189 * and the model is read attribute-by-attribute so the heavy $appends
190 * accessors (url, billingInfo, overridden_status, …) never fire.
191 */
192 private static function loadSubscriptions($productId, $variationId)
193 {
194 $store = MCPHelper::currencyCode();
195
196 $query = Subscription::query()->where('product_id', $productId);
197 if ($variationId !== null) {
198 $query->where('variation_id', $variationId);
199 }
200
201 /** @var \FluentCart\Framework\Database\Orm\Collection $subs */
202 $subs = $query
203 ->orderBy('id', 'ASC')
204 ->limit(self::MAX_SUBS + 1)
205 ->get(['id', 'billing_interval', 'recurring_total', 'bill_count', 'bill_times', 'status', 'next_billing_date', 'variation_id', 'config']);
206
207 $truncated = false;
208 if (method_exists($subs, 'count') && $subs->count() > self::MAX_SUBS) {
209 $truncated = true;
210 $subs = $subs->slice(0, self::MAX_SUBS);
211 }
212
213 $rows = [];
214 foreach ($subs as $sub) {
215 $config = is_array($sub->config) ? $sub->config : [];
216 $cur = isset($config['currency']) && $config['currency'] !== '' ? strtoupper((string) $config['currency']) : strtoupper($store);
217 $rows[] = [
218 'currency' => $cur,
219 'billing_interval' => $sub->billing_interval,
220 'recurring_total' => (int) $sub->recurring_total,
221 'bill_count' => (int) $sub->bill_count,
222 'bill_times' => (int) $sub->bill_times,
223 'status' => (string) $sub->status,
224 'next_billing_date' => $sub->next_billing_date,
225 ];
226 }
227
228 return ['rows' => $rows, 'truncated' => $truncated];
229 }
230
231 // -----------------------------------------------------------------
232 // Formatting (cents -> money envelope)
233 // -----------------------------------------------------------------
234
235 private static function formatData($product, $currency, $asOf, $window, $result)
236 {
237 $data = [
238 'product_id' => (int) $product->ID,
239 'product_name' => $product->post_title,
240 'currency' => $currency,
241 'as_of' => MCPHelper::toIso8601($asOf),
242 'window' => [
243 'from' => MCPHelper::toIso8601($window['from']),
244 'to' => MCPHelper::toIso8601($window['to']),
245 'range' => $window['range'],
246 'basis' => $window['basis'] === 'created_at' ? 'created_at' : 'paid_at',
247 ],
248 'one_time' => self::formatOneTime($result['one_time'], $currency),
249 'subscriptions' => self::formatSubscriptions($result['subscriptions'], $currency),
250 'totals' => self::formatTotals($result['totals'], $currency),
251 ];
252
253 if ($result['payment_schedule'] !== null) {
254 $data['payment_schedule'] = self::formatSchedule($result['payment_schedule'], $currency);
255 }
256
257 return $data;
258 }
259
260 private static function formatOneTime($o, $currency)
261 {
262 if ($o === null) {
263 return null;
264 }
265 return [
266 'units' => $o['units'],
267 'orders' => $o['orders'],
268 'gross_collected' => MCPHelper::money($o['gross_collected'], $currency),
269 'refunds' => MCPHelper::money($o['refunds'], $currency),
270 'net_collected' => MCPHelper::money($o['net_collected'], $currency),
271 'aov' => MCPHelper::money($o['aov'], $currency),
272 ];
273 }
274
275 private static function formatSubscriptions($s, $currency)
276 {
277 $finite = $s['finite'];
278 $finiteByInterval = [];
279 foreach ($finite['by_interval'] as $iv => $b) {
280 $finiteByInterval[$iv] = [
281 'count' => $b['count'],
282 'scheduled_remaining' => MCPHelper::money($b['scheduled_remaining'], $currency),
283 ];
284 }
285
286 $recurring = $s['recurring'];
287 $recurringByInterval = [];
288 foreach ($recurring['by_interval'] as $iv => $b) {
289 $recurringByInterval[$iv] = [
290 'count' => $b['count'],
291 'recurring_total_sum' => MCPHelper::money($b['recurring_total_sum'], $currency),
292 'mrr' => MCPHelper::money($b['mrr'], $currency),
293 'next_30d_scheduled' => MCPHelper::money($b['next_30d_scheduled'], $currency),
294 ];
295 }
296
297 return [
298 'finite' => [
299 'count' => $finite['count'],
300 'collected_to_date' => MCPHelper::money($finite['collected_to_date'], $currency),
301 'scheduled_remaining' => MCPHelper::money($finite['scheduled_remaining'], $currency),
302 'remaining_installments' => $finite['remaining_installments'],
303 'total_contract_value' => MCPHelper::money($finite['total_contract_value'], $currency),
304 'avg_completion' => $finite['avg_completion'],
305 'by_interval' => $finiteByInterval,
306 ],
307 'recurring' => [
308 'count' => $recurring['count'],
309 'collected_to_date' => MCPHelper::money($recurring['collected_to_date'], $currency),
310 'mrr' => MCPHelper::money($recurring['mrr'], $currency),
311 'arr' => MCPHelper::money($recurring['arr'], $currency),
312 'by_interval' => $recurringByInterval,
313 'next_30d_scheduled' => MCPHelper::money($recurring['next_30d_scheduled'], $currency),
314 'next_90d_scheduled' => MCPHelper::money($recurring['next_90d_scheduled'], $currency),
315 ],
316 'status_breakdown' => $s['status_breakdown'],
317 ];
318 }
319
320 private static function formatTotals($t, $currency)
321 {
322 return [
323 'collected_to_date' => MCPHelper::money($t['collected_to_date'], $currency),
324 'committed_finite' => MCPHelper::money($t['committed_finite'], $currency),
325 'total_contracted' => $t['total_contracted'] === null ? null : MCPHelper::money($t['total_contracted'], $currency),
326 'mrr' => MCPHelper::money($t['mrr'], $currency),
327 'arr' => MCPHelper::money($t['arr'], $currency),
328 ];
329 }
330
331 private static function formatSchedule($schedule, $currency)
332 {
333 $out = [];
334 foreach ($schedule as $b) {
335 $out[] = [
336 'period' => $b['period'],
337 'finite_installments' => MCPHelper::money($b['finite_installments'], $currency),
338 'recurring_renewals' => MCPHelper::money($b['recurring_renewals'], $currency),
339 'total_expected' => MCPHelper::money($b['total_expected'], $currency),
340 ];
341 }
342 return $out;
343 }
344
345 private static function summary($product, $currency, $result)
346 {
347 $collected = MCPHelper::displayAmount($result['totals']['collected_to_date'], $currency);
348
349 if ($result['has_perpetual']) {
350 return sprintf(
351 /* translators: 1: product title, 2: collected to date, 3: MRR, 4: ARR */
352 __('%1$s — %2$s collected to date; MRR %3$s (ARR %4$s). No single total_contracted: this product has perpetual subscriptions.', 'fluent-cart'),
353 $product->post_title,
354 $collected,
355 MCPHelper::displayAmount($result['totals']['mrr'], $currency),
356 MCPHelper::displayAmount($result['totals']['arr'], $currency)
357 );
358 }
359
360 $contracted = $result['totals']['total_contracted'] === null
361 ? MCPHelper::displayAmount(0, $currency)
362 : MCPHelper::displayAmount($result['totals']['total_contracted'], $currency);
363
364 return sprintf(
365 /* translators: 1: product title, 2: collected to date, 3: total contracted */
366 __('%1$s — %2$s collected to date; total contracted %3$s (all one-time and/or finite split-pay).', 'fluent-cart'),
367 $product->post_title,
368 $collected,
369 $contracted
370 );
371 }
372
373 // -----------------------------------------------------------------
374 // Param resolvers
375 // -----------------------------------------------------------------
376
377 private static function resolveAsOf($params)
378 {
379 if (!empty($params['as_of'])) {
380 try {
381 return (new \DateTime((string) $params['as_of'], new \DateTimeZone('UTC')))->format('Y-m-d H:i:s');
382 } catch (\Exception $e) {
383 // fall through to now
384 }
385 }
386 return gmdate('Y-m-d H:i:s');
387 }
388
389 private static function resolveHorizonMonths($params)
390 {
391 $map = ['3m' => 3, '6m' => 6, '12m' => 12];
392 $h = isset($params['horizon']) ? (string) $params['horizon'] : '12m';
393 return isset($map[$h]) ? $map[$h] : 12;
394 }
395
396 private static function resolveForwardStatuses($params)
397 {
398 $allowed = ['active', 'trialing', 'past_due', 'paused', 'canceled', 'all'];
399 $in = isset($params['subscription_status']) ? (array) $params['subscription_status'] : ['active'];
400 $out = [];
401 foreach ($in as $s) {
402 $s = strtolower(trim((string) $s));
403 if (in_array($s, $allowed, true) && !in_array($s, $out, true)) {
404 $out[] = $s;
405 }
406 }
407 if (empty($out)) {
408 $out = ['active'];
409 }
410 if (in_array('all', $out, true)) {
411 return ['all'];
412 }
413 return $out;
414 }
415
416 /**
417 * Resolve range / date_from / date_to into a UTC window for the one_time
418 * block. Mirrors the report tools: relative ranges resolve in UTC, custom
419 * dates override. all_time spans epoch..now.
420 *
421 * @return array{from:string,to:string,range:string,basis:string}|\WP_Error
422 */
423 private static function resolveWindow($params)
424 {
425 $tz = new \DateTimeZone('UTC');
426 $basis = (isset($params['date_basis']) && $params['date_basis'] === 'created_at') ? 'created_at' : 'paid_at';
427
428 // Explicit custom dates take precedence.
429 if (!empty($params['date_from']) || !empty($params['date_to'])) {
430 $from = self::dayBound(!empty($params['date_from']) ? $params['date_from'] : '1970-01-01', $tz, false);
431 $to = self::dayBound(!empty($params['date_to']) ? $params['date_to'] : 'now', $tz, true);
432 if ($from === null || $to === null) {
433 return MCPHelper::error('invalid_date', __('date_from / date_to must be YYYY-MM-DD or ISO 8601.', 'fluent-cart'), ['fields' => ['date_from', 'date_to']]);
434 }
435 return ['from' => $from, 'to' => $to, 'range' => 'custom', 'basis' => $basis];
436 }
437
438 $ranges = ['today', 'yesterday', 'last_7_days', 'last_30_days', 'this_month', 'last_month', 'mtd', 'qtd', 'ytd', 'last_quarter', 'last_year', 'all_time', 'since_launch'];
439 $range = (isset($params['range']) && in_array($params['range'], $ranges, true)) ? $params['range'] : 'all_time';
440
441 // since_launch is an alias of all_time (epoch..now) so the range vocabulary
442 // matches the report tools, which use since_launch. Echo whichever was asked.
443 if ($range === 'all_time' || $range === 'since_launch') {
444 return ['from' => '1970-01-01 00:00:00', 'to' => gmdate('Y-m-d H:i:s'), 'range' => $range, 'basis' => $basis];
445 }
446
447 $now = new \DateTime('now', $tz);
448 $startDt = clone $now;
449 $endDt = clone $now;
450
451 if ($range === 'yesterday') {
452 $startDt->modify('-1 day');
453 $endDt->modify('-1 day');
454 } elseif ($range === 'last_7_days') {
455 $startDt->modify('-6 days');
456 } elseif ($range === 'last_30_days') {
457 $startDt->modify('-29 days');
458 } elseif ($range === 'this_month' || $range === 'mtd') {
459 $startDt = new \DateTime($now->format('Y-m-01'), $tz);
460 } elseif ($range === 'last_month') {
461 $startDt = new \DateTime($now->format('Y-m-01'), $tz);
462 $startDt->modify('-1 month');
463 $endDt = (clone $startDt)->modify('last day of this month');
464 } elseif ($range === 'qtd') {
465 $startDt = self::quarterStart($now, $tz);
466 } elseif ($range === 'last_quarter') {
467 $qs = self::quarterStart($now, $tz);
468 $startDt = (clone $qs)->modify('-3 months');
469 $endDt = (clone $qs)->modify('-1 day');
470 } elseif ($range === 'ytd') {
471 $startDt = new \DateTime($now->format('Y-01-01'), $tz);
472 } elseif ($range === 'last_year') {
473 $year = (int) $now->format('Y') - 1;
474 $startDt = new \DateTime($year . '-01-01', $tz);
475 $endDt = new \DateTime($year . '-12-31', $tz);
476 }
477
478 return [
479 'from' => (clone $startDt)->setTime(0, 0, 0)->format('Y-m-d H:i:s'),
480 'to' => (clone $endDt)->setTime(23, 59, 59)->format('Y-m-d H:i:s'),
481 'range' => $range,
482 'basis' => $basis,
483 ];
484 }
485
486 private static function quarterStart($now, $tz)
487 {
488 $month = (int) $now->format('n');
489 $qStartMonth = (int) (floor(($month - 1) / 3) * 3 + 1);
490 return new \DateTime($now->format('Y') . '-' . str_pad($qStartMonth, 2, '0', STR_PAD_LEFT) . '-01', $tz);
491 }
492
493 /** Parse a date bound to a UTC 'Y-m-d H:i:s' at day start/end; null on failure. */
494 private static function dayBound($value, $tz, $endOfDay)
495 {
496 try {
497 $dt = new \DateTime((string) $value, $tz);
498 } catch (\Exception $e) {
499 return null;
500 }
501 if ($endOfDay) {
502 // Only pin to end-of-day for date-only input; keep explicit times intact.
503 if (preg_match('/^\d{4}-\d{2}-\d{2}$/', trim((string) $value))) {
504 $dt->setTime(23, 59, 59);
505 }
506 } else {
507 if (preg_match('/^\d{4}-\d{2}-\d{2}$/', trim((string) $value))) {
508 $dt->setTime(0, 0, 0);
509 }
510 }
511 $dt->setTimezone(new \DateTimeZone('UTC'));
512 return $dt->format('Y-m-d H:i:s');
513 }
514 }
515