PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.6
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.6
1.6.6 1.6.5 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 All 49 releases
fluent-cart / app / Modules / MCP / Support / ProductFinancialsCalculator.php

ProductFinancialsCalculator.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.6, at app/Modules/MCP/Support/ProductFinancialsCalculator.php

397 lines 16.9 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\Support;
4
5 /**
6 * Pure financial math for get-product-financials. NO WordPress, NO models, NO
7 * __() — every method takes plain arrays and returns plain arrays of integer
8 * cents, so the full spec §9 test matrix runs without a database. The tool
9 * layer (ProductFinancialsTools) loads rows, normalizes them, calls compute(),
10 * and wraps the cents through MCPHelper::money().
11 *
12 * PHP 7.4 safe: no null-safe, no match, no named args, no enums, no union types.
13 *
14 * A normalized subscription row is:
15 * [
16 * 'billing_interval' => 'monthly'|'quarterly'|'half_yearly'|'yearly'|'weekly'|'daily'|<other>,
17 * 'recurring_total' => int (cents),
18 * 'bill_count' => int,
19 * 'bill_times' => int, // 0 => perpetual, >0 => finite
20 * 'status' => string,
21 * 'next_billing_date' => 'Y-m-d H:i:s' (UTC) | null,
22 * ]
23 *
24 * Settlement is derived from bill_times here (single source of truth) — callers
25 * never pass it, exactly as Subscription::isInstallment() decides it.
26 */
27 class ProductFinancialsCalculator
28 {
29 /** Average days in a Gregorian month; keeps $150/half_yearly and $300/yearly both at $25 MRR. */
30 const DAYS_PER_MONTH = 30.436875;
31
32 /** Seconds in a day (WP's DAY_IN_SECONDS may be absent when run outside WordPress). */
33 const DAY_SECONDS = 86400;
34
35 /** Statuses that never collected real money — excluded from every money figure. */
36 const NON_COLLECTING = ['intended', 'pending'];
37
38 /** Canonical status set, seeded to 0 so status_breakdown is always complete. */
39 const STATUS_KEYS = [
40 'active', 'trialing', 'paused', 'canceled', 'failing', 'expired',
41 'expiring', 'past_due', 'intended', 'pending', 'completed',
42 ];
43
44 /**
45 * Map an input interval onto the store's canonical value. Accepts the v1
46 * spec aliases (semiannual, annual) for caller convenience; passes real
47 * values through untouched.
48 */
49 public static function normalizeInterval($interval)
50 {
51 $interval = strtolower(trim((string) $interval));
52 $aliases = [
53 'semiannual' => 'half_yearly',
54 'semi_annual' => 'half_yearly',
55 'semiannually' => 'half_yearly',
56 'biannual' => 'half_yearly',
57 'annual' => 'yearly',
58 'annually' => 'yearly',
59 'yearly' => 'yearly',
60 'monthly' => 'monthly',
61 'quarterly' => 'quarterly',
62 'half_yearly' => 'half_yearly',
63 'weekly' => 'weekly',
64 'daily' => 'daily',
65 ];
66 return isset($aliases[$interval]) ? $aliases[$interval] : $interval;
67 }
68
69 /**
70 * Split subscription rows into the ones matching $currency and the sorted,
71 * de-duplicated list of OTHER currencies present. Comparison is
72 * case-insensitive (gateways store 'usd'; we report 'USD'). Rows without a
73 * currency are treated as the report currency (store default). Never mixes
74 * currencies — the tool feeds only the kept rows to compute() and surfaces
75 * the others in meta.other_currencies.
76 *
77 * @return array{0: array, 1: array} [keptRows, otherCurrencies]
78 */
79 public static function filterByCurrency(array $rows, $currency)
80 {
81 $target = strtoupper((string) $currency);
82 $kept = [];
83 $others = [];
84 foreach ($rows as $row) {
85 $code = isset($row['currency']) && $row['currency'] !== ''
86 ? strtoupper((string) $row['currency'])
87 : $target;
88 if ($code === $target) {
89 $kept[] = $row;
90 } elseif (!in_array($code, $others, true)) {
91 $others[] = $code;
92 }
93 }
94 sort($others);
95 return [$kept, $others];
96 }
97
98 /** 'finite' (split-pay) when bill_times > 0, else 'perpetual' (open-ended). */
99 public static function settlement($billTimes)
100 {
101 return ((int) $billTimes > 0) ? 'finite' : 'perpetual';
102 }
103
104 /**
105 * How many months one billing cycle spans, for MRR normalization. Returns
106 * null for an unknown interval so it is excluded from MRR (but still counted
107 * and still projected onto the calendar by its day cadence).
108 */
109 public static function intervalInMonths($interval)
110 {
111 switch (self::normalizeInterval($interval)) {
112 case 'monthly':
113 return 1.0;
114 case 'quarterly':
115 return 3.0;
116 case 'half_yearly':
117 return 6.0;
118 case 'yearly':
119 return 12.0;
120 case 'weekly':
121 return 7.0 / self::DAYS_PER_MONTH;
122 case 'daily':
123 return 1.0 / self::DAYS_PER_MONTH;
124 default:
125 return null;
126 }
127 }
128
129 /** Normalized monthly run-rate contribution in cents (float). 0 for unknown intervals. */
130 public static function mrrContributionCents($recurringTotal, $interval)
131 {
132 $months = self::intervalInMonths($interval);
133 if ($months === null || $months <= 0) {
134 return 0.0;
135 }
136 return (float) $recurringTotal / $months;
137 }
138
139 /**
140 * Full financial rollup. See class doc for the row shape. $opts:
141 * as_of 'Y-m-d H:i:s' UTC (default now)
142 * horizon_months 3|6|12 (default 12)
143 * bucket 'month'|'week' (default month)
144 * forward_statuses array of statuses that feed forward metrics, or ['all']
145 * include_schedule bool — emit payment_schedule[] (30d/90d scalars always computed)
146 * one_time ['gross'=>int,'refunds'=>int,'units'=>int,'orders'=>int] | null
147 *
148 * Returns all money as integer cents; the tool wraps it through money().
149 */
150 public static function compute(array $subscriptions, array $opts = [])
151 {
152 $asOf = isset($opts['as_of']) ? $opts['as_of'] : gmdate('Y-m-d H:i:s');
153 $horizon = isset($opts['horizon_months']) ? (int) $opts['horizon_months'] : 12;
154 $bucket = (isset($opts['bucket']) && $opts['bucket'] === 'week') ? 'week' : 'month';
155 $forward = isset($opts['forward_statuses']) ? (array) $opts['forward_statuses'] : ['active'];
156 $withSchedule = !empty($opts['include_schedule']);
157 $oneTime = isset($opts['one_time']) && is_array($opts['one_time']) ? $opts['one_time'] : null;
158
159 $asOfTs = strtotime($asOf . ' UTC');
160 if ($asOfTs === false) {
161 $asOfTs = time();
162 }
163
164 $feedsForward = function ($status) use ($forward) {
165 if (in_array('all', $forward, true)) {
166 return true;
167 }
168 return in_array($status, $forward, true);
169 };
170
171 // ---- Status breakdown (ALWAYS the full picture) ----
172 $statusBreakdown = array_fill_keys(self::STATUS_KEYS, 0);
173 foreach ($subscriptions as $sub) {
174 $st = isset($sub['status']) ? (string) $sub['status'] : '';
175 if (!isset($statusBreakdown[$st])) {
176 $statusBreakdown[$st] = 0;
177 }
178 $statusBreakdown[$st]++;
179 }
180
181 // ---- Accumulators ----
182 $finite = [
183 'count' => 0, 'collected' => 0, 'remaining' => 0, 'contract' => 0,
184 'remaining_installments' => 0, 'completion_sum' => 0.0, 'completion_n' => 0, 'by_interval' => [],
185 ];
186 $recurring = [
187 'count' => 0, 'collected' => 0, 'mrr' => 0.0, 'by_interval' => [],
188 ];
189
190 $projSubs = []; // rows eligible for the forward calendar projection
191
192 foreach ($subscriptions as $sub) {
193 $status = isset($sub['status']) ? (string) $sub['status'] : '';
194 $interval = self::normalizeInterval(isset($sub['billing_interval']) ? $sub['billing_interval'] : '');
195 $recur = (int) (isset($sub['recurring_total']) ? $sub['recurring_total'] : 0);
196 $billCount = (int) (isset($sub['bill_count']) ? $sub['bill_count'] : 0);
197 $billTimes = (int) (isset($sub['bill_times']) ? $sub['bill_times'] : 0);
198 $settle = self::settlement($billTimes);
199
200 $collects = !in_array($status, self::NON_COLLECTING, true);
201 $isForward = $feedsForward($status);
202
203 // Lifetime-to-date collected: every real (non-intended/pending) sub.
204 if ($collects) {
205 if ($settle === 'finite') {
206 $finite['collected'] += $recur * $billCount;
207 } else {
208 $recurring['collected'] += $recur * $billCount;
209 }
210 }
211
212 // Forward commitments / run-rate: only status-filtered subs.
213 if ($isForward) {
214 if ($settle === 'finite') {
215 $finite['count']++;
216 $installmentsLeft = max(0, $billTimes - $billCount);
217 $remaining = $recur * $installmentsLeft;
218 $finite['remaining'] += $remaining;
219 $finite['remaining_installments'] += $installmentsLeft;
220 $finite['contract'] += $recur * $billTimes;
221 if ($billTimes > 0) {
222 $finite['completion_sum'] += min(1.0, $billCount / $billTimes);
223 $finite['completion_n']++;
224 }
225 if (!isset($finite['by_interval'][$interval])) {
226 $finite['by_interval'][$interval] = ['count' => 0, 'remaining' => 0];
227 }
228 $finite['by_interval'][$interval]['count']++;
229 $finite['by_interval'][$interval]['remaining'] += $remaining;
230 } else {
231 $recurring['count']++;
232 $mrrC = self::mrrContributionCents($recur, $interval);
233 $recurring['mrr'] += $mrrC;
234 if (!isset($recurring['by_interval'][$interval])) {
235 $recurring['by_interval'][$interval] = [
236 'count' => 0, 'recurring_total_sum' => 0, 'mrr' => 0.0, 'next_30d' => 0,
237 ];
238 }
239 $recurring['by_interval'][$interval]['count']++;
240 $recurring['by_interval'][$interval]['recurring_total_sum'] += $recur;
241 $recurring['by_interval'][$interval]['mrr'] += $mrrC;
242 }
243
244 // Eligible for projection if it has a real next-bill anchor.
245 $anchor = isset($sub['next_billing_date']) ? $sub['next_billing_date'] : null;
246 if ($anchor !== null && $anchor !== '' && strpos((string) $anchor, '0000-00-00') !== 0) {
247 $projSubs[] = [
248 'settlement' => $settle,
249 'interval' => $interval,
250 'recur' => $recur,
251 'remaining_bills' => ($settle === 'finite') ? max(0, $billTimes - $billCount) : -1,
252 'anchor' => (string) $anchor,
253 ];
254 }
255 }
256 }
257
258 // ---- Forward calendar projection ----
259 $projection = self::project($projSubs, $asOfTs, $horizon, $bucket);
260
261 // Fold recurring next_30d back into per-interval buckets.
262 foreach ($projection['by_interval_next_30d'] as $iv => $cents) {
263 if (isset($recurring['by_interval'][$iv])) {
264 $recurring['by_interval'][$iv]['next_30d'] = $cents;
265 }
266 }
267
268 $hasPerpetual = $recurring['count'] > 0;
269
270 $mrrCents = (int) round($recurring['mrr']);
271
272 // ---- Assemble the finite block ----
273 $finiteOut = [
274 'count' => $finite['count'],
275 'collected_to_date' => (int) $finite['collected'],
276 'scheduled_remaining' => (int) $finite['remaining'],
277 'remaining_installments' => (int) $finite['remaining_installments'],
278 'total_contract_value' => (int) $finite['contract'],
279 'avg_completion' => $finite['completion_n'] > 0
280 ? round($finite['completion_sum'] / $finite['completion_n'], 4) : 0,
281 'by_interval' => [],
282 ];
283 foreach ($finite['by_interval'] as $iv => $b) {
284 $finiteOut['by_interval'][$iv] = [
285 'count' => $b['count'],
286 'scheduled_remaining' => (int) $b['remaining'],
287 ];
288 }
289
290 // ---- Assemble the recurring block ----
291 $recurringOut = [
292 'count' => $recurring['count'],
293 'collected_to_date' => (int) $recurring['collected'],
294 'mrr' => $mrrCents,
295 'arr' => $mrrCents * 12,
296 'by_interval' => [],
297 'next_30d_scheduled' => (int) $projection['recurring_next_30d'],
298 'next_90d_scheduled' => (int) $projection['recurring_next_90d'],
299 ];
300 foreach ($recurring['by_interval'] as $iv => $b) {
301 $recurringOut['by_interval'][$iv] = [
302 'count' => $b['count'],
303 'recurring_total_sum' => (int) $b['recurring_total_sum'],
304 'mrr' => (int) round($b['mrr']),
305 'next_30d_scheduled' => (int) $b['next_30d'],
306 ];
307 }
308
309 // ---- One-time block (cents; window already applied by the caller) ----
310 $oneTimeOut = null;
311 $oneTimeNet = 0;
312 if ($oneTime !== null) {
313 $gross = (int) (isset($oneTime['gross']) ? $oneTime['gross'] : 0);
314 $refunds = (int) (isset($oneTime['refunds']) ? $oneTime['refunds'] : 0);
315 $orders = (int) (isset($oneTime['orders']) ? $oneTime['orders'] : 0);
316 $oneTimeNet = $gross - $refunds;
317 $oneTimeOut = [
318 'units' => (int) (isset($oneTime['units']) ? $oneTime['units'] : 0),
319 'orders' => $orders,
320 'gross_collected' => $gross,
321 'refunds' => $refunds,
322 'net_collected' => $oneTimeNet,
323 'aov' => $orders > 0 ? (int) round($gross / $orders) : 0,
324 ];
325 }
326
327 // ---- Headline totals ----
328 $collectedToDate = $oneTimeNet + (int) $finite['collected'] + (int) $recurring['collected'];
329 $committedFinite = (int) $finite['remaining'];
330
331 $notes = [];
332 if ($hasPerpetual) {
333 $totalContracted = null;
334 $notes[] = 'total_contracted is null because this product has perpetual (open-ended) subscriptions; use recurring.mrr / recurring.arr and payment_schedule instead.';
335 } else {
336 $totalContracted = $collectedToDate + $committedFinite;
337 }
338
339 $totals = [
340 'collected_to_date' => $collectedToDate,
341 'committed_finite' => $committedFinite,
342 'total_contracted' => $totalContracted,
343 'mrr' => $mrrCents,
344 'arr' => $mrrCents * 12,
345 ];
346
347 return [
348 'one_time' => $oneTimeOut,
349 'subscriptions' => [
350 'finite' => $finiteOut,
351 'recurring' => $recurringOut,
352 'status_breakdown' => $statusBreakdown,
353 ],
354 'payment_schedule' => $withSchedule ? $projection['schedule'] : null,
355 'totals' => $totals,
356 'has_perpetual' => $hasPerpetual,
357 'meta_notes' => $notes,
358 ];
359 }
360
361 /**
362 * Project future charges onto calendar buckets. Returns:
363 * schedule [ {period, finite_installments, recurring_renewals, total_expected} ]
364 * recurring_next_30d/90d int cents of recurring renewals within 30/90 days of as_of
365 * by_interval_next_30d [ interval => int cents ] recurring renewals within 30d
366 */
367 private static function project(array $projSubs, $asOfTs, $horizonMonths, $bucket)
368 {
369 $horizonEndTs = strtotime('+' . (int) $horizonMonths . ' months', $asOfTs);
370 if ($horizonEndTs === false) {
371 $horizonEndTs = $asOfTs;
372 }
373
374 // The walk itself lives in the shared PaymentProjector so get-upcoming-
375 // payments projects on the exact same rules (anchor on next_billing_date,
376 // step by interval, stop finite at remaining bills).
377 $p = PaymentProjector::project($projSubs, $asOfTs, $horizonEndTs, $bucket);
378
379 $schedule = [];
380 foreach ($p['buckets'] as $period => $b) {
381 $schedule[] = [
382 'period' => $period,
383 'finite_installments' => (int) $b['finite'],
384 'recurring_renewals' => (int) $b['recurring'],
385 'total_expected' => (int) ($b['finite'] + $b['recurring']),
386 ];
387 }
388
389 return [
390 'schedule' => $schedule,
391 'recurring_next_30d' => $p['recurring_next_30d'],
392 'recurring_next_90d' => $p['recurring_next_90d'],
393 'by_interval_next_30d' => $p['by_interval_next_30d'],
394 ];
395 }
396 }
397