'required|alpha_numeric_punct|max_length[50]|is_unique[discount_vouchers.code,id,{id}]', 'discount_type' => 'required|in_list[percent,fixed]', 'discount_value' => 'required|numeric|greater_than_equal_to[0]', 'max_uses' => 'permit_empty|is_natural', 'valid_from' => 'permit_empty|valid_date[Y-m-d]', 'valid_until' => 'permit_empty|valid_date[Y-m-d]', 'is_active' => 'in_list[0,1]', 'description' => 'permit_empty|string|max_length[1000]', // <-- NEW ]; protected $validationMessages = [ 'code' => [ 'is_unique' => 'The voucher code must be unique.', ], 'discount_type' => [ 'in_list' => 'The discount type must be either "percent" or "fixed".', ], ]; // Normalize inputs before save protected $beforeInsert = ['normalizeFields', 'validateDatesOrder']; protected $beforeUpdate = ['normalizeFields', 'validateDatesOrder']; protected function normalizeFields(array $data): array { if (!isset($data['data'])) return $data; // Normalize code: keep A-Z, 0-9 and dashes, uppercased if (array_key_exists('code', $data['data'])) { $raw = (string) $data['data']['code']; $normalized = strtoupper(preg_replace('/[^A-Z0-9\-]/i', '', trim($raw))); $data['data']['code'] = $normalized; } // Empty strings -> NULL for dates & max_uses foreach (['valid_from', 'valid_until'] as $f) { if (array_key_exists($f, $data['data'])) { $v = trim((string) $data['data'][$f]); $data['data'][$f] = ($v === '') ? null : $v; } } if (array_key_exists('max_uses', $data['data'])) { $v = $data['data']['max_uses']; $data['data']['max_uses'] = ($v === '' || $v === null) ? null : max(0, (int) $v); } // Coerce booleans if (array_key_exists('is_active', $data['data'])) { $data['data']['is_active'] = (int) !!$data['data']['is_active']; } return $data; } protected function validateDatesOrder(array $data): array { if (!isset($data['data'])) return $data; $from = $data['data']['valid_from'] ?? null; $until = $data['data']['valid_until'] ?? null; if ($from && $until && $from > $until) { // Inject a model error; save() will fail and return errors() $this->validator->setError('valid_until', 'Valid Until must be the same as or after Valid From.'); } return $data; } /** * Find a valid voucher for a student (not expired, active, under max uses, and not used by the student). */ public function findValidVoucher(string $code, int $studentId) { $code = strtoupper(preg_replace('/\s+/', '', trim($code))); // simple normalize to match stored code $builder = $this->db->table('discount_vouchers v') ->select('v.*') // Left join usages for this student only ->join('discount_usages u', 'u.voucher_id = v.id AND u.student_id = ' . (int) $studentId, 'left') ->where('v.code', $code) ->where('v.is_active', 1) // valid_from is null or <= today ->groupStart() ->where('v.valid_from IS NULL', null, false) ->orWhere('v.valid_from <=', date('Y-m-d')) ->groupEnd() // valid_until is null or >= today ->groupStart() ->where('v.valid_until IS NULL', null, false) ->orWhere('v.valid_until >=', date('Y-m-d')) ->groupEnd() // max_uses is null or times_used < max_uses (use raw to compare two columns) ->groupStart() ->where('v.max_uses IS NULL', null, false) ->orWhere('v.times_used < v.max_uses', null, false) ->groupEnd() // Not previously used by this student (no usage row) ->where('u.id IS NULL', null, false); return $builder->get()->getRow(); } }