discount = model(DiscountVoucher::class); $this->discountUsage = model(DiscountUsage::class); $this->invoice = model(Invoice::class); $this->user = model(User::class); $this->payment = model(Payment::class); $this->config = model(Configuration::class); $this->schoolYear = $this->config->getConfig('school_year') ?? ''; $sem = $this->config->getConfig('semester'); $this->semester = is_string($sem) && $sem !== '' ? $sem : null; } public function index() { $activeOnly = $this->request->getGet('active') === 'true'; $query = $this->discount->newQuery(); if ($activeOnly) { $today = now()->toDateString(); $query->where('is_active', 1) ->where(function ($q) use ($today) { $q->whereNull('valid_from')->orWhere('valid_from', '<=', $today); }) ->where(function ($q) use ($today) { $q->whereNull('valid_until')->orWhere('valid_until', '>=', $today); }); } $discounts = $query->orderBy('created_at', 'DESC')->get()->toArray(); return $this->success($discounts, 'Discount vouchers retrieved successfully'); } public function getByCode($code = null) { $discount = $this->findActiveDiscount($code); if (!$discount) { return $this->respondError('Discount voucher not found or expired', Response::HTTP_NOT_FOUND); } if ($this->isUsageLimitReached($discount)) { return $this->respondError('Discount voucher has reached maximum usage limit', Response::HTTP_BAD_REQUEST); } return $this->success($discount->toArray(), 'Discount voucher retrieved successfully'); } public function apply() { $user = $this->getCurrentUser(); if (!$user) { return $this->respondError('Unauthorized', Response::HTTP_UNAUTHORIZED); } $data = $this->payloadData(); if (empty($data)) { return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST); } $rules = [ 'code' => 'required', 'invoice_id' => 'required|integer', ]; $errors = $this->validateRequest($data, $rules); if (!empty($errors)) { return $this->respondValidationError($errors); } $code = strtoupper(trim((string) ($data['code'] ?? ''))); $discount = $this->findActiveDiscount($code); if (!$discount) { return $this->respondError('Invalid or expired discount voucher', Response::HTTP_BAD_REQUEST); } if ($this->isUsageLimitReached($discount)) { return $this->respondError('Discount voucher has reached maximum usage limit', Response::HTTP_BAD_REQUEST); } $exists = $this->discountUsage->newQuery() ->where('voucher_id', $discount->id) ->where('invoice_id', $data['invoice_id']) ->exists(); if ($exists) { return $this->respondError('Discount voucher already applied to this invoice', Response::HTTP_BAD_REQUEST); } try { $usage = $this->discountUsage->create([ 'voucher_id' => $discount->id, 'invoice_id' => $data['invoice_id'], 'used_by' => $user->id, 'used_at' => now()->toDateTimeString(), ]); $discount->increment('times_used'); return $this->success([ 'discount' => $discount->fresh()->toArray(), 'applied' => true, ], 'Discount voucher applied successfully'); } catch (\Throwable $e) { log_message('error', 'Discount apply error: ' . $e->getMessage()); return $this->respondError('Failed to apply discount voucher', Response::HTTP_INTERNAL_SERVER_ERROR); } } public function parents() { $parents = $this->user->getParents(); $result = []; foreach ($parents as $parent) { $discount = DB::table('discount_usages as du') ->selectRaw('COALESCE(SUM(du.discount_amount),0) AS total_discount') ->join('invoices as i', 'i.id', '=', 'du.invoice_id') ->where('i.parent_id', $parent['id']) ->where('i.school_year', $this->schoolYear) ->first(); $totalDiscount = (float) ($discount->total_discount ?? 0); $result[] = [ 'id' => $parent['id'], 'firstname' => $parent['firstname'], 'lastname' => $parent['lastname'], 'email' => $parent['email'], 'school_id' => $parent['school_id'] ?? null, 'total_discount' => $totalDiscount, 'has_discount' => $totalDiscount > 0 ? 1 : 0, ]; } return $this->success($result, 'Parent discounts retrieved'); } public function applyVoucher() { $data = $this->payloadData(); $rules = [ 'voucher_id' => 'required|integer', 'parent_ids' => 'required', ]; $errors = $this->validateRequest($data, $rules); if (!empty($errors)) { return $this->respondValidationError($errors); } $voucherId = (int) $data['voucher_id']; $parentIds = $this->normalizeParentIds($data['parent_ids']); if (empty($parentIds)) { return $this->respondError('At least one parent is required', Response::HTTP_BAD_REQUEST); } $voucher = $this->discount->find($voucherId); if (!$voucher) { return $this->respondError('Voucher not found', Response::HTTP_NOT_FOUND); } $remainingUses = max(0, (int) ($voucher['max_uses'] ?? 1) - (int) ($voucher['times_used'] ?? 0)); if ($remainingUses <= 0) { return $this->respondError('Voucher has reached its maximum allowed uses', Response::HTTP_BAD_REQUEST); } $description = $this->normalizeDescription($voucher['description'] ?? ''); $now = utc_now(); $fullyCoveredInvoiceIds = []; $appliedInvoices = []; $errors = []; $usedCount = 0; DB::beginTransaction(); try { foreach ($parentIds as $parentId) { $invoices = $this->invoice->getInvoicesByUserId($parentId, $this->schoolYear); if (empty($invoices)) { $errors[] = "Parent {$parentId} has no invoices for {$this->schoolYear}."; continue; } foreach ($invoices as $invoice) { if ($invoice['balance'] <= 0) { continue; } if ($remainingUses <= 0) { break 2; } $initialPreBalance = $this->getCurrentInvoiceBalance($invoice['id'], $this->schoolYear); $already = $this->discountUsage->newQuery() ->where('voucher_id', $voucherId) ->where('invoice_id', $invoice['id']) ->exists(); if ($already) { continue; } $rawDiscount = ($voucher['discount_type'] === 'percent') ? round(((float) $invoice['total_amount'] * (float) $voucher['discount_value']) / 100, 2) : (float) $voucher['discount_value']; $discountAmount = min($rawDiscount, $initialPreBalance); if ($discountAmount <= 0) { continue; } $this->db->table('discount_usages')->insert([ 'voucher_id' => $voucherId, 'invoice_id' => $invoice['id'], 'parent_id' => $parentId, 'discount_amount' => $discountAmount, 'description' => $description, 'school_year' => $this->schoolYear, 'semester' => $this->semester, 'updated_by' => $this->getCurrentUserId() ?? 0, 'used_at' => $now, 'created_at' => $now, 'updated_at' => $now, ]); $newBalance = max(0.0, round(((float) $invoice['balance']) - $discountAmount, 2)); $this->invoice->update($invoice['id'], [ 'balance' => $newBalance, 'has_discount' => 1, 'updated_at' => $now, ]); $postBalance = max(0.0, round($initialPreBalance - $discountAmount, 2)); $currentBalance = $this->getCurrentInvoiceBalance($invoice['id'], $this->schoolYear); $this->discount->newQuery() ->where('id', $voucherId) ->set('times_used', 'times_used + 1', false) ->update(); [$eventData, $studentData] = $this->buildPaymentEventData( $invoice['id'], (string) $voucherId, $discountAmount, 'discount', $now, '', 0, $initialPreBalance, $postBalance ); event('paymentReceived', [$eventData, $studentData]); $epsilon = 0.01; $fullyCovered = (abs($discountAmount - $initialPreBalance) <= $epsilon) || ($postBalance <= $epsilon) || ($currentBalance <= $epsilon); if ($fullyCovered) { $fullyCoveredInvoiceIds[] = (int) $invoice['id']; } $appliedInvoices[] = (int) $invoice['id']; $remainingUses--; $usedCount++; if ($remainingUses <= 0) { $this->discount->update($voucherId, [ 'is_active' => 0, 'updated_at' => $now, ]); break 2; } } } DB::commit(); } catch (\Throwable $e) { DB::rollBack(); log_message('error', 'Voucher application failed: ' . $e->getMessage()); return $this->respondError('Voucher application failed. Transaction rolled back.', Response::HTTP_INTERNAL_SERVER_ERROR); } $enrollmentUpdates = 0; foreach (array_unique($fullyCoveredInvoiceIds) as $invoiceId) { try { $enrollmentUpdates += $this->updateEnrollmentStatusIfPaid($invoiceId); } catch (\Throwable $e) { log_message('error', 'updateEnrollmentStatusIfPaid failed for invoice {iid}: {err}', [ 'iid' => $invoiceId, 'err' => $e->getMessage(), ]); } } return $this->success([ 'applied_invoices' => array_values(array_unique($appliedInvoices)), 'errors' => $errors, 'enrollments' => $enrollmentUpdates, 'used_count' => $usedCount, ], 'Voucher applied successfully'); } public function createVoucher() { $data = $this->payloadData(); $rules = [ 'code' => 'required|string', 'discount_type' => 'required|in:percent,fixed', 'discount_value' => 'required|numeric', ]; $errors = $this->validateRequest($data, $rules); if (!empty($errors)) { return $this->respondValidationError($errors); } $code = strtoupper(preg_replace('/[^A-Z0-9\-]/i', '', trim((string) $data['code']))); if ($code === '') { return $this->respondError('Voucher code is required', Response::HTTP_BAD_REQUEST); } $payload = [ 'code' => $code, 'discount_type' => strtolower((string) $data['discount_type']), 'discount_value' => (float) $data['discount_value'], 'max_uses' => isset($data['max_uses']) && $data['max_uses'] !== '' ? (int) $data['max_uses'] : null, 'valid_from' => $this->normalizeDate($data['valid_from'] ?? null), 'valid_until' => $this->normalizeDate($data['valid_until'] ?? null), 'is_active' => isset($data['is_active']) ? (bool) $data['is_active'] : true, 'description' => trim((string) ($data['description'] ?? '')), ]; if ($payload['valid_from'] && $payload['valid_until'] && $payload['valid_from'] > $payload['valid_until']) { return $this->respondError('Valid until must be the same as or after valid from', Response::HTTP_BAD_REQUEST); } try { $voucher = $this->discount->create($payload); } catch (\Throwable $e) { log_message('error', 'Voucher creation failed: ' . $e->getMessage()); return $this->respondError('Failed to save voucher: ' . $e->getMessage()); } return $this->respondCreated($voucher->toArray(), 'Voucher created successfully'); } public function editVoucher($id) { $voucher = $this->discount->find($id); if (!$voucher) { return $this->respondError('Voucher not found', Response::HTTP_NOT_FOUND); } $data = $this->payloadData(); if (empty($data)) { return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST); } $update = []; foreach (['code', 'discount_type', 'discount_value', 'max_uses', 'valid_from', 'valid_until', 'is_active', 'description'] as $field) { if (array_key_exists($field, $data)) { $value = $data[$field]; if (in_array($field, ['valid_from', 'valid_until'], true)) { $value = $this->normalizeDate($value); } $update[$field] = $value; } } if (!empty($update)) { $this->discount->update($id, $update); } return $this->success($this->discount->find($id), 'Voucher updated successfully'); } protected function findActiveDiscount(?string $code) { if (empty($code)) { return null; } $today = now()->toDateString(); return $this->discount->newQuery() ->where('code', $code) ->where('is_active', 1) ->where(function ($q) use ($today) { $q->whereNull('valid_from')->orWhere('valid_from', '<=', $today); }) ->where(function ($q) use ($today) { $q->whereNull('valid_until')->orWhere('valid_until', '>=', $today); }) ->first(); } protected function isUsageLimitReached($discount): bool { if (!$discount->max_uses) { return false; } return isset($discount->times_used) && $discount->times_used >= $discount->max_uses; } private function normalizeParentIds($input): array { $values = []; if (is_string($input)) { $decoded = json_decode($input, true); if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { $values = $decoded; } else { $values = [$input]; } } elseif (is_array($input)) { $values = $input; } return array_values(array_filter(array_map('intval', $values), fn($id) => $id > 0)); } private function normalizeDescription(?string $description): string { if ($description === null) { return ''; } $description = trim($description); return preg_replace( '/^Auto-generated on \d{4}-\d{2}-\d{2}\s*[—-]\s*reason:\s*/i', '', $description ); } private function normalizeDate($value): ?string { if ($value === null || $value === '') { return null; } $date = preg_replace('/[^0-9\-]/', '', (string) $value); return preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) ? $date : null; } private function getCurrentInvoiceBalance($invoiceId, $schoolYear): float { $invoice = $this->invoice->find($invoiceId); if (!$invoice) { return 0.0; } $qb = $this->payment ->select('COALESCE(SUM(paid_amount),0) AS total_paid') ->where('invoice_id', $invoiceId) ->where('school_year', $schoolYear); $table = $this->payment->table; if ($this->db->getSchemaBuilder()->hasColumn($table, 'status')) { $qb->where(function ($q) { $q->whereNotIn('status', ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled']) ->orWhereNull('status'); }); } if ($this->db->getSchemaBuilder()->hasColumn($table, 'is_void')) { $qb->where(function ($q) { $q->where('is_void', 0) ->orWhereNull('is_void'); }); } $rowPaid = $qb->first(); $totalPaid = (float) ($rowPaid['total_paid'] ?? 0); $rowDisc = DB::table('discount_usages as du') ->selectRaw('COALESCE(SUM(du.discount_amount),0) AS total_disc') ->join('invoices as i', 'i.id', '=', 'du.invoice_id') ->where('du.invoice_id', $invoiceId) ->where('i.school_year', $schoolYear) ->first(); $totalDisc = (float) ($rowDisc->total_disc ?? 0); $rowRefund = DB::table('refunds') ->selectRaw('COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid') ->where('invoice_id', $invoiceId) ->where('school_year', $schoolYear) ->whereIn('status', ['Partial', 'Paid']) ->first(); $totalRefundPaid = (float) ($rowRefund->total_refund_paid ?? 0); $total = (float) ($invoice['total_amount'] ?? 0); return max(0.0, round($total - $totalPaid - $totalDisc - $totalRefundPaid, 2)); } private function updateEnrollmentStatusIfPaid(int $invoiceId): int { $invoice = $this->invoice->find($invoiceId); if (!$invoice) { log_message('warning', 'Invoice not found: {id}', ['id' => $invoiceId]); return 0; } $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); if ($parentId <= 0 || $this->schoolYear === '') { log_message('warning', 'Missing parent_id/school_year for invoice #{id}', ['id' => $invoiceId]); return 0; } $paidEnrollmentIds = []; try { $rows = DB::table('invoice_items') ->select('enrollment_id') ->where('invoice_id', $invoiceId) ->whereNotNull('enrollment_id') ->get() ->pluck('enrollment_id') ->map('intval') ->filter(fn($eid) => $eid > 0) ->unique() ->values() ->toArray(); $paidEnrollmentIds = $rows; } catch (\Throwable $e) { // ignore } $builder = DB::table('enrollments') ->where('parent_id', $parentId) ->where('school_year', $this->schoolYear) ->where(function ($query) { $query->where('enrollment_status', 'payment pending') ->orWhere('enrollment_status', 'Payment pending') ->orWhere('enrollment_status', 'Payment Pending') ->orWhere('enrollment_status', 'PAYMENT PENDING') ->orWhereRaw("LOWER(TRIM(REPLACE(enrollment_status, CHAR(160), ' '))) = 'payment pending'"); }); if ($this->semester !== null) { $builder->where('semester', $this->semester); } if (!empty($paidEnrollmentIds)) { $builder->whereIn('id', $paidEnrollmentIds); } $toUpdateIds = $builder->pluck('id')->map('intval')->toArray(); if (empty($toUpdateIds)) { return 0; } $updated = DB::table('enrollments') ->whereIn('id', $toUpdateIds) ->update([ 'enrollment_status' => 'enrolled', 'enrollment_date' => local_date(utc_now(), 'Y-m-d'), ]); return $updated; } private function buildPaymentEventData( int $invoiceId, string $transactionId, float $amount, string $paymentMethod, string $paymentDate, ?string $checkNumber, int $installmentSeq, float $preBalance, float $postBalance ): array { $invoice = $this->invoice->find($invoiceId); if (!$invoice) { throw new \RuntimeException("Invoice {$invoiceId} not found"); } $parentRow = DB::table('users') ->select('id', 'firstname', 'lastname', 'email') ->where('id', $invoice['parent_id']) ->first(); if (!$parentRow) { throw new \RuntimeException("Parent not found for invoice {$invoiceId}"); } $studentRows = DB::table('students as s') ->select('s.id', 's.firstname', 's.lastname', 'sc.class_section_id') ->join('student_class as sc', 'sc.student_id', '=', 's.id') ->where('s.parent_id', $invoice['parent_id']) ->where('sc.school_year', $this->schoolYear) ->when($this->semester !== null, fn($q) => $q->where('sc.semester', $this->semester)) ->get() ->toArray(); $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' => $transactionId, 'payment_date' => $paymentDate, 'amount' => $amount, 'method' => $paymentMethod, 'check_number' => $checkNumber, 'installment_seq' => $installmentSeq, 'invoice_total' => (float) $invoice['total_amount'], 'pre_balance' => $preBalance, 'post_balance' => $postBalance, 'portalLink' => URL::to('/parent/invoices/' . $invoiceId), ]; return [$data, $studentRows]; } }