'Masjid', // 2 => 'Donation', ]; public function __construct() { parent::__construct(); $this->invoice = model(Invoice::class); $this->payment = model(Payment::class); $this->refund = model(Refund::class); $this->expense = model(Expense::class); $this->reimbursement = model(Reimbursement::class); $this->discount = model(DiscountUsage::class); $this->config = model(Configuration::class); $this->additionalCharge = model(AdditionalCharge::class); } /** * Detailed financial report with invoices, payments, payment breakdown, refunds, expenses, reimbursements, discounts */ public function report() { $dateFrom = $this->request->getGet('date_from'); $dateTo = $this->request->getGet('date_to'); $schoolYear = $this->request->getGet('school_year'); try { $db = \Config\Database::connect(); // Build filtered models $invoice = $this->invoice; $refund = $this->refund; $discount = $this->discount; $expense = $this->expense; $reimbursement = $this->reimbursement; // Apply filters if ($schoolYear) { $invoice->where('invoices.school_year', $schoolYear); $refund->where('school_year', $schoolYear); $discount->where('school_year', $schoolYear); $expense->where('school_year', $schoolYear); $reimbursement->where('school_year', $schoolYear); } $hasFrom = !empty($dateFrom); $hasTo = !empty($dateTo); if ($hasFrom) { $invoice->where('DATE(COALESCE(invoices.issue_date, invoices.created_at)) >=', $dateFrom); $refund->where('DATE(COALESCE(refunded_at, created_at)) >=', $dateFrom); $discount->where('DATE(COALESCE(used_at, created_at)) >=', $dateFrom); $expense->where('DATE(created_at) >=', $dateFrom); $reimbursement->where('DATE(created_at) >=', $dateFrom); } if ($hasTo) { $invoice->where('DATE(COALESCE(invoices.issue_date, invoices.created_at)) <=', $dateTo); $refund->where('DATE(COALESCE(refunded_at, created_at)) <=', $dateTo); $discount->where('DATE(COALESCE(used_at, created_at)) <=', $dateTo); $expense->where('DATE(created_at) <=', $dateTo); $reimbursement->where('DATE(created_at) <=', $dateTo); } // Get invoices $invoices = $invoice ->select("invoices.*, CONCAT(users.firstname, ' ', users.lastname) AS parent_name") ->join('users', 'users.id = invoices.parent_id') ->findAll(); // Helper to build filtered Payment $buildPayment = function () use ($schoolYear, $dateFrom, $dateTo) { $pm = $this->payment; if ($schoolYear) { $pm->where('school_year', $schoolYear); } if (!empty($dateFrom)) { $pm->where('DATE(payment_date) >=', $dateFrom); } if (!empty($dateTo)) { $pm->where('DATE(payment_date) <=', $dateTo); } return $pm; }; // Payments aggregated by invoice_id $payments = $buildPayment() ->select('invoice_id, SUM(paid_amount) AS paid_amount') ->where('invoice_id IS NOT NULL') ->groupBy('invoice_id') ->findAll(); // Payment breakdown by method (cash/credit/check) $paymentBreakdownRows = $buildPayment() ->select(" invoice_id, CASE WHEN LOWER(TRIM(payment_method)) = 'cash' THEN 'cash' WHEN LOWER(TRIM(payment_method)) IN ('check','cheque') THEN 'check' WHEN LOWER(TRIM(payment_method)) IN ('credit','card','credit card','debit','debit card','visa','mastercard') THEN 'credit' ELSE 'other' END AS method, SUM(paid_amount) AS amount ") ->where('invoice_id IS NOT NULL') ->groupBy('invoice_id, method') ->findAll(); $paymentBreakdown = []; foreach ($paymentBreakdownRows as $row) { $iid = (int)($row['invoice_id'] ?? 0); if ($iid <= 0) continue; $method = (string)$row['method']; $amount = (float)$row['amount']; if (!isset($paymentBreakdown[$iid])) $paymentBreakdown[$iid] = []; $paymentBreakdown[$iid][$method] = $amount; } // Overall payment totals by method $paymentTotalsRow = $buildPayment() ->select(" SUM(paid_amount) AS total_all, SUM(CASE WHEN LOWER(TRIM(payment_method)) = 'cash' THEN paid_amount ELSE 0 END) AS total_cash, SUM(CASE WHEN LOWER(TRIM(payment_method)) IN ('check','cheque') THEN paid_amount ELSE 0 END) AS total_check, SUM(CASE WHEN LOWER(TRIM(payment_method)) IN ('credit','card','credit card','debit','debit card','visa','mastercard') THEN paid_amount ELSE 0 END) AS total_credit ") ->where('invoice_id IS NOT NULL') ->first() ?? []; $paymentTotals = [ 'total_all' => (float)($paymentTotalsRow['total_all'] ?? 0), 'total_cash' => (float)($paymentTotalsRow['total_cash'] ?? 0), 'total_check' => (float)($paymentTotalsRow['total_check'] ?? 0), 'total_credit' => (float)($paymentTotalsRow['total_credit'] ?? 0), ]; // Refunds (grouped) $refunds = $refund ->select('invoice_id, school_year, SUM(refund_paid_amount) AS total_refunded') ->where('invoice_id IS NOT NULL') ->groupBy(['invoice_id', 'school_year']) ->findAll(); // Discounts (grouped) $discounts = $discount ->select('invoice_id, school_year, SUM(discount_amount) AS discount_amount') ->where('invoice_id IS NOT NULL') ->groupBy(['invoice_id', 'school_year']) ->get() ->getResultArray(); // Expenses $expenses = $expense ->select('category, SUM(amount) AS total_amount') ->groupBy('category') ->findAll(); // Reimbursements $reimbursements = $reimbursement ->select('status, SUM(amount) AS total_amount') ->groupBy('status') ->findAll(); // School year options $schoolYears = []; try { $rows = $db->table('invoices') ->select('DISTINCT school_year', false) ->where('school_year IS NOT NULL', null, false) ->orderBy('school_year', 'DESC') ->get()->getResultArray(); foreach ($rows as $r) { $val = (string)($r['school_year'] ?? ''); if ($val !== '' && !in_array($val, $schoolYears, true)) $schoolYears[] = $val; } } catch (\Throwable $e) {} if (empty($schoolYears) && !empty($schoolYear)) { $schoolYears[] = (string)$schoolYear; } return $this->success([ 'selectedYear' => $schoolYear, 'dateFrom' => $dateFrom, 'dateTo' => $dateTo, 'schoolYears' => $schoolYears, 'invoices' => $invoices, 'payments' => $payments, 'paymentBreakdown' => $paymentBreakdown, 'paymentTotals' => $paymentTotals, 'refunds' => $refunds, 'expenses' => $expenses, 'reimbursements' => $reimbursements, 'discounts' => $discounts, ], 'Financial report retrieved successfully'); } catch (\Throwable $e) { log_message('error', 'Financial report error: ' . $e->getMessage()); return $this->respondError('Failed to retrieve financial report', Response::HTTP_INTERNAL_SERVER_ERROR); } } /** * Financial report summary with aggregated totals */ public function reportSummary() { $dateFrom = $this->request->getGet('date_from'); $dateTo = $this->request->getGet('date_to'); $schoolYear = $this->request->getGet('school_year'); try { $data = $this->getFinancialSummary($dateFrom, $dateTo, $schoolYear); // Build school year options $schoolYears = []; try { $db = \Config\Database::connect(); $rows = $db->table('invoices') ->select('DISTINCT school_year', false) ->where('school_year IS NOT NULL', null, false) ->orderBy('school_year', 'DESC') ->get()->getResultArray(); foreach ($rows as $r) { $val = (string)($r['school_year'] ?? ''); if ($val !== '' && !in_array($val, $schoolYears, true)) $schoolYears[] = $val; } } catch (\Throwable $e) {} if (empty($schoolYears) && !empty($data['schoolYear'])) { $schoolYears[] = (string)$data['schoolYear']; } $data['schoolYears'] = $schoolYears; return $this->success($data, 'Financial report summary retrieved successfully'); } catch (\Throwable $e) { log_message('error', 'Financial report summary error: ' . $e->getMessage()); return $this->respondError('Failed to retrieve financial report summary', Response::HTTP_INTERNAL_SERVER_ERROR); } } /** * Download financial report as CSV */ public function downloadCsv() { $dateFrom = $this->request->getGet('date_from'); $dateTo = $this->request->getGet('date_to'); $schoolYear = $this->request->getGet('school_year'); try { $invoice = $this->invoice; $expense = $this->expense; $reimbursement = $this->reimbursement; // Invoices with filters $invBuilder = $invoice ->select("invoices.*, CONCAT(users.firstname, ' ', users.lastname) AS parent_name") ->join('users', 'users.id = invoices.parent_id'); if (!empty($schoolYear)) { $invBuilder->where('invoices.school_year', $schoolYear); } if (!empty($dateFrom)) { $invBuilder->where('DATE(COALESCE(invoices.issue_date, invoices.created_at)) >=', $dateFrom); } if (!empty($dateTo)) { $invBuilder->where('DATE(COALESCE(invoices.issue_date, invoices.created_at)) <=', $dateTo); } $invoices = $invBuilder->findAll(); // Expenses summary $expBuilder = $expense->select('category, SUM(amount) AS total_amount'); if (!empty($schoolYear)) { $expBuilder->where('school_year', $schoolYear); } if (!empty($dateFrom)) { $expBuilder->where('DATE(created_at) >=', $dateFrom); } if (!empty($dateTo)) { $expBuilder->where('DATE(created_at) <=', $dateTo); } $expenses = $expBuilder->groupBy('category')->findAll(); // Reimbursements summary $reimbBuilder = $reimbursement->select('status, SUM(amount) AS total_amount'); if (!empty($schoolYear)) { $reimbBuilder->where('school_year', $schoolYear); } if (!empty($dateFrom)) { $reimbBuilder->where('DATE(created_at) >=', $dateFrom); } if (!empty($dateTo)) { $reimbBuilder->where('DATE(created_at) <=', $dateTo); } $reimbursements = $reimbBuilder->groupBy('status')->findAll(); $filename = 'financial_report_' . date('Ymd_His') . '.csv'; $headers = [ 'Content-Type' => 'text/csv', 'Content-Disposition' => 'attachment; filename="' . $filename . '"', ]; return new StreamedResponse(function () use ($invoices, $expenses, $reimbursements) { $out = fopen('php://output', 'w'); // Invoices section fputcsv($out, ['Invoice #', 'Parent', 'School Year', 'Total', 'Paid', 'Balance', 'Refund']); foreach ($invoices as $inv) { fputcsv($out, [ $inv['invoice_number'] ?? '', $inv['parent_name'] ?? '', $inv['school_year'] ?? '', $inv['total_amount'] ?? 0, $inv['paid_amount'] ?? 0, $inv['balance'] ?? 0, $inv['refund_amount'] ?? 0, ]); } // Expenses section fputcsv($out, []); fputcsv($out, ['Expenses Summary']); fputcsv($out, ['Category', 'Total Amount']); foreach ($expenses as $exp) { fputcsv($out, [ $exp['category'] ?? '', $exp['total_amount'] ?? 0, ]); } // Reimbursements section fputcsv($out, []); fputcsv($out, ['Reimbursements Summary']); fputcsv($out, ['Status', 'Total Amount']); foreach ($reimbursements as $r) { fputcsv($out, [ $r['status'] ?? '', $r['total_amount'] ?? 0, ]); } fclose($out); }, Response::HTTP_OK, $headers); } catch (\Throwable $e) { log_message('error', 'CSV download error: ' . $e->getMessage()); return $this->respondError('Failed to generate CSV', Response::HTTP_INTERNAL_SERVER_ERROR); } } /** * Get financial summary data */ protected function getFinancialSummary(?string $dateFrom = null, ?string $dateTo = null, ?string $schoolYear = null): array { $schoolYear = $schoolYear ?: $this->config->getConfig('school_year'); $invoice = $this->invoice; $payment = $this->payment; $refund = $this->refund; $expense = $this->expense; $reimbursement = $this->reimbursement; $discount = $this->discount; // Invoices: Total Charges $invoiceBuilder = $invoice->where('school_year', $schoolYear); if (!empty($dateFrom)) { $invoiceBuilder->where('DATE(COALESCE(invoices.issue_date, invoices.created_at)) >=', $dateFrom); } if (!empty($dateTo)) { $invoiceBuilder->where('DATE(COALESCE(invoices.issue_date, invoices.created_at)) <=', $dateTo); } $invoices = $invoiceBuilder->findAll(); $totalCharges = array_sum(array_column($invoices, 'total_amount')); // Additional Charges $acBuilder = $this->additionalCharge->where('school_year', $schoolYear) ->where('status !=', 'void'); if ($dateFrom && $dateTo) { $acBuilder->where('due_date >=', $dateFrom) ->where('due_date <=', $dateTo); } $acResult = $acBuilder->selectSum('amount')->first(); $totalExtraCharges = isset($acResult['amount']) ? (float) $acResult['amount'] : 0.00; $totalCharges = $totalCharges + $totalExtraCharges; // Payments: Total Paid $paymentBuilder = $payment->where('school_year', $schoolYear); if (!empty($dateFrom)) { $paymentBuilder->where('DATE(payment_date) >=', $dateFrom); } if (!empty($dateTo)) { $paymentBuilder->where('DATE(payment_date) <=', $dateTo); } $paymentResult = $paymentBuilder->selectSum('paid_amount')->first(); $totalPaid = isset($paymentResult['paid_amount']) ? (float) $paymentResult['paid_amount'] : 0.00; // Expenses $expenseBuilder = $expense->where('school_year', $schoolYear); if (!empty($dateFrom)) { $expenseBuilder->where('DATE(created_at) >=', $dateFrom); } if (!empty($dateTo)) { $expenseBuilder->where('DATE(created_at) <=', $dateTo); } $expenseResult = $expenseBuilder->selectSum('amount')->first(); $totalExpenses = isset($expenseResult['amount']) ? (float) $expenseResult['amount'] : 0.00; // Reimbursements $reimbursementBuilder = $reimbursement->where('school_year', $schoolYear); if (!empty($dateFrom)) { $reimbursementBuilder->where('DATE(created_at) >=', $dateFrom); } if (!empty($dateTo)) { $reimbursementBuilder->where('DATE(created_at) <=', $dateTo); } $reimbursementResult = $reimbursementBuilder->selectSum('amount')->first(); $totalReimbursements = isset($reimbursementResult['amount']) ? (float) $reimbursementResult['amount'] : 0.00; // Donations line: reimbursements recorded against Masjid/Donation recipients $donationToSchool = 0.0; $specialRecipientIds = array_map('intval', array_keys(self::SPECIAL_RECIPIENTS)); if (!empty($specialRecipientIds)) { $donationBuilder = $reimbursement ->where('school_year', $schoolYear) ->whereIn('reimbursed_to', $specialRecipientIds); if (!empty($dateFrom)) { $donationBuilder->where('DATE(created_at) >=', $dateFrom); } if (!empty($dateTo)) { $donationBuilder->where('DATE(created_at) <=', $dateTo); } $donationRow = $donationBuilder->selectSum('amount')->first(); $donationToSchool = isset($donationRow['amount']) ? (float) $donationRow['amount'] : 0.00; } $totalReimbursements = max(0.0, $totalReimbursements - $donationToSchool); // Refunds $refundBuilder = $refund ->where('school_year', $schoolYear) ->where('status', 'Paid') ->where('refund_paid_amount IS NOT NULL'); if (!empty($dateFrom)) { $refundBuilder->where('DATE(COALESCE(refunded_at, created_at)) >=', $dateFrom); } if (!empty($dateTo)) { $refundBuilder->where('DATE(COALESCE(refunded_at, created_at)) <=', $dateTo); } $refundResult = $refundBuilder->selectSum('refund_paid_amount')->first(); $totalRefunds = isset($refundResult['refund_paid_amount']) ? (float) $refundResult['refund_paid_amount'] : 0.00; // Discounts $discountBuilder = $discount ->join('invoices', 'invoices.id = discount_usages.invoice_id') ->where('invoices.school_year', $schoolYear); if (!empty($dateFrom)) { $discountBuilder->where('DATE(COALESCE(discount_usages.used_at, discount_usages.created_at)) >=', $dateFrom); } if (!empty($dateTo)) { $discountBuilder->where('DATE(COALESCE(discount_usages.used_at, discount_usages.created_at)) <=', $dateTo); } $discountResult = $discountBuilder->selectSum('discount_amount')->first(); $totalDiscounts = isset($discountResult['discount_amount']) ? (float) $discountResult['discount_amount'] : 0.00; // Net & Unpaid $totalUnpaid = ($totalCharges - $totalDiscounts) - $totalPaid; $amountCollected = $totalPaid; $netAmount = ($totalCharges - $totalDiscounts - $totalRefunds); return [ 'schoolYear' => $schoolYear, 'dateFrom' => $dateFrom, 'dateTo' => $dateTo, 'totalCharges' => $totalCharges, 'totalExtraCharges' => $totalExtraCharges, 'totalDiscounts' => $totalDiscounts, 'totalRefunds' => $totalRefunds, 'totalExpenses' => $totalExpenses, 'totalReimbursements' => $totalReimbursements, 'donationToSchool' => $donationToSchool, 'totalPaid' => $totalPaid, 'amountCollected' => $amountCollected, 'totalUnpaid' => $totalUnpaid, 'netAmount' => $netAmount, ]; } /** * List parents with outstanding balances (> 0) for a school year */ public function unpaidParents() { $schoolYear = trim((string)($this->request->getGet('school_year') ?? '')); if ($schoolYear === '') { $schoolYear = (string) ($this->config->getConfig('school_year') ?? date('Y')); } try { $db = \Config\Database::connect(); // Build school year options $schoolYears = []; try { $rows = $db->table('invoices') ->select('DISTINCT school_year', false) ->where('school_year IS NOT NULL', null, false) ->orderBy('school_year', 'DESC') ->get()->getResultArray(); foreach ($rows as $r) { $val = (string)($r['school_year'] ?? ''); if ($val !== '' && !in_array($val, $schoolYears, true)) $schoolYears[] = $val; } } catch (\Throwable $e) {} if (empty($schoolYears) && !empty($schoolYear)) { $schoolYears[] = (string)$schoolYear; } // Aggregate balances by parent $invRows = $db->table('invoices i') ->select('i.id, i.parent_id, i.total_amount, u.firstname, u.lastname, u.email') ->join('users u', 'u.id = i.parent_id', 'inner') ->where('i.school_year', $schoolYear) ->orderBy('i.parent_id', 'ASC') ->orderBy('i.id', 'ASC') ->get()->getResultArray(); $byParent = []; $paymentsTbl = 'payments'; $hasStatus = $db->fieldExists('status', $paymentsTbl); $hasVoid = $db->fieldExists('is_void', $paymentsTbl); foreach ($invRows as $r) { $iid = (int)($r['id'] ?? 0); $pid = (int)($r['parent_id'] ?? 0); if ($iid <= 0 || $pid <= 0) continue; // Sum payments for this invoice $qb = $db->table('payments')->select('COALESCE(SUM(paid_amount),0) AS tot')->where('invoice_id', $iid); if ($hasStatus) { $qb->groupStart() ->whereNotIn('status', ['void','voided','refunded','failed','chargeback','declined','reversed','canceled','cancelled']) ->orWhere('status IS NULL', null, false) ->groupEnd(); } if ($hasVoid) { $qb->groupStart() ->where('is_void', 0) ->orWhere('is_void IS NULL', null, false) ->groupEnd(); } $paidRow = $qb->get()->getRowArray(); $paidSum = (float)($paidRow['tot'] ?? 0); // Sum discounts $discRow = $db->table('discount_usages') ->select('COALESCE(SUM(discount_amount),0) AS tot') ->where('invoice_id', $iid) ->get()->getRowArray(); $discSum = (float)($discRow['tot'] ?? 0); // Sum refunds PAID $refRow = $db->table('refunds') ->select('COALESCE(SUM(refund_paid_amount),0) AS tot') ->where('invoice_id', $iid) ->whereIn('status', ['Partial','Paid']) ->get()->getRowArray(); $refSum = (float)($refRow['tot'] ?? 0); $total = (float)($r['total_amount'] ?? 0); $balance = max(0.0, round($total - $discSum - $paidSum - $refSum, 2)); if (!isset($byParent[$pid])) { $byParent[$pid] = [ 'parent_id' => $pid, 'firstname' => (string)($r['firstname'] ?? ''), 'lastname' => (string)($r['lastname'] ?? ''), 'email' => (string)($r['email'] ?? ''), 'total_invoice' => 0.0, 'total_balance' => 0.0, 'total_paid' => 0.0, ]; } $byParent[$pid]['total_invoice'] += $total; $byParent[$pid]['total_balance'] += $balance; $byParent[$pid]['total_paid'] += $paidSum; } // Build rows list (only parents with positive balance) $rows = []; $installmentEndRaw = (string) ($this->config->getConfig('installment_date') ?? ''); $tzName = (string) (config('School')->attendance['timezone'] ?? 'America/New_York'); $tzNY = new \DateTimeZone($tzName); $todayNY = new \DateTimeImmutable('today', $tzNY); $endDate = null; if ($installmentEndRaw) { try { $endDate = new \DateTimeImmutable($installmentEndRaw, $tzNY); } catch (\Throwable $e) { $endDate = null; } } $monthsUntil = function (?\DateTimeImmutable $end) use ($todayNY): int { if (!$end) return 0; $y = (int)$end->format('Y') - (int)$todayNY->format('Y'); $m = (int)$end->format('n') - (int)$todayNY->format('n'); $months = $y * 12 + $m; if ((int)$end->format('j') > (int)$todayNY->format('j')) $months += 1; return max(0, $months); }; foreach ($byParent as $pid => $agg) { $balance = (float)($agg['total_balance'] ?? 0); if ($balance > 0.00001) { $remMonths = $monthsUntil($endDate); $remainingInstallments = max(1, $remMonths); $instAmount = round($balance / $remainingInstallments, 2); $rows[] = [ 'parent_id' => $pid, 'firstname' => $agg['firstname'], 'lastname' => $agg['lastname'], 'email' => $agg['email'], 'total_invoice' => (float)$agg['total_invoice'], 'total_balance' => $balance, 'remaining_installments' => $remainingInstallments, 'installment_amount' => $instAmount, ]; } } // Order by total_balance desc usort($rows, static function ($a, $b) { return ($b['total_balance'] <=> $a['total_balance']); }); // Compute next installment date $tz = new \DateTimeZone($tzName); $now = new \DateTime('now', $tz); $nextInstallmentDt = (clone $now)->modify('first day of next month'); $nextInstallmentYmd = $nextInstallmentDt->format('Y-m-d'); // Determine payment types $parentIds = array_values(array_unique(array_map(static fn($r) => (int)($r['parent_id'] ?? 0), $rows))); $hasPayments = []; $paidTotals = []; if (!empty($parentIds)) { try { $pRows = $db->table('payments') ->select('parent_id, SUM(paid_amount) AS total_paid') ->whereIn('parent_id', $parentIds) ->where('school_year', $schoolYear) ->groupBy('parent_id') ->get()->getResultArray(); foreach ($pRows as $pr) { $pid = (int)($pr['parent_id'] ?? 0); if ($pid > 0) { $hasPayments[$pid] = true; $paidTotals[$pid] = (float)($pr['total_paid'] ?? 0); } } } catch (\Throwable $e) {} } $dataRows = array_map(function(array $r) use ($hasPayments, $paidTotals, $nextInstallmentYmd) { $pid = (int)($r['parent_id'] ?? 0); $name = trim((string)($r['firstname'] ?? '') . ' ' . (string)($r['lastname'] ?? '')); return [ 'parent_id' => $pid, 'parent_name' => $name, 'email' => (string)($r['email'] ?? ''), 'total_invoice' => (float)($r['total_invoice'] ?? 0), 'total_balance' => (float)($r['total_balance'] ?? 0), 'remaining_installments' => (int)($r['remaining_installments'] ?? 0), 'installment_amount' => (float)($r['installment_amount'] ?? 0), 'type' => isset($hasPayments[$pid]) ? 'installment' : 'no_payment', 'total_paid' => isset($paidTotals[$pid]) ? (float)$paidTotals[$pid] : 0.0, 'has_installment'=> isset($hasPayments[$pid]) ? 1 : 0, 'next_installment' => $nextInstallmentYmd, ]; }, $rows); return $this->success([ 'school_year' => $schoolYear, 'schoolYears' => $schoolYears, 'results' => $dataRows, ], 'Unpaid parents retrieved successfully'); } catch (\Throwable $e) { log_message('error', 'Unpaid parents error: ' . $e->getMessage()); return $this->respondError('Failed to retrieve unpaid parents', Response::HTTP_INTERNAL_SERVER_ERROR); } } }