910 lines
38 KiB
PHP
910 lines
38 KiB
PHP
<?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 A–Z, 0–9 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) {}
|
||
|
||
$discountableTotal = $tuitionSubtotal + $additionalSubtotal;
|
||
$nonDiscountableTotal = $eventSubtotal;
|
||
$newTotal = round($discountableTotal + $nonDiscountableTotal, 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);
|
||
|
||
$appliedDiscount = min($totalDisc, $discountableTotal);
|
||
$newBalance = max(0.0, $newTotal - $appliedDiscount - $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];
|
||
}
|
||
}
|