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

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

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