PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.5.3
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.5.3
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 / Tools / SubscriptionTools.php

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

517 lines 26.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\Tools;
4
5 use FluentCart\App\Helpers\Helper;
6 use FluentCart\App\Models\Subscription;
7 use FluentCart\App\Modules\MCP\Support\MCPHelper;
8 use FluentCart\App\Modules\MCP\Support\PermissionGate;
9 use FluentCart\App\Modules\MCP\Support\WriteGuard;
10
11 /**
12 * Subscription tools — find recurring plans, then load one fully.
13 *
14 * Parameter design:
15 * - list-subscriptions filters on what owners triage by: status, the next
16 * billing window (to find upcoming renewals), interval, customer/product.
17 * - next_billing_before is the key MRR/churn-prevention lever — "what renews
18 * in the next 7 days?" — so it's a first-class filter, not buried.
19 * - get-subscription is lean by default; renewal transactions and labels are
20 * opt-in via include[].
21 * - Money (recurring_total) uses the subscription's own currency.
22 */
23 class SubscriptionTools
24 {
25 public static function definitions()
26 {
27 $statuses = ContextTools::ENUMS['subscription_statuses'];
28 $intervals = ContextTools::ENUMS['billing_intervals'];
29
30 return [
31 'fluent-cart/list-subscriptions' => [
32 'label' => __('List Subscriptions', 'fluent-cart'),
33 'description' => __('Find and filter subscriptions. Compact rows carry customer, plan, status, recurring_total, interval, next/created/canceled dates, and installment fields: is_installment, installments_paid, installments_remaining, total_contract_value = recurring_total x bill_times (the full committed price). Use plan_type to split fixed-term installment/split-pay from open-ended recurring plans; next_billing_before for upcoming renewals; created_*/canceled_* ranges for cohorts and churn — a completed installment is paid-in-full, not churn. summary_only=true returns just the aggregates (count by status, committed recurring total, remaining installments). min_recurring is in store currency, not cents.', 'fluent-cart'),
34 'input_schema' => [
35 'type' => 'object',
36 'properties' => [
37 'status' => ['type' => 'string', 'enum' => $statuses],
38 'plan_type' => ['type' => 'string', 'enum' => ['installment', 'recurring', 'all'], 'default' => 'all', 'description' => 'installment = fixed-term split-pay such as a lifetime license paid in N installments where bill_times > 0; recurring = open-ended subscription where bill_times = 0; all = both.'],
39 'customer_id' => ['type' => 'integer'],
40 'product_id' => ['type' => 'integer'],
41 'billing_interval' => ['type' => 'string', 'enum' => $intervals],
42 'next_billing_after' => ['type' => 'string', 'description' => 'YYYY-MM-DD or ISO 8601, UTC.'],
43 'next_billing_before' => ['type' => 'string', 'description' => 'YYYY-MM-DD or ISO 8601, UTC. Use to find upcoming renewals.'],
44 'created_after' => ['type' => 'string', 'description' => 'YYYY-MM-DD or ISO 8601, UTC. Subscriptions started on or after this date.'],
45 'created_before' => ['type' => 'string', 'description' => 'YYYY-MM-DD or ISO 8601, UTC. Subscriptions started on or before this date.'],
46 'canceled_after' => ['type' => 'string', 'description' => 'YYYY-MM-DD or ISO 8601, UTC. Subscriptions canceled on or after this date. Pair with status=canceled to measure churn in a window.'],
47 'canceled_before' => ['type' => 'string', 'description' => 'YYYY-MM-DD or ISO 8601, UTC. Subscriptions canceled on or before this date.'],
48 'min_recurring' => ['type' => 'number', 'description' => 'Minimum recurring total in store currency.'],
49 'sort_by' => ['type' => 'string', 'enum' => ['id', 'next_billing_date', 'created_at', 'canceled_at', 'recurring_total'], 'default' => 'id'],
50 'sort_type' => ['type' => 'string', 'enum' => ['ASC', 'DESC'], 'default' => 'DESC'],
51 'fields' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'Optional: return only these row keys to shrink the payload (subscription_id is always kept). Available: status, item_name, customer, recurring_total, billing_interval, next_billing_date, created_at, canceled_at, bill_count, bill_times, is_installment, installments_paid, installments_remaining, total_contract_value, currency, label. Omit for the full row.'],
52 'summary_only' => ['type' => 'boolean', 'description' => 'When true, return ONLY aggregates across all matching subscriptions — count_by_status, summed recurring_total, and total remaining installments — with no per-record array. Answers "how many active subs and how much is committed" with a tiny payload. Honors all the filters above.'],
53 'page' => ['type' => 'integer', 'default' => 1],
54 'per_page' => ['type' => 'integer', 'default' => 15, 'description' => 'Max 200.'],
55 ],
56 ],
57 'execute_callback' => [self::class, 'listSubscriptions'],
58 'permission_callback' => function () {
59 return PermissionGate::can('subscriptions/view');
60 },
61 'annotations' => ['readonly' => true],
62 ],
63
64 'fluent-cart/get-subscription' => [
65 'label' => __('Get Subscription', 'fluent-cart'),
66 'description' => __('Full detail for one subscription: lifecycle dates, billing schedule, signup/trial, parent order, gateway ids. Add include[] for transactions (renewal history) and labels. Identify by subscription_id.', 'fluent-cart'),
67 'input_schema' => [
68 'type' => 'object',
69 'properties' => [
70 'subscription_id' => ['type' => 'integer'],
71 'include' => [
72 'type' => 'array',
73 'items' => ['type' => 'string', 'enum' => ['transactions', 'labels']],
74 ],
75 ],
76 'required' => ['subscription_id'],
77 ],
78 'execute_callback' => [self::class, 'getSubscription'],
79 'permission_callback' => function () {
80 return PermissionGate::can('subscriptions/view');
81 },
82 'annotations' => ['readonly' => true],
83 ],
84
85 'fluent-cart/change-subscription-status' => [
86 'label' => __('Change Subscription Status', 'fluent-cart'),
87 'description' => __('Cancel a subscription through its gateway — destructive. Call dry_run:true first to preview and get a confirm_token, then call again with that confirm_token plus an idempotency_key to execute. Cancellation is immediate. The preview reports payment_mode and live_gateway_action; a LIVE cancellation requires operator opt-in, and test-mode always works.', 'fluent-cart'),
88 'input_schema' => [
89 'type' => 'object',
90 'properties' => [
91 'subscription_id' => ['type' => 'integer'],
92 'action' => ['type' => 'string', 'enum' => ['cancel'], 'description' => 'Only cancel is supported. Pause/resume are not available.'],
93 'when' => ['type' => 'string', 'enum' => ['immediately'], 'default' => 'immediately', 'description' => 'Cancellation is immediate. Deferred (period-end) cancellation is not yet supported.'],
94 'reason' => ['type' => 'string'],
95 'dry_run' => ['type' => 'boolean', 'description' => 'Preview without cancelling. Returns a confirm_token. Do this first.'],
96 'confirm_token' => ['type' => 'string'],
97 'idempotency_key' => ['type' => 'string'],
98 ],
99 'required' => ['subscription_id', 'action'],
100 ],
101 'execute_callback' => [self::class, 'changeSubscriptionStatus'],
102 'permission_callback' => function () {
103 return PermissionGate::can('subscriptions/manage');
104 },
105 // Cancels via the gateway — destructive. readonly:false is explicit
106 // so a client never mistakes it for a preview-only tool.
107 'annotations' => ['readonly' => false, 'destructive' => true],
108 ],
109 ];
110 }
111
112 public static function listSubscriptions($params = [])
113 {
114 $query = Subscription::query();
115
116 foreach (['status', 'billing_interval'] as $col) {
117 if (!empty($params[$col])) {
118 $query->where($col, sanitize_text_field($params[$col]));
119 }
120 }
121 // Reuse the model's plan-type definition (bill_times threshold) so the
122 // filter and the per-row is_installment flag can never disagree.
123 $planType = self::allowed($params, 'plan_type', ['installment', 'recurring', 'all'], 'all');
124 if ($planType !== 'all') {
125 $query->ofPlanType($planType);
126 }
127 if (!empty($params['customer_id'])) {
128 $query->where('customer_id', (int) $params['customer_id']);
129 }
130 if (!empty($params['product_id'])) {
131 $query->where('product_id', (int) $params['product_id']);
132 }
133 $dateFilters = [
134 'next_billing_after' => ['next_billing_date', '>='],
135 'next_billing_before' => ['next_billing_date', '<='],
136 'created_after' => ['created_at', '>='],
137 'created_before' => ['created_at', '<='],
138 'canceled_after' => ['canceled_at', '>='],
139 'canceled_before' => ['canceled_at', '<='],
140 ];
141 foreach ($dateFilters as $field => $spec) {
142 if (empty($params[$field])) {
143 continue;
144 }
145 $date = self::toDbDate($params[$field]);
146 if ($date === null) {
147 return self::invalidDateError($field);
148 }
149 $query->where($spec[0], $spec[1], $date);
150 }
151 if (isset($params['min_recurring'])) {
152 $query->where('recurring_total', '>=', Helper::toCent($params['min_recurring']));
153 }
154
155 // Bonus: aggregate-only mode — counts + sums across ALL matching
156 // subscriptions (not just one page), no per-record array. Respects every
157 // filter applied above.
158 if (!empty($params['summary_only'])) {
159 return self::summaryResponse($query);
160 }
161
162 $paging = MCPHelper::pagination($params, 15, 200);
163 $query->with('customer');
164
165 $sortBy = self::allowed($params, 'sort_by', ['id', 'next_billing_date', 'created_at', 'canceled_at', 'recurring_total'], 'id');
166 $sortType = strtoupper(isset($params['sort_type']) ? $params['sort_type'] : 'DESC') === 'ASC' ? 'ASC' : 'DESC';
167 $query->orderBy($sortBy, $sortType);
168 if ($sortBy !== 'id') {
169 $query->orderBy('id', 'DESC');
170 }
171
172 $paginator = $query->paginate($paging['per_page'], ['*'], 'page', $paging['page']);
173 $total = self::total($paginator);
174
175 $fields = isset($params['fields']) ? $params['fields'] : null;
176 $rows = [];
177 foreach (MCPHelper::paginatorItems($paginator) as $sub) {
178 $rows[] = MCPHelper::pickFields(self::formatRow($sub), $fields, ['subscription_id']);
179 }
180
181 return MCPHelper::envelope(
182 sprintf(
183 /* translators: %d: number of matching subscriptions */
184 _n('%d subscription found.', '%d subscriptions found.', $total, 'fluent-cart'),
185 $total
186 ),
187 ['subscriptions' => $rows],
188 MCPHelper::pagingMeta($paginator)
189 );
190 }
191
192 /**
193 * Aggregate-only response for summary_only: status counts, summed
194 * recurring_total and total remaining installments across the full filtered
195 * set. Two lightweight GROUP BY / SUM scans, no row hydration. Money is in the
196 * store currency (subscriptions are not currency-scoped), matching formatRow.
197 */
198 private static function summaryResponse($query)
199 {
200 $byStatusRows = (clone $query)
201 ->selectRaw('status, COUNT(*) as cnt, COALESCE(SUM(recurring_total), 0) as recurring_sum')
202 ->groupBy('status')
203 ->get();
204
205 $byStatus = [];
206 $totalCount = 0;
207 $recurringSum = 0;
208 foreach ($byStatusRows as $row) {
209 $count = (int) $row->cnt;
210 $sum = (int) $row->recurring_sum;
211 $byStatus[(string) $row->status] = [
212 'count' => $count,
213 'recurring_total_sum' => MCPHelper::moneyCompact($sum),
214 ];
215 $totalCount += $count;
216 $recurringSum += $sum;
217 }
218
219 // Remaining installments across finite (bill_times > 0) plans only.
220 $remRow = (clone $query)
221 ->selectRaw('COALESCE(SUM(CASE WHEN bill_times > 0 THEN GREATEST(bill_times - bill_count, 0) ELSE 0 END), 0) as rem')
222 ->first();
223 $remaining = $remRow ? (int) $remRow->rem : 0;
224
225 $summary = sprintf(
226 /* translators: 1: subscription count, 2: summed recurring total */
227 __('%1$d subscriptions; committed recurring total %2$s.', 'fluent-cart'),
228 $totalCount,
229 MCPHelper::displayAmount($recurringSum, MCPHelper::currencyCode())
230 );
231
232 return MCPHelper::envelope(
233 $summary,
234 [
235 'summary_only' => true,
236 'total_count' => $totalCount,
237 'recurring_total_sum' => MCPHelper::moneyCompact($recurringSum),
238 'remaining_installments_total' => $remaining,
239 'count_by_status' => $byStatus,
240 ],
241 ['currency' => MCPHelper::currencyCode(), 'note' => 'Aggregates across all matching subscriptions; money is in the store currency, not currency-scoped.']
242 );
243 }
244
245 private static function formatRow($sub)
246 {
247 $customer = ($sub->relationLoaded('customer') && $sub->customer) ? $sub->customer : null;
248 $currency = strtoupper((string) $sub->currency);
249
250 $isInstallment = $sub->isInstallment();
251
252 return [
253 'subscription_id' => (int) $sub->id,
254 'label' => self::label($sub, $customer),
255 'status' => $sub->status,
256 'item_name' => $sub->item_name,
257 'customer' => $customer ? ['id' => (int) $customer->id, 'name' => MCPHelper::personName($customer), 'email' => $customer->email] : null,
258 'recurring_total' => MCPHelper::moneyCompact($sub->recurring_total),
259 'billing_interval' => $sub->billing_interval,
260 'next_billing_date' => MCPHelper::toIso8601($sub->next_billing_date),
261 'created_at' => MCPHelper::toIso8601($sub->created_at),
262 'canceled_at' => MCPHelper::toIso8601($sub->canceled_at),
263 'bill_count' => (int) $sub->bill_count,
264 'bill_times' => (int) $sub->bill_times,
265 // Derived installment view (bill_times > 0). total_contract_value is
266 // null for open-ended plans, which have no fixed committed total.
267 'is_installment' => $isInstallment,
268 'installments_paid' => (int) $sub->bill_count,
269 'installments_remaining' => $sub->installmentsRemaining(),
270 'total_contract_value' => $isInstallment ? MCPHelper::money($sub->totalContractValue(), $currency) : null,
271 'currency' => $currency,
272 ];
273 }
274
275 private static function label($sub, $customer)
276 {
277 $who = $customer ? MCPHelper::personName($customer) : __('Customer', 'fluent-cart');
278
279 return sprintf(
280 /* translators: 1: plan name, 2: customer name, 3: status */
281 __('%1$s for %2$s — %3$s', 'fluent-cart'),
282 $sub->item_name,
283 $who,
284 $sub->status
285 );
286 }
287
288 public static function getSubscription($params = [])
289 {
290 if (empty($params['subscription_id'])) {
291 return MCPHelper::error('missing_identifier', __('subscription_id is required.', 'fluent-cart'));
292 }
293
294 $sub = Subscription::query()
295 ->where('id', (int) $params['subscription_id'])
296 ->with('customer')
297 ->first();
298
299 if (!$sub) {
300 return MCPHelper::error('subscription_not_found', __('No subscription found for the given subscription_id.', 'fluent-cart'));
301 }
302
303 $include = isset($params['include']) ? (array) $params['include'] : [];
304 $currency = strtoupper((string) $sub->currency);
305
306 $data = [
307 'subscription_id' => (int) $sub->id,
308 'uuid' => $sub->uuid,
309 'status' => $sub->status,
310 'item_name' => $sub->item_name,
311 'customer' => $sub->customer ? ['id' => (int) $sub->customer->id, 'name' => MCPHelper::personName($sub->customer), 'email' => $sub->customer->email] : null,
312 'parent_order_id' => $sub->parent_order_id ? (int) $sub->parent_order_id : null,
313 'product_id' => $sub->product_id ? (int) $sub->product_id : null,
314 'variation_id' => $sub->variation_id ? (int) $sub->variation_id : null,
315 'quantity' => (int) $sub->quantity,
316 'billing' => [
317 'interval' => $sub->billing_interval,
318 'signup_fee' => MCPHelper::money($sub->signup_fee, $currency),
319 'recurring_amount' => MCPHelper::money($sub->recurring_amount, $currency),
320 'recurring_total' => MCPHelper::money($sub->recurring_total, $currency),
321 'bill_times' => (int) $sub->bill_times,
322 'bill_count' => (int) $sub->bill_count,
323 'collection_method' => $sub->collection_method,
324 'is_installment' => $sub->isInstallment(),
325 'installments_paid' => (int) $sub->bill_count,
326 'installments_remaining' => $sub->installmentsRemaining(),
327 'total_contract_value' => $sub->isInstallment() ? MCPHelper::money($sub->totalContractValue(), $currency) : null,
328 ],
329 'next_billing_date' => MCPHelper::toIso8601($sub->next_billing_date),
330 'trial_ends_at' => MCPHelper::toIso8601($sub->trial_ends_at),
331 'expire_at' => MCPHelper::toIso8601($sub->expire_at),
332 'canceled_at' => MCPHelper::toIso8601($sub->canceled_at),
333 'created_at' => MCPHelper::toIso8601($sub->created_at),
334 'currency' => $currency,
335 ];
336
337 if (in_array('transactions', $include, true)) {
338 $data['transactions'] = self::transactions($sub, $currency);
339 }
340 if (in_array('labels', $include, true)) {
341 $data['labels'] = self::labels($sub);
342 }
343
344 return MCPHelper::envelope(self::label($sub, $sub->customer), $data);
345 }
346
347 private static function transactions($sub, $currency)
348 {
349 $sub->load('transactions');
350 $out = [];
351 if (!$sub->relationLoaded('transactions')) {
352 return $out;
353 }
354 foreach ($sub->transactions as $txn) {
355 $out[] = [
356 'id' => (int) $txn->id,
357 'type' => $txn->transaction_type,
358 'status' => $txn->status,
359 'payment_method' => $txn->payment_method,
360 'amount' => MCPHelper::money($txn->total, $txn->currency ? $txn->currency : $currency),
361 'created_at' => MCPHelper::toIso8601($txn->created_at),
362 ];
363 }
364 return $out;
365 }
366
367 private static function labels($sub)
368 {
369 $sub->load('labels');
370 $out = [];
371 if (!$sub->relationLoaded('labels')) {
372 return $out;
373 }
374 foreach ($sub->labels as $label) {
375 $val = $label->value;
376 $out[] = ['id' => (int) $label->id, 'title' => is_array($val) ? (isset($val['title']) ? $val['title'] : null) : $val];
377 }
378 return $out;
379 }
380
381 // -----------------------------------------------------------------
382 // change-subscription-status (write, destructive — dry_run + idempotency)
383 // -----------------------------------------------------------------
384
385 public static function changeSubscriptionStatus($params = [])
386 {
387 if (empty($params['subscription_id'])) {
388 return MCPHelper::error('missing_identifier', __('subscription_id is required.', 'fluent-cart'));
389 }
390 $action = isset($params['action']) ? sanitize_text_field($params['action']) : '';
391 if ($action !== 'cancel') {
392 return MCPHelper::error('invalid_action', __('action must be cancel. Pause and resume are not supported.', 'fluent-cart'));
393 }
394
395 $sub = Subscription::query()->where('id', (int) $params['subscription_id'])->first();
396 if (!$sub) {
397 return MCPHelper::error('subscription_not_found', __('No subscription found for the given subscription_id.', 'fluent-cart'));
398 }
399 if (in_array($sub->status, ['canceled', 'cancelled', 'expired'], true)) {
400 return MCPHelper::error(
401 'already_ended',
402 sprintf(
403 /* translators: %1$s: current subscription status */
404 __('Subscription is already in status %1$s.', 'fluent-cart'),
405 $sub->status
406 )
407 );
408 }
409
410 // Cancellation is always immediate: core marks the subscription canceled
411 // on save regardless of effective_from, so we never advertise deferral.
412 $when = 'immediately';
413
414 // Gateway mode from the most recent transaction on this subscription.
415 $modeTxn = $sub->transactions()->orderBy('id', 'DESC')->first();
416 $paymentMode = $modeTxn ? $modeTxn->payment_mode : '';
417
418 $tool = 'fluent-cart/change-subscription-status';
419 $entityKey = 'subscription:' . $sub->id;
420 // Bind the previewed timing into the fingerprint so a token minted for
421 // one `when` can't confirm a different one.
422 $fingerprint = 'status:' . $sub->status . '|when:' . $when;
423
424 if (!empty($params['dry_run'])) {
425 return MCPHelper::envelope(
426 sprintf(
427 /* translators: 1: subscription id, 2: plan name, 3: when */
428 __('Preview: cancel subscription #%1$d %2$s, effective %3$s.', 'fluent-cart'),
429 (int) $sub->id,
430 $sub->item_name,
431 $when
432 ),
433 WriteGuard::preview($tool, $entityKey, $fingerprint, [
434 'subscription_id' => (int) $sub->id,
435 'current_status' => $sub->status,
436 'action' => 'cancel',
437 'effective' => $when,
438 'payment_mode' => $paymentMode,
439 'live_gateway_action' => WriteGuard::isLiveMode($paymentMode),
440 ])
441 );
442 }
443
444 $confirm = WriteGuard::confirm($tool, $entityKey, $fingerprint, isset($params['confirm_token']) ? $params['confirm_token'] : '');
445 if (is_wp_error($confirm)) {
446 return $confirm;
447 }
448
449 // Real-money guard: a live cancellation needs explicit opt-in (test always OK).
450 $liveGate = WriteGuard::liveGatewayAllowed($paymentMode);
451 if (is_wp_error($liveGate)) {
452 return $liveGate;
453 }
454
455 $reason = isset($params['reason']) ? sanitize_text_field($params['reason']) : 'Canceled via AI assistant';
456 $idemKey = isset($params['idempotency_key']) ? (string) $params['idempotency_key'] : '';
457
458 $result = WriteGuard::idempotent($tool, $entityKey, $idemKey, function () use ($sub, $reason, $when) {
459 return $sub->cancelRemoteSubscription([
460 'reason' => $reason,
461 'effective_from' => $when === 'immediately' ? 'immediately' : '',
462 ]);
463 });
464
465 if (is_wp_error($result)) {
466 return $result;
467 }
468
469 $sub = Subscription::query()->where('id', (int) $params['subscription_id'])->first();
470
471 return MCPHelper::envelope(
472 sprintf(
473 /* translators: 1: subscription id, 2: when */
474 __('Subscription #%1$d canceled, effective %2$s.', 'fluent-cart'),
475 (int) $sub->id,
476 $when
477 ),
478 ['subscription_id' => (int) $sub->id, 'status' => $sub->status, 'canceled_at' => MCPHelper::toIso8601($sub->canceled_at)]
479 );
480 }
481
482 private static function allowed($params, $key, array $allowed, $default)
483 {
484 $val = isset($params[$key]) ? $params[$key] : $default;
485 return in_array($val, $allowed, true) ? $val : $default;
486 }
487
488 private static function total($paginator)
489 {
490 return MCPHelper::paginatorTotal($paginator);
491 }
492
493 private static function toDbDate($value)
494 {
495 try {
496 return (new \DateTime((string) $value, new \DateTimeZone('UTC')))->format('Y-m-d H:i:s');
497 } catch (\Exception $e) {
498 // Return null so callers reject the input. An epoch fallback would
499 // silently turn a typo'd date bound into an unbounded "match all".
500 return null;
501 }
502 }
503
504 private static function invalidDateError($field)
505 {
506 return MCPHelper::error(
507 'invalid_date',
508 sprintf(
509 /* translators: 1: field name */
510 __('%1$s is not a valid date. Use YYYY-MM-DD or ISO 8601.', 'fluent-cart'),
511 $field
512 ),
513 ['fields' => [$field]]
514 );
515 }
516 }
517