PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.5.2
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.5.2
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.2, at app/Modules/MCP/Tools/SubscriptionTools.php

431 lines 20.5 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: customer, plan, status, recurring total, interval, next/created/canceled dates. Use next_billing_before to find upcoming renewals; created_* and canceled_* ranges to inspect cohorts and churn. 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 'customer_id' => ['type' => 'integer'],
39 'product_id' => ['type' => 'integer'],
40 'billing_interval' => ['type' => 'string', 'enum' => $intervals],
41 'next_billing_after' => ['type' => 'string', 'description' => 'YYYY-MM-DD or ISO 8601, UTC.'],
42 'next_billing_before' => ['type' => 'string', 'description' => 'YYYY-MM-DD or ISO 8601, UTC. Use to find upcoming renewals.'],
43 'created_after' => ['type' => 'string', 'description' => 'YYYY-MM-DD or ISO 8601, UTC. Subscriptions started on or after this date.'],
44 'created_before' => ['type' => 'string', 'description' => 'YYYY-MM-DD or ISO 8601, UTC. Subscriptions started on or before this date.'],
45 '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.'],
46 'canceled_before' => ['type' => 'string', 'description' => 'YYYY-MM-DD or ISO 8601, UTC. Subscriptions canceled on or before this date.'],
47 'min_recurring' => ['type' => 'number', 'description' => 'Minimum recurring total in store currency.'],
48 'sort_by' => ['type' => 'string', 'enum' => ['id', 'next_billing_date', 'created_at', 'canceled_at', 'recurring_total'], 'default' => 'id'],
49 'sort_type' => ['type' => 'string', 'enum' => ['ASC', 'DESC'], 'default' => 'DESC'],
50 'page' => ['type' => 'integer', 'default' => 1],
51 'per_page' => ['type' => 'integer', 'default' => 15, 'description' => 'Max 200.'],
52 ],
53 ],
54 'execute_callback' => [self::class, 'listSubscriptions'],
55 'permission_callback' => function () {
56 return PermissionGate::can('subscriptions/view');
57 },
58 'annotations' => ['readonly' => true],
59 ],
60
61 'fluent-cart/get-subscription' => [
62 'label' => __('Get Subscription', 'fluent-cart'),
63 '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'),
64 'input_schema' => [
65 'type' => 'object',
66 'properties' => [
67 'subscription_id' => ['type' => 'integer'],
68 'include' => [
69 'type' => 'array',
70 'items' => ['type' => 'string', 'enum' => ['transactions', 'labels']],
71 ],
72 ],
73 'required' => ['subscription_id'],
74 ],
75 'execute_callback' => [self::class, 'getSubscription'],
76 'permission_callback' => function () {
77 return PermissionGate::can('subscriptions/view');
78 },
79 'annotations' => ['readonly' => true],
80 ],
81
82 'fluent-cart/change-subscription-status' => [
83 'label' => __('Change Subscription Status', 'fluent-cart'),
84 'description' => __('Cancel a subscription through its gateway. Cancel is destructive — call dry_run:true first to preview and receive a confirm_token, then call again with that confirm_token plus an idempotency_key to execute. Cancellation takes effect immediately (the subscription is marked canceled now). The preview reports payment_mode and live_gateway_action; executing a LIVE cancellation requires the operator to opt in (test-mode always works).', 'fluent-cart'),
85 'input_schema' => [
86 'type' => 'object',
87 'properties' => [
88 'subscription_id' => ['type' => 'integer'],
89 'action' => ['type' => 'string', 'enum' => ['cancel'], 'description' => 'Only cancel is supported. Pause/resume are not available.'],
90 'when' => ['type' => 'string', 'enum' => ['immediately'], 'default' => 'immediately', 'description' => 'Cancellation is immediate. Deferred (period-end) cancellation is not yet supported.'],
91 'reason' => ['type' => 'string'],
92 'dry_run' => ['type' => 'boolean', 'description' => 'Preview without cancelling. Returns a confirm_token. Do this first.'],
93 'confirm_token' => ['type' => 'string'],
94 'idempotency_key' => ['type' => 'string'],
95 ],
96 'required' => ['subscription_id', 'action'],
97 ],
98 'execute_callback' => [self::class, 'changeSubscriptionStatus'],
99 'permission_callback' => function () {
100 return PermissionGate::can('subscriptions/manage');
101 },
102 'annotations' => ['destructive' => true],
103 ],
104 ];
105 }
106
107 public static function listSubscriptions($params = [])
108 {
109 $paging = MCPHelper::pagination($params, 15, 200);
110 $query = Subscription::query()->with('customer');
111
112 foreach (['status', 'billing_interval'] as $col) {
113 if (!empty($params[$col])) {
114 $query->where($col, sanitize_text_field($params[$col]));
115 }
116 }
117 if (!empty($params['customer_id'])) {
118 $query->where('customer_id', (int) $params['customer_id']);
119 }
120 if (!empty($params['product_id'])) {
121 $query->where('product_id', (int) $params['product_id']);
122 }
123 $dateFilters = [
124 'next_billing_after' => ['next_billing_date', '>='],
125 'next_billing_before' => ['next_billing_date', '<='],
126 'created_after' => ['created_at', '>='],
127 'created_before' => ['created_at', '<='],
128 'canceled_after' => ['canceled_at', '>='],
129 'canceled_before' => ['canceled_at', '<='],
130 ];
131 foreach ($dateFilters as $field => $spec) {
132 if (empty($params[$field])) {
133 continue;
134 }
135 $date = self::toDbDate($params[$field]);
136 if ($date === null) {
137 return self::invalidDateError($field);
138 }
139 $query->where($spec[0], $spec[1], $date);
140 }
141 if (isset($params['min_recurring'])) {
142 $query->where('recurring_total', '>=', Helper::toCent($params['min_recurring']));
143 }
144
145 $sortBy = self::allowed($params, 'sort_by', ['id', 'next_billing_date', 'created_at', 'canceled_at', 'recurring_total'], 'id');
146 $sortType = strtoupper(isset($params['sort_type']) ? $params['sort_type'] : 'DESC') === 'ASC' ? 'ASC' : 'DESC';
147 $query->orderBy($sortBy, $sortType);
148 if ($sortBy !== 'id') {
149 $query->orderBy('id', 'DESC');
150 }
151
152 $paginator = $query->paginate($paging['per_page'], ['*'], 'page', $paging['page']);
153 $total = self::total($paginator);
154
155 $rows = [];
156 foreach (MCPHelper::paginatorItems($paginator) as $sub) {
157 $rows[] = self::formatRow($sub);
158 }
159
160 return MCPHelper::envelope(
161 sprintf(
162 /* translators: %d: number of matching subscriptions */
163 _n('%d subscription found.', '%d subscriptions found.', $total, 'fluent-cart'),
164 $total
165 ),
166 ['subscriptions' => $rows],
167 MCPHelper::pagingMeta($paginator)
168 );
169 }
170
171 private static function formatRow($sub)
172 {
173 $customer = ($sub->relationLoaded('customer') && $sub->customer) ? $sub->customer : null;
174 $currency = strtoupper((string) $sub->currency);
175
176 return [
177 'subscription_id' => (int) $sub->id,
178 'label' => self::label($sub, $customer),
179 'status' => $sub->status,
180 'item_name' => $sub->item_name,
181 'customer' => $customer ? ['id' => (int) $customer->id, 'name' => MCPHelper::personName($customer), 'email' => $customer->email] : null,
182 'recurring_total' => MCPHelper::moneyCompact($sub->recurring_total),
183 'billing_interval' => $sub->billing_interval,
184 'next_billing_date' => MCPHelper::toIso8601($sub->next_billing_date),
185 'created_at' => MCPHelper::toIso8601($sub->created_at),
186 'canceled_at' => MCPHelper::toIso8601($sub->canceled_at),
187 'bill_count' => (int) $sub->bill_count,
188 'bill_times' => (int) $sub->bill_times,
189 'currency' => $currency,
190 ];
191 }
192
193 private static function label($sub, $customer)
194 {
195 $who = $customer ? MCPHelper::personName($customer) : __('Customer', 'fluent-cart');
196
197 return sprintf(
198 /* translators: 1: plan name, 2: customer name, 3: status */
199 __('%1$s for %2$s — %3$s', 'fluent-cart'),
200 $sub->item_name,
201 $who,
202 $sub->status
203 );
204 }
205
206 public static function getSubscription($params = [])
207 {
208 if (empty($params['subscription_id'])) {
209 return MCPHelper::error('missing_identifier', __('subscription_id is required.', 'fluent-cart'));
210 }
211
212 $sub = Subscription::query()
213 ->where('id', (int) $params['subscription_id'])
214 ->with('customer')
215 ->first();
216
217 if (!$sub) {
218 return MCPHelper::error('subscription_not_found', __('No subscription found for the given subscription_id.', 'fluent-cart'));
219 }
220
221 $include = isset($params['include']) ? (array) $params['include'] : [];
222 $currency = strtoupper((string) $sub->currency);
223
224 $data = [
225 'subscription_id' => (int) $sub->id,
226 'uuid' => $sub->uuid,
227 'status' => $sub->status,
228 'item_name' => $sub->item_name,
229 'customer' => $sub->customer ? ['id' => (int) $sub->customer->id, 'name' => MCPHelper::personName($sub->customer), 'email' => $sub->customer->email] : null,
230 'parent_order_id' => $sub->parent_order_id ? (int) $sub->parent_order_id : null,
231 'product_id' => $sub->product_id ? (int) $sub->product_id : null,
232 'variation_id' => $sub->variation_id ? (int) $sub->variation_id : null,
233 'quantity' => (int) $sub->quantity,
234 'billing' => [
235 'interval' => $sub->billing_interval,
236 'signup_fee' => MCPHelper::money($sub->signup_fee, $currency),
237 'recurring_amount' => MCPHelper::money($sub->recurring_amount, $currency),
238 'recurring_total' => MCPHelper::money($sub->recurring_total, $currency),
239 'bill_times' => (int) $sub->bill_times,
240 'bill_count' => (int) $sub->bill_count,
241 'collection_method' => $sub->collection_method,
242 ],
243 'next_billing_date' => MCPHelper::toIso8601($sub->next_billing_date),
244 'trial_ends_at' => MCPHelper::toIso8601($sub->trial_ends_at),
245 'expire_at' => MCPHelper::toIso8601($sub->expire_at),
246 'canceled_at' => MCPHelper::toIso8601($sub->canceled_at),
247 'created_at' => MCPHelper::toIso8601($sub->created_at),
248 'currency' => $currency,
249 ];
250
251 if (in_array('transactions', $include, true)) {
252 $data['transactions'] = self::transactions($sub, $currency);
253 }
254 if (in_array('labels', $include, true)) {
255 $data['labels'] = self::labels($sub);
256 }
257
258 return MCPHelper::envelope(self::label($sub, $sub->customer), $data);
259 }
260
261 private static function transactions($sub, $currency)
262 {
263 $sub->load('transactions');
264 $out = [];
265 if (!$sub->relationLoaded('transactions')) {
266 return $out;
267 }
268 foreach ($sub->transactions as $txn) {
269 $out[] = [
270 'id' => (int) $txn->id,
271 'type' => $txn->transaction_type,
272 'status' => $txn->status,
273 'payment_method' => $txn->payment_method,
274 'amount' => MCPHelper::money($txn->total, $txn->currency ? $txn->currency : $currency),
275 'created_at' => MCPHelper::toIso8601($txn->created_at),
276 ];
277 }
278 return $out;
279 }
280
281 private static function labels($sub)
282 {
283 $sub->load('labels');
284 $out = [];
285 if (!$sub->relationLoaded('labels')) {
286 return $out;
287 }
288 foreach ($sub->labels as $label) {
289 $val = $label->value;
290 $out[] = ['id' => (int) $label->id, 'title' => is_array($val) ? (isset($val['title']) ? $val['title'] : null) : $val];
291 }
292 return $out;
293 }
294
295 // -----------------------------------------------------------------
296 // change-subscription-status (write, destructive — dry_run + idempotency)
297 // -----------------------------------------------------------------
298
299 public static function changeSubscriptionStatus($params = [])
300 {
301 if (empty($params['subscription_id'])) {
302 return MCPHelper::error('missing_identifier', __('subscription_id is required.', 'fluent-cart'));
303 }
304 $action = isset($params['action']) ? sanitize_text_field($params['action']) : '';
305 if ($action !== 'cancel') {
306 return MCPHelper::error('invalid_action', __('action must be cancel. Pause and resume are not supported.', 'fluent-cart'));
307 }
308
309 $sub = Subscription::query()->where('id', (int) $params['subscription_id'])->first();
310 if (!$sub) {
311 return MCPHelper::error('subscription_not_found', __('No subscription found for the given subscription_id.', 'fluent-cart'));
312 }
313 if (in_array($sub->status, ['canceled', 'cancelled', 'expired'], true)) {
314 return MCPHelper::error(
315 'already_ended',
316 sprintf(
317 /* translators: %1$s: current subscription status */
318 __('Subscription is already in status %1$s.', 'fluent-cart'),
319 $sub->status
320 )
321 );
322 }
323
324 // Cancellation is always immediate: core marks the subscription canceled
325 // on save regardless of effective_from, so we never advertise deferral.
326 $when = 'immediately';
327
328 // Gateway mode from the most recent transaction on this subscription.
329 $modeTxn = $sub->transactions()->orderBy('id', 'DESC')->first();
330 $paymentMode = $modeTxn ? $modeTxn->payment_mode : '';
331
332 $tool = 'fluent-cart/change-subscription-status';
333 $entityKey = 'subscription:' . $sub->id;
334 // Bind the previewed timing into the fingerprint so a token minted for
335 // one `when` can't confirm a different one.
336 $fingerprint = 'status:' . $sub->status . '|when:' . $when;
337
338 if (!empty($params['dry_run'])) {
339 return MCPHelper::envelope(
340 sprintf(
341 /* translators: 1: subscription id, 2: plan name, 3: when */
342 __('Preview: cancel subscription #%1$d %2$s, effective %3$s.', 'fluent-cart'),
343 (int) $sub->id,
344 $sub->item_name,
345 $when
346 ),
347 WriteGuard::preview($tool, $entityKey, $fingerprint, [
348 'subscription_id' => (int) $sub->id,
349 'current_status' => $sub->status,
350 'action' => 'cancel',
351 'effective' => $when,
352 'payment_mode' => $paymentMode,
353 'live_gateway_action' => WriteGuard::isLiveMode($paymentMode),
354 ])
355 );
356 }
357
358 $confirm = WriteGuard::confirm($tool, $entityKey, $fingerprint, isset($params['confirm_token']) ? $params['confirm_token'] : '');
359 if (is_wp_error($confirm)) {
360 return $confirm;
361 }
362
363 // Real-money guard: a live cancellation needs explicit opt-in (test always OK).
364 $liveGate = WriteGuard::liveGatewayAllowed($paymentMode);
365 if (is_wp_error($liveGate)) {
366 return $liveGate;
367 }
368
369 $reason = isset($params['reason']) ? sanitize_text_field($params['reason']) : 'Canceled via AI assistant';
370 $idemKey = isset($params['idempotency_key']) ? (string) $params['idempotency_key'] : '';
371
372 $result = WriteGuard::idempotent($tool, $entityKey, $idemKey, function () use ($sub, $reason, $when) {
373 return $sub->cancelRemoteSubscription([
374 'reason' => $reason,
375 'effective_from' => $when === 'immediately' ? 'immediately' : '',
376 ]);
377 });
378
379 if (is_wp_error($result)) {
380 return $result;
381 }
382
383 $sub = Subscription::query()->where('id', (int) $params['subscription_id'])->first();
384
385 return MCPHelper::envelope(
386 sprintf(
387 /* translators: 1: subscription id, 2: when */
388 __('Subscription #%1$d canceled, effective %2$s.', 'fluent-cart'),
389 (int) $sub->id,
390 $when
391 ),
392 ['subscription_id' => (int) $sub->id, 'status' => $sub->status, 'canceled_at' => MCPHelper::toIso8601($sub->canceled_at)]
393 );
394 }
395
396 private static function allowed($params, $key, array $allowed, $default)
397 {
398 $val = isset($params[$key]) ? $params[$key] : $default;
399 return in_array($val, $allowed, true) ? $val : $default;
400 }
401
402 private static function total($paginator)
403 {
404 return MCPHelper::paginatorTotal($paginator);
405 }
406
407 private static function toDbDate($value)
408 {
409 try {
410 return (new \DateTime((string) $value, new \DateTimeZone('UTC')))->format('Y-m-d H:i:s');
411 } catch (\Exception $e) {
412 // Return null so callers reject the input. An epoch fallback would
413 // silently turn a typo'd date bound into an unbounded "match all".
414 return null;
415 }
416 }
417
418 private static function invalidDateError($field)
419 {
420 return MCPHelper::error(
421 'invalid_date',
422 sprintf(
423 /* translators: 1: field name */
424 __('%1$s is not a valid date. Use YYYY-MM-DD or ISO 8601.', 'fluent-cart'),
425 $field
426 ),
427 ['fields' => [$field]]
428 );
429 }
430 }
431