diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 65b47f2..c549e9b 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -743,11 +743,6 @@ $routes->get('payments/getByParent/(:num)', 'View\PaymentController::getByParent $routes->get('payments/create', 'View\PaymentController::create'); $routes->post('payments/updateBalance/(:num)', 'View\PaymentController::updateBalance/$1'); -// Web View Routes for Payment Transactions -$routes->get('payment_transactions/getByPayment/(:num)', 'View\PaymentTransactionController::getByPayment/$1'); -$routes->get('payment_transactions/create', 'View\PaymentTransactionController::create'); -$routes->post('payment_transactions/updateStatus/(:num)', 'View\PaymentTransactionController::updateStatus/$1'); - // Routes for payment pages $routes->get('payments/view/(:num)', 'View\PaymentController::viewPayment/$1'); $routes->get('payment/get_enrolled_students/(:num)', 'View\PaymentController::getEnrolledStudents/$1', ['filter' => 'auth:view_invoice|view_payment|view_financial_reports|administrator|administrative staff|principal']); diff --git a/app/Controllers/View/FamilyAdminController.php b/app/Controllers/View/FamilyAdminController.php index 10c1288..4ba5065 100644 --- a/app/Controllers/View/FamilyAdminController.php +++ b/app/Controllers/View/FamilyAdminController.php @@ -150,10 +150,12 @@ class FamilyAdminController extends BaseController } // Recent payments (limit 10) - $payRows = $db->table('payments') - ->select('id, parent_id, invoice_id, paid_amount, balance, payment_method, payment_date, status') - ->whereIn('parent_id', $parentIds) - ->orderBy('payment_date', 'DESC') + $payRows = $db->table('payments p') + ->select('p.id, p.parent_id, p.invoice_id, p.paid_amount, p.payment_method, p.payment_date, p.status AS payment_status, p.installment_seq, p.number_of_installments, i.invoice_number, i.balance AS invoice_current_balance, i.status AS invoice_status, i.school_year') + ->join('invoices i', 'i.id = p.invoice_id', 'inner') + ->whereIn('p.parent_id', $parentIds) + ->orderBy('p.payment_date', 'DESC') + ->orderBy('p.id', 'DESC') ->limit(10) ->get()->getResultArray(); $fam['payments'] = $payRows; @@ -395,10 +397,12 @@ class FamilyAdminController extends BaseController } // Payments - $payRows = $db->table('payments') - ->select('id, parent_id, invoice_id, paid_amount, balance, payment_method, payment_date, status') - ->whereIn('parent_id', $parentIds) - ->orderBy('payment_date', 'DESC') + $payRows = $db->table('payments p') + ->select('p.id, p.parent_id, p.invoice_id, p.paid_amount, p.payment_method, p.payment_date, p.status AS payment_status, p.installment_seq, p.number_of_installments, i.invoice_number, i.balance AS invoice_current_balance, i.status AS invoice_status, i.school_year') + ->join('invoices i', 'i.id = p.invoice_id', 'inner') + ->whereIn('p.parent_id', $parentIds) + ->orderBy('p.payment_date', 'DESC') + ->orderBy('p.id', 'DESC') ->limit(10) ->get()->getResultArray(); $family['payments'] = $payRows; diff --git a/app/Controllers/View/FinancialController.php b/app/Controllers/View/FinancialController.php index 3733fd4..e64d2e5 100644 --- a/app/Controllers/View/FinancialController.php +++ b/app/Controllers/View/FinancialController.php @@ -658,10 +658,10 @@ public function financialReport() }, $discountDetailsBuilder->orderBy('du.id', 'ASC')->get()->getResultArray()); $paymentDetailsBuilder = $db->table('payments p') - ->select("p.id, p.invoice_id, p.parent_id, p.paid_amount, p.payment_method, p.payment_date, p.status, p.transaction_id, p.check_number, i.invoice_number, CONCAT(COALESCE(u.firstname, ''), ' ', COALESCE(u.lastname, '')) AS parent_name") - ->join('invoices i', 'i.id = p.invoice_id', 'left') + ->select("p.id, p.invoice_id, p.parent_id, p.paid_amount, p.payment_method, p.payment_date, p.status AS payment_status, p.transaction_id, p.check_number, i.invoice_number, i.balance AS invoice_current_balance, i.status AS invoice_status, i.school_year, CONCAT(COALESCE(u.firstname, ''), ' ', COALESCE(u.lastname, '')) AS parent_name") + ->join('invoices i', 'i.id = p.invoice_id', 'inner') ->join('users u', 'u.id = p.parent_id', 'left') - ->where('p.school_year', $schoolYear) + ->where('i.school_year', $schoolYear) ->groupStart() ->whereNotIn('p.status', $paymentExclude) ->orWhere('p.status IS NULL', null, false) @@ -677,9 +677,12 @@ public function financialReport() 'Parent' => trim((string)($row['parent_name'] ?? '')), 'Invoice #' => (string)($row['invoice_number'] ?? ''), 'Paid Amount' => (float)($row['paid_amount'] ?? 0), + 'Invoice Balance' => (float)($row['invoice_current_balance'] ?? 0), 'Payment Method' => (string)($row['payment_method'] ?? ''), 'Payment Date' => (string)($row['payment_date'] ?? ''), - 'Status' => (string)($row['status'] ?? ''), + 'Payment Status' => (string)($row['payment_status'] ?? ''), + 'Invoice Status' => (string)($row['invoice_status'] ?? ''), + 'School Year' => (string)($row['school_year'] ?? ''), 'Transaction ID' => (string)($row['transaction_id'] ?? ''), 'Check Number' => (string)($row['check_number'] ?? ''), ]; diff --git a/app/Controllers/View/PaymentController.php b/app/Controllers/View/PaymentController.php index 25cfd3d..74460c1 100644 --- a/app/Controllers/View/PaymentController.php +++ b/app/Controllers/View/PaymentController.php @@ -85,7 +85,6 @@ class PaymentController extends ResourceController 'payment_date' => $this->request->getPost('payment_date'), 'payment_type' => $this->request->getPost('payment_type'), 'status' => 'Pending', - 'semester' => $this->request->getPost('semester'), 'school_year' => $this->request->getPost('school_year'), ]; @@ -99,7 +98,8 @@ class PaymentController extends ResourceController // API: Get payments by parent ID public function getByParentAPI($parentId) { - $payments = $this->paymentModel->getPaymentsByParentId($parentId); + $selectedYear = $this->getSelectedPaymentHistoryYear(); + $payments = $this->paymentModel->getPaymentsByParentId((int) $parentId, $selectedYear); if ($payments) { return $this->respond($payments); } else { @@ -110,13 +110,8 @@ class PaymentController extends ResourceController // View: Get payments by parent ID (for web views) public function getByParent($parentId) { - // Fetch payments with invoice number (if available) - $payments = $this->paymentModel - ->select('payments.*, invoices.invoice_number') - ->join('invoices', 'invoices.id = payments.invoice_id', 'left') - ->where('payments.parent_id', (int)$parentId) - ->orderBy('payments.payment_date', 'DESC') - ->findAll(); + $selectedYear = $this->getSelectedPaymentHistoryYear(); + $payments = $this->paymentModel->getPaymentsByParentId((int) $parentId, $selectedYear); // Parent display name $parent = $this->userModel->select('firstname, lastname')->find((int)$parentId) ?: []; @@ -140,6 +135,13 @@ class PaymentController extends ResourceController ]); } + private function getSelectedPaymentHistoryYear(): ?string + { + $selectedYear = $this->request->getGet('school_year') ?? $this->schoolYear; + + return $selectedYear !== '' ? $selectedYear : null; + } + // View: Create a new payment plan public function create() { @@ -346,12 +348,12 @@ class PaymentController extends ResourceController ->where('parent_id', $parentId) ->findAll(); - // Payments (paginated) - $payments = $this->paymentModel - ->where('parent_id', $parentId) - ->orderBy('payment_date', 'DESC') - ->paginate(10); - $pager = $this->paymentModel->pager; + // Payments (paginated). Join invoices so history is filtered by invoice term + // and displays current invoice state instead of stale payment snapshots. + $selectedYear = $this->getSelectedPaymentHistoryYear(); + $paymentHistory = $this->paymentModel->parentPaymentHistoryQuery($parentId, $selectedYear); + $payments = $paymentHistory->paginate(10); + $pager = $paymentHistory->pager; // Invoices $rawInvoices = $this->invoiceModel @@ -972,8 +974,7 @@ class PaymentController extends ResourceController $checkFile, $transactionId, $paymentDate, - $this->schoolYear, - $this->semester, + $invYear, $checkNumber, $installmentSeq, (array) $this->invoiceModel->find($invoiceId), @@ -1015,7 +1016,7 @@ class PaymentController extends ResourceController $rowParentDisc = $this->db->table('discount_usages') ->selectSum('discount_amount', 'sum_disc') ->where('parent_id', $parentId) - ->where('school_year', $this->schoolYear) + ->where('school_year', $invYear) ->get()->getRowArray(); if ($rowParentDisc && isset($rowParentDisc['sum_disc'])) { $parentYearDiscountTotal = (float)$rowParentDisc['sum_disc']; @@ -1270,7 +1271,6 @@ class PaymentController extends ResourceController $transactionId = null, $paymentDate = null, $schoolYear = null, - $semester = null, $checkNumber = null, ?int $installmentSeq = null, ?array $invoice = null, @@ -1315,8 +1315,7 @@ class PaymentController extends ResourceController 'check_file' => $checkFile, 'check_number' => (strtolower($paymentMethod) === 'check') ? $checkNumber : null, 'updated_by' => session()->get('user_id'), - 'school_year' => $schoolYear ?? $this->schoolYear, - 'semester' => $semester ?? $this->semester, + 'school_year' => $schoolYear ?? ($invoice['school_year'] ?? $this->schoolYear), ]; if (!$this->paymentModel->insert($paymentData)) { diff --git a/app/Database/Migrations/2026-05-30-000001_FinancialSystemLedgerCleanup.php b/app/Database/Migrations/2026-05-30-000001_FinancialSystemLedgerCleanup.php index e5bc09f..7cb4ba7 100644 --- a/app/Database/Migrations/2026-05-30-000001_FinancialSystemLedgerCleanup.php +++ b/app/Database/Migrations/2026-05-30-000001_FinancialSystemLedgerCleanup.php @@ -9,7 +9,11 @@ class FinancialSystemLedgerCleanup extends Migration public function up() { $this->addInstallmentSequenceColumn(); + $this->backfillInstallmentSequence(); + $this->ensurePaymentDateHasTime(); $this->ensureIndexes(); + $this->repairPaymentInvoiceTerms(); + $this->normalizePaymentStatuses(); $this->ensureConfigurationDefaults(); $this->archivePaypalTables(); $this->refreshFinancialNavItems(); @@ -24,6 +28,7 @@ class FinancialSystemLedgerCleanup extends Migration } $this->dropIndexIfExists('payments', 'idx_payments_invoice_id'); + $this->dropIndexIfExists('payments', 'idx_payments_parent_year'); $this->dropIndexIfExists('payments', 'idx_payments_parent_year_semester'); $this->dropIndexIfExists('payments', 'uniq_payments_transaction_id'); $this->dropIndexIfExists('invoices', 'idx_invoices_parent_year_semester'); @@ -68,10 +73,33 @@ class FinancialSystemLedgerCleanup extends Migration } } + protected function backfillInstallmentSequence(): void + { + if (!$this->db->tableExists('payments') || !$this->db->fieldExists('installment_seq', 'payments')) { + return; + } + + $this->db->query( + 'UPDATE `payments` + SET `installment_seq` = `number_of_installments` + WHERE `installment_seq` IS NULL' + ); + } + + protected function ensurePaymentDateHasTime(): void + { + if (!$this->db->tableExists('payments') || !$this->db->fieldExists('payment_date', 'payments')) { + return; + } + + $this->db->query('ALTER TABLE `payments` MODIFY `payment_date` DATETIME NOT NULL'); + } + protected function ensureIndexes(): void { $this->addIndexIfMissing('payments', 'idx_payments_invoice_id', ['invoice_id']); - $this->addIndexIfMissing('payments', 'idx_payments_parent_year_semester', ['parent_id', 'school_year', 'semester']); + $this->dropIndexIfExists('payments', 'idx_payments_parent_year_semester'); + $this->addIndexIfMissing('payments', 'idx_payments_parent_year', ['parent_id', 'school_year']); $this->addIndexIfMissing('invoices', 'idx_invoices_parent_year_semester', ['parent_id', 'school_year', 'semester']); $this->addIndexIfMissing('discount_usages', 'idx_discount_usages_invoice_id', ['invoice_id']); $this->addIndexIfMissing('discount_usages', 'idx_discount_usages_parent_year_semester', ['parent_id', 'school_year', 'semester']); @@ -86,6 +114,33 @@ class FinancialSystemLedgerCleanup extends Migration } } + protected function repairPaymentInvoiceTerms(): void + { + if (!$this->db->tableExists('payments') || !$this->db->tableExists('invoices')) { + return; + } + + $this->db->query( + 'UPDATE `payments` p + JOIN `invoices` i ON i.`id` = p.`invoice_id` + SET p.`school_year` = i.`school_year` + WHERE COALESCE(p.`school_year`, \'\') <> COALESCE(i.`school_year`, \'\')' + ); + } + + protected function normalizePaymentStatuses(): void + { + if (!$this->db->tableExists('payments') || !$this->db->fieldExists('status', 'payments')) { + return; + } + + $this->db->query( + "UPDATE `payments` + SET `status` = 'recorded' + WHERE LOWER(TRIM(`status`)) IN ('paid', 'partially paid', 'payment recorded', 'full', 'completed')" + ); + } + protected function ensureConfigurationDefaults(): void { if (!$this->db->tableExists('configuration')) { diff --git a/app/Libraries/FinancialStatus.php b/app/Libraries/FinancialStatus.php index 4d1f8ec..3182cdf 100644 --- a/app/Libraries/FinancialStatus.php +++ b/app/Libraries/FinancialStatus.php @@ -7,6 +7,8 @@ final class FinancialStatus public const INVOICE_UNPAID = 'unpaid'; public const INVOICE_PARTIALLY_PAID = 'partially_paid'; public const INVOICE_PAID = 'paid'; + public const INVOICE_OVERPAID = 'overpaid'; + public const INVOICE_CANCELLED = 'cancelled'; public const PAYMENT_RECORDED = 'recorded'; public const PAYMENT_VOIDED = 'voided'; @@ -18,6 +20,23 @@ final class FinancialStatus public const PAYMENT_CANCELED = 'canceled'; public const PAYMENT_CANCELLED = 'cancelled'; + public const PAYMENT_TRANSACTION_STATUSES = [ + self::PAYMENT_RECORDED, + self::PAYMENT_VOIDED, + self::PAYMENT_REFUNDED, + self::PAYMENT_FAILED, + self::PAYMENT_REVERSED, + self::PAYMENT_CHARGEBACK, + ]; + + public const INVOICE_STATUSES = [ + self::INVOICE_UNPAID, + self::INVOICE_PARTIALLY_PAID, + self::INVOICE_PAID, + self::INVOICE_OVERPAID, + self::INVOICE_CANCELLED, + ]; + public const REFUND_PENDING = 'pending'; public const REFUND_APPROVED = 'approved'; public const REFUND_REJECTED = 'rejected'; @@ -55,6 +74,8 @@ final class FinancialStatus return match (self::normalize($status)) { 'paid', 'full' => self::INVOICE_PAID, 'partially paid', 'partially_paid', 'partial' => self::INVOICE_PARTIALLY_PAID, + 'overpaid' => self::INVOICE_OVERPAID, + 'cancelled', 'canceled', 'cancelled invoice', 'canceled invoice' => self::INVOICE_CANCELLED, default => self::INVOICE_UNPAID, }; } diff --git a/app/Models/PaymentModel.php b/app/Models/PaymentModel.php index b22b8d9..1ea6d9b 100644 --- a/app/Models/PaymentModel.php +++ b/app/Models/PaymentModel.php @@ -2,7 +2,9 @@ namespace App\Models; +use CodeIgniter\Database\ConnectionInterface; use CodeIgniter\Model; +use CodeIgniter\Validation\ValidationInterface; class PaymentModel extends Model { @@ -10,12 +12,22 @@ class PaymentModel extends Model protected $primaryKey = 'id'; protected $returnType = 'array'; + /** + * Keep this list broad, then filter it in __construct() against the real DB schema. + * This prevents CI from trying to insert/update columns that do not exist, such as `balance`. + */ protected $allowedFields = [ 'parent_id', 'invoice_id', + + // Legacy invoice snapshot fields. Do not use these as source of truth. 'total_amount', + + // Actual payment fields. 'paid_amount', 'balance', + 'balance_amount', + 'balance_after_payment', 'number_of_installments', 'installment_seq', 'transaction_id', @@ -24,26 +36,44 @@ class PaymentModel extends Model 'payment_method', 'payment_date', 'school_year', - 'semester', 'status', - 'updated_by' + 'updated_by', ]; protected $useTimestamps = false; - // ❗ Your DB handles created_at / updated_at with default CURRENT_TIMESTAMP - // Remove CI4 auto timestamp management unless you modify your DB schema + + /** + * Let DB defaults handle created_at / updated_at. + * Keep validation strict for actual payment data, but do not require legacy snapshot columns. + */ + protected $cleanValidationRules = true; protected $validationRules = [ 'parent_id' => 'required|integer', 'invoice_id' => 'required|integer', - 'total_amount' => 'required|decimal', - 'paid_amount' => 'required|decimal', - 'balance' => 'required|decimal', - 'number_of_installments' => 'required|integer', - 'payment_method' => 'required|max_length[50]', // ✅ Removed 'string' - 'payment_date' => 'required|valid_date[Y-m-d H:i:s]', - 'status' => 'required|max_length[50]', // ✅ Removed 'string' + + // Legacy snapshot. Optional because invoice total belongs to invoices table. + 'total_amount' => 'permit_empty|decimal', + + 'paid_amount' => 'required|decimal|greater_than[0]', + + // These are optional because not every live schema has a balance snapshot column. + 'balance' => 'permit_empty|decimal|greater_than_equal_to[0]', + 'balance_amount' => 'permit_empty|decimal|greater_than_equal_to[0]', + 'balance_after_payment' => 'permit_empty|decimal|greater_than_equal_to[0]', + + // Legacy/current sequence columns. + 'number_of_installments' => 'permit_empty|integer|greater_than[0]', + 'installment_seq' => 'permit_empty|integer|greater_than[0]', + + 'transaction_id' => 'permit_empty|max_length[100]', + 'payment_method' => 'required|in_list[cash,check,card]', + 'payment_date' => 'required|valid_date', + 'school_year' => 'permit_empty|max_length[20]', + 'status' => 'required|max_length[50]', + 'check_file' => 'permit_empty|max_length[255]', 'check_number' => 'permit_empty|max_length[100]', + 'updated_by' => 'permit_empty|integer', ]; protected $validationMessages = [ @@ -56,112 +86,225 @@ class PaymentModel extends Model 'integer' => 'Invoice ID must be an integer.', ], 'total_amount' => [ - 'required' => 'Total amount is required.', 'decimal' => 'Total amount must be a valid decimal.', ], 'paid_amount' => [ 'required' => 'Paid amount is required.', 'decimal' => 'Paid amount must be a valid decimal.', + 'greater_than' => 'Paid amount must be greater than zero.', ], 'balance' => [ - 'required' => 'Balance is required.', 'decimal' => 'Balance must be a valid decimal.', + 'greater_than_equal_to' => 'Balance must not be negative.', + ], + 'balance_amount' => [ + 'decimal' => 'Balance amount must be a valid decimal.', + 'greater_than_equal_to' => 'Balance amount must not be negative.', + ], + 'balance_after_payment' => [ + 'decimal' => 'Balance after payment must be a valid decimal.', + 'greater_than_equal_to' => 'Balance after payment must not be negative.', ], 'payment_date' => [ 'required' => 'Payment date is required.', - 'valid_date' => 'Payment date must be a valid date (Y-m-d H:i:s).', + 'valid_date' => 'Payment date must be a valid date.', ], 'payment_method' => [ 'required' => 'Payment method is required.', - 'max_length' => 'Payment method must not exceed 50 characters.', + 'in_list' => 'Payment method must be cash, check, or card.', ], 'status' => [ 'required' => 'Status is required.', 'max_length' => 'Status must not exceed 50 characters.', ], 'number_of_installments' => [ - 'required' => 'Number of installments is required.', 'integer' => 'Number of installments must be an integer.', + 'greater_than' => 'Number of installments must be greater than zero.', + ], + 'installment_seq' => [ + 'integer' => 'Installment sequence must be an integer.', + 'greater_than' => 'Installment sequence must be greater than zero.', ], 'check_number' => [ 'max_length' => 'Check number must not exceed 100 characters.', - ] + ], ]; - /** - * Get payments by parent ID. - * - * @param int $parentId - * @return array - */ - public function getPaymentsByParentId(int $parentId): array + private array $paymentColumns = []; + + private array $excludedPaymentStatuses = [ + 'void', + 'voided', + 'refunded', + 'failed', + 'chargeback', + 'declined', + 'reversed', + 'canceled', + 'cancelled', + ]; + + public function __construct(?ConnectionInterface $db = null, ?ValidationInterface $validation = null) { - return $this->where('parent_id', $parentId) - ->orderBy('payment_date', 'DESC') - ->findAll(); + parent::__construct($db, $validation); + + $this->paymentColumns = $this->getTableColumns($this->table); + + // Remove fields that do not exist in the live DB. + // This prevents errors like "Unknown column balance". + $this->allowedFields = array_values(array_filter( + $this->allowedFields, + fn (string $field): bool => in_array($field, $this->paymentColumns, true) + )); + + // Remove validation rules for columns that do not exist. + foreach (array_keys($this->validationRules) as $field) { + if (!in_array($field, $this->allowedFields, true)) { + unset($this->validationRules[$field]); + unset($this->validationMessages[$field]); + } + } } /** - * Calculate the total paid amount for a specific parent. + * Get payments by parent ID with invoice context. * - * @param int $parentId - * @return float + * The source of truth for invoice total/status/balance is invoices, not payments. + */ + public function getPaymentsByParentId(int $parentId, ?string $schoolYear = null): array + { + return $this->parentPaymentHistoryQuery($parentId, $schoolYear)->findAll(); + } + + /** + * Build parent payment history query with invoice context. + * + * Kept public so controllers can paginate without duplicating select/join logic. + */ + public function parentPaymentHistoryQuery(int $parentId, ?string $schoolYear = null): self + { + $paymentBalanceSelect = $this->getPaymentBalanceSelectExpression('payments'); + + $invoiceBalanceSelect = $this->columnExists('invoices', 'balance') + ? 'invoices.balance AS invoice_current_balance' + : '0.00 AS invoice_current_balance'; + + $select = [ + 'payments.id', + 'payments.invoice_id', + 'invoices.invoice_number', + 'payments.transaction_id', + 'payments.paid_amount', + 'payments.payment_method', + 'payments.check_number', + 'payments.check_file', + 'payments.payment_date', + 'payments.installment_seq', + 'payments.number_of_installments', + $paymentBalanceSelect, + 'payments.status AS payment_status', + 'invoices.total_amount AS invoice_total', + $invoiceBalanceSelect, + 'invoices.status AS invoice_status', + 'invoices.school_year', + ]; + + $model = new self($this->db); + $builder = $model->select(implode(', ', $select), false) + ->join('invoices', 'invoices.id = payments.invoice_id', 'inner') + ->where('payments.parent_id', $parentId); + + if ($schoolYear !== null && $schoolYear !== '') { + $builder->where('invoices.school_year', $schoolYear); + } + + return $builder + ->orderBy('payments.payment_date', 'DESC') + ->orderBy('payments.id', 'DESC'); + } + + /** + * Calculate the total paid amount for a parent in a school year. + * + * Uses invoices.school_year so old poisoned payment term data does not corrupt totals. */ public function getTotalPaidByParentId(int $parentId, string $schoolYear): float { - $db = \Config\Database::connect(); - - $result = $db->table('payments') - ->selectSum('paid_amount', 'total_paid') - ->where('parent_id', $parentId) - ->where('school_year', $schoolYear) - ->whereNotIn('status', ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled']) + $result = $this->db->table('payments p') + ->selectSum('p.paid_amount', 'total_paid') + ->join('invoices i', 'i.id = p.invoice_id', 'inner') + ->where('p.parent_id', $parentId) + ->where('i.school_year', $schoolYear) + ->where($this->successfulPaymentWhereSql('p.status'), null, false) ->get() ->getRowArray(); - return $result && isset($result['total_paid']) ? (float) $result['total_paid'] : 0.00; + return $result && isset($result['total_paid']) + ? (float) $result['total_paid'] + : 0.00; } - /** - * Update balance for a specific payment. + * Legacy method. * - * @param int $paymentId - * @param float $amountPaid - * @return bool + * This should not be used for new payment recording. + * New payments should be inserted as immutable payment rows and invoices recalculated by InvoiceLedgerService. */ public function updateBalance(int $paymentId, float $amountPaid): bool { + if ($amountPaid <= 0) { + return false; + } + $payment = $this->find($paymentId); if (!$payment) { return false; } - $newBalance = $payment['balance'] - $amountPaid; + $updateData = [ + 'paid_amount' => round((float) ($payment['paid_amount'] ?? 0) + $amountPaid, 2), + ]; - return $this->update($paymentId, [ - 'paid_amount' => $payment['paid_amount'] + $amountPaid, - 'balance' => $newBalance - ]); + $balanceField = $this->getPaymentBalanceField(); + if ($balanceField !== null && array_key_exists($balanceField, $payment)) { + $updateData[$balanceField] = max( + 0.00, + round((float) ($payment[$balanceField] ?? 0) - $amountPaid, 2) + ); + } + + return $this->update($paymentId, $updateData); } /** * Get payments by school year. * - * @param string $schoolYear - * @return array + * Uses invoice school year when possible because payment.school_year may have old bad data. */ public function getPaymentsByYear(string $schoolYear): array { - return $this->where('school_year', $schoolYear) - ->orderBy('payment_date', 'DESC') + $paymentBalanceSelect = $this->getPaymentBalanceSelectExpression('payments'); + + $select = [ + 'payments.*', + 'invoices.invoice_number', + 'invoices.total_amount AS invoice_total', + 'invoices.status AS invoice_status', + $paymentBalanceSelect, + ]; + + return $this->select(implode(', ', $select), false) + ->join('invoices', 'invoices.id = payments.invoice_id', 'inner') + ->where('invoices.school_year', $schoolYear) + ->orderBy('payments.payment_date', 'DESC') + ->orderBy('payments.id', 'DESC') ->findAll(); } /** - * Generate a new transaction ID. + * Generate a transaction ID. * - * @return string + * This is okay for legacy/manual use, but a DB UNIQUE KEY on transaction_id is still required. */ public function generateNewTransactionId(): string { @@ -173,41 +316,115 @@ class PaymentModel extends Model ->first(); if ($latest && isset($latest['transaction_id'])) { - $lastNumber = (int) str_replace($prefix, '', $latest['transaction_id']); + $lastNumber = (int) str_replace($prefix, '', (string) $latest['transaction_id']); $newNumber = $lastNumber + 1; } else { $newNumber = 1; } - return $prefix . str_pad($newNumber, 6, '0', STR_PAD_LEFT); + return $prefix . str_pad((string) $newNumber, 6, '0', STR_PAD_LEFT); } + /** + * Get latest successful payment per invoice. + * + * Uses latest payment ID instead of latest date only. + * Date-only matching can return duplicates when several payments happen on the same day. + */ public function getPaymentsByInvoice($invoiceIds): array { - // Normalize input if (!is_array($invoiceIds)) { $invoiceIds = [$invoiceIds]; } + + $invoiceIds = array_values(array_filter(array_map('intval', $invoiceIds))); + if (empty($invoiceIds)) { return []; } - // Subquery: latest payment_date per invoice (Full/Partial only) $latestSub = $this->db->table('payments') - ->select('invoice_id, MAX(payment_date) AS max_date') - ->whereIn('status', ['Full', 'Paid', 'Partially Paid']) + ->select('invoice_id, MAX(id) AS max_id') + ->whereIn('invoice_id', $invoiceIds) + ->where('paid_amount >', 0) + ->where($this->successfulPaymentWhereSql('status'), null, false) ->groupBy('invoice_id') ->getCompiledSelect(); - // Main query: join payments to the latest per invoice - $rows = $this->db->table('payments p') - ->select('p.invoice_id, p.paid_amount AS last_paid_amount, p.payment_date AS last_payment_date, p.status AS last_payment_status') - ->join("($latestSub) latest", 'latest.invoice_id = p.invoice_id AND latest.max_date = p.payment_date', 'inner', false) // <-- escape=false + return $this->db->table('payments p') + ->select([ + 'p.invoice_id', + 'p.id AS payment_id', + 'p.paid_amount AS last_paid_amount', + 'p.payment_date AS last_payment_date', + 'p.status AS last_payment_status', + 'p.transaction_id', + 'p.payment_method', + 'p.check_number', + ]) + ->join( + "($latestSub) latest", + 'latest.invoice_id = p.invoice_id AND latest.max_id = p.id', + 'inner', + false + ) ->whereIn('p.invoice_id', $invoiceIds) - ->whereIn('p.status', ['Full', 'Paid', 'Partially Paid']) ->get() ->getResultArray(); + } - return $rows; + private function getPaymentBalanceField(): ?string + { + foreach (['balance_after_payment', 'balance', 'balance_amount'] as $field) { + if (in_array($field, $this->paymentColumns, true)) { + return $field; + } + } + + return null; + } + + private function getPaymentBalanceSelectExpression(string $alias = 'payments'): string + { + $field = $this->getPaymentBalanceField(); + + if ($field === null) { + return '0.00 AS balance_after_payment'; + } + + return "{$alias}.{$field} AS balance_after_payment"; + } + + private function successfulPaymentWhereSql(string $column): string + { + $escapedStatuses = array_map( + fn (string $status): string => $this->db->escape($status), + $this->excludedPaymentStatuses + ); + + return 'LOWER(COALESCE(' . $column . ', \'\')) NOT IN (' . implode(', ', $escapedStatuses) . ')'; + } + + private function getTableColumns(string $table): array + { + try { + return $this->db->getFieldNames($table); + } catch (\Throwable $e) { + log_message('error', '[PaymentModel] Could not read table columns for {table}: {error}', [ + 'table' => $table, + 'error' => $e->getMessage(), + ]); + + return []; + } + } + + private function columnExists(string $table, string $column): bool + { + try { + return $this->db->fieldExists($column, $table); + } catch (\Throwable $e) { + return false; + } } } diff --git a/app/Views/family/card.php b/app/Views/family/card.php index c325df5..1022a88 100644 --- a/app/Views/family/card.php +++ b/app/Views/family/card.php @@ -350,19 +350,20 @@ if ($returnUrl === '') { - + - + - + - + + diff --git a/app/Views/family/index.php b/app/Views/family/index.php index 33edd21..04d1f65 100644 --- a/app/Views/family/index.php +++ b/app/Views/family/index.php @@ -263,19 +263,20 @@
InvoiceAmountBalanceMethodDateStatusInvoiceAmountInvoice BalanceMethodDatePayment StatusInvoice Status
$$$
- + - + - + - + + diff --git a/app/Views/payment/_list_modal.php b/app/Views/payment/_list_modal.php index 5a20f33..1f52ba1 100644 --- a/app/Views/payment/_list_modal.php +++ b/app/Views/payment/_list_modal.php @@ -21,15 +21,17 @@ - + - + + + - + @@ -40,9 +42,11 @@ - + - + + + diff --git a/app/Views/payment/manual_pay.php b/app/Views/payment/manual_pay.php index a913e45..b36e3fa 100644 --- a/app/Views/payment/manual_pay.php +++ b/app/Views/payment/manual_pay.php @@ -219,11 +219,12 @@ - + - + + @@ -242,7 +243,7 @@ ?> - + - - + + + - + - + + + - + - + - + - + + + diff --git a/payment_data_fix_plan.md b/payment_data_fix_plan.md new file mode 100644 index 0000000..a5a81b5 --- /dev/null +++ b/payment_data_fix_plan.md @@ -0,0 +1,492 @@ +# Payment Data Repair Plan + +## Scope + +This plan fixes the payment table, invoice/payment display logic, and manual payment write flow. + +Reviewed inputs: + +- `invoices(2).sql` +- `payments(1).sql` +- `PaymentController(2).php` +- `PaymentTransactionController(1).php` + +## Current findings + +| Check | Result | +|---|---:| +| Invoice rows | 126 | +| Payment rows | 321 | +| Invoice `paid_amount` vs `SUM(payments.paid_amount)` mismatches | 0 | +| Payment rows where `payments.total_amount` differs from linked invoice total | 106 | +| Invoices affected by stale `payments.total_amount` | 66 | +| Payment rows with negative `payments.balance` | 5 | +| Payment rows whose `semester` / `school_year` differs from linked invoice | 26 | +| Invoice IDs with no payment rows | 46, 76, 77, 108, 112, 122 | + +Main conclusion: payment rows are not missing. The system is reading and storing payment context inconsistently. The payment table is being treated as both a transaction ledger and a cached invoice snapshot, which is why the UI looks wrong. + +--- + +## Priority 1 — Protect production before repair + +- [ ] Take a database backup. +- [ ] Export `invoices`, `payments`, `discount_usages`, `refunds`, `balance_transfers`, and any `payment_transactions` table. +- [ ] Run all reconciliation SQL in read-only mode first. +- [ ] Apply data repair only after confirming the result count of each query. + +Recommended backup command: + +```bash +mysqldump -u -p invoices payments discount_usages refunds balance_transfers payment_transactions > payment_repair_backup.sql +``` + +--- + +## Priority 2 — Add missing schema support + +The PHP controller writes `installment_seq`, but the uploaded `payments` schema only has `number_of_installments`. + +Add a dedicated installment sequence column: + +```sql +ALTER TABLE payments + ADD COLUMN installment_seq INT NULL AFTER number_of_installments; + +UPDATE payments +SET installment_seq = number_of_installments +WHERE installment_seq IS NULL; +``` + +Change `payment_date` from `DATE` to `DATETIME` so multiple same-day payments can be sorted correctly: + +```sql +ALTER TABLE payments + MODIFY payment_date DATETIME NOT NULL; +``` + +Add indexes: + +```sql +CREATE INDEX idx_payments_invoice_id ON payments(invoice_id); +CREATE INDEX idx_payments_parent_year ON payments(parent_id, school_year, semester); +CREATE UNIQUE INDEX uq_payments_transaction_id ON payments(transaction_id); +CREATE INDEX idx_invoices_parent_year ON invoices(parent_id, school_year, semester); +``` + +--- + +## Priority 3 — Repair existing payment school year and semester + +Payments should inherit their term from the linked invoice. The current data has payments marked `Spring` while their invoice is `Fall`. + +Preview first: + +```sql +SELECT + p.id AS payment_id, + p.invoice_id, + p.parent_id, + p.school_year AS payment_school_year, + p.semester AS payment_semester, + i.school_year AS invoice_school_year, + i.semester AS invoice_semester +FROM payments p +JOIN invoices i ON i.id = p.invoice_id +WHERE p.school_year <> i.school_year + OR p.semester <> i.semester +ORDER BY p.id; +``` + +Repair: + +```sql +UPDATE payments p +JOIN invoices i ON i.id = p.invoice_id +SET + p.school_year = i.school_year, + p.semester = i.semester +WHERE + p.school_year <> i.school_year + OR p.semester <> i.semester; +``` + +--- + +## Priority 4 — Stop trusting stale copied fields + +Do not use these fields as authoritative in the UI: + +- `payments.total_amount` +- `payments.balance` +- `payments.status` as invoice status + +Use: + +- `payments.paid_amount` as the actual transaction amount. +- `invoices.total_amount` as the invoice total. +- `InvoiceLedgerService` for current invoice balance. +- Invoice status from recalculation, not from the latest payment row. + +The payment history table should display: + +```text +invoice_number +payment_date +paid_amount +payment_method +check_number +transaction_id +installment_seq +balance_after_payment +invoice_total +invoice_current_balance +invoice_status +school_year +semester +``` + +--- + +## Priority 5 — Fix `manualPayUpdate()` term assignment + +Current bug: the controller locks the invoice and reads the invoice school year, but then passes `$this->schoolYear` and `$this->semester` into `processPayment()`. That stores payment rows under the active config term instead of the invoice term. + +Change the invoice lock query: + +```php +$row = $this->db->query( + 'SELECT id, parent_id, total_amount, school_year, semester FROM invoices WHERE id = ? FOR UPDATE', + [$invoiceId] +)->getRowArray(); + +$parentId = (int) ($row['parent_id'] ?? 0); +$invYear = (string) ($row['school_year'] ?? $this->schoolYear); +$invSemester = (string) ($row['semester'] ?? $this->semester); +``` + +Then pass the invoice term into `processPayment()`: + +```php +$ok = $this->processPayment( + $invoiceId, + $amount, + $paymentMethod, + $checkFile, + $transactionId, + $paymentDate, + $invYear, + $invSemester, + $checkNumber, + $installmentSeq, + (array) $this->invoiceModel->find($invoiceId), + $currentBalance +); +``` + +Do not use the current configuration term when recording a payment for an existing invoice. The invoice owns the term. + +--- + +## Priority 6 — Replace weak parent payment queries + +Current parent payment lookup is too broad and does not include enough invoice context. + +Replace simple payment-only queries with invoice-joined queries. + +Example: + +```php +$selectedYear = $this->request->getGet('school_year') ?? $this->schoolYear; +$selectedSemester = $this->request->getGet('semester') ?? null; + +$paymentsQuery = $this->paymentModel + ->select([ + 'payments.id', + 'payments.invoice_id', + 'invoices.invoice_number', + 'payments.transaction_id', + 'payments.paid_amount', + 'payments.payment_method', + 'payments.check_number', + 'payments.check_file', + 'payments.payment_date', + 'payments.installment_seq', + 'payments.number_of_installments', + 'payments.balance AS balance_after_payment', + 'payments.status AS payment_status', + 'invoices.total_amount AS invoice_total', + 'invoices.balance AS invoice_current_balance', + 'invoices.status AS invoice_status', + 'invoices.school_year', + 'invoices.semester', + ]) + ->join('invoices', 'invoices.id = payments.invoice_id', 'inner') + ->where('payments.parent_id', $parentId) + ->where('invoices.school_year', $selectedYear); + +if ($selectedSemester !== null && $selectedSemester !== '') { + $paymentsQuery->where('invoices.semester', $selectedSemester); +} + +$payments = $paymentsQuery + ->orderBy('payments.payment_date', 'DESC') + ->orderBy('payments.id', 'DESC') + ->paginate(10); +``` + +Apply this to: + +- `getByParent()` +- `manualPaySearch()` +- Any dashboard or parent profile payment history endpoint +- Any admin finance page reading payment history + +--- + +## Priority 7 — Normalize payment status + +Payment status and invoice status must not mean the same thing. + +Payment statuses should be transaction-level: + +```text +Recorded +Voided +Refunded +Failed +Reversed +Chargeback +``` + +Invoice statuses should be computed: + +```text +Unpaid +Partially Paid +Paid +Overpaid +Cancelled +``` + +After the UI stops depending on `payments.status` as invoice status, normalize existing successful payment rows: + +```sql +UPDATE payments +SET status = 'Recorded' +WHERE LOWER(status) IN ('paid', 'partially paid', 'payment recorded'); +``` + +Update `FinancialStatus` to include transaction statuses separately from invoice statuses. + +--- + +## Priority 8 — Decide what to do with `payment_transactions` + +`PaymentTransactionController` writes to a separate transaction model, but manual payments are recorded directly into `payments`. + +Pick one architecture: + +### Preferred + +Use `payments` as the single payment transaction ledger. + +Then: + +- [ ] Retire payment transaction pages/routes if unused. +- [ ] Do not build UI reports from `payment_transactions`. +- [ ] Keep gateway attempts in a separate `payment_attempts` or `online_payment_transactions` table only if needed. + +### Alternative + +If `payment_transactions` must stay: + +- [ ] Create a transaction row every time a payment row is inserted. +- [ ] Do it inside the same DB transaction. +- [ ] Never show `payment_transactions` without joining back to `payments`. + +Preferred option is cleaner. Two ledgers become two lies unless aggressively synchronized. + +--- + +## Priority 9 — Recalculate invoice ledger + +After schema and controller fixes, recalculate every invoice through `InvoiceLedgerService`. + +Required behavior: + +- [ ] Sum successful payments from `payments`. +- [ ] Subtract discounts from `discount_usages`. +- [ ] Apply refunds/credits consistently. +- [ ] Include additional/event charges if they belong in invoice total. +- [ ] Update cached `invoices.paid_amount`, `invoices.balance`, and `invoices.status`. +- [ ] Never calculate invoice balance directly inside controllers. + +Suggested maintenance command: + +```php +foreach ($invoiceModel->findAll() as $invoice) { + $invoiceLedgerService->recalculateInvoice((int) $invoice['id']); +} +``` + +Run this from a CLI command, not from a browser request. + +--- + +## Priority 10 — Reconciliation SQL + +Run these checks after repair. + +### Stale payment totals + +```sql +SELECT + p.id AS payment_id, + p.invoice_id, + p.parent_id, + p.total_amount AS payment_total_amount, + i.total_amount AS invoice_total_amount, + p.paid_amount, + p.balance, + p.payment_date +FROM payments p +JOIN invoices i ON i.id = p.invoice_id +WHERE p.total_amount <> i.total_amount +ORDER BY p.invoice_id, p.id; +``` + +Expected after UI fix: rows may still exist historically, but the UI must not rely on `payments.total_amount`. + +### Term mismatch + +```sql +SELECT + p.id AS payment_id, + p.invoice_id, + p.parent_id, + p.school_year AS payment_school_year, + p.semester AS payment_semester, + i.school_year AS invoice_school_year, + i.semester AS invoice_semester +FROM payments p +JOIN invoices i ON i.id = p.invoice_id +WHERE p.school_year <> i.school_year + OR p.semester <> i.semester +ORDER BY p.id; +``` + +Expected: zero rows. + +### Negative payment balance snapshots + +```sql +SELECT + id, + parent_id, + invoice_id, + total_amount, + paid_amount, + balance, + status, + payment_date +FROM payments +WHERE balance < 0 +ORDER BY invoice_id, id; +``` + +Expected: zero rows after balance snapshot repair, or ignored if `payments.balance` is deprecated. + +### Invoices with no payment rows + +```sql +SELECT + i.id, + i.parent_id, + i.invoice_number, + i.total_amount, + i.paid_amount, + i.balance, + i.has_discount, + i.status +FROM invoices i +LEFT JOIN payments p ON p.invoice_id = i.id +WHERE p.id IS NULL +ORDER BY i.id; +``` + +Expected: only invoices fully covered by discounts, credits, cancellations, or legitimate zero-payment states. + +### Invoice paid amount consistency + +```sql +SELECT + i.id AS invoice_id, + i.parent_id, + i.invoice_number, + i.paid_amount AS invoice_paid_amount, + COALESCE(SUM(p.paid_amount), 0) AS payment_sum +FROM invoices i +LEFT JOIN payments p ON p.invoice_id = i.id +GROUP BY i.id, i.parent_id, i.invoice_number, i.paid_amount +HAVING ROUND(i.paid_amount, 2) <> ROUND(payment_sum, 2) +ORDER BY i.id; +``` + +Expected: zero rows, unless invoice paid amount intentionally includes imported legacy payments that are not in `payments`. + +--- + +## Priority 11 — Tests to add + +### Unit tests + +- [ ] Payment creation uses invoice `school_year` and `semester`, not config term. +- [ ] Payment amount cannot exceed current ledger balance. +- [ ] Card payment must equal full current balance. +- [ ] Editing a payment recalculates invoice balance. +- [ ] `installment_seq` increments only successful payments. +- [ ] Voided/refunded/failed payments are excluded from balance. +- [ ] `payments.total_amount` is never used as current invoice total in DTOs. + +### Integration tests + +- [ ] Parent payment history returns payments joined to invoice number. +- [ ] Parent payment history filters by invoice school year. +- [ ] Parent payment history filters by invoice semester. +- [ ] Admin finance table shows transaction amount and current invoice balance correctly. +- [ ] Manual payment insert, invoice recalculation, and notification event are atomic. + +### Data repair tests + +- [ ] Term mismatch query returns zero rows after repair. +- [ ] Negative payment balance query returns zero rows or deprecated field is hidden. +- [ ] Invoice paid amount consistency query returns zero rows. +- [ ] Invoices with no payments are manually classified as discount-only, cancelled, imported, or broken. + +--- + +## Acceptance criteria + +The fix is complete only when: + +- [ ] Existing payments are visible in parent and admin payment history. +- [ ] Payment history shows transaction amount, not stale invoice total. +- [ ] Payment rows inherit school year and semester from invoice. +- [ ] No payment row has mismatched invoice term. +- [ ] No new payment insert depends on global config term for existing invoices. +- [ ] `installment_seq` exists and is populated. +- [ ] Invoice balances are recalculated by `InvoiceLedgerService`. +- [ ] `payment_transactions` is either retired or explicitly separated as gateway-attempt history. +- [ ] Reconciliation SQL has been run and saved as evidence. + +--- + +## Do not do this + +- Do not manually edit invoice balances row by row. +- Do not use `payments.total_amount` as invoice total. +- Do not use `payments.status` as invoice status. +- Do not filter payment history only by `payments.school_year` if invoice term exists. +- Do not keep two independent payment ledgers unless one is clearly marked as gateway attempts. +- Do not trust the latest payment row balance as the current invoice balance. diff --git a/tests/app/Database/Migrations/FinancialSystemLedgerCleanupTest.php b/tests/app/Database/Migrations/FinancialSystemLedgerCleanupTest.php index 68c65a2..7585040 100644 --- a/tests/app/Database/Migrations/FinancialSystemLedgerCleanupTest.php +++ b/tests/app/Database/Migrations/FinancialSystemLedgerCleanupTest.php @@ -25,6 +25,26 @@ class TestableFinancialSystemLedgerCleanup extends FinancialSystemLedgerCleanup $this->calls[] = 'ensureIndexes'; } + protected function backfillInstallmentSequence(): void + { + $this->calls[] = 'backfillInstallmentSequence'; + } + + protected function ensurePaymentDateHasTime(): void + { + $this->calls[] = 'ensurePaymentDateHasTime'; + } + + protected function repairPaymentInvoiceTerms(): void + { + $this->calls[] = 'repairPaymentInvoiceTerms'; + } + + protected function normalizePaymentStatuses(): void + { + $this->calls[] = 'normalizePaymentStatuses'; + } + protected function ensureConfigurationDefaults(): void { $this->calls[] = 'ensureConfigurationDefaults'; @@ -61,7 +81,11 @@ class FinancialSystemLedgerCleanupTest extends CIUnitTestCase $this->assertSame([ 'addInstallmentSequenceColumn', + 'backfillInstallmentSequence', + 'ensurePaymentDateHasTime', 'ensureIndexes', + 'repairPaymentInvoiceTerms', + 'normalizePaymentStatuses', 'ensureConfigurationDefaults', 'archivePaypalTables', 'refreshFinancialNavItems', diff --git a/tests/app/Models/PaymentModelMetadataTest.php b/tests/app/Models/PaymentModelMetadataTest.php index 19c9d36..b7e9985 100644 --- a/tests/app/Models/PaymentModelMetadataTest.php +++ b/tests/app/Models/PaymentModelMetadataTest.php @@ -11,9 +11,14 @@ class PaymentModelMetadataTest extends CIUnitTestCase { $model = new PaymentModel(); $fields = self::getPrivateProperty($model, 'allowedFields'); + $columns = self::getPrivateProperty($model, 'paymentColumns'); - $this->assertContains('installment_seq', $fields); - $this->assertContains('transaction_id', $fields); - $this->assertContains('check_file', $fields); + foreach (['installment_seq', 'transaction_id', 'check_file'] as $field) { + if (in_array($field, $columns, true)) { + $this->assertContains($field, $fields); + } else { + $this->assertNotContains($field, $fields); + } + } } }
InvoiceAmountBalanceMethodDateStatusInvoiceAmountInvoice BalanceMethodDatePayment StatusInvoice Status
$$$
Date Invoice PaidBalanceInvoice Balance MethodStatusPayment StatusInvoice StatusSchool Year
No paymentNo payment
$$$
Paid Date Paid AmountBalanceInvoice Balance Payment Method Check Number InstallmentsStatusPayment StatusInvoice Status Check/Card File Action
$$$ Cash @@ -255,8 +256,9 @@ View / diff --git a/app/Views/payment_list.php b/app/Views/payment_list.php index 429c88e..b27c214 100644 --- a/app/Views/payment_list.php +++ b/app/Views/payment_list.php @@ -19,25 +19,29 @@ Date Invoice # Paid AmountBalanceInvoice Balance MethodStatusPayment StatusInvoice StatusSchool Year
No paymentNo payment
$$$