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 === '') {
- | Invoice | Amount | Balance | Method | Date | Status |
+ Invoice | Amount | Invoice Balance | Method | Date | Payment Status | Invoice Status |
- |
+ |
$= number_format((float)($p['paid_amount'] ?? 0), 2) ?> |
- $= number_format((float)($p['balance'] ?? 0), 2) ?> |
+ $= number_format((float)($p['invoice_current_balance'] ?? 0), 2) ?> |
= esc($p['payment_method'] ?? '') ?> |
= esc(!empty($p['payment_date']) ? local_date($p['payment_date'], 'm-d-Y') : '') ?> |
- = esc($p['status'] ?? '') ?> |
+ = esc($p['payment_status'] ?? '') ?> |
+ = esc($p['invoice_status'] ?? '') ?> |
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 @@
- | Invoice | Amount | Balance | Method | Date | Status |
+ Invoice | Amount | Invoice Balance | Method | Date | Payment Status | Invoice Status |
- |
+ |
$= number_format((float)($p['paid_amount'] ?? 0), 2) ?> |
- $= number_format((float)($p['balance'] ?? 0), 2) ?> |
+ $= number_format((float)($p['invoice_current_balance'] ?? 0), 2) ?> |
= esc($p['payment_method'] ?? '') ?> |
= esc(!empty($p['payment_date']) ? local_date($p['payment_date'], 'm-d-Y') : '') ?> |
- = esc($p['status'] ?? '') ?> |
+ = esc($p['payment_status'] ?? '') ?> |
+ = esc($p['invoice_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 @@
Date |
Invoice |
Paid |
- Balance |
+ Invoice Balance |
Method |
- Status |
+ Payment Status |
+ Invoice Status |
+ School Year |
- | No payment |
+ No payment |
@@ -40,9 +42,11 @@
= $inv !== '' ? esc($inv) : ('#' . (int)($p['invoice_id'] ?? 0)) ?>
$= number_format((float)($p['paid_amount'] ?? 0), 2) ?> |
- $= number_format((float)($p['balance'] ?? 0), 2) ?> |
+ $= number_format((float)($p['invoice_current_balance'] ?? 0), 2) ?> |
= esc($p['payment_method'] ?? '') ?> |
- = esc($p['status'] ?? '') ?> |
+ = esc($p['payment_status'] ?? '') ?> |
+ = esc($p['invoice_status'] ?? '') ?> |
+ = esc($p['school_year'] ?? '') ?> |
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 @@
| Paid Date |
Paid Amount |
- Balance |
+ Invoice Balance |
Payment Method |
Check Number |
Installments |
- Status |
+ Payment Status |
+ Invoice Status |
Check/Card File |
Action |
@@ -242,7 +243,7 @@
?>
= esc($paidAt) ?> |
$= number_format((float)($payment['paid_amount'] ?? 0), 2) ?> |
- $= number_format((float)($payment['balance'] ?? 0), 2) ?> |
+ $= number_format((float)($payment['invoice_current_balance'] ?? 0), 2) ?> |
Cash
@@ -255,8 +256,9 @@
|
= !empty($payment['check_number']) ? esc($payment['check_number']) : '-' ?> |
- = esc($payment['number_of_installments'] ?? '') ?> |
- = esc($payment['status'] ?? '') ?> |
+ = esc($payment['installment_seq'] ?? $payment['number_of_installments'] ?? '') ?> |
+ = esc($payment['payment_status'] ?? '') ?> |
+ = esc($payment['invoice_status'] ?? '') ?> |
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 Amount |
- Balance |
+ Invoice Balance |
Method |
- Status |
+ Payment Status |
+ Invoice Status |
+ School Year |
- | No payment |
+ No payment |
| = esc(!empty($p['payment_date']) ? local_date($p['payment_date'], 'm-d-Y') : '') ?> |
- = esc($p['invoice_id'] ?? '') ?> |
+ = esc($p['invoice_number'] ?? ('#' . (int)($p['invoice_id'] ?? 0))) ?> |
$= number_format((float)($p['paid_amount'] ?? 0), 2) ?> |
- $= number_format((float)($p['balance'] ?? 0), 2) ?> |
+ $= number_format((float)($p['invoice_current_balance'] ?? 0), 2) ?> |
= esc($p['payment_method'] ?? '') ?> |
- = esc($p['status'] ?? '') ?> |
+ = esc($p['payment_status'] ?? '') ?> |
+ = esc($p['invoice_status'] ?? '') ?> |
+ = esc($p['school_year'] ?? '') ?> |
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);
+ }
+ }
}
}