'required|integer', 'invoice_id' => 'required|integer', // 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 = [ 'parent_id' => [ 'required' => 'Parent ID is required.', 'integer' => 'Parent ID must be an integer.', ], 'invoice_id' => [ 'required' => 'Invoice ID is required.', 'integer' => 'Invoice ID must be an integer.', ], 'total_amount' => [ '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' => [ '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.', ], 'payment_method' => [ 'required' => 'Payment method is required.', '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' => [ '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.', ], ]; private array $paymentColumns = []; private array $excludedPaymentStatuses = [ 'void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled', ]; public function __construct(?ConnectionInterface $db = null, ?ValidationInterface $validation = null) { 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]); } } } /** * Get payments by parent ID with invoice context. * * 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 { $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; } /** * Legacy method. * * 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; } $updateData = [ 'paid_amount' => round((float) ($payment['paid_amount'] ?? 0) + $amountPaid, 2), ]; $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. * * Uses invoice school year when possible because payment.school_year may have old bad data. */ public function getPaymentsByYear(string $schoolYear): array { $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 transaction ID. * * This is okay for legacy/manual use, but a DB UNIQUE KEY on transaction_id is still required. */ public function generateNewTransactionId(): string { $currentYear = date('Y'); $prefix = $currentYear . '-'; $latest = $this->like('transaction_id', $prefix, 'after') ->orderBy('transaction_id', 'DESC') ->first(); if ($latest && isset($latest['transaction_id'])) { $lastNumber = (int) str_replace($prefix, '', (string) $latest['transaction_id']); $newNumber = $lastNumber + 1; } else { $newNumber = 1; } 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 { if (!is_array($invoiceIds)) { $invoiceIds = [$invoiceIds]; } $invoiceIds = array_values(array_filter(array_map('intval', $invoiceIds))); if (empty($invoiceIds)) { return []; } $latestSub = $this->db->table('payments') ->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(); 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) ->get() ->getResultArray(); } 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; } } }