PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.4.1
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.4.1
1.6.5 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 All 48 releases
fluent-cart / app / Modules / MCP / Tools / CouponTools.php

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

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