725 lines
31 KiB
PHP
Executable File
725 lines
31 KiB
PHP
Executable File
<?php
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
use App\Models\DiscountUsage;
|
|
use App\Models\Expense;
|
|
use App\Models\Invoice;
|
|
use App\Models\Payment;
|
|
use App\Models\Reimbursement;
|
|
use App\Models\Refund;
|
|
use App\Models\Configuration;
|
|
use App\Models\AdditionalCharge;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
|
|
|
class FinancialController extends BaseApiController
|
|
{
|
|
protected Invoice $invoice;
|
|
protected Payment $payment;
|
|
protected Refund $refund;
|
|
protected Expense $expense;
|
|
protected Reimbursement $reimbursement;
|
|
protected DiscountUsage $discount;
|
|
protected Configuration $config;
|
|
protected AdditionalCharge $additionalCharge;
|
|
|
|
// Special recipients for donations (Masjid/Donation reimbursements)
|
|
protected const SPECIAL_RECIPIENTS = [
|
|
// Add recipient IDs here if needed
|
|
// Example: 1 => '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);
|
|
}
|
|
}
|
|
}
|