PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
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 trunk All 48 releases
← All changes | app/Modules/MCP/Tools/SubscriptionTools.php +145 -28 1.5.0 → 1.6.5 View file →
@@ -3,8 +3,9 @@
3 3 namespace FluentCart\App\Modules\MCP\Tools;
4 4
5 5 use FluentCart\App\Helpers\Helper;
6 6 use FluentCart\App\Models\Subscription;
7 +use FluentCart\App\Modules\MCP\Support\AdvancedSearch;
7 8 use FluentCart\App\Modules\MCP\Support\MCPHelper;
8 9 use FluentCart\App\Modules\MCP\Support\PermissionGate;
9 10 use FluentCart\App\Modules\MCP\Support\WriteGuard;
10 11
@@ -23,19 +24,21 @@
23 24 class SubscriptionTools
24 25 {
25 26 public static function definitions()
26 27 {
27 - $statuses = ContextTools::ENUMS['subscription_statuses'];
28 - $intervals = ContextTools::ENUMS['billing_intervals'];
28 + $enums = ContextTools::enums();
29 + $statuses = $enums['subscription_statuses'];
30 + $intervals = $enums['billing_intervals'];
29 31
30 32 return [
31 33 'fluent-cart/list-subscriptions' => [
32 34 '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'),
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'),
34 36 'input_schema' => [
35 37 'type' => 'object',
36 38 'properties' => [
37 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.'],
38 41 'customer_id' => ['type' => 'integer'],
39 42 'product_id' => ['type' => 'integer'],
40 43 'billing_interval' => ['type' => 'string', 'enum' => $intervals],
41 44 'next_billing_after' => ['type' => 'string', 'description' => 'YYYY-MM-DD or ISO 8601, UTC.'],
@@ -44,10 +47,13 @@
44 47 'created_before' => ['type' => 'string', 'description' => 'YYYY-MM-DD or ISO 8601, UTC. Subscriptions started on or before this date.'],
45 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.'],
46 49 'canceled_before' => ['type' => 'string', 'description' => 'YYYY-MM-DD or ISO 8601, UTC. Subscriptions canceled on or before this date.'],
47 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.'],
48 52 'sort_by' => ['type' => 'string', 'enum' => ['id', 'next_billing_date', 'created_at', 'canceled_at', 'recurring_total'], 'default' => 'id'],
49 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.'],
50 56 'page' => ['type' => 'integer', 'default' => 1],
51 57 'per_page' => ['type' => 'integer', 'default' => 15, 'description' => 'Max 200.'],
52 58 ],
53 59 ],
@@ -80,9 +86,9 @@
80 86 ],
81 87
82 88 'fluent-cart/change-subscription-status' => [
83 89 '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'),
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'),
85 91 'input_schema' => [
86 92 'type' => 'object',
87 93 'properties' => [
88 94 'subscription_id' => ['type' => 'integer'],
@@ -98,9 +104,11 @@
98 104 'execute_callback' => [self::class, 'changeSubscriptionStatus'],
99 105 'permission_callback' => function () {
100 106 return PermissionGate::can('subscriptions/manage');
101 107 },
102 - 'annotations' => ['destructive' => true],
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],
103 111 ],
104 112 ];
105 113 }
106 114
@@ -105,10 +113,23 @@
105 113 }
106 114
107 115 public static function listSubscriptions($params = [])
108 116 {
109 - $paging = MCPHelper::pagination($params, 15, 200);
110 - $query = Subscription::query()->with('customer');
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 + }
111 132
112 133 foreach (['status', 'billing_interval'] as $col) {
113 134 if (!empty($params[$col])) {
114 135 $query->where($col, sanitize_text_field($params[$col]));
@@ -113,8 +134,14 @@
113 134 if (!empty($params[$col])) {
114 135 $query->where($col, sanitize_text_field($params[$col]));
115 136 }
116 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 + }
117 144 if (!empty($params['customer_id'])) {
118 145 $query->where('customer_id', (int) $params['customer_id']);
119 146 }
120 147 if (!empty($params['product_id'])) {
@@ -141,8 +168,18 @@
141 168 if (isset($params['min_recurring'])) {
142 169 $query->where('recurring_total', '>=', Helper::toCent($params['min_recurring']));
143 170 }
144 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 +
145 182 $sortBy = self::allowed($params, 'sort_by', ['id', 'next_billing_date', 'created_at', 'canceled_at', 'recurring_total'], 'id');
146 183 $sortType = strtoupper(isset($params['sort_type']) ? $params['sort_type'] : 'DESC') === 'ASC' ? 'ASC' : 'DESC';
147 184 $query->orderBy($sortBy, $sortType);
148 185 if ($sortBy !== 'id') {
@@ -151,13 +188,19 @@
151 188
152 189 $paginator = $query->paginate($paging['per_page'], ['*'], 'page', $paging['page']);
153 190 $total = self::total($paginator);
154 191
155 - $rows = [];
192 + $fields = isset($params['fields']) ? $params['fields'] : null;
193 + $rows = [];
156 194 foreach (MCPHelper::paginatorItems($paginator) as $sub) {
157 - $rows[] = self::formatRow($sub);
195 + $rows[] = MCPHelper::pickFields(self::formatRow($sub), $fields, ['subscription_id']);
158 196 }
159 197
198 + $meta = MCPHelper::pagingMeta($paginator);
199 + if ($advWarnings) {
200 + $meta['warnings'] = $advWarnings;
201 + }
202 +
160 203 return MCPHelper::envelope(
161 204 sprintf(
162 205 /* translators: %d: number of matching subscriptions */
163 206 _n('%d subscription found.', '%d subscriptions found.', $total, 'fluent-cart'),
@@ -163,31 +206,100 @@
163 206 _n('%d subscription found.', '%d subscriptions found.', $total, 'fluent-cart'),
164 207 $total
165 208 ),
166 209 ['subscriptions' => $rows],
167 - MCPHelper::pagingMeta($paginator)
210 + $meta
168 211 );
169 212 }
170 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 +
171 272 private static function formatRow($sub)
172 273 {
173 274 $customer = ($sub->relationLoaded('customer') && $sub->customer) ? $sub->customer : null;
174 275 $currency = strtoupper((string) $sub->currency);
175 276
277 + $isInstallment = $sub->isInstallment();
278 +
176 279 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,
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,
190 302 ];
191 303 }
192 304
193 305 private static function label($sub, $customer)
@@ -231,15 +343,20 @@
231 343 'product_id' => $sub->product_id ? (int) $sub->product_id : null,
232 344 'variation_id' => $sub->variation_id ? (int) $sub->variation_id : null,
233 345 'quantity' => (int) $sub->quantity,
234 346 'billing' => [
235 - 'interval' => $sub->billing_interval,
236 - 'signup_fee' => MCPHelper::money($sub->signup_fee, $currency),
347 + 'interval' => $sub->billing_interval,
348 + 'signup_fee' => MCPHelper::money($sub->signup_fee, $currency),
237 349 '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,
350 + 'recurring_total' => MCPHelper::money($sub->recurring_total, $currency),
351 + 'bill_times' => (int) $sub->bill_times,
352 + 'bill_count' => (int) $sub->bill_count,
241 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,
242 359 ],
243 360 'next_billing_date' => MCPHelper::toIso8601($sub->next_billing_date),
244 361 'trial_ends_at' => MCPHelper::toIso8601($sub->trial_ends_at),
245 362 'expire_at' => MCPHelper::toIso8601($sub->expire_at),