| @@ -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); |