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 / Support / PaymentProjector.php

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

156 lines 6.0 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 forward billing projection, shared by get-product-financials (via
7 * ProductFinancialsCalculator) and get-upcoming-payments. Given per-subscription
8 * anchors (next_billing_date), normalized intervals and finite remaining-bill
9 * caps, it walks each subscription forward from as_of to a horizon and sums the
10 * expected charges into calendar buckets — separating finite (split-pay)
11 * installments from open-ended recurring renewals.
12 *
13 * The billing rules it encodes (single source of truth for both tools):
14 * - Anchor on next_billing_date, never created_at.
15 * - Step by the subscription's own interval.
16 * - A finite (bill_times > 0) subscription stops after its remaining bills;
17 * a perpetual one runs to the horizon.
18 *
19 * No WordPress, no models — plain arrays of integer cents, so it is unit-tested
20 * without a database and both callers get identical numbers.
21 *
22 * PHP 7.4 safe: no null-safe, no match, no named args, no enums, no union types.
23 */
24 class PaymentProjector
25 {
26 /** Seconds in a day (WP's DAY_IN_SECONDS may be absent when run outside WordPress). */
27 const DAY_SECONDS = 86400;
28
29 /** Hard per-sub iteration cap (daily over 12 months ~ 366) so a bad interval/anchor can't spin. */
30 const MAX_EVENTS = 4000;
31
32 /**
33 * Project a set of subscription descriptors onto calendar buckets.
34 *
35 * A descriptor is:
36 * [
37 * 'settlement' => 'finite'|'perpetual',
38 * 'interval' => normalized interval string (monthly|quarterly|…),
39 * 'recur' => int cents per charge,
40 * 'remaining_bills' => int (finite) | -1 (perpetual/unbounded),
41 * 'anchor' => 'Y-m-d H:i:s' UTC next-bill anchor,
42 * ]
43 *
44 * @param array $projSubs descriptors (see above)
45 * @param int $asOfTs unix ts — never bill before this instant
46 * @param int $horizonEndTs unix ts — never bill after this instant
47 * @param string $bucket 'day' | 'week' | 'month'
48 *
49 * @return array{
50 * buckets: array<string, array{finite:int,recurring:int,finite_count:int,recurring_count:int}>,
51 * recurring_next_30d:int, recurring_next_90d:int, by_interval_next_30d: array<string,int>
52 * } buckets are period-sorted; the next_30d/90d scalars are relative to as_of.
53 */
54 public static function project(array $projSubs, $asOfTs, $horizonEndTs, $bucket)
55 {
56 $bucket = ($bucket === 'week' || $bucket === 'day') ? $bucket : 'month';
57
58 $cutoff30 = $asOfTs + 30 * self::DAY_SECONDS;
59 $cutoff90 = $asOfTs + 90 * self::DAY_SECONDS;
60
61 $buckets = [];
62 $recurring30 = 0;
63 $recurring90 = 0;
64 $byIntervalNext30 = [];
65
66 $utc = new \DateTimeZone('UTC');
67
68 foreach ($projSubs as $sub) {
69 $modifier = self::advanceModifier($sub['interval']);
70 try {
71 $date = new \DateTimeImmutable($sub['anchor'], $utc);
72 } catch (\Exception $e) {
73 continue;
74 }
75 // Never bill in the past: fast-forward the anchor up to as_of.
76 $events = 0;
77 while ($date->getTimestamp() < $asOfTs && $events < self::MAX_EVENTS) {
78 $date = $date->modify($modifier);
79 $events++;
80 }
81
82 $count = 0;
83 $limit = ($sub['remaining_bills'] < 0) ? PHP_INT_MAX : (int) $sub['remaining_bills'];
84 $isFinite = ($sub['settlement'] === 'finite');
85
86 while ($date->getTimestamp() <= $horizonEndTs && $count < $limit && $events < self::MAX_EVENTS) {
87 $ts = $date->getTimestamp();
88 $key = ($bucket === 'week')
89 ? $date->format('o-\WW')
90 : (($bucket === 'day') ? $date->format('Y-m-d') : $date->format('Y-m'));
91
92 if (!isset($buckets[$key])) {
93 $buckets[$key] = ['finite' => 0, 'recurring' => 0, 'finite_count' => 0, 'recurring_count' => 0];
94 }
95
96 if ($isFinite) {
97 $buckets[$key]['finite'] += $sub['recur'];
98 $buckets[$key]['finite_count']++;
99 } else {
100 $buckets[$key]['recurring'] += $sub['recur'];
101 $buckets[$key]['recurring_count']++;
102 if ($ts <= $cutoff30) {
103 $recurring30 += $sub['recur'];
104 if (!isset($byIntervalNext30[$sub['interval']])) {
105 $byIntervalNext30[$sub['interval']] = 0;
106 }
107 $byIntervalNext30[$sub['interval']] += $sub['recur'];
108 }
109 if ($ts <= $cutoff90) {
110 $recurring90 += $sub['recur'];
111 }
112 }
113
114 $date = $date->modify($modifier);
115 $count++;
116 $events++;
117 }
118 }
119
120 ksort($buckets);
121
122 return [
123 'buckets' => $buckets,
124 'recurring_next_30d' => $recurring30,
125 'recurring_next_90d' => $recurring90,
126 'by_interval_next_30d' => $byIntervalNext30,
127 ];
128 }
129
130 /**
131 * DateInterval-style modifier to advance one cycle. Calendar months for
132 * monthly+, days for weekly/daily, a 30-day fallback for unknowns so an
133 * unrecognized interval still projects rather than looping forever. Expects a
134 * value already run through ProductFinancialsCalculator::normalizeInterval.
135 */
136 public static function advanceModifier($interval)
137 {
138 switch ($interval) {
139 case 'monthly':
140 return '+1 month';
141 case 'quarterly':
142 return '+3 months';
143 case 'half_yearly':
144 return '+6 months';
145 case 'yearly':
146 return '+1 year';
147 case 'weekly':
148 return '+7 days';
149 case 'daily':
150 return '+1 day';
151 default:
152 return '+30 days';
153 }
154 }
155 }
156