Files
alrahma_sunday_school/app/Controllers/View/DiscountController.php
T
2026-02-10 22:11:06 -05:00

907 lines
38 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\DiscountVoucherModel;
use App\Models\DiscountUsageModel;
use App\Models\InvoiceModel;
use App\Models\UserModel;
use App\Models\ConfigurationModel;
use App\Models\PaymentModel;
use App\Models\EnrollmentModel;
use App\Models\EventChargesModel;
use App\Models\AdditionalChargeModel;
use App\Models\ClassSectionModel;
use CodeIgniter\Events\Events;
class DiscountController extends BaseController
{
protected $configModel;
protected $voucherModel;
protected $invoiceModel;
protected $userModel;
protected $db;
protected $schoolYear;
protected $semester;
protected $paymentModel;
protected $enrollmentModel;
protected $eventChargesModel;
protected $additionalChargeModel;
protected $classSectionModel;
public function __construct()
{
$this->configModel = new ConfigurationModel();
$this->voucherModel = new DiscountVoucherModel();
$this->invoiceModel = new InvoiceModel();
$this->userModel = new UserModel();
$this->db = \Config\Database::connect();
$this->paymentModel = new PaymentModel();
$this->enrollmentModel = new EnrollmentModel();
$this->eventChargesModel = new EventChargesModel();
$this->additionalChargeModel = new AdditionalChargeModel();
$this->classSectionModel = new ClassSectionModel();
$this->schoolYear = $this->configModel->getConfig('school_year');
$this->semester = $this->configModel->getConfig('semester');
}
public function applyVoucher()
{
if (strtolower($this->request->getMethod()) === 'post') {
$voucherId = $this->request->getPost('voucher_id');
$parentIds = $this->request->getPost('parent_ids') ?? [];
$allowAdditional = (bool) $this->request->getPost('allow_additional');
if (empty($parentIds)) {
return redirect()->back()->with('error', 'Please select at least one parent.');
}
$voucher = $this->voucherModel->find($voucherId);
if (!$voucher) {
return redirect()->back()->with('error', 'Voucher not found.');
}
// Normalize description from voucher (optional)
$voucherDescription = trim((string) ($voucher['description'] ?? ''));
$voucherDescription = preg_replace(
'/^Auto-generated on \d{4}-\d{2}-\d{2}\s*[—-]\s*reason:\s*/i',
'',
$voucherDescription
);
$maxUsesRaw = $voucher['max_uses'] ?? null;
$maxUses = ($maxUsesRaw === null || $maxUsesRaw === '') ? null : (int) $maxUsesRaw;
$timesUsed = (int) ($voucher['times_used'] ?? 0);
$remainingUses = ($maxUses === null) ? PHP_INT_MAX : ($maxUses - $timesUsed);
if ($remainingUses <= 0) {
return redirect()->back()->with('error', 'This voucher has reached its maximum allowed uses.');
}
// If not allowing additional discounts, filter out parents who already have discounts this school year.
if (!$allowAdditional) {
$rows = $this->db->table('discount_usages du')
->select('i.parent_id')
->join('invoices i', 'i.id = du.invoice_id')
->whereIn('i.parent_id', $parentIds)
->where('i.school_year', $this->schoolYear)
->groupBy('i.parent_id')
->get()
->getResultArray();
$blockedParentIds = array_map(static fn($r) => (int) $r['parent_id'], $rows);
if (!empty($blockedParentIds)) {
$parentIds = array_values(array_diff(
array_map('intval', $parentIds),
$blockedParentIds
));
}
if (empty($parentIds)) {
return redirect()->back()->with('error', 'All selected parents already have discounts. Enable "Allow additional discounts" to apply another.');
}
}
$paymentDate = utc_now();
// Collect invoice IDs that end up fully covered by the voucher in this run
$fullyCoveredInvoiceIds = [];
$touchedInvoiceIds = [];
$appliedCount = 0;
$this->db->transStart();
foreach ($parentIds as $parentId) {
// Fetch invoices for this parent & school year
$invoices = $this->invoiceModel->getInvoicesByUserId($parentId, $this->schoolYear);
if (empty($invoices)) {
// Do NOT early-return inside a transaction; just continue
session()->setFlashdata('error', 'A selected parent has no invoice in record.');
continue;
}
foreach ($invoices as $invoice) {
if ($remainingUses <= 0) break 2; // out of parentIds loop too
// Snapshot current balance BEFORE applying
$initialPreBalance = (float) $this->getCurrentInvoiceBalance($invoice['id'], $this->schoolYear);
if ($initialPreBalance <= 0) {
log_message(
'error',
'applyVoucher skip: zero balance | voucher_id={vid} parent_id={pid} invoice_id={iid} invoice_number={inum} balance={bal}',
[
'vid' => (int)$voucherId,
'pid' => (int)$parentId,
'iid' => (int)$invoice['id'],
'inum' => (string)($invoice['invoice_number'] ?? ''),
'bal' => $initialPreBalance,
]
);
continue;
}
// Already used this voucher on this invoice?
if (!$allowAdditional) {
$exists = $this->db->table('discount_usages du')
->join('invoices i', 'du.invoice_id = i.id')
->where('du.voucher_id', $voucherId)
->where('du.invoice_id', $invoice['id'])
->where('i.school_year', $this->schoolYear)
->countAllResults();
if ($exists) {
log_message(
'error',
'applyVoucher skip: voucher already used | voucher_id={vid} parent_id={pid} invoice_id={iid} invoice_number={inum}',
[
'vid' => (int)$voucherId,
'pid' => (int)$parentId,
'iid' => (int)$invoice['id'],
'inum' => (string)($invoice['invoice_number'] ?? ''),
]
);
continue;
}
}
// Calculate discount
$rawDiscount = ($voucher['discount_type'] === 'percent')
? round(((float)$invoice['total_amount'] * (float)$voucher['discount_value']) / 100, 2)
: (float) $voucher['discount_value'];
// Cap by CURRENT invoice balance snapshot
$discount = min($rawDiscount, $initialPreBalance);
// Nothing to do if no discount
if ($discount <= 0) {
log_message(
'error',
'applyVoucher skip: discount <= 0 | voucher_id={vid} parent_id={pid} invoice_id={iid} invoice_number={inum} raw_discount={raw} balance={bal}',
[
'vid' => (int)$voucherId,
'pid' => (int)$parentId,
'iid' => (int)$invoice['id'],
'inum' => (string)($invoice['invoice_number'] ?? ''),
'raw' => $rawDiscount,
'bal' => $initialPreBalance,
]
);
continue;
}
// Insert discount usage
$now = utc_now();
$this->db->table('discount_usages')->insert([
'voucher_id' => $voucherId,
'invoice_id' => $invoice['id'],
'parent_id' => $parentId,
'discount_amount' => $discount,
'description' => $voucherDescription,
'school_year' => $this->schoolYear,
'updated_by' => session()->get('user_id'),
'used_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
// Update invoice balance based on pre-discount snapshot (supports multiple discounts)
$newBalance = max(0.0, round($initialPreBalance - $discount, 2));
$this->db->table('invoices')
->where('id', $invoice['id'])
->update([
'balance' => $newBalance,
'has_discount' => 1,
'updated_at' => $now,
]);
// Compute post-balance based on pre-snapshot (more stable than $invoice['balance'])
$postBalance = round($initialPreBalance - $discount, 2);
if ($postBalance < 0) $postBalance = 0.0;
// (Optional) current balance re-check (in case of concurrent writes)
$currentBalance = (float) $this->getCurrentInvoiceBalance($invoice['id'], $this->schoolYear);
// Increment voucher usage
$this->db->table('discount_vouchers')
->where('id', $voucherId)
->set('times_used', 'COALESCE(times_used,0) + 1', false)
->update();
// Prepare and trigger payment event
[$eventData, $studentData] = $this->buildPaymentEventData(
$invoice['id'],
$voucherId,
$discount,
'discount',
$paymentDate,
'',
0,
$initialPreBalance, // pre-payment snapshot
$postBalance // computed post-payment
);
Events::trigger('paymentReceived', $eventData, $studentData);
$touchedInvoiceIds[] = (int) $invoice['id'];
$appliedCount++;
// If voucher covered the entire current balance, mark to update enrollments
// Use a small epsilon to tolerate cents rounding.
$epsilon = 0.01;
$fullyCovered = (abs($discount - $initialPreBalance) <= $epsilon)
|| ($postBalance <= $epsilon)
|| ($currentBalance <= $epsilon);
if ($fullyCovered) {
$fullyCoveredInvoiceIds[] = (int) $invoice['id'];
}
$remainingUses--;
// Deactivate if we just hit the cap
if ($remainingUses <= 0 && $maxUses !== null) {
$this->db->table('discount_vouchers')
->where('id', $voucherId)
->update([
'is_active' => 0,
'updated_at' => $now,
]);
break 2;
}
}
}
$this->db->transComplete();
if ($this->db->transStatus() === false) {
return redirect()->back()->with('error', 'Voucher application failed. Transaction rolled back.');
}
if ($appliedCount === 0) {
return redirect()->back()->with('error', 'No discounts were applied. The voucher may already be used for these invoices or balances are zero.');
}
// ✅ Ensure voucher deactivates when max_uses reached (handles edge cases)
if ($maxUses !== null) {
$row = $this->db->table('discount_vouchers')
->select('times_used')
->where('id', $voucherId)
->get()
->getRowArray();
$usedNow = (int) ($row['times_used'] ?? 0);
if ($usedNow >= $maxUses) {
$this->db->table('discount_vouchers')
->where('id', $voucherId)
->update([
'is_active' => 0,
'updated_at' => utc_now(),
]);
}
}
// ✅ AFTER COMMIT: recalculate invoice totals/balance/paid/discount/refund
foreach (array_unique($touchedInvoiceIds) as $iid) {
try {
$this->recalculateInvoice($iid, $this->schoolYear);
} catch (\Throwable $e) {
log_message('error', 'recalculateInvoice failed for invoice {iid}: {err}', [
'iid' => $iid,
'err' => $e->getMessage(),
]);
}
}
// ✅ AFTER COMMIT: update enrollments for fully-covered invoices
// Avoid nested transactions by doing this post-commit.
$updatedTotal = 0;
foreach (array_unique($fullyCoveredInvoiceIds) as $iid) {
try {
$updatedTotal += (int) $this->updateEnrollmentStatusIfPaid($iid);
} catch (\Throwable $e) {
log_message('error', 'updateEnrollmentStatusIfPaid failed for invoice {iid}: {err}', [
'iid' => $iid,
'err' => $e->getMessage(),
]);
}
}
if ($updatedTotal > 0) {
return redirect()->back()->with('success', 'Voucher applied successfully and enrollments updated.');
}
return redirect()->back()->with('success', 'Voucher applied successfully.');
}
// GET: load page
$parents = $this->userModel->getParents();
foreach ($parents as &$parent) {
$discount = $this->db->table('discount_usages')
->select('COALESCE(SUM(discount_amount),0) AS total_discount')
->where('parent_id', $parent['id'])
->where('school_year', $this->schoolYear)
->get()
->getRowArray();
$parent['total_discount'] = $discount['total_discount'] ?? 0;
$parent['has_discount'] = ($parent['total_discount'] > 0) ? 1 : 0;
}
unset($parent);
return view('discounts/apply_voucher', [
'vouchers' => $this->voucherModel->where('is_active', 1)->findAll(),
'parents' => $parents,
]);
}
private function updateEnrollmentStatusIfPaid(int $invoiceId): int
{
// 1) Fetch invoice
$invoice = $this->invoiceModel->find($invoiceId);
if (!$invoice) {
log_message('warning', 'Invoice not found: {id}', ['id' => $invoiceId]);
return 0;
}
// 2) Payment check (any payment recorded: balance < total)
$total = (float) ($invoice['total_amount'] ?? 0);
$balance = (float) ($invoice['balance'] ?? 0);
if (!($total > 0 && $balance < $total)) {
log_message('info', 'No payment yet. Skipping enrollment update. Invoice #{id}', ['id' => $invoiceId]);
return 0;
}
$parentId = (int) ($invoice['parent_id'] ?? 0);
$schoolYear = (string) $this->schoolYear;
if ($parentId <= 0 || $schoolYear === '') {
log_message('warning', 'updateEnrollmentStatusIfPaid: missing parent_id/school_year for invoice #{id}', ['id' => $invoiceId]);
return 0;
}
// 3) Try to limit to enrollments actually present on the invoice (optional)
// If invoice_items.enrollment_id doesn't exist, this silently falls back to parent/year scope.
$paidEnrollmentIds = [];
try {
$rows = $this->db->table('invoice_items')
->select('enrollment_id')
->where('invoice_id', $invoiceId)
->where('enrollment_id IS NOT NULL', null, false)
->get()->getResultArray();
foreach ($rows as $r) {
$eid = (int) ($r['enrollment_id'] ?? 0);
if ($eid > 0) $paidEnrollmentIds[] = $eid;
}
$paidEnrollmentIds = array_values(array_unique($paidEnrollmentIds));
} catch (\Throwable $e) {
// No-op: table/column might not exist in your schema
}
$db = $this->db;
$db->transBegin();
try {
// 4) Preview IDs that will be updated (good for debugging “partials”)
$previewBuilder = $db->table('enrollments')
->select('id')
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->groupStart()
// tolerant match for 'payment pending' (case/space/nbsp)
->where('enrollment_status', 'payment pending')
->orWhere('enrollment_status', 'Payment pending')
->orWhere('enrollment_status', 'Payment Pending')
->orWhere('enrollment_status', 'PAYMENT PENDING')
->orWhere("LOWER(TRIM(REPLACE(enrollment_status, CHAR(160), ' '))) = 'payment pending'", null, false)
->groupEnd();
if (!empty($paidEnrollmentIds)) {
$previewBuilder->whereIn('id', $paidEnrollmentIds);
}
$toUpdateIds = array_map(
static fn($r) => (int)$r['id'],
$previewBuilder->get()->getResultArray()
);
if (empty($toUpdateIds)) {
$db->transCommit();
log_message('info', 'No enrollments matched pending status for parent {p}, year {y}, sem {s}.', [
'p' => $parentId,
'y' => $schoolYear,
's' => 'ALL'
]);
return 0;
}
// 5) Perform update with same filters
$builder = $db->table('enrollments');
$builder->set('enrollment_status', 'enrolled')
// Use DB date if you prefer: ->set('enrollment_date', 'CURRENT_DATE()', false)
->set('enrollment_date', local_date(utc_now(), 'Y-m-d'))
->whereIn('id', $toUpdateIds);
if ($builder->update() === false) {
$err = $db->error();
throw new \RuntimeException('Enrollments update failed: ' . ($err['message'] ?? 'unknown DB error'));
}
$affected = $db->affectedRows();
$db->transCommit();
log_message(
'info',
'Enrollment status -> enrolled for {n} row(s). parent={p}, year={y}, sem={s}, ids=[{ids}]',
[
'n' => $affected,
'p' => $parentId,
'y' => $schoolYear,
's' => 'ALL',
'ids' => implode(',', $toUpdateIds),
]
);
return $affected;
} catch (\Throwable $e) {
$db->transRollback();
log_message('error', 'updateEnrollmentStatusIfPaid error: ' . $e->getMessage());
return 0;
}
}
public function listVouchers()
{
$vouchers = $this->voucherModel->findAll();
return view('discounts/list', ['vouchers' => $vouchers]);
}
public function createVoucher()
{
if (strtolower($this->request->getMethod()) === 'post') {
// -------- Gather & normalize inputs --------
$rawCode = (string) $this->request->getPost('code');
// Keep AZ, 09 and dashes; uppercase for consistency
$code = strtoupper(preg_replace('/[^A-Z0-9\-]/i', '', trim($rawCode)));
$discountType = strtolower((string) $this->request->getPost('discount_type')); // 'percent' | 'fixed'
$discountValue = (float) ($this->request->getPost('discount_value') ?? 0);
$maxUsesRaw = $this->request->getPost('max_uses');
$maxUses = ($maxUsesRaw === '' || $maxUsesRaw === null) ? null : max(0, (int) $maxUsesRaw);
$validFrom = trim((string) ($this->request->getPost('valid_from') ?? ''));
$validUntil = trim((string) ($this->request->getPost('valid_until') ?? ''));
$isActive = $this->request->getPost('is_active') ? 1 : 0;
// NEW: Description / Reason
$description = trim((string) ($this->request->getPost('description') ?? ''));
// -------- Basic validations (before model rules) --------
$errors = [];
if ($code === '') {
$errors[] = 'Voucher code is required.';
}
if (!in_array($discountType, ['percent', 'fixed'], true)) {
$errors[] = 'Discount type must be "percent" or "fixed".';
}
if ($discountType === 'percent') {
if ($discountValue <= 0 || $discountValue > 100) {
$errors[] = 'Percentage discount must be between 0 and 100.';
}
} else { // fixed
if ($discountValue < 0) {
$errors[] = 'Fixed discount must be 0 or greater.';
}
}
// Dates come from <input type="date"> as YYYY-MM-DD
$validFrom = $validFrom !== '' ? $validFrom : null;
$validUntil = $validUntil !== '' ? $validUntil : null;
// Ensure date format is plausible
$isDate = static function (?string $d): bool {
if ($d === null) return true;
return (bool) preg_match('/^\d{4}-\d{2}-\d{2}$/', $d);
};
if (!$isDate($validFrom)) $errors[] = 'Valid From must be a date (YYYY-MM-DD).';
if (!$isDate($validUntil)) $errors[] = 'Valid Until must be a date (YYYY-MM-DD).';
if ($validFrom && $validUntil && $validFrom > $validUntil) {
$errors[] = 'Valid Until must be the same as or after Valid From.';
}
// Optional: require a reason when auto-generated codes are used
// if ($description === '' && strlen($code) === 10) {
// $errors[] = 'Please add a description/reason for this voucher.';
// }
if (!empty($errors)) {
return redirect()->back()->withInput()->with('error', implode(' ', $errors));
}
// -------- Prepare payload for model --------
$data = [
'code' => $code,
'discount_type' => $discountType,
'discount_value' => $discountValue,
'max_uses' => $maxUses,
'valid_from' => $validFrom,
'valid_until' => $validUntil,
'is_active' => $isActive,
'description' => $description, // <-- NEW
];
if ($this->voucherModel->save($data)) {
return redirect()->to('/discounts/list')->with('success', 'Voucher created successfully.');
}
// Model-level errors (unique code, etc.)
return redirect()->back()
->withInput()
->with('error', 'Failed to save voucher: ' . implode('; ', (array) $this->voucherModel->errors()));
}
return view('discounts/create');
}
public function editVoucher($id)
{
$voucher = $this->voucherModel->find($id);
if (!$voucher) {
return redirect()->to('discounts/list')->with('error', 'Voucher not found');
}
if (strtolower($this->request->getMethod()) === 'post') {
$data = [
'id' => $id,
'code' => $this->request->getPost('code'),
'discount_type' => $this->request->getPost('discount_type'),
'discount_value' => $this->request->getPost('discount_value'),
'max_uses' => $this->request->getPost('max_uses'),
'valid_from' => $this->request->getPost('valid_from') ?: null,
'valid_until' => $this->request->getPost('valid_until') ?: null,
'is_active' => $this->request->getPost('is_active') ? 1 : 0,
];
$this->voucherModel->save($data);
return redirect()->to('discounts/list')->with('success', 'Voucher updated successfully');
}
return view('discounts/edit', ['voucher' => $voucher]);
}
/**
* 🔄 Helper: Current invoice balance (school-year scoped) = total - payments - discounts - refundsPaid
*/
private function getCurrentInvoiceBalance($invoiceId, $schoolYear)
{
$invoice = $this->invoiceModel->find($invoiceId);
if (!$invoice) return 0.0;
// Payments (exclude void/refund/failed, honor year)
$qb = $this->paymentModel
->select('COALESCE(SUM(paid_amount),0) AS total_paid')
->where('invoice_id', $invoiceId)
->where('school_year', $schoolYear);
$table = $this->paymentModel->table;
$hasStatus = $this->db->fieldExists('status', $table);
$hasVoid = $this->db->fieldExists('is_void', $table);
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();
}
$rowPaid = $qb->first();
$totalPaid = (float)($rowPaid['total_paid'] ?? 0);
// Discounts for this invoice in this year
$rowDisc = $this->db->table('discount_usages du')
->select('COALESCE(SUM(du.discount_amount),0) AS total_disc')
->join('invoices i', 'i.id = du.invoice_id')
->where('du.invoice_id', $invoiceId)
->where('i.school_year', $schoolYear)
->get()->getRowArray();
$totalDisc = (float)($rowDisc['total_disc'] ?? 0);
// Refunds PAID for this invoice in this year
$rowRefund = $this->db->table('refunds')
->select('COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid')
->where('invoice_id', $invoiceId)
->where('school_year', $schoolYear)
->whereIn('status', ['Partial', 'Paid'])
->get()->getRowArray();
$totalRefundPaid = (float)($rowRefund['total_refund_paid'] ?? 0);
$total = (float)($invoice['total_amount'] ?? 0);
return max(0.0, round($total - $totalPaid - $totalDisc - $totalRefundPaid, 2));
}
/**
* 🔄 Recalculate invoice totals and status based on all payments for current school year
*/
private function recalculateInvoice($invoiceId, $schoolYear): void
{
$invoice = $this->invoiceModel->find($invoiceId);
if (!$invoice) return;
$parentId = (int)($invoice['parent_id'] ?? 0);
if ($parentId <= 0) return;
// ---- Tuition (recompute from enrollments) ----
$enrollments = $this->enrollmentModel
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->findAll();
$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;
}
}
// Refund window check if after deadline, withdrawn still billed
$refundDeadline = (string)($this->configModel->getConfig('refund_deadline') ?? '');
$refundAllowed = true;
try {
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
$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);
}
// Grade threshold and fees
$gradeFee = (int)($this->configModel->getConfig('grade_fee') ?? 9);
$firstStudentFee = (float)($this->configModel->getConfig('first_student_fee') ?? 350);
$secondStudentFee = (float)($this->configModel->getConfig('second_student_fee') ?? 200);
$youthFee = (float)($this->configModel->getConfig('youth_fee') ?? 180);
// Normalize grades for tuition students
foreach ($tuitionStudents as &$s) {
$name = null;
if (!empty($s['class_section_id'])) {
$name = $this->classSectionModel->getClassSectionNameBySectionId($s['class_section_id']);
}
$s['grade_name'] = is_string($name) ? strtoupper(trim($name)) : 'N/A';
}
unset($s);
// Count regular vs youth and compute tuition
$regularCount = 0;
$youthCount = 0;
foreach ($tuitionStudents as $s) {
$lvl = $this->parseGradeLevel($s['grade_name']);
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;
}
// ---- Event charges (parent-year) ----
$eventSubtotal = 0.0;
try {
$events = $this->eventChargesModel->getChargesWithEventInfo($parentId, $schoolYear) ?? [];
foreach ($events as $ev) { $eventSubtotal += (float)($ev['charged'] ?? 0.0); }
} catch (\Throwable $e) {}
// ---- Additional charges (per-invoice) ----
$additionalSubtotal = 0.0;
try {
$rows = $this->additionalChargeModel
->select('charge_type, amount')
->where('invoice_id', $invoiceId)
->where('status', 'applied')
->findAll();
foreach ($rows as $r) {
$amt = (float)($r['amount'] ?? 0);
$typ = strtolower((string)($r['charge_type'] ?? 'add'));
if ($typ === 'deduct') $amt = -abs($amt); else $amt = abs($amt);
$additionalSubtotal += $amt;
}
} catch (\Throwable $e) {}
$newTotal = round($tuitionSubtotal + $eventSubtotal + $additionalSubtotal, 2);
// ---- Payments / Discounts / Refunds ----
$db = $this->db;
$table = $this->paymentModel->table;
$hasStatus = $db->fieldExists('status', $table);
$hasVoid = $db->fieldExists('is_void', $table);
$exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
$qb = $this->paymentModel
->where('invoice_id', $invoiceId)
->where('school_year', $schoolYear);
if ($hasStatus) {
$qb->groupStart()
->whereNotIn('status', $exclude)
->orWhere('status IS NULL', null, false)
->groupEnd();
}
if ($hasVoid) {
$qb->groupStart()
->where('is_void', 0)
->orWhere('is_void IS NULL', null, false)
->groupEnd();
}
$payments = $qb->findAll();
$totalPaid = 0.0;
foreach ($payments as $p) { $totalPaid += (float)($p['paid_amount'] ?? 0); }
$discRow = $this->db->table('discount_usages')
->select('COALESCE(SUM(discount_amount),0) AS total_disc')
->where('invoice_id', $invoiceId)
->get()->getRowArray();
$totalDisc = (float)($discRow['total_disc'] ?? 0);
$refundRow = $this->db->table('refunds')
->select('COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid')
->where('invoice_id', $invoiceId)
->whereIn('status', ['Partial','Paid'])
->get()->getRowArray();
$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 ($this->db->fieldExists('discount', $this->invoiceModel->table)) {
$updateData['discount'] = $totalDisc;
}
$this->invoiceModel->update($invoiceId, $updateData);
}
/**
* Parse a grade name into an integer level for tuition rules.
* Kindergarten -> 1, Youth -> > 9, numeric grades passthrough.
*/
private function parseGradeLevel($grade): int
{
if (is_numeric($grade)) return (int)$grade;
if (!is_string($grade)) return 999;
$g = strtoupper(trim((string)$grade));
$g = preg_replace('/\s+/', ' ', $g);
$g = str_replace(['.', '_', '-'], ['', '', ' '], $g);
// KG/K/Kindergarten
$kg = ['K','KG','K G','KINDER','KINDERGARTEN'];
if (in_array($g, $kg, true)) return 1;
// Pre-K -> treat below regular
$pk = ['PK','P K','PREK','PRE K','PRE KINDER','PREKINDER'];
if (in_array($g, $pk, true)) return -1;
// Youth variants: Y, YOUTH, Y1, YOUTH2 -> map to 10+
if (preg_match('/^Y(?:OUTH)?\s*(\d+)?$/', $g, $m)) {
$n = isset($m[1]) && $m[1] !== '' ? max(1, (int)$m[1]) : 1;
$gradeFee = (int)($this->configModel->getConfig('grade_fee') ?? 9);
return $gradeFee + $n;
}
if (preg_match('/^(?:GR?ADE\s*)?(\d{1,2})\s*([A-Z]*)$/', $g, $m)) {
return (int)$m[1];
}
return 999;
}
/**
* Collect parent/invoice/payment data to trigger handlePaymentReceived().
*
* @return array [$data, $studentdata]
*/
private function buildPaymentEventData(
int $invoiceId,
string $transactionIdOrRef,
float $amount,
string $paymentMethod,
string $paymentDate,
?string $checkNumber,
int $installmentSeq,
float $preBalance, // ✅ new param: initial (pre-payment) balance
float $postBalance // ✅ new param: computed post-payment balance
): array {
$invoice = $this->invoiceModel->find($invoiceId);
if (!$invoice) {
throw new \RuntimeException("Invoice {$invoiceId} not found");
}
$parentRow = $this->db->table('users')
->select('id, firstname, lastname, email')
->where('id', $invoice['parent_id'])
->get()
->getRowArray();
if (!$parentRow) {
throw new \RuntimeException("Parent not found for invoice {$invoiceId}");
}
// Students (adjust joins to your schema)
$studentRows = $this->db->table('students s')
->select('s.id, s.firstname, s.lastname, sc.class_section_id')
->join('student_class sc', 'sc.student_id = s.id', 'left')
->where('s.parent_id', $invoice['parent_id'])
->where('sc.school_year', $this->schoolYear)
->get()
->getResultArray();
$data = [
'user_id' => (int) $parentRow['id'],
'email' => $parentRow['email'],
'firstname' => $parentRow['firstname'],
'lastname' => $parentRow['lastname'],
'school_year' => $this->schoolYear,
'semester' => $this->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, // ✅ the captured pre-payment balance
'post_balance' => $postBalance, // ✅ computed post-payment balance
'portalLink' => site_url('parent/invoices/' . $invoiceId),
];
return [$data, $studentRows];
}
}