PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
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
← All changes | app/Http/Requests/CouponRequest.php +98 -17 1.3.26 → 1.6.5 View file →
@@ -26,15 +26,23 @@
26 26 'maxLength:50',
27 27 function ($attribute, $value) {
28 28 $id = absint(App::request()->get('id'));
29 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 +
30 38 // Skip the check if updating and the code belongs to the same record
31 39 if ($id) {
32 - $existing = Coupon::query()->where('code', $value)
40 + $existing = Coupon::query()->where('code', $code)
33 41 ->where('id', '!=', $id)
34 42 ->first();
35 43 } else {
36 - $existing = Coupon::query()->where('code', $value)->first();
44 + $existing = Coupon::query()->where('code', $code)->first();
37 45 }
38 46
39 47 if ($existing) {
40 48 return sprintf(__('This coupon code is already in use.', 'fluent-cart'));
@@ -46,14 +54,36 @@
46 54 'priority' => 'nullable|numeric|min:0',
47 55 'type' => 'required|in:fixed,percentage,free_shipping,buy_x_get_y',
48 56 'conditions' => 'nullable|array',
49 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',
50 75 'conditions.max_discount_amount' => 'nullable|numeric|min:0',
51 76 'conditions.apply_to_whole_cart' => 'nullable|sanitizeText',
52 77 'conditions.apply_to_quantity' => 'nullable|sanitizeText',
53 78 'conditions.buy_products' => 'nullable|array',
54 79 'conditions.get_products' => 'nullable|array',
55 - 'conditions.max_per_customer' => 'nullable|numeric',
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',
56 86 'conditions.excluded_categories' => 'nullable',
57 87 'conditions.included_categories' => 'nullable',
58 88 'conditions.excluded_products' => 'nullable',
59 89 'conditions.included_products' => 'nullable',
@@ -61,10 +91,16 @@
61 91 'conditions.is_recurring' => 'nullable',
62 92 'conditions.max_uses' => [
63 93 'nullable',
64 94 'numeric',
95 + 'min:0',
65 96 function ($attribute, $value) {
66 - if ($this->get("max_uses") < $this->get("max_per_customer")) {
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) {
67 103 return sprintf(__("Max uses must be greater than or equal to max per customer.", 'fluent-cart'));
68 104 }
69 105 return null;
70 106 },
@@ -81,27 +117,66 @@
81 117 },
82 118 ],
83 119 'status' => 'required|in:active,expired,disabled,scheduled',
84 120 'notes' => 'nullable|sanitizeTextArea',
85 - 'stackable' => 'required|sanitizeText|maxLength:50',
86 - 'show_on_checkout' => 'required|sanitizeText|maxLength:50',
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',
87 127 'start_date' => [
88 - 'required_if:end_date,!=,null',
89 - 'string',
90 - 'nullable'
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 + }
91 153 ],
92 154 'end_date' => [
93 155 'nullable',
94 156 'string',
95 - function ($attribute, $value) use ($startDate, $endDate) {
96 - if ($value !== null) {
97 - $startDateTime = strtotime(trim($startDate));
98 - $endDateTime = strtotime(trim($endDate));
157 + function ($attribute, $value) use ($startDate) {
158 + if ($value === null || $value === '') {
159 + return null;
160 + }
99 161
100 - if ($endDateTime <= $startDateTime) {
101 - return sprintf(esc_html__("The end date must be after the start date.", 'fluent-cart'));
102 - }
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');
103 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 + }
104 179 return null;
105 180 },
106 181 ],
107 182 ];
@@ -117,9 +192,9 @@
117 192 'code.required' => esc_html__('Code is required.', 'fluent-cart'),
118 193 'type.required' => esc_html__('Type is required.', 'fluent-cart'),
119 194 'amount.required' => esc_html__('Amount is required.', 'fluent-cart'),
120 195 'buy_quantity.required_if' => esc_html__('Buy quantity is required. ', 'fluent-cart'),
121 - 'start_date.required_if' => esc_html__('Start date is required. ', 'fluent-cart'),
196 + 'start_date.required_with' => esc_html__('Start date is required. ', 'fluent-cart'),
122 197 'end_date.required_if' => esc_html__('End date is required. ', 'fluent-cart'),
123 198 'end_date.date' => esc_html__('The end date type should be date.', 'fluent-cart'),
124 199 ];
125 200 }
@@ -139,8 +214,14 @@
139 214 $sanitizedData = [];
140 215 $sanitizedData['min_purchase_amount'] = floatval(Arr::get($value, 'min_purchase_amount') ?? 0);
141 216 $sanitizedData['max_discount_amount'] = floatval(Arr::get($value, 'max_discount_amount') ?? 0);
142 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 + }
143 224 $sanitizedData['apply_to_whole_cart'] = sanitize_text_field(Arr::get($value, 'apply_to_whole_cart') ?? 'no');
144 225 $sanitizedData['apply_to_quantity'] = sanitize_text_field(Arr::get($value, 'apply_to_quantity') ?? 'no');
145 226 $sanitizedData['max_uses'] = intval(Arr::get($value, 'max_uses') ?? 0);
146 227 $sanitizedData['max_per_customer'] = intval(Arr::get($value, 'max_per_customer') ?? 0);