configModel = new ConfigurationModel(); $this->voucherModel = new DiscountVoucherModel(); $this->invoiceModel = new InvoiceModel(); $this->userModel = new UserModel(); $this->db = \Config\Database::connect(); $this->paymentModel = new PaymentModel(); $this->enrollmentModel = new EnrollmentModel(); $this->eventChargesModel = new EventChargesModel(); $this->additionalChargeModel = new AdditionalChargeModel(); $this->classSectionModel = new ClassSectionModel(); $this->invoiceLedgerService = new InvoiceLedgerService(); $this->schoolYear = $this->configModel->getConfig('school_year'); $this->semester = $this->configModel->getConfig('semester'); } public function applyVoucher() { if (strtolower($this->request->getMethod()) === 'post') { $voucherId = $this->request->getPost('voucher_id'); $parentIds = $this->request->getPost('parent_ids') ?? []; $allowAdditional = (bool) $this->request->getPost('allow_additional'); if (empty($parentIds)) { return redirect()->back()->with('error', 'Please select at least one parent.'); } $voucher = $this->voucherModel->find($voucherId); if (!$voucher) { return redirect()->back()->with('error', 'Voucher not found.'); } // Normalize description from voucher (optional) $voucherDescription = trim((string) ($voucher['description'] ?? '')); $voucherDescription = preg_replace( '/^Auto-generated on \d{4}-\d{2}-\d{2}\s*[—-]\s*reason:\s*/i', '', $voucherDescription ); $maxUsesRaw = $voucher['max_uses'] ?? null; $maxUses = ($maxUsesRaw === null || $maxUsesRaw === '') ? null : (int) $maxUsesRaw; $timesUsed = (int) ($voucher['times_used'] ?? 0); $remainingUses = ($maxUses === null) ? PHP_INT_MAX : ($maxUses - $timesUsed); if ($remainingUses <= 0) { return redirect()->back()->with('error', 'This voucher has reached its maximum allowed uses.'); } // If not allowing additional discounts, filter out parents who already have discounts this school year. if (!$allowAdditional) { $rows = $this->db->table('discount_usages du') ->select('i.parent_id') ->join('invoices i', 'i.id = du.invoice_id') ->whereIn('i.parent_id', $parentIds) ->where('i.school_year', $this->schoolYear) ->groupBy('i.parent_id') ->get() ->getResultArray(); $blockedParentIds = array_map(static fn($r) => (int) $r['parent_id'], $rows); if (!empty($blockedParentIds)) { $parentIds = array_values(array_diff( array_map('intval', $parentIds), $blockedParentIds )); } if (empty($parentIds)) { return redirect()->back()->with('error', 'All selected parents already have discounts. Enable "Allow additional discounts" to apply another.'); } } $paymentDate = utc_now(); // Collect invoice IDs that end up fully covered by the voucher in this run $fullyCoveredInvoiceIds = []; $touchedInvoiceIds = []; $appliedCount = 0; $this->db->transStart(); foreach ($parentIds as $parentId) { // Fetch invoices for this parent & school year $invoices = $this->invoiceModel->getInvoicesByUserId($parentId, $this->schoolYear); if (empty($invoices)) { // Do NOT early-return inside a transaction; just continue session()->setFlashdata('error', 'A selected parent has no invoice in record.'); continue; } foreach ($invoices as $invoice) { if ($remainingUses <= 0) break 2; // out of parentIds loop too $this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [(int) $invoice['id']]); // Snapshot current balance BEFORE applying $initialPreBalance = (float) $this->getCurrentInvoiceBalance($invoice['id'], $this->schoolYear); if ($initialPreBalance <= 0) { log_message( 'error', 'applyVoucher skip: zero balance | voucher_id={vid} parent_id={pid} invoice_id={iid} invoice_number={inum} balance={bal}', [ 'vid' => (int)$voucherId, 'pid' => (int)$parentId, 'iid' => (int)$invoice['id'], 'inum' => (string)($invoice['invoice_number'] ?? ''), 'bal' => $initialPreBalance, ] ); continue; } // Already used this voucher on this invoice? if (!$allowAdditional) { $exists = $this->db->table('discount_usages du') ->join('invoices i', 'du.invoice_id = i.id') ->where('du.voucher_id', $voucherId) ->where('du.invoice_id', $invoice['id']) ->where('i.school_year', $this->schoolYear) ->countAllResults(); if ($exists) { log_message( 'error', 'applyVoucher skip: voucher already used | voucher_id={vid} parent_id={pid} invoice_id={iid} invoice_number={inum}', [ 'vid' => (int)$voucherId, 'pid' => (int)$parentId, 'iid' => (int)$invoice['id'], 'inum' => (string)($invoice['invoice_number'] ?? ''), ] ); continue; } } // Calculate discount $rawDiscount = ($voucher['discount_type'] === 'percent') ? round(((float)$invoice['total_amount'] * (float)$voucher['discount_value']) / 100, 2) : (float) $voucher['discount_value']; // Cap by CURRENT invoice balance snapshot $discount = min($rawDiscount, $initialPreBalance); // Nothing to do if no discount if ($discount <= 0) { log_message( 'error', 'applyVoucher skip: discount <= 0 | voucher_id={vid} parent_id={pid} invoice_id={iid} invoice_number={inum} raw_discount={raw} balance={bal}', [ 'vid' => (int)$voucherId, 'pid' => (int)$parentId, 'iid' => (int)$invoice['id'], 'inum' => (string)($invoice['invoice_number'] ?? ''), 'raw' => $rawDiscount, 'bal' => $initialPreBalance, ] ); continue; } // Insert discount usage $now = utc_now(); $this->db->table('discount_usages')->insert([ 'voucher_id' => $voucherId, 'invoice_id' => $invoice['id'], 'parent_id' => $parentId, 'discount_amount' => $discount, 'description' => $voucherDescription, 'school_year' => $this->schoolYear, 'updated_by' => session()->get('user_id'), 'used_at' => $now, 'created_at' => $now, 'updated_at' => $now, ]); $ledger = $this->invoiceLedgerService->recalculateInvoice((int) $invoice['id']); $postBalance = (float) ($ledger['balance'] ?? 0.0); $currentBalance = $postBalance; // Increment voucher usage $this->db->table('discount_vouchers') ->where('id', $voucherId) ->set('times_used', 'COALESCE(times_used,0) + 1', false) ->update(); // Prepare and trigger payment event [$eventData, $studentData] = $this->buildPaymentEventData( $invoice['id'], $voucherId, $discount, 'discount', $paymentDate, '', 0, $initialPreBalance, // pre-payment snapshot $postBalance // computed post-payment ); Events::trigger('paymentReceived', $eventData, $studentData); $touchedInvoiceIds[] = (int) $invoice['id']; $appliedCount++; // If voucher covered the entire current balance, mark to update enrollments // Use a small epsilon to tolerate cents rounding. $epsilon = 0.01; $fullyCovered = (abs($discount - $initialPreBalance) <= $epsilon) || ($postBalance <= $epsilon) || ($currentBalance <= $epsilon); if ($fullyCovered) { $fullyCoveredInvoiceIds[] = (int) $invoice['id']; } $remainingUses--; // Deactivate if we just hit the cap if ($remainingUses <= 0 && $maxUses !== null) { $this->db->table('discount_vouchers') ->where('id', $voucherId) ->update([ 'is_active' => 0, 'updated_at' => $now, ]); break 2; } } } $this->db->transComplete(); if ($this->db->transStatus() === false) { return redirect()->back()->with('error', 'Voucher application failed. Transaction rolled back.'); } if ($appliedCount === 0) { return redirect()->back()->with('error', 'No discounts were applied. The voucher may already be used for these invoices or balances are zero.'); } // ✅ Ensure voucher deactivates when max_uses reached (handles edge cases) if ($maxUses !== null) { $row = $this->db->table('discount_vouchers') ->select('times_used') ->where('id', $voucherId) ->get() ->getRowArray(); $usedNow = (int) ($row['times_used'] ?? 0); if ($usedNow >= $maxUses) { $this->db->table('discount_vouchers') ->where('id', $voucherId) ->update([ 'is_active' => 0, 'updated_at' => utc_now(), ]); } } // ✅ AFTER COMMIT: recalculate invoice totals/balance/paid/discount/refund foreach (array_unique($touchedInvoiceIds) as $iid) { try { $this->recalculateInvoice($iid, $this->schoolYear); } catch (\Throwable $e) { log_message('error', 'recalculateInvoice failed for invoice {iid}: {err}', [ 'iid' => $iid, 'err' => $e->getMessage(), ]); } } // ✅ AFTER COMMIT: update enrollments for fully-covered invoices // Avoid nested transactions by doing this post-commit. $updatedTotal = 0; foreach (array_unique($fullyCoveredInvoiceIds) as $iid) { try { $updatedTotal += (int) $this->updateEnrollmentStatusIfPaid($iid); } catch (\Throwable $e) { log_message('error', 'updateEnrollmentStatusIfPaid failed for invoice {iid}: {err}', [ 'iid' => $iid, 'err' => $e->getMessage(), ]); } } if ($updatedTotal > 0) { return redirect()->back()->with('success', 'Voucher applied successfully and enrollments updated.'); } return redirect()->back()->with('success', 'Voucher applied successfully.'); } // GET: load page $parents = $this->userModel->getParents(); foreach ($parents as &$parent) { $discount = $this->db->table('discount_usages') ->select('COALESCE(SUM(discount_amount),0) AS total_discount') ->where('parent_id', $parent['id']) ->where('school_year', $this->schoolYear) ->get() ->getRowArray(); $parent['total_discount'] = $discount['total_discount'] ?? 0; $parent['has_discount'] = ($parent['total_discount'] > 0) ? 1 : 0; } unset($parent); return view('discounts/apply_voucher', [ 'vouchers' => $this->voucherModel->where('is_active', 1)->findAll(), 'parents' => $parents, ]); } private function updateEnrollmentStatusIfPaid(int $invoiceId): int { // 1) Fetch invoice $invoice = $this->invoiceModel->find($invoiceId); if (!$invoice) { log_message('warning', 'Invoice not found: {id}', ['id' => $invoiceId]); return 0; } // 2) Payment check (any payment recorded: balance < total) $total = (float) ($invoice['total_amount'] ?? 0); $balance = (float) ($invoice['balance'] ?? 0); if (!($total > 0 && $balance < $total)) { log_message('info', 'No payment yet. Skipping enrollment update. Invoice #{id}', ['id' => $invoiceId]); return 0; } $parentId = (int) ($invoice['parent_id'] ?? 0); $schoolYear = (string) $this->schoolYear; if ($parentId <= 0 || $schoolYear === '') { log_message('warning', 'updateEnrollmentStatusIfPaid: missing parent_id/school_year for invoice #{id}', ['id' => $invoiceId]); return 0; } // 3) Try to limit to enrollments actually present on the invoice (optional) // If invoice_items.enrollment_id doesn't exist, this silently falls back to parent/year scope. $paidEnrollmentIds = []; try { $rows = $this->db->table('invoice_items') ->select('enrollment_id') ->where('invoice_id', $invoiceId) ->where('enrollment_id IS NOT NULL', null, false) ->get()->getResultArray(); foreach ($rows as $r) { $eid = (int) ($r['enrollment_id'] ?? 0); if ($eid > 0) $paidEnrollmentIds[] = $eid; } $paidEnrollmentIds = array_values(array_unique($paidEnrollmentIds)); } catch (\Throwable $e) { // No-op: table/column might not exist in your schema } $db = $this->db; $db->transBegin(); try { // 4) Preview IDs that will be updated (good for debugging “partials”) $previewBuilder = $db->table('enrollments') ->select('id') ->where('parent_id', $parentId) ->where('school_year', $schoolYear) ->groupStart() // tolerant match for 'payment pending' (case/space/nbsp) ->where('enrollment_status', 'payment pending') ->orWhere('enrollment_status', 'Payment pending') ->orWhere('enrollment_status', 'Payment Pending') ->orWhere('enrollment_status', 'PAYMENT PENDING') ->orWhere("LOWER(TRIM(REPLACE(enrollment_status, CHAR(160), ' '))) = 'payment pending'", null, false) ->groupEnd(); if (!empty($paidEnrollmentIds)) { $previewBuilder->whereIn('id', $paidEnrollmentIds); } $toUpdateIds = array_map( static fn($r) => (int)$r['id'], $previewBuilder->get()->getResultArray() ); if (empty($toUpdateIds)) { $db->transCommit(); log_message('info', 'No enrollments matched pending status for parent {p}, year {y}, sem {s}.', [ 'p' => $parentId, 'y' => $schoolYear, 's' => 'ALL' ]); return 0; } // 5) Perform update with same filters $builder = $db->table('enrollments'); $builder->set('enrollment_status', 'enrolled') // Use DB date if you prefer: ->set('enrollment_date', 'CURRENT_DATE()', false) ->set('enrollment_date', local_date(utc_now(), 'Y-m-d')) ->whereIn('id', $toUpdateIds); if ($builder->update() === false) { $err = $db->error(); throw new \RuntimeException('Enrollments update failed: ' . ($err['message'] ?? 'unknown DB error')); } $affected = $db->affectedRows(); $db->transCommit(); log_message( 'info', 'Enrollment status -> enrolled for {n} row(s). parent={p}, year={y}, sem={s}, ids=[{ids}]', [ 'n' => $affected, 'p' => $parentId, 'y' => $schoolYear, 's' => 'ALL', 'ids' => implode(',', $toUpdateIds), ] ); return $affected; } catch (\Throwable $e) { $db->transRollback(); log_message('error', 'updateEnrollmentStatusIfPaid error: ' . $e->getMessage()); return 0; } } public function listVouchers() { $vouchers = $this->voucherModel->findAll(); return view('discounts/list', ['vouchers' => $vouchers]); } public function createVoucher() { if (strtolower($this->request->getMethod()) === 'post') { // -------- Gather & normalize inputs -------- $rawCode = (string) $this->request->getPost('code'); // Keep A–Z, 0–9 and dashes; uppercase for consistency $code = strtoupper(preg_replace('/[^A-Z0-9\-]/i', '', trim($rawCode))); $discountType = strtolower((string) $this->request->getPost('discount_type')); // 'percent' | 'fixed' $discountValue = (float) ($this->request->getPost('discount_value') ?? 0); $maxUsesRaw = $this->request->getPost('max_uses'); $maxUses = ($maxUsesRaw === '' || $maxUsesRaw === null) ? null : max(0, (int) $maxUsesRaw); $validFrom = trim((string) ($this->request->getPost('valid_from') ?? '')); $validUntil = trim((string) ($this->request->getPost('valid_until') ?? '')); $isActive = $this->request->getPost('is_active') ? 1 : 0; // NEW: Description / Reason $description = trim((string) ($this->request->getPost('description') ?? '')); // -------- Basic validations (before model rules) -------- $errors = []; if ($code === '') { $errors[] = 'Voucher code is required.'; } if (!in_array($discountType, ['percent', 'fixed'], true)) { $errors[] = 'Discount type must be "percent" or "fixed".'; } if ($discountType === 'percent') { if ($discountValue <= 0 || $discountValue > 100) { $errors[] = 'Percentage discount must be between 0 and 100.'; } } else { // fixed if ($discountValue < 0) { $errors[] = 'Fixed discount must be 0 or greater.'; } } // Dates come from as YYYY-MM-DD $validFrom = $validFrom !== '' ? $validFrom : null; $validUntil = $validUntil !== '' ? $validUntil : null; // Ensure date format is plausible $isDate = static function (?string $d): bool { if ($d === null) return true; return (bool) preg_match('/^\d{4}-\d{2}-\d{2}$/', $d); }; if (!$isDate($validFrom)) $errors[] = 'Valid From must be a date (YYYY-MM-DD).'; if (!$isDate($validUntil)) $errors[] = 'Valid Until must be a date (YYYY-MM-DD).'; if ($validFrom && $validUntil && $validFrom > $validUntil) { $errors[] = 'Valid Until must be the same as or after Valid From.'; } // Optional: require a reason when auto-generated codes are used // if ($description === '' && strlen($code) === 10) { // $errors[] = 'Please add a description/reason for this voucher.'; // } if (!empty($errors)) { return redirect()->back()->withInput()->with('error', implode(' ', $errors)); } // -------- Prepare payload for model -------- $data = [ 'code' => $code, 'discount_type' => $discountType, 'discount_value' => $discountValue, 'max_uses' => $maxUses, 'valid_from' => $validFrom, 'valid_until' => $validUntil, 'is_active' => $isActive, 'description' => $description, // <-- NEW ]; if ($this->voucherModel->save($data)) { return redirect()->to('/discounts/list')->with('success', 'Voucher created successfully.'); } // Model-level errors (unique code, etc.) return redirect()->back() ->withInput() ->with('error', 'Failed to save voucher: ' . implode('; ', (array) $this->voucherModel->errors())); } return view('discounts/create'); } public function editVoucher($id) { $voucher = $this->voucherModel->find($id); if (!$voucher) { return redirect()->to('discounts/list')->with('error', 'Voucher not found'); } if (strtolower($this->request->getMethod()) === 'post') { $data = [ 'id' => $id, 'code' => $this->request->getPost('code'), 'discount_type' => $this->request->getPost('discount_type'), 'discount_value' => $this->request->getPost('discount_value'), 'max_uses' => $this->request->getPost('max_uses'), 'valid_from' => $this->request->getPost('valid_from') ?: null, 'valid_until' => $this->request->getPost('valid_until') ?: null, 'is_active' => $this->request->getPost('is_active') ? 1 : 0, ]; $this->voucherModel->save($data); return redirect()->to('discounts/list')->with('success', 'Voucher updated successfully'); } return view('discounts/edit', ['voucher' => $voucher]); } /** * 🔄 Helper: Current invoice balance (school-year scoped) = total - payments - discounts - refundsPaid */ private function getCurrentInvoiceBalance($invoiceId, $schoolYear) { try { return (float) ($this->invoiceLedgerService->calculateInvoice((int) $invoiceId)['balance'] ?? 0.0); } catch (\Throwable $e) { return 0.0; } } /** * 🔄 Recalculate invoice totals and status based on all payments for current school year */ private function recalculateInvoice($invoiceId, $schoolYear): void { $this->invoiceLedgerService->recalculateInvoice((int) $invoiceId); } /** * Parse a grade name into an integer level for tuition rules. * Kindergarten -> 1, Youth -> > 9, numeric grades passthrough. */ private function parseGradeLevel($grade): int { if (is_numeric($grade)) return (int)$grade; if (!is_string($grade)) return 999; $g = strtoupper(trim((string)$grade)); $g = preg_replace('/\s+/', ' ', $g); $g = str_replace(['.', '_', '-'], ['', '', ' '], $g); // KG/K/Kindergarten $kg = ['K','KG','K G','KINDER','KINDERGARTEN']; if (in_array($g, $kg, true)) return 1; // Pre-K -> treat below regular $pk = ['PK','P K','PREK','PRE K','PRE KINDER','PREKINDER']; if (in_array($g, $pk, true)) return -1; // Youth variants: Y, YOUTH, Y1, YOUTH2 -> map to 10+ if (preg_match('/^Y(?:OUTH)?\s*(\d+)?$/', $g, $m)) { $n = isset($m[1]) && $m[1] !== '' ? max(1, (int)$m[1]) : 1; $gradeFee = (int)($this->configModel->getConfig('grade_fee') ?? 9); return $gradeFee + $n; } if (preg_match('/^(?:GR?ADE\s*)?(\d{1,2})\s*([A-Z]*)$/', $g, $m)) { return (int)$m[1]; } return 999; } /** * Collect parent/invoice/payment data to trigger handlePaymentReceived(). * * @return array [$data, $studentdata] */ private function buildPaymentEventData( int $invoiceId, string $transactionIdOrRef, float $amount, string $paymentMethod, string $paymentDate, ?string $checkNumber, int $installmentSeq, float $preBalance, // ✅ new param: initial (pre-payment) balance float $postBalance // ✅ new param: computed post-payment balance ): array { $invoice = $this->invoiceModel->find($invoiceId); if (!$invoice) { throw new \RuntimeException("Invoice {$invoiceId} not found"); } $parentRow = $this->db->table('users') ->select('id, firstname, lastname, email') ->where('id', $invoice['parent_id']) ->get() ->getRowArray(); if (!$parentRow) { throw new \RuntimeException("Parent not found for invoice {$invoiceId}"); } // Students (adjust joins to your schema) $studentRows = $this->db->table('students s') ->select('s.id, s.firstname, s.lastname, sc.class_section_id') ->join('student_class sc', 'sc.student_id = s.id', 'left') ->where('s.parent_id', $invoice['parent_id']) ->where('sc.school_year', $this->schoolYear) ->get() ->getResultArray(); $data = [ 'user_id' => (int) $parentRow['id'], 'email' => $parentRow['email'], 'firstname' => $parentRow['firstname'], 'lastname' => $parentRow['lastname'], 'school_year' => $this->schoolYear, 'semester' => $this->semester, 'invoice_id' => $invoiceId, 'transaction_id' => $transactionIdOrRef, 'payment_date' => $paymentDate, 'amount' => $amount, 'method' => $paymentMethod, 'check_number' => $checkNumber, 'installment_seq' => $installmentSeq, 'invoice_total' => (float) $invoice['total_amount'], 'pre_balance' => $preBalance, // ✅ the captured pre-payment balance 'post_balance' => $postBalance, // ✅ computed post-payment balance 'portalLink' => site_url('parent/invoices/' . $invoiceId), ]; return [$data, $studentRows]; } }