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 / Http / Requests / CouponRequest.php

CouponRequest.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.4, at app/Http/Requests/CouponRequest.php

261 lines 13.8 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\Http\Requests;
4
5 use FluentCart\App\App;
6 use FluentCart\App\Models\Coupon;
7 use FluentCart\Framework\Foundation\RequestGuard;
8 use FluentCart\Framework\Support\Arr;
9
10 class CouponRequest extends RequestGuard
11 {
12
13 /**
14 * @return array
15 */
16 public function rules(): array
17 {
18 $startDate = $this->get("start_date");
19 $endDate = $this->get("end_date");
20
21 return [
22 'title' => 'required|sanitizeText|maxLength:200',
23 'code' => [
24 'required',
25 'string',
26 'maxLength:50',
27 function ($attribute, $value) {
28 $id = absint(App::request()->get('id'));
29
30 // Compare what will actually be STORED, not the raw input:
31 // sanitize() runs sanitize_text_field after validation, which
32 // trims — so a raw " CODE" passed this check unsanitized and
33 // then detonated on the fct_coupons UNIQUE index with a raw
34 // SQL error page (leading whitespace matters to MySQL VARCHAR
35 // comparison). Same ordering the MCP CouponTools already uses.
36 $code = sanitize_text_field((string) $value);
37
38 // Skip the check if updating and the code belongs to the same record
39 if ($id) {
40 $existing = Coupon::query()->where('code', $code)
41 ->where('id', '!=', $id)
42 ->first();
43 } else {
44 $existing = Coupon::query()->where('code', $code)->first();
45 }
46
47 if ($existing) {
48 return sprintf(__('This coupon code is already in use.', 'fluent-cart'));
49 }
50 return null;
51 }
52
53 ],
54 'priority' => 'nullable|numeric|min:0',
55 'type' => 'required|in:fixed,percentage,free_shipping,buy_x_get_y',
56 'conditions' => 'nullable|array',
57 'conditions.min_purchase_amount' => 'nullable|numeric|min:0',
58 'conditions.max_purchase_amount' => [
59 'nullable',
60 'numeric',
61 'min:0',
62 function ($attribute, $value) {
63 // 0 / empty means "no max limit" (the form placeholder says
64 // so), therefore only cross-check when both sides are capped.
65 $min = floatval($this->get('conditions.min_purchase_amount'));
66 $max = floatval($value);
67
68 if ($max > 0 && $min > 0 && $min > $max) {
69 return sprintf(__('Max spend amount must be greater than or equal to min spend amount.', 'fluent-cart'));
70 }
71 return null;
72 },
73 ],
74 'conditions.min_amount_basis' => 'nullable|in:subtotal,total',
75 'conditions.max_discount_amount' => 'nullable|numeric|min:0',
76 'conditions.apply_to_whole_cart' => 'nullable|sanitizeText',
77 'conditions.apply_to_quantity' => 'nullable|sanitizeText',
78 'conditions.buy_products' => 'nullable|array',
79 'conditions.get_products' => 'nullable|array',
80 // min:0 on both usage limits: the gates compare `count >= limit`
81 // behind truthiness checks, so a persisted negative limit is truthy
82 // and permanently reports "max uses exceeded" once the coupon (or
83 // the customer) has a single use. 0 stays valid — it means
84 // unlimited and is falsy-guarded past the gates.
85 'conditions.max_per_customer' => 'nullable|numeric|min:0',
86 'conditions.excluded_categories' => 'nullable',
87 'conditions.included_categories' => 'nullable',
88 'conditions.excluded_products' => 'nullable',
89 'conditions.included_products' => 'nullable',
90 'conditions.email_restrictions' => 'nullable',
91 'conditions.is_recurring' => 'nullable',
92 'conditions.max_uses' => [
93 'nullable',
94 'numeric',
95 'min:0',
96 function ($attribute, $value) {
97 // Both limits are optional and 0 means "unlimited", so only compare
98 // when the submission actually caps both.
99 $maxUses = intval($value);
100 $maxPerCustomer = intval($this->get("conditions.max_per_customer"));
101
102 if ($maxUses > 0 && $maxPerCustomer > 0 && $maxUses < $maxPerCustomer) {
103 return sprintf(__("Max uses must be greater than or equal to max per customer.", 'fluent-cart'));
104 }
105 return null;
106 },
107 ],
108 'amount' => [
109 'required',
110 'numeric',
111 'min:0',
112 function ($attribute, $value) {
113 if ($this->get("type") === 'percentage' && $value > 100) {
114 return sprintf(__("For percentage type, the amount should not be greater than 100.", 'fluent-cart'));
115 }
116 return null;
117 },
118 ],
119 'status' => 'required|in:active,expired,disabled,scheduled',
120 'notes' => 'nullable|sanitizeTextArea',
121 // in:yes,no — consumers disagree on how to read anything else (the
122 // admin stacking gate checks === 'no', the storefront checks
123 // === 'yes'), so a bogus value behaves differently per calculator.
124 // status and type already constrain via in:; these were overlooked.
125 'stackable' => 'required|in:yes,no',
126 'show_on_checkout' => 'required|in:yes,no',
127 'start_date' => [
128 // required_if only supports the equality form — the old
129 // `required_if:end_date,!=,null` was never parsed and silently
130 // no-oped. required_with is implicit and isPresent() treats
131 // '' / null as absent, so this fires exactly when an end date
132 // is supplied without a start date. No 'nullable' here: in this
133 // validator nullable short-circuits implicit required_* rules
134 // (verified). No bare 'string' rule either: the admin form
135 // always sends the key, nulled when the schedule is empty, and
136 // the string rule's presence check (Arr::has) treats that null
137 // as present — so 'string' would reject every unscheduled
138 // coupon. The closure enforces string-ness only on real values.
139 'required_with:end_date',
140 function ($attribute, $value) {
141 if (is_null($value) || $value === '') {
142 return null;
143 }
144 // is_string alone is not enough: a garbage string passed
145 // straight through to DateTime::anyTimeToGmt() in the
146 // controller, which threw a plugin_exception disclosing the
147 // absolute DateTime.php path. The value must actually parse.
148 if (!is_string($value) || strtotime(trim($value)) === false) {
149 return esc_html__('The start date must be a valid date string.', 'fluent-cart');
150 }
151 return null;
152 }
153 ],
154 'end_date' => [
155 'nullable',
156 'string',
157 function ($attribute, $value) use ($startDate) {
158 if ($value === null || $value === '') {
159 return null;
160 }
161
162 // strtotime('garbage') is false — the old comparison coerced
163 // it to 0, reporting unparseable end dates with a misleading
164 // end-after-start message (and letting them through entirely
165 // beside a pre-1970 start date's negative timestamp).
166 $endDateTime = is_string($value) ? strtotime(trim($value)) : false;
167 if ($endDateTime === false) {
168 return esc_html__('The end date must be a valid date string.', 'fluent-cart');
169 }
170
171 // Only compare when the start date itself parses — a bad
172 // start date is reported by its own rule, not this one.
173 $startDateTime = is_string($startDate) && $startDate !== ''
174 ? strtotime(trim($startDate))
175 : false;
176 if ($startDateTime !== false && $endDateTime <= $startDateTime) {
177 return sprintf(esc_html__("The end date must be after the start date.", 'fluent-cart'));
178 }
179 return null;
180 },
181 ],
182 ];
183 }
184
185 /**
186 * @return array
187 */
188 public function messages(): array
189 {
190 return [
191 'title.required' => esc_html__('Title is required.', 'fluent-cart'),
192 'code.required' => esc_html__('Code is required.', 'fluent-cart'),
193 'type.required' => esc_html__('Type is required.', 'fluent-cart'),
194 'amount.required' => esc_html__('Amount is required.', 'fluent-cart'),
195 'buy_quantity.required_if' => esc_html__('Buy quantity is required. ', 'fluent-cart'),
196 'start_date.required_with' => esc_html__('Start date is required. ', 'fluent-cart'),
197 'end_date.required_if' => esc_html__('End date is required. ', 'fluent-cart'),
198 'end_date.date' => esc_html__('The end date type should be date.', 'fluent-cart'),
199 ];
200 }
201
202 /**
203 * @return array
204 */
205 public function sanitize(): array
206 {
207 return [
208 'title' => 'sanitize_text_field',
209 'code' => 'sanitize_text_field',
210 'priority' => 'intval',
211 'type' => 'sanitize_text_field',
212 'conditions' => function ($value) {
213
214 $sanitizedData = [];
215 $sanitizedData['min_purchase_amount'] = floatval(Arr::get($value, 'min_purchase_amount') ?? 0);
216 $sanitizedData['max_discount_amount'] = floatval(Arr::get($value, 'max_discount_amount') ?? 0);
217 $sanitizedData['max_purchase_amount'] = floatval(Arr::get($value, 'max_purchase_amount') ?? 0);
218 // Only carry the basis when a valid value is supplied. When it is omitted the key is
219 // left absent so create() can default it and update() can preserve the stored value —
220 // never force 'subtotal' onto a legacy coupon whose client simply doesn't send the field.
221 if (in_array(Arr::get($value, 'min_amount_basis'), ['subtotal', 'total'], true)) {
222 $sanitizedData['min_amount_basis'] = Arr::get($value, 'min_amount_basis');
223 }
224 $sanitizedData['apply_to_whole_cart'] = sanitize_text_field(Arr::get($value, 'apply_to_whole_cart') ?? 'no');
225 $sanitizedData['apply_to_quantity'] = sanitize_text_field(Arr::get($value, 'apply_to_quantity') ?? 'no');
226 $sanitizedData['max_uses'] = intval(Arr::get($value, 'max_uses') ?? 0);
227 $sanitizedData['max_per_customer'] = intval(Arr::get($value, 'max_per_customer') ?? 0);
228 $sanitizedData['excluded_categories'] = (is_array(Arr::get($value, 'excluded_categories')) ? Arr::get($value, 'excluded_categories') : []);
229 $sanitizedData['included_categories'] = is_array(Arr::get($value, 'included_categories')) ? Arr::get($value, 'included_categories') : [];
230 $sanitizedData['excluded_products'] = is_array(Arr::get($value, 'excluded_products')) ? Arr::get($value, 'excluded_products') : [];
231 $sanitizedData['included_products'] = is_array(Arr::get($value, 'included_products')) ? Arr::get($value, 'included_products') : [];
232 $sanitizedData['email_restrictions'] = sanitize_text_field(Arr::get($value, 'email_restrictions') ?? '');
233 $sanitizedData['is_recurring'] = Arr::get($value, 'is_recurring') === 'yes' ? 'yes' : 'no';
234
235
236 $arrayValues = ['excluded_categories', 'included_categories', 'excluded_products', 'included_products'];
237 foreach ($arrayValues as $key) {
238 $sanitizedData[$key] = array_unique(array_map('sanitize_text_field', $sanitizedData[$key]));
239 }
240
241 return $sanitizedData;
242 },
243 'amount' => 'floatval',
244 'conditions.apply_to_quantity' => 'sanitize_text_field',
245 'conditions.buy_quantity' => 'intval',
246 'conditions.get_quantity' => 'intval',
247 'conditions.max_uses' => 'intval',
248 'conditions.max_per_customer' => 'intval',
249 'status' => 'sanitize_text_field',
250 'notes' => 'sanitize_text_field',
251 'stackable' => 'sanitize_text_field',
252 'show_on_checkout' => 'sanitize_text_field',
253 'start_date' => 'sanitize_text_field',
254 'end_date' => 'sanitize_text_field',
255 'metaValue' => function ($value) {
256 return $value;
257 }
258 ];
259 }
260 }
261