| @@ -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), |