find($invoiceId); if (!$invoice) { return 0.0; } $paymentQuery = Payment::query() ->selectRaw('COALESCE(SUM(paid_amount),0) AS total_paid') ->where('invoice_id', $invoiceId) ->where('school_year', $schoolYear); if (Schema::hasColumn('payments', 'status')) { $paymentQuery->where(function ($q) { $q->whereNotIn('status', [ 'void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled', ])->orWhereNull('status'); }); } if (Schema::hasColumn('payments', 'is_void')) { $paymentQuery->where(function ($q) { $q->where('is_void', 0)->orWhereNull('is_void'); }); } $rowPaid = (array) $paymentQuery->first(); $totalPaid = (float) ($rowPaid['total_paid'] ?? 0); $rowDisc = (array) DB::table('discount_usages as du') ->join('invoices as i', 'i.id', '=', 'du.invoice_id') ->where('du.invoice_id', $invoiceId) ->where('i.school_year', $schoolYear) ->selectRaw('COALESCE(SUM(du.discount_amount),0) AS total_disc') ->first(); $totalDisc = (float) ($rowDisc['total_disc'] ?? 0); $rowRefund = (array) 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)); } public function recalculateInvoice(int $invoiceId, string $schoolYear): void { $invoice = Invoice::query()->find($invoiceId); if (!$invoice) { return; } $parentId = (int) ($invoice->parent_id ?? 0); if ($parentId <= 0) { return; } $enrollments = Enrollment::query() ->where('parent_id', $parentId) ->where('school_year', $schoolYear) ->get() ->map(fn ($row) => (array) $row) ->all(); $registered = []; $withdrawn = []; foreach ($enrollments as $e) { $row = [ 'student_id' => (int) ($e['student_id'] ?? 0), 'class_section_id' => $e['class_section_id'] ?? null, 'enrollment_status' => (string) ($e['enrollment_status'] ?? ''), ]; if (in_array($row['enrollment_status'], ['enrolled', 'payment pending'], true)) { $registered[] = $row; } elseif (in_array($row['enrollment_status'], ['withdrawn', 'refund pending', 'withdraw under review'], true)) { $withdrawn[] = $row; } } $refundDeadline = (string) (Configuration::getConfig('refund_deadline') ?? ''); $refundAllowed = true; if ($refundDeadline !== '') { try { $tzName = function_exists('user_timezone') ? (string) user_timezone() : 'UTC'; $tz = new \DateTimeZone($tzName); $today = new \DateTimeImmutable('today', $tz); $deadline = new \DateTimeImmutable($refundDeadline, $tz); $refundAllowed = $today <= $deadline; } catch (\Throwable $e) { $refundAllowed = true; } } $tuitionStudents = $registered; if (!$refundAllowed) { $tuitionStudents = array_merge($tuitionStudents, $withdrawn); } $gradeFee = (int) (Configuration::getConfig('grade_fee') ?? 9); $firstStudentFee = (float) (Configuration::getConfig('first_student_fee') ?? 350); $secondStudentFee = (float) (Configuration::getConfig('second_student_fee') ?? 200); $youthFee = (float) (Configuration::getConfig('youth_fee') ?? 180); foreach ($tuitionStudents as &$s) { $name = null; if (!empty($s['class_section_id'])) { $name = ClassSection::getClassSectionNameBySectionId($s['class_section_id']); } $s['grade_name'] = is_string($name) ? strtoupper(trim($name)) : 'N/A'; } unset($s); $regularCount = 0; $youthCount = 0; foreach ($tuitionStudents as $s) { $lvl = $this->parseGradeLevel((string) ($s['grade_name'] ?? ''), $gradeFee); if ($lvl > $gradeFee) { $youthCount++; } else { $regularCount++; } } $tuitionSubtotal = 0.0; $tuitionSubtotal += $youthCount * $youthFee; if ($regularCount >= 2) { $tuitionSubtotal += $firstStudentFee + ($regularCount - 1) * $secondStudentFee; } elseif ($regularCount === 1) { $tuitionSubtotal += $firstStudentFee; } $eventSubtotal = 0.0; $events = EventCharges::getChargesWithEventInfo($parentId, $schoolYear) ?? []; foreach ($events as $ev) { $eventSubtotal += (float) ($ev['charged'] ?? 0.0); } $additionalSubtotal = 0.0; $rows = AdditionalCharge::query() ->select('charge_type', 'amount') ->where('invoice_id', $invoiceId) ->where('status', 'applied') ->get() ->map(fn ($row) => (array) $row) ->all(); foreach ($rows as $r) { $amt = (float) ($r['amount'] ?? 0); $typ = strtolower((string) ($r['charge_type'] ?? 'add')); $amt = $typ === 'deduct' ? -abs($amt) : abs($amt); $additionalSubtotal += $amt; } $newTotal = round($tuitionSubtotal + $eventSubtotal + $additionalSubtotal, 2); $paymentQuery = Payment::query() ->where('invoice_id', $invoiceId) ->where('school_year', $schoolYear); if (Schema::hasColumn('payments', 'status')) { $paymentQuery->where(function ($q) { $q->whereNotIn('status', [ 'void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled', ])->orWhereNull('status'); }); } if (Schema::hasColumn('payments', 'is_void')) { $paymentQuery->where(function ($q) { $q->where('is_void', 0)->orWhereNull('is_void'); }); } $payments = $paymentQuery->get()->map(fn ($row) => (array) $row)->all(); $totalPaid = 0.0; foreach ($payments as $p) { $totalPaid += (float) ($p['paid_amount'] ?? 0); } $discRow = (array) DB::table('discount_usages') ->selectRaw('COALESCE(SUM(discount_amount),0) AS total_disc') ->where('invoice_id', $invoiceId) ->first(); $totalDisc = (float) ($discRow['total_disc'] ?? 0); $refundRow = (array) DB::table('refunds') ->selectRaw('COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid') ->where('invoice_id', $invoiceId) ->whereIn('status', ['Partial', 'Paid']) ->first(); $totalRefundPaid = (float) ($refundRow['total_refund_paid'] ?? 0); $newBalance = max(0.0, $newTotal - $totalDisc - $totalPaid - $totalRefundPaid); $newStatus = $newBalance <= 0.00001 ? 'Paid' : ($totalPaid > 0 ? 'Partially Paid' : 'Unpaid'); $updateData = [ 'total_amount' => $newTotal, 'paid_amount' => $totalPaid, 'balance' => $newBalance, 'status' => $newStatus, 'has_discount' => $totalDisc > 0.0 ? 1 : 0, ]; if (Schema::hasColumn('invoices', 'discount')) { $updateData['discount'] = $totalDisc; } Invoice::query()->whereKey($invoiceId)->update($updateData); } public function updateEnrollmentStatusIfPaid(int $invoiceId, string $schoolYear): int { $invoice = Invoice::query()->find($invoiceId); if (!$invoice) { return 0; } $total = (float) ($invoice->total_amount ?? 0); $balance = (float) ($invoice->balance ?? 0); if (!($total > 0 && $balance < $total)) { return 0; } $parentId = (int) ($invoice->parent_id ?? 0); if ($parentId <= 0 || $schoolYear === '') { return 0; } $paidEnrollmentIds = []; if (Schema::hasTable('invoice_items') && Schema::hasColumn('invoice_items', 'enrollment_id')) { $rows = DB::table('invoice_items') ->select('enrollment_id') ->where('invoice_id', $invoiceId) ->whereNotNull('enrollment_id') ->get() ->map(fn ($row) => (array) $row) ->all(); foreach ($rows as $r) { $eid = (int) ($r['enrollment_id'] ?? 0); if ($eid > 0) { $paidEnrollmentIds[] = $eid; } } } $query = Enrollment::query() ->select('id') ->where('parent_id', $parentId) ->where('school_year', $schoolYear) ->where(function ($q) { $q->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 (!empty($paidEnrollmentIds)) { $query->whereIn('id', array_values(array_unique($paidEnrollmentIds))); } $toUpdateIds = $query->get()->pluck('id')->map(fn ($id) => (int) $id)->all(); if (empty($toUpdateIds)) { return 0; } $affected = Enrollment::query() ->whereIn('id', $toUpdateIds) ->update([ 'enrollment_status' => 'enrolled', 'enrollment_date' => $this->formatLocalDate('Y-m-d'), ]); return (int) $affected; } public function buildPaymentEventData( int $invoiceId, string $transactionIdOrRef, float $amount, string $paymentMethod, string $paymentDate, ?string $checkNumber, int $installmentSeq, float $preBalance, float $postBalance, string $schoolYear, string $semester ): array { $invoice = Invoice::query()->find($invoiceId); if (!$invoice) { throw new \RuntimeException("Invoice {$invoiceId} not found"); } $parentRow = (array) DB::table('users') ->select('id', 'firstname', 'lastname', 'email') ->where('id', $invoice->parent_id) ->first(); if (empty($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') ->leftJoin('student_class as sc', 'sc.student_id', '=', 's.id') ->where('s.parent_id', $invoice->parent_id) ->where('sc.school_year', $schoolYear) ->get() ->map(fn ($row) => (array) $row) ->all(); $data = [ 'user_id' => (int) $parentRow['id'], 'email' => $parentRow['email'], 'firstname' => $parentRow['firstname'], 'lastname' => $parentRow['lastname'], 'school_year' => $schoolYear, 'semester' => $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, 'post_balance' => $postBalance, 'portalLink' => $this->urls->spaParentInvoiceDetailUrl($invoiceId), ]; return [$data, $studentRows]; } private function parseGradeLevel(string $grade, int $gradeFee): int { if (is_numeric($grade)) { return (int) $grade; } $g = strtoupper(trim($grade)); $g = preg_replace('/\\s+/', ' ', $g); $g = str_replace(['.', '_', '-'], ['', '', ' '], $g); $kg = ['K', 'KG', 'K G', 'KINDER', 'KINDERGARTEN']; if (in_array($g, $kg, true)) { return 1; } $pk = ['PK', 'P K', 'PREK', 'PRE K', 'PRE KINDER', 'PREKINDER']; if (in_array($g, $pk, true)) { return -1; } if (preg_match('/^Y(?:OUTH)?\\s*(\\d+)?$/', $g, $m)) { $n = isset($m[1]) && $m[1] !== '' ? max(1, (int) $m[1]) : 1; return $gradeFee + $n; } if (preg_match('/^(?:GR?ADE\\s*)?(\\d{1,2})\\s*([A-Z]*)$/', $g, $m)) { return (int) $m[1]; } return 999; } private function formatLocalDate(string $format): string { if (function_exists('local_date') && function_exists('utc_now')) { return (string) local_date(utc_now(), $format); } return now()->format($format); } }