Fix semester context, attendance rosters, and billing workflows
- 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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user