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 / CouponTools.php

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

355 lines 16.1 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\Helpers\Helper;
6 use FluentCart\App\Models\Coupon;
7 use FluentCart\App\Modules\MCP\Support\MCPHelper;
8 use FluentCart\App\Modules\MCP\Support\PermissionGate;
9 use FluentCart\App\Services\DateTime\DateTime;
10 use FluentCart\Api\Resource\CouponResource;
11
12 /**
13 * Coupon tools (read).
14 *
15 * Parameter design:
16 * - list-coupons filters on status, free-text (code/title), and the common
17 * "which coupons are usable right now?" question via active_now, which
18 * accounts for status + the start/end window in one flag.
19 * - amount is returned raw alongside type, because a percentage coupon's amount
20 * is a percent (10 = 10%) while a fixed coupon's is a currency value — the
21 * agent must read `type` to interpret `amount`. We don't force a money shape
22 * onto a value that may not be money.
23 *
24 * manage-coupon (create/update/deactivate) is a write tool and ships in the
25 * write stage.
26 */
27 class CouponTools
28 {
29 public static function definitions()
30 {
31 return [
32 'fluent-cart/list-coupons' => [
33 'label' => __('List Coupons', 'fluent-cart'),
34 'description' => __('Find and filter coupons with usage counts (times_used) and validity windows. Filter by status, by code substring, or by type; paginate with page/per_page. Interpret amount via type: percentage means a percent (10 = 10 percent), fixed means a currency value. Use active_now to get only coupons usable today (status active and within the start/end window — i.e. not expired). status inactive means disabled.', 'fluent-cart'),
35 'input_schema' => [
36 'type' => 'object',
37 'properties' => [
38 'search' => ['type' => 'string', 'description' => 'Matches coupon code or title.'],
39 'code' => ['type' => 'string', 'description' => 'Matches the coupon code only (substring). Narrower than search.'],
40 'status' => ['type' => 'string', 'enum' => ['active', 'inactive'], 'description' => 'inactive = disabled. For "expired", use active_now=false / read valid_now on each row.'],
41 'type' => ['type' => 'string', 'enum' => ['fixed', 'percentage']],
42 'active_now' => ['type' => 'boolean', 'description' => 'Only coupons that are active and within their start/end window right now.'],
43 'sort_by' => ['type' => 'string', 'enum' => ['id', 'use_count', 'priority', 'end_date'], 'default' => 'id'],
44 'sort_type' => ['type' => 'string', 'enum' => ['ASC', 'DESC'], 'default' => 'DESC'],
45 'page' => ['type' => 'integer', 'default' => 1],
46 'per_page' => ['type' => 'integer', 'default' => 25, 'description' => 'Max 100.'],
47 ],
48 ],
49 'execute_callback' => [self::class, 'listCoupons'],
50 'permission_callback' => function () {
51 return PermissionGate::can('coupons/view');
52 },
53 'annotations' => ['readonly' => true],
54 ],
55
56 'fluent-cart/manage-coupon' => [
57 'label' => __('Manage Coupon', 'fluent-cart'),
58 'description' => __('Create, update, or deactivate a coupon. action=create needs code, type, and amount. action=update and action=deactivate need coupon_id. Deactivate sets status to inactive rather than deleting. Interpret amount by type: percentage is a percent, fixed is a currency value.', 'fluent-cart'),
59 'input_schema' => [
60 'type' => 'object',
61 'properties' => [
62 'action' => ['type' => 'string', 'enum' => ['create', 'update', 'deactivate']],
63 'coupon_id' => ['type' => 'integer', 'description' => 'Required for update and deactivate.'],
64 'code' => ['type' => 'string'],
65 'title' => ['type' => 'string'],
66 'type' => ['type' => 'string', 'enum' => ['fixed', 'percentage']],
67 'amount' => ['type' => 'number'],
68 'status' => ['type' => 'string', 'enum' => ['active', 'inactive']],
69 'stackable' => ['type' => 'string', 'enum' => ['yes', 'no']],
70 'start_date' => ['type' => 'string', 'description' => 'YYYY-MM-DD or ISO 8601, UTC.'],
71 'end_date' => ['type' => 'string', 'description' => 'YYYY-MM-DD or ISO 8601, UTC.'],
72 ],
73 'required' => ['action'],
74 ],
75 'execute_callback' => [self::class, 'manageCoupon'],
76 'permission_callback' => function () {
77 return PermissionGate::can('coupons/manage');
78 },
79 // Mutating; deactivate sets status inactive rather than deleting,
80 // so not destructive. create is not idempotent (a repeat makes a
81 // second coupon), so no idempotent hint on the dispatcher.
82 'annotations' => ['readonly' => false, 'destructive' => false],
83 ],
84 ];
85 }
86
87 public static function manageCoupon($params = [])
88 {
89 $action = isset($params['action']) ? sanitize_text_field($params['action']) : '';
90 if (!in_array($action, ['create', 'update', 'deactivate'], true)) {
91 return MCPHelper::error('invalid_action', __('action must be one of: create, update, deactivate.', 'fluent-cart'));
92 }
93
94 $fields = ['code', 'title', 'type', 'amount', 'status', 'stackable', 'start_date', 'end_date'];
95 $data = [];
96 foreach ($fields as $f) {
97 if (isset($params[$f])) {
98 $data[$f] = is_string($params[$f]) ? sanitize_text_field($params[$f]) : $params[$f];
99 }
100 }
101
102 // Enforce the advertised enums server-side (create + update). Sanitize
103 // alone would persist an out-of-enum value like status:'banana', which
104 // breaks admin list filters and active_now logic. Reject, don't drop.
105 $enums = [
106 'type' => ['fixed', 'percentage'],
107 'status' => ['active', 'inactive'],
108 'stackable' => ['yes', 'no'],
109 ];
110 foreach ($enums as $field => $allowed) {
111 if (isset($data[$field]) && !in_array($data[$field], $allowed, true)) {
112 return MCPHelper::error(
113 'invalid_param',
114 sprintf(
115 /* translators: 1: field name, 2: allowed values */
116 __('Invalid value for %1$s. Allowed: %2$s.', 'fluent-cart'),
117 $field,
118 implode(', ', $allowed)
119 ),
120 ['fields' => [$field], 'allowed' => $allowed]
121 );
122 }
123 }
124
125 if ($action === 'create') {
126 $missing = [];
127 foreach (['code', 'type', 'amount'] as $req) {
128 if (!isset($data[$req])) {
129 $missing[] = $req;
130 }
131 }
132 if ($missing) {
133 return MCPHelper::error('missing_param', __('create requires code, type, and amount.', 'fluent-cart'), ['fields' => $missing]);
134 }
135
136 $amountCheck = self::validateAmount($data['type'], $data['amount']);
137 if (is_wp_error($amountCheck)) {
138 return $amountCheck;
139 }
140
141 if (Coupon::query()->where('code', $data['code'])->exists()) {
142 return MCPHelper::error(
143 'duplicate_code',
144 sprintf(
145 /* translators: 1: coupon code */
146 __('A coupon with code "%1$s" already exists.', 'fluent-cart'),
147 $data['code']
148 ),
149 ['fields' => ['code']]
150 );
151 }
152
153 // Never persist a null status — default a new coupon to active.
154 if (!isset($data['status'])) {
155 $data['status'] = 'active';
156 }
157
158 return self::couponResult(CouponResource::create($data), __('Coupon created.', 'fluent-cart'));
159 }
160
161 if (empty($params['coupon_id'])) {
162 return MCPHelper::error('missing_identifier', __('coupon_id is required for update and deactivate.', 'fluent-cart'));
163 }
164 $coupon = Coupon::query()->find((int) $params['coupon_id']);
165 if (!$coupon) {
166 return MCPHelper::error('coupon_not_found', __('No coupon found for the given coupon_id.', 'fluent-cart'));
167 }
168
169 if ($action === 'deactivate') {
170 $coupon->update(['status' => 'inactive']);
171 return self::couponResult($coupon, __('Coupon deactivated.', 'fluent-cart'));
172 }
173
174 if (!$data) {
175 return MCPHelper::error('missing_param', __('Provide at least one field to update.', 'fluent-cart'));
176 }
177
178 // Write only the supplied fields directly on the model. We intentionally
179 // bypass CouponResource::update here: its formatAmount() step assumes a
180 // full coupon payload and corrupts partial updates — a percentage amount
181 // gets converted to cents (9 -> 900), and an omitted amount/conditions is
182 // reset to zero. amount is interpreted against the effective type:
183 // percentage stays a raw percent; fixed converts to cents like the admin.
184 $type = isset($data['type']) ? $data['type'] : $coupon->type;
185 $update = [];
186 foreach (['code', 'title', 'type', 'status', 'stackable', 'start_date', 'end_date'] as $f) {
187 if (isset($data[$f])) {
188 $update[$f] = $data[$f];
189 }
190 }
191 if (isset($data['amount'])) {
192 $amountCheck = self::validateAmount($type, $data['amount']);
193 if (is_wp_error($amountCheck)) {
194 return $amountCheck;
195 }
196 $update['amount'] = ($type === 'percentage') ? (0 + $data['amount']) : Helper::toCent($data['amount']);
197 }
198
199 $coupon->update($update);
200
201 return self::couponResult($coupon, __('Coupon updated.', 'fluent-cart'));
202 }
203
204 private static function couponResult($result, $summary)
205 {
206 if (is_wp_error($result)) {
207 return $result;
208 }
209 $coupon = (is_array($result) && isset($result['data'])) ? $result['data'] : $result;
210 $out = ['coupon_id' => (is_object($coupon) && isset($coupon->id)) ? (int) $coupon->id : null];
211 if (is_object($coupon)) {
212 // Echo the full resulting record so the caller needn't re-read.
213 $out['code'] = isset($coupon->code) ? $coupon->code : null;
214 $out['title'] = isset($coupon->title) ? $coupon->title : null;
215 $out['type'] = isset($coupon->type) ? $coupon->type : null;
216 $out['amount'] = self::couponAmount($coupon);
217 $out['status'] = isset($coupon->status) ? $coupon->status : null;
218 }
219 return MCPHelper::envelope($summary, $out);
220 }
221
222 /**
223 * Coupon amount in the units the tool contract promises: a percentage value
224 * for percentage coupons (stored as-is), a store-currency value for fixed
225 * coupons (stored in cents, so divided back). Always numeric.
226 */
227 private static function couponAmount($coupon)
228 {
229 if ($coupon->type === 'fixed') {
230 return 0 + Helper::toDecimalWithoutComma((int) $coupon->amount);
231 }
232 return is_numeric($coupon->amount) ? 0 + $coupon->amount : $coupon->amount;
233 }
234
235 /** Validate a coupon amount against its type. Returns true or a WP_Error. */
236 private static function validateAmount($type, $amount)
237 {
238 if (!is_numeric($amount)) {
239 return MCPHelper::error('invalid_amount', __('amount must be a number.', 'fluent-cart'), ['fields' => ['amount']]);
240 }
241 $amount = 0 + $amount;
242 if ($type === 'percentage') {
243 if ($amount <= 0 || $amount > 100) {
244 return MCPHelper::error('invalid_amount', __('A percentage coupon amount must be greater than 0 and at most 100.', 'fluent-cart'), ['fields' => ['amount']]);
245 }
246 } elseif ($amount < 0) {
247 return MCPHelper::error('invalid_amount', __('A fixed coupon amount cannot be negative.', 'fluent-cart'), ['fields' => ['amount']]);
248 }
249 return true;
250 }
251
252 public static function listCoupons($params = [])
253 {
254 $paging = MCPHelper::pagination($params, 25);
255 $query = Coupon::query();
256
257 if (!empty($params['search'])) {
258 $like = '%' . sanitize_text_field($params['search']) . '%';
259 $query->where(function ($q) use ($like) {
260 $q->where('code', 'LIKE', $like)->orWhere('title', 'LIKE', $like);
261 });
262 }
263 if (!empty($params['code'])) {
264 $query->where('code', 'LIKE', '%' . sanitize_text_field($params['code']) . '%');
265 }
266 if (!empty($params['status'])) {
267 $query->where('status', sanitize_text_field($params['status']));
268 }
269 if (!empty($params['type'])) {
270 $query->where('type', sanitize_text_field($params['type']));
271 }
272
273 $now = DateTime::gmtNow()->format('Y-m-d H:i:s');
274 if (!empty($params['active_now'])) {
275 $query->where('status', 'active')
276 ->where(function ($q) use ($now) {
277 $q->whereNull('start_date')->orWhere('start_date', '<=', $now);
278 })
279 ->where(function ($q) use ($now) {
280 $q->whereNull('end_date')->orWhere('end_date', '>=', $now);
281 });
282 }
283
284 $sortBy = self::allowed($params, 'sort_by', ['id', 'use_count', 'priority', 'end_date'], 'id');
285 $sortType = strtoupper(isset($params['sort_type']) ? $params['sort_type'] : 'DESC') === 'ASC' ? 'ASC' : 'DESC';
286 $query->orderBy($sortBy, $sortType);
287 if ($sortBy !== 'id') {
288 $query->orderBy('id', 'DESC');
289 }
290
291 $paginator = $query->paginate($paging['per_page'], ['*'], 'page', $paging['page']);
292 $total = self::total($paginator);
293
294 $rows = [];
295 foreach (MCPHelper::paginatorItems($paginator) as $coupon) {
296 $rows[] = self::formatRow($coupon, $now);
297 }
298
299 return MCPHelper::envelope(
300 sprintf(
301 /* translators: %d: number of matching coupons */
302 _n('%d coupon found.', '%d coupons found.', $total, 'fluent-cart'),
303 $total
304 ),
305 ['coupons' => $rows],
306 MCPHelper::pagingMeta($paginator)
307 );
308 }
309
310 private static function formatRow($coupon, $now)
311 {
312 return [
313 'coupon_id' => (int) $coupon->id,
314 'code' => $coupon->code,
315 'title' => $coupon->title,
316 'type' => $coupon->type,
317 'amount' => self::couponAmount($coupon),
318 'status' => $coupon->status,
319 'use_count' => (int) $coupon->use_count,
320 // times_used is the doc-facing alias of use_count — same value, kept so
321 // agents can read either name.
322 'times_used' => (int) $coupon->use_count,
323 'stackable' => $coupon->stackable,
324 'start_date' => MCPHelper::toIso8601($coupon->start_date),
325 'end_date' => MCPHelper::toIso8601($coupon->end_date),
326 'valid_now' => self::validNow($coupon, $now),
327 ];
328 }
329
330 private static function validNow($coupon, $now)
331 {
332 if ($coupon->status !== 'active') {
333 return false;
334 }
335 if ($coupon->start_date && $coupon->start_date > $now) {
336 return false;
337 }
338 if ($coupon->end_date && $coupon->end_date < $now) {
339 return false;
340 }
341 return true;
342 }
343
344 private static function allowed($params, $key, array $allowed, $default)
345 {
346 $val = isset($params[$key]) ? $params[$key] : $default;
347 return in_array($val, $allowed, true) ? $val : $default;
348 }
349
350 private static function total($paginator)
351 {
352 return MCPHelper::paginatorTotal($paginator);
353 }
354 }
355