| 1 |
<?php |
| 2 |
|
| 3 |
namespace FluentCart\App\Modules\MCP\Tools; |
| 4 |
|
| 5 |
use FluentCart\App\Models\OrderTransaction; |
| 6 |
use FluentCart\App\Modules\MCP\Support\MCPHelper; |
| 7 |
use FluentCart\App\Modules\MCP\Support\PermissionGate; |
| 8 |
|
| 9 |
/** |
| 10 |
* The payment ledger — every charge, refund, dispute and signup fee across all |
| 11 |
* orders and subscriptions. |
| 12 |
* |
| 13 |
* This is the one surface the per-order / per-subscription views cannot answer: |
| 14 |
* questions that cut ACROSS records — "all refunds last week", "failed renewal |
| 15 |
* charges this month" (dunning triage), "this customer's payment history". |
| 16 |
* Before this tool an agent had to fetch every order and stitch transactions by |
| 17 |
* hand. |
| 18 |
* |
| 19 |
* Design rules (shared with ReportTools): |
| 20 |
* - Date window resolution is delegated to ReportTools::resolveRange, so the |
| 21 |
* range vocabulary (today … since_launch, date_from/date_to, since) and UTC |
| 22 |
* semantics are identical to every report. |
| 23 |
* - Money is NEVER summed across currencies: summary.amount_by_currency reports |
| 24 |
* one total per currency, and each row carries its own currency. |
| 25 |
* - Rows are compact (moneyCompact + explicit per-row currency); summary_only |
| 26 |
* returns just the aggregates for a tiny payload. |
| 27 |
*/ |
| 28 |
class TransactionTools |
| 29 |
{ |
| 30 |
// transaction_type values (Status::TRANSACTION_TYPE_*). |
| 31 |
const TYPES = ['charge', 'refund', 'dispute', 'signup_fee']; |
| 32 |
|
| 33 |
// status values (Status::TRANSACTION_*). |
| 34 |
const STATUSES = ['succeeded', 'authorized', 'pending', 'refunded', 'failed', 'dispute_lost']; |
| 35 |
|
| 36 |
const DEFAULT_PER_PAGE = 25; |
| 37 |
|
| 38 |
public static function definitions() |
| 39 |
{ |
| 40 |
return [ |
| 41 |
'fluent-cart/list-transactions' => [ |
| 42 |
'label' => __('List Transactions', 'fluent-cart'), |
| 43 |
'description' => __('The payment ledger across every order and subscription — charges, refunds, disputes, signup fees. Filter by type, status, order_id, subscription_id, customer_id, payment_method, currency, mode or a date window, then paginate. Answers cross-record questions the per-order and per-subscription views cannot: "all refunds last week", failed renewal charges for dunning, one customer\'s payment history. Money is summed PER currency in summary.amount_by_currency, never across. summary_only:true returns just the counts and totals with no rows.', 'fluent-cart'), |
| 44 |
'input_schema' => [ |
| 45 |
'type' => 'object', |
| 46 |
'properties' => [ |
| 47 |
'type' => ['type' => 'string', 'enum' => self::TYPES, 'description' => 'charge = a payment taken; refund = money returned; dispute = chargeback; signup_fee = subscription signup fee.'], |
| 48 |
'status' => ['type' => 'string', 'enum' => self::STATUSES, 'description' => 'succeeded, failed = use for dunning / failed renewals, pending, authorized, refunded, dispute_lost.'], |
| 49 |
'order_id' => ['type' => 'integer', 'description' => 'Transactions for one order.'], |
| 50 |
'subscription_id' => ['type' => 'integer', 'description' => 'Transactions for one subscription — its renewal charges, signup fee, refunds.'], |
| 51 |
'customer_id' => ['type' => 'integer', 'description' => 'All transactions for one customer, matched via the parent order.'], |
| 52 |
'payment_method' => ['type' => 'string', 'description' => 'Gateway slug, e.g. stripe, paypal, mollie.'], |
| 53 |
'currency' => ['type' => 'string', 'description' => 'ISO currency filter. Omit to include all currencies; totals stay split per currency.'], |
| 54 |
'mode' => ['type' => 'string', 'enum' => ['live', 'test', 'all'], 'default' => 'all', 'description' => 'Payment mode. all (default) includes both; pass live to exclude test transactions. Echoed as meta.mode.'], |
| 55 |
'range' => ['type' => 'string', 'enum' => ReportTools::RANGES, 'description' => 'Relative date window on created_at, resolved in UTC using the same range vocabulary as the reports. Defaults to last_30_days. Or pass date_from/date_to, or since.'], |
| 56 |
'date_from' => ['type' => 'string', 'description' => 'ISO 8601 datetime or YYYY-MM-DD, UTC. Window start; overrides range.'], |
| 57 |
'date_to' => ['type' => 'string', 'description' => 'ISO 8601 datetime or YYYY-MM-DD, UTC. Window end; overrides range.'], |
| 58 |
'since' => ['type' => 'string', 'description' => 'ISO 8601 datetime, UTC. Only transactions after this instant — "what settled since my last check".'], |
| 59 |
'summary_only' => ['type' => 'boolean', 'description' => 'Return ONLY the aggregate summary — matching_count, per-type and per-status counts, amount_by_currency — with no per-transaction rows. Answers "how much did we refund" cheaply.'], |
| 60 |
'page' => ['type' => 'integer', 'default' => 1], |
| 61 |
'per_page' => ['type' => 'integer', 'default' => 25, 'description' => 'Max 100.'], |
| 62 |
'sort_by' => ['type' => 'string', 'enum' => ['created_at', 'total', 'id'], 'default' => 'created_at', 'description' => 'created_at is newest-first by default; total ranks by amount.'], |
| 63 |
'sort_type' => ['type' => 'string', 'enum' => ['ASC', 'DESC'], 'default' => 'DESC'], |
| 64 |
], |
| 65 |
], |
| 66 |
'output_schema' => MCPHelper::envelopeSchema([ |
| 67 |
'type' => 'object', |
| 68 |
'properties' => [ |
| 69 |
'transactions' => [ |
| 70 |
'type' => 'array', |
| 71 |
'description' => 'One page of matching transactions (absent when summary_only is set).', |
| 72 |
'items' => [ |
| 73 |
'type' => 'object', |
| 74 |
'properties' => [ |
| 75 |
'id' => ['type' => 'integer'], |
| 76 |
'order_id' => ['type' => 'integer'], |
| 77 |
'subscription_id' => ['description' => 'Subscription id, or null for one-off charges.'], |
| 78 |
'type' => ['type' => 'string', 'enum' => self::TYPES], |
| 79 |
'status' => ['type' => 'string'], |
| 80 |
'payment_method' => ['type' => 'string'], |
| 81 |
'mode' => ['type' => 'string', 'description' => 'live or test.'], |
| 82 |
'amount' => ['type' => 'number', 'description' => 'Compact decimal in the row currency.'], |
| 83 |
'currency' => ['type' => 'string', 'description' => 'ISO 4217; per-row, since a result can span currencies.'], |
| 84 |
'card_brand' => ['description' => 'Card brand, or null.'], |
| 85 |
'card_last_4' => ['description' => 'Last 4 digits, or null.'], |
| 86 |
'vendor_charge_id' => ['type' => 'string'], |
| 87 |
'customer' => ['description' => 'id, name, email resolved via the parent order; null if unresolved.'], |
| 88 |
'created_at' => ['description' => 'ISO-8601 UTC, or null.'], |
| 89 |
], |
| 90 |
], |
| 91 |
], |
| 92 |
'summary' => [ |
| 93 |
'type' => 'object', |
| 94 |
'properties' => [ |
| 95 |
'matching_count' => ['type' => 'integer'], |
| 96 |
'by_type' => ['type' => 'object', 'description' => 'Count keyed by transaction type.'], |
| 97 |
'by_status' => ['type' => 'object', 'description' => 'Count keyed by status.'], |
| 98 |
'amount_by_currency' => [ |
| 99 |
'type' => 'array', |
| 100 |
'description' => 'One total per currency — money is never summed across currencies.', |
| 101 |
'items' => [ |
| 102 |
'type' => 'object', |
| 103 |
'properties' => [ |
| 104 |
'currency' => ['type' => 'string'], |
| 105 |
'count' => ['type' => 'integer'], |
| 106 |
'total' => MCPHelper::moneyDef(), |
| 107 |
], |
| 108 |
], |
| 109 |
], |
| 110 |
], |
| 111 |
], |
| 112 |
], |
| 113 |
], ['date_basis' => ['type' => 'string'], 'mode' => ['type' => 'string'], 'page' => ['type' => 'object']]), |
| 114 |
'execute_callback' => [self::class, 'listTransactions'], |
| 115 |
'permission_callback' => function () { |
| 116 |
return PermissionGate::can('orders/view'); |
| 117 |
}, |
| 118 |
'annotations' => ['readonly' => true], |
| 119 |
], |
| 120 |
]; |
| 121 |
} |
| 122 |
|
| 123 |
public static function listTransactions($params = []) |
| 124 |
{ |
| 125 |
// Same UTC window + range vocabulary as every report. |
| 126 |
$range = ReportTools::resolveRange($params); |
| 127 |
$mode = self::mode($params); |
| 128 |
|
| 129 |
$base = OrderTransaction::query() |
| 130 |
->where('created_at', '>=', $range['start']) |
| 131 |
->where('created_at', '<=', $range['end']); |
| 132 |
|
| 133 |
self::applyFilters($base, $params, $mode); |
| 134 |
|
| 135 |
// Summary first, off a clone, so it reflects the whole filtered set — not |
| 136 |
// just the current page. Money stays split per currency. |
| 137 |
$summary = self::summarize(clone $base); |
| 138 |
|
| 139 |
$meta = [ |
| 140 |
'date_basis' => 'created_at', |
| 141 |
'mode' => $mode, |
| 142 |
'range' => [ |
| 143 |
'start' => MCPHelper::toIso8601($range['start']), |
| 144 |
'end' => MCPHelper::toIso8601($range['end']), |
| 145 |
'label' => $range['label'], |
| 146 |
], |
| 147 |
]; |
| 148 |
|
| 149 |
if (!empty($params['summary_only'])) { |
| 150 |
return MCPHelper::envelope(self::summaryLine($summary), ['summary' => $summary], $meta); |
| 151 |
} |
| 152 |
|
| 153 |
$paging = MCPHelper::pagination($params, self::DEFAULT_PER_PAGE); |
| 154 |
$sortBy = in_array(isset($params['sort_by']) ? $params['sort_by'] : '', ['created_at', 'total', 'id'], true) ? $params['sort_by'] : 'created_at'; |
| 155 |
$sortType = strtoupper(isset($params['sort_type']) ? $params['sort_type'] : 'DESC') === 'ASC' ? 'ASC' : 'DESC'; |
| 156 |
|
| 157 |
// Deterministic total order: tie-break on id so identical calls never |
| 158 |
// reshuffle rows across pages. |
| 159 |
$base->orderBy($sortBy, $sortType); |
| 160 |
if ($sortBy !== 'id') { |
| 161 |
$base->orderBy('id', 'DESC'); |
| 162 |
} |
| 163 |
|
| 164 |
// Eager-load a trimmed parent order + its customer so each row can name |
| 165 |
// the customer without an N+1 per transaction. |
| 166 |
$base->with([ |
| 167 |
'order' => function ($q) { |
| 168 |
$q->select(['id', 'customer_id', 'currency']); |
| 169 |
}, |
| 170 |
'order.customer' => function ($q) { |
| 171 |
$q->select(['id', 'first_name', 'last_name', 'email']); |
| 172 |
}, |
| 173 |
]); |
| 174 |
|
| 175 |
$paginator = $base->paginate($paging['per_page'], ['*'], 'page', $paging['page']); |
| 176 |
|
| 177 |
$rows = []; |
| 178 |
foreach (MCPHelper::paginatorItems($paginator) as $txn) { |
| 179 |
$rows[] = self::formatRow($txn); |
| 180 |
} |
| 181 |
|
| 182 |
return MCPHelper::envelope( |
| 183 |
self::summaryLine($summary), |
| 184 |
['transactions' => $rows, 'summary' => $summary], |
| 185 |
array_merge($meta, MCPHelper::pagingMeta($paginator)) |
| 186 |
); |
| 187 |
} |
| 188 |
|
| 189 |
/** |
| 190 |
* Live/test/all — filters the payment_mode column. 'all' (default) is a no-op |
| 191 |
* so both are included, matching the report tools' mode convention. |
| 192 |
*/ |
| 193 |
private static function mode($params) |
| 194 |
{ |
| 195 |
$m = isset($params['mode']) ? strtolower(sanitize_text_field((string) $params['mode'])) : 'all'; |
| 196 |
return in_array($m, ['live', 'test'], true) ? $m : 'all'; |
| 197 |
} |
| 198 |
|
| 199 |
private static function applyFilters($query, $params, $mode) |
| 200 |
{ |
| 201 |
if (!empty($params['type']) && in_array($params['type'], self::TYPES, true)) { |
| 202 |
$query->where('transaction_type', $params['type']); |
| 203 |
} |
| 204 |
if (!empty($params['status']) && in_array($params['status'], self::STATUSES, true)) { |
| 205 |
$query->where('status', $params['status']); |
| 206 |
} |
| 207 |
if (!empty($params['order_id'])) { |
| 208 |
$query->where('order_id', (int) $params['order_id']); |
| 209 |
} |
| 210 |
if (!empty($params['subscription_id'])) { |
| 211 |
$query->where('subscription_id', (int) $params['subscription_id']); |
| 212 |
} |
| 213 |
if (!empty($params['payment_method'])) { |
| 214 |
$query->where('payment_method', sanitize_text_field($params['payment_method'])); |
| 215 |
} |
| 216 |
if (!empty($params['currency'])) { |
| 217 |
$query->where('currency', strtoupper(sanitize_text_field($params['currency']))); |
| 218 |
} |
| 219 |
if ($mode !== 'all') { |
| 220 |
$query->where('payment_mode', $mode); |
| 221 |
} |
| 222 |
|
| 223 |
// customer_id lives on the parent order, not the transaction — scope via a |
| 224 |
// whereHas subquery (never a join, so the per-currency SUMs can't fan out). |
| 225 |
if (!empty($params['customer_id'])) { |
| 226 |
$cid = (int) $params['customer_id']; |
| 227 |
$query->whereHas('order', function ($q) use ($cid) { |
| 228 |
$q->where('customer_id', $cid); |
| 229 |
}); |
| 230 |
} |
| 231 |
} |
| 232 |
|
| 233 |
/** |
| 234 |
* Aggregate the filtered set WITHOUT crossing currencies. Counts are |
| 235 |
* currency-agnostic (by_type, by_status); money is one total per currency. |
| 236 |
*/ |
| 237 |
private static function summarize($query) |
| 238 |
{ |
| 239 |
$byCurrency = (clone $query) |
| 240 |
->selectRaw('currency, COUNT(*) as cnt, COALESCE(SUM(total), 0) as total') |
| 241 |
->groupBy('currency') |
| 242 |
->get(); |
| 243 |
|
| 244 |
$amountByCurrency = []; |
| 245 |
$count = 0; |
| 246 |
foreach ($byCurrency as $row) { |
| 247 |
$cnt = (int) $row->cnt; |
| 248 |
$count += $cnt; |
| 249 |
$code = $row->currency ? strtoupper($row->currency) : MCPHelper::currencyCode(); |
| 250 |
$amountByCurrency[] = [ |
| 251 |
'currency' => $code, |
| 252 |
'count' => $cnt, |
| 253 |
'total' => MCPHelper::money((int) $row->total, $code), |
| 254 |
]; |
| 255 |
} |
| 256 |
|
| 257 |
return [ |
| 258 |
'matching_count' => $count, |
| 259 |
// Cast to object: the output_schema types these as 'object', but a |
| 260 |
// dynamically-built PHP map is an empty array [] when nothing matches, |
| 261 |
// which json_encodes to [] and fails structured-output validation. |
| 262 |
// (object) forces {} when empty and a JSON object otherwise. |
| 263 |
'by_type' => (object) self::countBy((clone $query), 'transaction_type'), |
| 264 |
'by_status' => (object) self::countBy((clone $query), 'status'), |
| 265 |
'amount_by_currency' => $amountByCurrency, |
| 266 |
'note' => 'amount_by_currency sums transaction total within each currency; charges and refunds are both stored as positive amounts, so filter by type for a single-sided figure.', |
| 267 |
]; |
| 268 |
} |
| 269 |
|
| 270 |
/** COUNT(*) grouped by one column, as a { value: count } map. */ |
| 271 |
private static function countBy($query, $column) |
| 272 |
{ |
| 273 |
$rows = $query->selectRaw($column . ', COUNT(*) as cnt')->groupBy($column)->get(); |
| 274 |
$out = []; |
| 275 |
foreach ($rows as $row) { |
| 276 |
$key = $row->{$column}; |
| 277 |
if ($key === null || $key === '') { |
| 278 |
$key = 'unknown'; |
| 279 |
} |
| 280 |
$out[$key] = (int) $row->cnt; |
| 281 |
} |
| 282 |
return $out; |
| 283 |
} |
| 284 |
|
| 285 |
private static function formatRow($txn) |
| 286 |
{ |
| 287 |
$currency = $txn->currency ? strtoupper($txn->currency) : MCPHelper::currencyCode(); |
| 288 |
|
| 289 |
$customer = null; |
| 290 |
$order = $txn->relationLoaded('order') ? $txn->order : null; |
| 291 |
if ($order && $order->relationLoaded('customer') && $order->customer) { |
| 292 |
$customer = [ |
| 293 |
'id' => (int) $order->customer->id, |
| 294 |
'name' => MCPHelper::personName($order->customer), |
| 295 |
'email' => $order->customer->email, |
| 296 |
]; |
| 297 |
} |
| 298 |
|
| 299 |
return [ |
| 300 |
'id' => (int) $txn->id, |
| 301 |
'order_id' => (int) $txn->order_id, |
| 302 |
'subscription_id' => $txn->subscription_id ? (int) $txn->subscription_id : null, |
| 303 |
'type' => $txn->transaction_type, |
| 304 |
'status' => $txn->status, |
| 305 |
'payment_method' => $txn->payment_method, |
| 306 |
'mode' => $txn->payment_mode, |
| 307 |
'amount' => MCPHelper::moneyCompact($txn->total), |
| 308 |
'currency' => $currency, |
| 309 |
'card_brand' => $txn->card_brand, |
| 310 |
'card_last_4' => $txn->card_last_4, |
| 311 |
'vendor_charge_id' => $txn->vendor_charge_id, |
| 312 |
'customer' => $customer, |
| 313 |
'created_at' => MCPHelper::toIso8601($txn->created_at), |
| 314 |
]; |
| 315 |
} |
| 316 |
|
| 317 |
private static function summaryLine($summary) |
| 318 |
{ |
| 319 |
$parts = []; |
| 320 |
foreach ($summary['amount_by_currency'] as $c) { |
| 321 |
$parts[] = $c['total']['display']; |
| 322 |
} |
| 323 |
$money = $parts ? implode(', ', $parts) : MCPHelper::displayAmount(0); |
| 324 |
|
| 325 |
return sprintf( |
| 326 |
/* translators: 1: transaction count, 2: total amount per currency */ |
| 327 |
_n('%1$d transaction totaling %2$s.', '%1$d transactions totaling %2$s.', (int) $summary['matching_count'], 'fluent-cart'), |
| 328 |
(int) $summary['matching_count'], |
| 329 |
$money |
| 330 |
); |
| 331 |
} |
| 332 |
} |
| 333 |
|