'decimal:2', 'max_uses' => 'integer', 'times_used' => 'integer', 'is_active' => 'boolean', 'valid_from' => 'date', 'valid_until' => 'date', ]; /* ========================= * Model hooks (CI beforeInsert/beforeUpdate equivalents) * ========================= */ protected static function booted(): void { static::creating(function (self $m) { $m->normalizeFields(); $m->validateDatesOrder(); // throws if invalid }); static::updating(function (self $m) { $m->normalizeFields(); $m->validateDatesOrder(); // throws if invalid }); } private function normalizeFields(): void { // Normalize code: keep A-Z, 0-9 and dashes, uppercased if ($this->isDirty('code') && $this->code !== null) { $raw = (string) $this->code; $this->code = strtoupper(preg_replace('/[^A-Z0-9\-]/i', '', trim($raw))); } // Empty strings -> NULL for dates & max_uses foreach (['valid_from', 'valid_until'] as $f) { if ($this->isDirty($f)) { $v = $this->{$f}; if (is_string($v)) { $v = trim($v); $this->{$f} = ($v === '') ? null : $v; } } } if ($this->isDirty('max_uses')) { $v = $this->max_uses; if ($v === '' || $v === null) { $this->max_uses = null; } else { $this->max_uses = max(0, (int) $v); } } if ($this->isDirty('is_active')) { $this->is_active = (bool) $this->is_active; } } private function validateDatesOrder(): void { if (!empty($this->valid_from) && !empty($this->valid_until)) { $from = Carbon::parse($this->valid_from)->toDateString(); $until = Carbon::parse($this->valid_until)->toDateString(); if ($from > $until) { // In Laravel, prefer validating in FormRequest. // This keeps your CI behavior by failing the save. throw new \InvalidArgumentException('Valid Until must be the same as or after Valid From.'); } } } /* ========================= * Query helpers * ========================= */ /** * Find a valid voucher for a student (not expired, active, under max uses, and not used by the student). * * NOTE: Your CI joins discount_usages on u.student_id. That requires discount_usages.student_id to exist. * If your discount_usages table does NOT have student_id (some of your other code uses parent_id), * switch the filter to parent_id instead. */ public static function findValidVoucher(string $code, int $studentId): ?self { $code = strtoupper(preg_replace('/\s+/', '', trim($code))); $today = now()->toDateString(); $q = static::query() ->from('discount_vouchers as v') ->select('v.*') // Left join usages for this student only ->leftJoin('discount_usages as u', function ($join) use ($studentId) { $join->on('u.voucher_id', '=', 'v.id') ->where('u.student_id', '=', (int) $studentId); }) ->where('v.code', $code) ->where('v.is_active', 1) // valid_from is null or <= today ->where(function ($w) use ($today) { $w->whereNull('v.valid_from') ->orWhere('v.valid_from', '<=', $today); }) // valid_until is null or >= today ->where(function ($w) use ($today) { $w->whereNull('v.valid_until') ->orWhere('v.valid_until', '>=', $today); }) // max_uses is null or times_used < max_uses ->where(function ($w) { $w->whereNull('v.max_uses') ->orWhereColumn('v.times_used', '<', 'v.max_uses'); }) // Not previously used by this student (no usage row) ->whereNull('u.id') ->limit(1); return $q->first(); } }