| @@ -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,15 +24,16 @@ | ||
| 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 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.', '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], |
| @@ -45,11 +47,12 @@ | ||
| 45 | 47 | 'created_before' => ['type' => 'string', 'description' => 'YYYY-MM-DD or ISO 8601, UTC. Subscriptions started on or before this date.'], |
| 46 | 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.'], |
| 47 | 49 | 'canceled_before' => ['type' => 'string', 'description' => 'YYYY-MM-DD or ISO 8601, UTC. Subscriptions canceled on or before this date.'], |
| 48 | 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.'], | |
| 49 | 52 | 'sort_by' => ['type' => 'string', 'enum' => ['id', 'next_billing_date', 'created_at', 'canceled_at', 'recurring_total'], 'default' => 'id'], |
| 50 | 53 | 'sort_type' => ['type' => 'string', 'enum' => ['ASC', 'DESC'], 'default' => 'DESC'], |
| 51 | - '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 | + '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.'], | |
| 52 | 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.'], |
| 53 | 56 | 'page' => ['type' => 'integer', 'default' => 1], |
| 54 | 57 | 'per_page' => ['type' => 'integer', 'default' => 15, 'description' => 'Max 200.'], |
| 55 | 58 | ], |
| @@ -110,9 +113,23 @@ | ||
| 110 | 113 | } |
| 111 | 114 | |
| 112 | 115 | public static function listSubscriptions($params = []) |
| 113 | 116 | { |
| 114 | - $query = Subscription::query(); | |
| 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 | + } | |
| 115 | 132 | |
| 116 | 133 | foreach (['status', 'billing_interval'] as $col) { |
| 117 | 134 | if (!empty($params[$col])) { |
| 118 | 135 | $query->where($col, sanitize_text_field($params[$col])); |
| @@ -155,9 +172,9 @@ | ||
| 155 | 172 | // Bonus: aggregate-only mode — counts + sums across ALL matching |
| 156 | 173 | // subscriptions (not just one page), no per-record array. Respects every |
| 157 | 174 | // filter applied above. |
| 158 | 175 | if (!empty($params['summary_only'])) { |
| 159 | - return self::summaryResponse($query); | |
| 176 | + return self::summaryResponse($query, $advWarnings); | |
| 160 | 177 | } |
| 161 | 178 | |
| 162 | 179 | $paging = MCPHelper::pagination($params, 15, 200); |
| 163 | 180 | $query->with('customer'); |
| @@ -177,8 +194,13 @@ | ||
| 177 | 194 | foreach (MCPHelper::paginatorItems($paginator) as $sub) { |
| 178 | 195 | $rows[] = MCPHelper::pickFields(self::formatRow($sub), $fields, ['subscription_id']); |
| 179 | 196 | } |
| 180 | 197 | |
| 198 | + $meta = MCPHelper::pagingMeta($paginator); | |
| 199 | + if ($advWarnings) { | |
| 200 | + $meta['warnings'] = $advWarnings; | |
| 201 | + } | |
| 202 | + | |
| 181 | 203 | return MCPHelper::envelope( |
| 182 | 204 | sprintf( |
| 183 | 205 | /* translators: %d: number of matching subscriptions */ |
| 184 | 206 | _n('%d subscription found.', '%d subscriptions found.', $total, 'fluent-cart'), |
| @@ -184,9 +206,9 @@ | ||
| 184 | 206 | _n('%d subscription found.', '%d subscriptions found.', $total, 'fluent-cart'), |
| 185 | 207 | $total |
| 186 | 208 | ), |
| 187 | 209 | ['subscriptions' => $rows], |
| 188 | - MCPHelper::pagingMeta($paginator) | |
| 210 | + $meta | |
| 189 | 211 | ); |
| 190 | 212 | } |
| 191 | 213 | |
| 192 | 214 | /** |
| @@ -194,9 +216,9 @@ | ||
| 194 | 216 | * recurring_total and total remaining installments across the full filtered |
| 195 | 217 | * set. Two lightweight GROUP BY / SUM scans, no row hydration. Money is in the |
| 196 | 218 | * store currency (subscriptions are not currency-scoped), matching formatRow. |
| 197 | 219 | */ |
| 198 | - private static function summaryResponse($query) | |
| 220 | + private static function summaryResponse($query, array $advWarnings = []) | |
| 199 | 221 | { |
| 200 | 222 | $byStatusRows = (clone $query) |
| 201 | 223 | ->selectRaw('status, COUNT(*) as cnt, COALESCE(SUM(recurring_total), 0) as recurring_sum') |
| 202 | 224 | ->groupBy('status') |
| @@ -228,8 +250,13 @@ | ||
| 228 | 250 | $totalCount, |
| 229 | 251 | MCPHelper::displayAmount($recurringSum, MCPHelper::currencyCode()) |
| 230 | 252 | ); |
| 231 | 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 | + | |
| 232 | 259 | return MCPHelper::envelope( |
| 233 | 260 | $summary, |
| 234 | 261 | [ |
| 235 | 262 | 'summary_only' => true, |
| @@ -237,9 +264,9 @@ | ||
| 237 | 264 | 'recurring_total_sum' => MCPHelper::moneyCompact($recurringSum), |
| 238 | 265 | 'remaining_installments_total' => $remaining, |
| 239 | 266 | 'count_by_status' => $byStatus, |
| 240 | 267 | ], |
| 241 | - ['currency' => MCPHelper::currencyCode(), 'note' => 'Aggregates across all matching subscriptions; money is in the store currency, not currency-scoped.'] | |
| 268 | + $meta | |
| 242 | 269 | ); |
| 243 | 270 | } |
| 244 | 271 | |
| 245 | 272 | private static function formatRow($sub) |
| @@ -263,8 +290,11 @@ | ||
| 263 | 290 | 'bill_count' => (int) $sub->bill_count, |
| 264 | 291 | 'bill_times' => (int) $sub->bill_times, |
| 265 | 292 | // Derived installment view (bill_times > 0). total_contract_value is |
| 266 | 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', | |
| 267 | 297 | 'is_installment' => $isInstallment, |
| 268 | 298 | 'installments_paid' => (int) $sub->bill_count, |
| 269 | 299 | 'installments_remaining' => $sub->installmentsRemaining(), |
| 270 | 300 | 'total_contract_value' => $isInstallment ? MCPHelper::money($sub->totalContractValue(), $currency) : null, |
| @@ -320,8 +350,9 @@ | ||
| 320 | 350 | 'recurring_total' => MCPHelper::money($sub->recurring_total, $currency), |
| 321 | 351 | 'bill_times' => (int) $sub->bill_times, |
| 322 | 352 | 'bill_count' => (int) $sub->bill_count, |
| 323 | 353 | 'collection_method' => $sub->collection_method, |
| 354 | + 'plan_type' => $sub->isInstallment() ? 'installment' : 'recurring', | |
| 324 | 355 | 'is_installment' => $sub->isInstallment(), |
| 325 | 356 | 'installments_paid' => (int) $sub->bill_count, |
| 326 | 357 | 'installments_remaining' => $sub->installmentsRemaining(), |
| 327 | 358 | 'total_contract_value' => $sub->isInstallment() ? MCPHelper::money($sub->totalContractValue(), $currency) : null, |