| 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 |
|