PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.4
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 1.2.0 All 47 releases
fluent-cart / app / Modules / MCP / Tools / TransactionTools.php

TransactionTools.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.4, at app/Modules/MCP/Tools/TransactionTools.php

333 lines 17.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\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