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

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

305 lines 14.2 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\Subscription;
6 use FluentCart\App\Models\OrderTransaction;
7 use FluentCart\App\Modules\MCP\Support\MCPHelper;
8 use FluentCart\App\Modules\MCP\Support\PermissionGate;
9 use FluentCart\App\Modules\MCP\Support\PaymentProjector;
10 use FluentCart\App\Modules\MCP\Support\ProductFinancialsCalculator as Calc;
11
12 /**
13 * get-upcoming-payments — the renewal cohort view: expected billings grouped by
14 * date over a window, separating finite (split-pay) installments from open-ended
15 * recurring renewals, with an at_risk figure derived from past_due subscriptions
16 * and the store's historical renewal success rate.
17 *
18 * It anchors on next_billing_date (never created_at), steps by each
19 * subscription's interval, and stops finite plans at their remaining bills —
20 * the exact projection get-product-financials uses, via the shared
21 * PaymentProjector, so the two never disagree.
22 *
23 * Single-currency (defaults to store currency); other currencies present are
24 * listed in meta.other_currencies rather than silently summed.
25 */
26 class PaymentScheduleTools
27 {
28 /** Statuses that produce a future charge and so feed the projection. */
29 const FORWARD_STATUSES = ['active', 'past_due', 'trialing'];
30
31 /** Safety ceiling on subscriptions loaded for one projection. */
32 const MAX_SUBS = 50000;
33
34 public static function definitions()
35 {
36 return [
37 'fluent-cart/get-upcoming-payments' => [
38 'label' => __('Get Upcoming Payments', 'fluent-cart'),
39 'description' => __('Forward view of expected subscription billings grouped by date, split into finite_installments (split-pay, bill_times > 0) vs recurring_renewals (open-ended), plus an at_risk figure from past_due subscriptions discounted by the store\'s historical renewal success rate. Anchors on next_billing_date, steps by each plan\'s interval, stops finite plans at their remaining bills. Optional product_id/variation_id scope to one product. Single currency: store default, others in meta.other_currencies. date_from/date_to default to now..+90 days. Money is {amount, amount_cents, currency, display}.', 'fluent-cart'),
40 'input_schema' => [
41 'type' => 'object',
42 'properties' => [
43 'product_id' => ['type' => 'integer', 'description' => 'Limit to subscriptions for one product. Omit for the whole store.'],
44 'variation_id' => ['type' => 'integer', 'description' => 'Limit to one variation.'],
45 'currency' => ['type' => 'string', 'description' => 'ISO currency for the (single-currency) report. Defaults to store currency.'],
46 'date_from' => ['type' => 'string', 'description' => 'ISO 8601 or YYYY-MM-DD, UTC. Window start. Default now.'],
47 'date_to' => ['type' => 'string', 'description' => 'ISO 8601 or YYYY-MM-DD, UTC. Window end. Default 90 days out.'],
48 'bucket' => ['type' => 'string', 'enum' => ['day', 'month'], 'default' => 'day', 'description' => 'Calendar granularity of the schedule.'],
49 ],
50 ],
51 'execute_callback' => [self::class, 'getUpcomingPayments'],
52 'permission_callback' => function () {
53 return PermissionGate::can('reports/view');
54 },
55 'annotations' => ['readonly' => true],
56 ],
57 ];
58 }
59
60 public static function getUpcomingPayments($params = [])
61 {
62 $productId = !empty($params['product_id']) ? (int) $params['product_id'] : null;
63 $variationId = !empty($params['variation_id']) ? (int) $params['variation_id'] : null;
64 $currency = !empty($params['currency']) ? strtoupper(sanitize_text_field($params['currency'])) : MCPHelper::currencyCode();
65 $bucket = (isset($params['bucket']) && $params['bucket'] === 'month') ? 'month' : 'day';
66
67 $window = self::resolveWindow($params);
68 if (is_wp_error($window)) {
69 return $window;
70 }
71
72 $load = self::loadSubs($productId, $variationId);
73 list($kept, $otherCurrencies) = Calc::filterByCurrency($load['rows'], $currency);
74
75 // Build projection descriptors (drop rows with no usable next-bill anchor).
76 $projSubs = [];
77 $pastDueSubs = [];
78 foreach ($kept as $row) {
79 $ps = self::toProjSub($row);
80 if ($ps === null) {
81 continue;
82 }
83 $projSubs[] = $ps;
84 if ($row['status'] === 'past_due') {
85 $pastDueSubs[] = $ps;
86 }
87 }
88
89 $fromTs = strtotime($window['from'] . ' UTC');
90 $toTs = strtotime($window['to'] . ' UTC');
91
92 $projection = PaymentProjector::project($projSubs, $fromTs, $toTs, $bucket);
93 $pastDueProj = PaymentProjector::project($pastDueSubs, $fromTs, $toTs, $bucket);
94
95 $buckets = self::formatBuckets($projection['buckets'], $currency);
96 $totals = self::totals($projection['buckets']);
97 $pastDueTotal = self::totals($pastDueProj['buckets'])['total_expected'];
98
99 $successRate = self::renewalSuccessRate();
100 // Unknown history -> treat the whole past_due expectation as at risk.
101 $atRiskCents = ($successRate === null)
102 ? $pastDueTotal
103 : (int) round($pastDueTotal * (1 - $successRate));
104
105 $data = [
106 'window' => [
107 'from' => MCPHelper::toIso8601($window['from']),
108 'to' => MCPHelper::toIso8601($window['to']),
109 'bucket' => $bucket,
110 ],
111 'schedule' => $buckets,
112 'totals' => [
113 'finite_installments' => MCPHelper::money($totals['finite'], $currency),
114 'recurring_renewals' => MCPHelper::money($totals['recurring'], $currency),
115 'total_expected' => MCPHelper::money($totals['total_expected'], $currency),
116 'expected_charges' => $totals['finite_count'] + $totals['recurring_count'],
117 ],
118 'at_risk' => [
119 'amount' => MCPHelper::money($atRiskCents, $currency),
120 'from_past_due_expected' => MCPHelper::money($pastDueTotal, $currency),
121 'past_due_subscriptions' => count($pastDueSubs),
122 'historical_success_rate' => $successRate,
123 'basis' => $successRate === null
124 ? 'no renewal history yet — full past_due expectation shown as at risk'
125 : 'store-wide renewal charge success rate (succeeded / (succeeded + failed))',
126 ],
127 ];
128
129 $meta = [
130 'currency' => $currency,
131 'other_currencies' => $otherCurrencies,
132 'forward_statuses' => self::FORWARD_STATUSES,
133 'note' => 'Projected from next_billing_date, stepping by each plan\'s interval; finite plans stop at remaining bills. Forward-billing statuses only (active, past_due, trialing).',
134 ];
135 if ($load['truncated']) {
136 $meta['warnings'] = [sprintf(
137 /* translators: %1$d: subscription load cap */
138 __('More than %1$d subscriptions matched; the projection uses the first %1$d and may be incomplete.', 'fluent-cart'),
139 self::MAX_SUBS
140 )];
141 }
142
143 return MCPHelper::envelope(self::summary($totals, $atRiskCents, $currency, count($buckets)), $data, $meta);
144 }
145
146 // -----------------------------------------------------------------
147 // Loaders / builders
148 // -----------------------------------------------------------------
149
150 /**
151 * Forward-billing subscriptions, optionally scoped to one product/variation.
152 * Currency is derived per row from the config JSON (there is no currency
153 * column) so the caller can single-currency scope. Reads a limited column set
154 * so the model's heavy $appends accessors never fire.
155 */
156 private static function loadSubs($productId, $variationId)
157 {
158 $store = MCPHelper::currencyCode();
159
160 $query = Subscription::query()->whereIn('status', self::FORWARD_STATUSES);
161 if ($productId !== null) {
162 $query->where('product_id', $productId);
163 }
164 if ($variationId !== null) {
165 $query->where('variation_id', $variationId);
166 }
167
168 /** @var \FluentCart\Framework\Database\Orm\Collection $subs */
169 $subs = $query
170 ->orderBy('id', 'ASC')
171 ->limit(self::MAX_SUBS + 1)
172 ->get(['id', 'billing_interval', 'recurring_total', 'bill_count', 'bill_times', 'status', 'next_billing_date', 'variation_id', 'config']);
173
174 $truncated = false;
175 if (method_exists($subs, 'count') && $subs->count() > self::MAX_SUBS) {
176 $truncated = true;
177 $subs = $subs->slice(0, self::MAX_SUBS);
178 }
179
180 $rows = [];
181 foreach ($subs as $sub) {
182 $config = is_array($sub->config) ? $sub->config : [];
183 $cur = isset($config['currency']) && $config['currency'] !== '' ? strtoupper((string) $config['currency']) : strtoupper($store);
184 $rows[] = [
185 'currency' => $cur,
186 'billing_interval' => $sub->billing_interval,
187 'recurring_total' => (int) $sub->recurring_total,
188 'bill_count' => (int) $sub->bill_count,
189 'bill_times' => (int) $sub->bill_times,
190 'status' => (string) $sub->status,
191 'next_billing_date' => $sub->next_billing_date,
192 ];
193 }
194
195 return ['rows' => $rows, 'truncated' => $truncated];
196 }
197
198 /** Normalize a sub row into a PaymentProjector descriptor, or null if it can't bill. */
199 private static function toProjSub($row)
200 {
201 $anchor = isset($row['next_billing_date']) ? $row['next_billing_date'] : null;
202 if ($anchor === null || $anchor === '' || strpos((string) $anchor, '0000-00-00') === 0) {
203 return null;
204 }
205 $billTimes = (int) $row['bill_times'];
206 $billCount = (int) $row['bill_count'];
207 $settle = Calc::settlement($billTimes);
208
209 return [
210 'settlement' => $settle,
211 'interval' => Calc::normalizeInterval($row['billing_interval']),
212 'recur' => (int) $row['recurring_total'],
213 'remaining_bills' => ($settle === 'finite') ? max(0, $billTimes - $billCount) : -1,
214 'anchor' => (string) $anchor,
215 'status' => $row['status'],
216 ];
217 }
218
219 private static function formatBuckets($buckets, $currency)
220 {
221 $out = [];
222 foreach ($buckets as $period => $b) {
223 $out[] = [
224 'period' => $period,
225 'finite_installments' => MCPHelper::money((int) $b['finite'], $currency),
226 'recurring_renewals' => MCPHelper::money((int) $b['recurring'], $currency),
227 'total_expected' => MCPHelper::money((int) ($b['finite'] + $b['recurring']), $currency),
228 'finite_count' => (int) $b['finite_count'],
229 'recurring_count' => (int) $b['recurring_count'],
230 ];
231 }
232 return $out;
233 }
234
235 private static function totals($buckets)
236 {
237 $t = ['finite' => 0, 'recurring' => 0, 'finite_count' => 0, 'recurring_count' => 0];
238 foreach ($buckets as $b) {
239 $t['finite'] += (int) $b['finite'];
240 $t['recurring'] += (int) $b['recurring'];
241 $t['finite_count'] += (int) $b['finite_count'];
242 $t['recurring_count'] += (int) $b['recurring_count'];
243 }
244 $t['total_expected'] = $t['finite'] + $t['recurring'];
245 return $t;
246 }
247
248 /**
249 * Store-wide renewal charge success rate: succeeded / (succeeded + failed) over
250 * transaction_type=charge, order_type=renewal. null when there is no renewal
251 * history to learn from. Store-wide (not product-scoped) because renewal
252 * success is gateway/dunning-driven, and per-product scoping would not scale.
253 */
254 private static function renewalSuccessRate()
255 {
256 $base = OrderTransaction::query()
257 ->where('transaction_type', 'charge')
258 ->where('order_type', 'renewal');
259
260 $succeeded = (int) (clone $base)->where('status', 'succeeded')->count();
261 $failed = (int) (clone $base)->where('status', 'failed')->count();
262 $total = $succeeded + $failed;
263
264 return $total > 0 ? round($succeeded / $total, 4) : null;
265 }
266
267 private static function resolveWindow($params)
268 {
269 $tz = new \DateTimeZone('UTC');
270 $from = !empty($params['date_from']) ? self::instant($params['date_from'], $tz, false) : gmdate('Y-m-d H:i:s');
271 $to = !empty($params['date_to']) ? self::instant($params['date_to'], $tz, true) : gmdate('Y-m-d H:i:s', strtotime('+90 days'));
272
273 if ($from === null || $to === null) {
274 return MCPHelper::error('invalid_date', __('date_from / date_to must be ISO 8601 or YYYY-MM-DD.', 'fluent-cart'), ['fields' => ['date_from', 'date_to']]);
275 }
276 return ['from' => $from, 'to' => $to];
277 }
278
279 /** Parse a date bound to UTC 'Y-m-d H:i:s'; date-only snaps to the day edge. */
280 private static function instant($value, $tz, $isEnd)
281 {
282 try {
283 $dt = new \DateTime((string) $value, $tz);
284 } catch (\Exception $e) {
285 return null;
286 }
287 if (preg_match('/^\d{4}-\d{2}-\d{2}$/', trim((string) $value))) {
288 $dt->setTime($isEnd ? 23 : 0, $isEnd ? 59 : 0, $isEnd ? 59 : 0);
289 }
290 $dt->setTimezone(new \DateTimeZone('UTC'));
291 return $dt->format('Y-m-d H:i:s');
292 }
293
294 private static function summary($totals, $atRiskCents, $currency, $bucketCount)
295 {
296 return sprintf(
297 /* translators: 1: total expected, 2: number of buckets, 3: at-risk amount */
298 __('%1$s expected across %2$d billing periods; %3$s at risk from past-due plans.', 'fluent-cart'),
299 MCPHelper::displayAmount($totals['total_expected'], $currency),
300 $bucketCount,
301 MCPHelper::displayAmount($atRiskCents, $currency)
302 );
303 }
304 }
305