Fix semester context, attendance rosters, and billing workflows
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 31s
Tests / PHPUnit (push) Failing after 55s

- load global semester helpers consistently and use date-based semester defaults
- fix grading and daily attendance duplicate student/section rows
- keep attendance violations scoped to the current semester by default
- update invoice, refund, discount, payment, and financial aid flows
- add configuration cleanup migrations for duplicate calendar/semester keys
- refresh parent registration/report-card and print request handling
- update related models, services, views, cron notes, and test coverage
This commit is contained in:
root
2026-08-16 17:41:11 -04:00
parent 36c7e3fc6d
commit 0ac3a8375e
99 changed files with 1598 additions and 814 deletions
+146 -29
View File
@@ -80,7 +80,7 @@ class InvoiceController extends ResourceController
$this->gradeFee = $this->configModel->getConfig('grade_fee');
$this->schoolYear = $this->currentSchoolYearName();
$this->semester = $this->configModel->getConfig('semester');
$this->semester = getSemester();
$this->dueDate = $this->configModel->getConfig('first_day_of_school')
?: $this->configModel->getConfig('due_date');
$this->firstStudentFee = (float) ($this->configModel->getConfig('first_student_fee') ?? 380);
@@ -136,16 +136,9 @@ class InvoiceController extends ResourceController
: ($invoice['updated_at'] ?? null);
$parentData['invoice_id'] = $invoice['id'];
// ✅ Fetch refund amount actually PAID this year (Partial/Paid)
$refund = $this->db->table('refunds')
->select('COALESCE(SUM(refund_paid_amount),0) AS refund_paid_amount')
->where('parent_id', $parent['id'])
->where('school_year', $schoolYear)
->whereIn('status', ['Partial','Paid'])
->get()
->getRowArray();
$parentData['refund_amount'] = (float)($refund['refund_paid_amount'] ?? 0.0);
$refundSummary = $this->paidRefundSummaryForParentYear((int) $parent['id'], (string) $schoolYear);
$parentData['refund_amount'] = $refundSummary['amount'];
$parentData['refund_details'] = $refundSummary['details'];
log_message('info', "Latest invoice for parent {$parent['firstname']} {$parent['lastname']} in school year $schoolYear: Amount = {$invoice['total_amount']}, Updated at = {$invoice['updated_at']}");
} else {
@@ -230,6 +223,120 @@ class InvoiceController extends ResourceController
}
}
private function paidRefundSummaryForParentYear(int $parentId, string $schoolYear): array
{
$refund = $this->db->table('refunds')
->select('COALESCE(SUM(refund_paid_amount),0) AS refund_paid_amount')
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->whereIn('status', ['Partial', 'Paid'])
->get()
->getRowArray();
$details = [];
if ($this->db->tableExists('refund_payouts')) {
$details = $this->db->table('refund_payouts rp')
->select('rp.amount_cents, rp.payment_method, rp.check_number, rp.processed_at, rp.created_at')
->join('refunds r', 'r.id = rp.refund_id', 'inner')
->where('r.parent_id', $parentId)
->where('r.school_year', $schoolYear)
->where('rp.payout_type', 'cash_out')
->whereIn('rp.status', ['completed', 'processing'])
->orderBy('COALESCE(rp.processed_at, rp.created_at)', 'DESC', false)
->get()
->getResultArray();
$details = array_map(static function (array $row): array {
return [
'amount' => ((int) ($row['amount_cents'] ?? 0)) / 100,
'date' => $row['processed_at'] ?? $row['created_at'] ?? null,
'method' => $row['payment_method'] ?? '',
'check_number' => $row['check_number'] ?? '',
];
}, $details);
}
if (empty($details)) {
$legacyRows = $this->db->table('refunds')
->select('refund_paid_amount, refund_method, check_nbr, refunded_at, updated_at')
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->whereIn('status', ['Partial', 'Paid'])
->where('refund_paid_amount >', 0)
->orderBy('COALESCE(refunded_at, updated_at)', 'DESC', false)
->get()
->getResultArray();
$details = array_map(static function (array $row): array {
return [
'amount' => (float) ($row['refund_paid_amount'] ?? 0),
'date' => $row['refunded_at'] ?? $row['updated_at'] ?? null,
'method' => $row['refund_method'] ?? '',
'check_number' => $row['check_nbr'] ?? '',
];
}, $legacyRows);
}
return [
'amount' => (float) ($refund['refund_paid_amount'] ?? 0.0),
'details' => $details,
];
}
private function paidRefundDetailsForInvoice(int $invoiceId): array
{
$details = [];
if ($this->db->tableExists('refund_payouts')) {
$rows = $this->db->table('refund_payouts rp')
->select('rp.amount_cents, rp.payment_method, rp.check_number, rp.processed_at, rp.created_at, rp.payout_type')
->join('refunds r', 'r.id = rp.refund_id', 'inner')
->where('r.invoice_id', $invoiceId)
->whereIn('rp.payout_type', ['cash_out', 'reversal'])
->where('rp.status', 'completed')
->orderBy('COALESCE(rp.processed_at, rp.created_at)', 'ASC', false)
->get()
->getResultArray();
foreach ($rows as $row) {
$amount = ((int) ($row['amount_cents'] ?? 0)) / 100;
if (($row['payout_type'] ?? '') === 'reversal') {
$amount *= -1;
}
$details[] = [
'amount' => $amount,
'date' => $row['processed_at'] ?? $row['created_at'] ?? null,
'method' => $row['payment_method'] ?? '',
'check_number' => $row['check_number'] ?? '',
'type' => $row['payout_type'] ?? 'cash_out',
];
}
}
if (empty($details)) {
$rows = $this->db->table('refunds')
->select('refund_paid_amount, refund_method, check_nbr, refunded_at, updated_at')
->where('invoice_id', $invoiceId)
->whereIn('status', ['Partial', 'Paid'])
->where('refund_paid_amount >', 0)
->orderBy('COALESCE(refunded_at, updated_at)', 'ASC', false)
->get()
->getResultArray();
foreach ($rows as $row) {
$details[] = [
'amount' => (float) ($row['refund_paid_amount'] ?? 0),
'date' => $row['refunded_at'] ?? $row['updated_at'] ?? null,
'method' => $row['refund_method'] ?? '',
'check_number' => $row['check_nbr'] ?? '',
'type' => 'cash_out',
];
}
}
return $details;
}
private function assertSchoolYearNameWritable(string $schoolYear): void
{
service('schoolYearWriteGuard')->assertWritable(
@@ -297,14 +404,9 @@ class InvoiceController extends ResourceController
}
$parentData['invoice_id'] = $invoice['id'] ?? null;
// Refund total paid for parent/year (Partial/Paid)
$refund = $this->db->table('refunds')
->select('COALESCE(SUM(refund_paid_amount),0) AS refund_paid_amount')
->where('parent_id', $parent['id'])
->where('school_year', $schoolYear)
->whereIn('status', ['Partial','Paid'])
->get()->getRowArray();
$parentData['refund_amount'] = (float)($refund['refund_paid_amount'] ?? 0.0);
$refundSummary = $this->paidRefundSummaryForParentYear((int) $parent['id'], (string) $schoolYear);
$parentData['refund_amount'] = $refundSummary['amount'];
$parentData['refund_details'] = $refundSummary['details'];
break; // only most recent as before
}
}
@@ -503,15 +605,6 @@ class InvoiceController extends ResourceController
log_message('info', "Updated invoice ID {$invoice['id']} for parent ID {$parentId}.");
$updated = true;
} else {
// Generate invoice number
$schoolId = $this->userModel->getSchoolIdByUserId($parentId);
if (!empty($schoolId)) {
$invoiceNumber = 'INV-' . $schoolId . '-' . uniqid();
} else {
log_message('warning', "No school ID found for parent_id {$parentId}, generating fallback invoice number.");
$invoiceNumber = uniqid('INV-');
}
$issueUtc = (new DateTime('now', new DateTimeZone('UTC')))->format('Y-m-d H:i:s');
// Due date: interpret the date in configured/user local TZ,
@@ -527,7 +620,7 @@ class InvoiceController extends ResourceController
try {
$issueResult = $this->invoiceIssuanceService->issueInvoice(new IssueInvoiceCommand([
'parent_id' => $parentId,
'invoice_number' => $invoiceNumber,
'invoice_number' => $this->invoiceIssuanceService->generateInvoiceNumber($schoolYear, (int)$parentId),
'total_amount' => $totalAmount,
'paid_amount' => 0,
'balance' => $totalAmount,
@@ -894,6 +987,7 @@ class InvoiceController extends ResourceController
->findAll();
$refundsPaidTotal = (float) ($ledger['refund_paid_total'] ?? 0.0);
$refundDetails = $this->paidRefundDetailsForInvoice((int) $invoiceId);
/* ============================================================
* ADDITIONAL CHARGES (itemized) for this invoice
@@ -964,6 +1058,7 @@ class InvoiceController extends ResourceController
'additionalChargeLines' => $additionalChargeLines,
'invoiceLines' => $invoiceLines,
'refundsPaidTotal' => $refundsPaidTotal,
'refundDetails' => $refundDetails,
'ledger' => $ledger,
];
}
@@ -1265,6 +1360,28 @@ class InvoiceController extends ResourceController
$push($dt, $desc, -1 * $amt, 'discount');
}
// --- Refund payouts (positive) integrated into the timeline
foreach (($refundDetails ?? []) as $refund) {
$amount = (float)($refund['amount'] ?? 0.0);
if (abs($amount) < 0.00001) {
continue;
}
$dt = $toLocal($refund['date'] ?? null, true);
$method = trim((string)($refund['method'] ?? ''));
$checkNumber = trim((string)($refund['check_number'] ?? ''));
$isReversal = (string)($refund['type'] ?? 'cash_out') === 'reversal';
$desc = $isReversal ? 'Refund reversal' : 'Refund paid';
if ($method !== '') {
$desc .= ' (' . $method . ')';
}
if ($checkNumber !== '') {
$desc .= ' - Check #' . $checkNumber;
}
$push($dt, $desc, $amount, 'refund');
}
// --- Sort by exact timestamp, then by insertion sequence for stability
usort($transactions, function ($a, $b) {
// Different days: keep chronological by timestamp