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

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