diff --git a/app/Controllers/View/EventController.php b/app/Controllers/View/EventController.php index cdd9684..e71dba6 100644 --- a/app/Controllers/View/EventController.php +++ b/app/Controllers/View/EventController.php @@ -15,6 +15,7 @@ use App\Models\ClassSectionModel; use App\Models\ParentModel; use App\Models\PaymentModel; use App\Models\CalendarModel; +use App\Libraries\FinancialStatus; use App\Services\EmailService; use Config\Database; use App\Controllers\View\InvoiceController; @@ -1175,7 +1176,7 @@ class EventController extends ResourceController (float)($invoice['balance'] ?? 0.0) ) ?? 0); } elseif (!$isPaid && $paymentId > 0) { - $this->paymentModel->delete($paymentId); + $this->voidPayment($paymentId, 'Event charge marked unpaid.'); $paymentId = 0; } @@ -1258,20 +1259,13 @@ class EventController extends ResourceController return null; } - $monthSemester = $semester ?: $this->semester; - $newPaid = (float)$invoicePaid + $amount; - $newBalance = (float)$invoiceBalance - $amount; - $paymentStatus = ($newBalance <= 0.00001) - ? 'Paid' - : (($newPaid > 0) ? 'Partially Paid' : 'Unpaid'); - - $exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled']; - $priorCount = $this->paymentModel + $newBalance = max(0.0, round((float)$invoiceBalance - $amount, 2)); + $row = $this->paymentModel->db->table('payments') + ->select('COALESCE(MAX(installment_seq), 0) + 1 AS next_seq', false) ->where('invoice_id', $invoiceId) - ->where('paid_amount >', 0) - ->whereNotIn('status', $exclude) - ->countAllResults(); - $installmentSeq = $priorCount + 1; + ->get() + ->getRowArray(); + $installmentSeq = (int)($row['next_seq'] ?? 1); $data = [ 'parent_id' => $parentId, @@ -1280,11 +1274,11 @@ class EventController extends ResourceController 'paid_amount' => $amount, 'balance' => $newBalance, 'number_of_installments' => $installmentSeq, + 'installment_seq' => $installmentSeq, 'payment_method' => 'cash', 'payment_date' => utc_now(), 'school_year' => $schoolYear, - 'semester' => $monthSemester, - 'status' => $paymentStatus, + 'status' => FinancialStatus::PAYMENT_RECORDED, 'transaction_id' => $this->paymentModel->generateNewTransactionId(), 'updated_by' => session()->get('user_id'), ]; @@ -1297,6 +1291,33 @@ class EventController extends ResourceController return (int)$this->paymentModel->getInsertID(); } + private function voidPayment(int $paymentId, string $reason): bool + { + if ($paymentId <= 0) { + return false; + } + + $data = [ + 'status' => FinancialStatus::PAYMENT_VOIDED, + 'updated_by' => session()->get('user_id'), + ]; + + if ($this->paymentModel->db->fieldExists('is_void', 'payments')) { + $data['is_void'] = 1; + } + if ($this->paymentModel->db->fieldExists('voided_at', 'payments')) { + $data['voided_at'] = utc_now(); + } + if ($this->paymentModel->db->fieldExists('voided_by', 'payments')) { + $data['voided_by'] = session()->get('user_id'); + } + if ($this->paymentModel->db->fieldExists('void_reason', 'payments')) { + $data['void_reason'] = $reason; + } + + return (bool) $this->paymentModel->update($paymentId, $data); + } + private function normalizeExternalName(?string $value): string { $raw = trim((string)$value); diff --git a/app/Controllers/View/PaymentController.php b/app/Controllers/View/PaymentController.php index 0603e63..417cbab 100644 --- a/app/Controllers/View/PaymentController.php +++ b/app/Controllers/View/PaymentController.php @@ -995,15 +995,11 @@ class PaymentController extends ResourceController ); } - // Installment sequence - $paidCount = $this->getSuccessfulPaymentCount($invoiceId); - $installmentSeq = $paidCount + 1; - // Generate a stable transaction id $transactionId = 'INV-' . $invoiceId . '-' . str_replace('.', '', (string)microtime(true)); // Record payment - $ok = $this->processPayment( + $paymentResult = $this->processPayment( $invoiceId, $amount, $paymentMethod, @@ -1012,15 +1008,16 @@ class PaymentController extends ResourceController $paymentDate, $invYear, $checkNumber, - $installmentSeq, - (array) $this->invoiceModel->find($invoiceId), + null, + (array) $row, $currentBalance ); - if (!$ok) { + if ($paymentResult === false) { $this->db->transRollback(); return redirect()->back()->with('error', 'Failed to record payment.'); } + $installmentSeq = (int) ($paymentResult['installment_seq'] ?? 1); if (!$this->recordManualPayment( (string)($row['invoice_number'] ?? $invoiceId), @@ -1383,34 +1380,57 @@ class PaymentController extends ResourceController $checkNumber = null, ?int $installmentSeq = null, ?array $invoice = null, - ?float $currentBalance = null + ?float $currentBalance = null, + ?string $idempotencyKey = null ) { $invoice = $invoice ?? $this->invoiceModel->find($invoiceId); if (!$invoice) { return false; } + $invoiceId = (int) ($invoice['id'] ?? $invoiceId); + $amount = round((float) $amount, 2); + if ($amount <= 0) { + return false; + } + $transactionId = $transactionId ?? 'INV-' . $invoiceId . '-' . time(); $paymentDate = $paymentDate ?? utc_now(); - // If sequence wasn't provided (legacy callers), compute it here. - // NOTE: If you call this from inside a transaction (recommended), this will be consistent. + if ($this->paymentModel->where('transaction_id', $transactionId)->first()) { + return false; + } + + if ($idempotencyKey !== null && $idempotencyKey !== '') { + $existing = $this->paymentModel->where('idempotency_key', $idempotencyKey)->first(); + if ($existing) { + return [ + 'payment_id' => (int) ($existing['id'] ?? 0), + 'installment_seq' => (int) ($existing['installment_seq'] ?? $existing['number_of_installments'] ?? 1), + 'duplicate' => true, + ]; + } + } + + // If sequence wasn't provided, compute it inside the caller's invoice lock. if ($installmentSeq === null || $installmentSeq < 1) { - $exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled']; - $priorCount = $this->paymentModel + $row = $this->db->table('payments') + ->select('COALESCE(MAX(installment_seq), 0) + 1 AS next_seq', false) ->where('invoice_id', $invoiceId) - ->where('paid_amount >', 0) - ->whereNotIn('status', $exclude) - ->countAllResults(); - $installmentSeq = $priorCount + 1; + ->get() + ->getRowArray(); + $installmentSeq = (int) ($row['next_seq'] ?? 1); } $preBalance = $currentBalance ?? $this->getCurrentInvoiceBalance((int) $invoiceId); - $newBalance = max(0.0, round($preBalance - (float) $amount, 2)); + if ($amount > $preBalance + 0.00001) { + return false; + } + $newBalance = max(0.0, round($preBalance - $amount, 2)); $paymentData = [ - 'parent_id' => $invoice['parent_id'], + 'parent_id' => (int) $invoice['parent_id'], 'invoice_id' => $invoiceId, 'total_amount' => $invoice['total_amount'], 'paid_amount' => $amount, @@ -1418,6 +1438,7 @@ class PaymentController extends ResourceController 'number_of_installments' => $installmentSeq, // <-- installment sequence (1,2,3,...) 'installment_seq' => $installmentSeq, 'transaction_id' => $transactionId, + 'idempotency_key' => $idempotencyKey, 'payment_method' => strtolower($paymentMethod), 'payment_date' => $paymentDate, 'status' => FinancialStatus::PAYMENT_RECORDED, @@ -1427,11 +1448,16 @@ class PaymentController extends ResourceController 'school_year' => $schoolYear ?? ($invoice['school_year'] ?? $this->schoolYear), ]; - if (!$this->paymentModel->insert($paymentData)) { + $paymentId = $this->paymentModel->insert($paymentData); + if (!$paymentId) { return false; } - return true; + return [ + 'payment_id' => (int) $paymentId, + 'installment_seq' => $installmentSeq, + 'duplicate' => false, + ]; } private function getSuccessfulPaymentCount(int $invoiceId): int diff --git a/app/Database/Migrations/2026-07-18-000300_PaymentsLogicAndDataRepairSupport.php b/app/Database/Migrations/2026-07-18-000300_PaymentsLogicAndDataRepairSupport.php new file mode 100644 index 0000000..61c5faf --- /dev/null +++ b/app/Database/Migrations/2026-07-18-000300_PaymentsLogicAndDataRepairSupport.php @@ -0,0 +1,456 @@ +db->tableExists('payments')) { + return; + } + + $this->createPaymentBackupTable(); + $this->ensurePaymentAuditColumns(); + $this->repairPaymentSequences(); + $this->normalizePaymentsFromInvoices(); + $this->ensureRepairTables(); + $this->ensurePaymentIndexes(); + } + + public function down() + { + $this->dropIndexIfExists('payments', 'uniq_payments_invoice_sequence'); + $this->dropIndexIfExists('payments', 'uniq_payments_idempotency_key'); + $this->dropIndexIfExists('payments', 'idx_payments_reversal'); + + foreach ([ + 'payment_repair_decisions', + 'payment_repair_transition_review', + 'payment_repair_invoice_review', + 'payment_repair_analysis', + 'invoice_opening_paid_balances', + 'invoice_adjustments', + 'payments_backup_20260718', + ] as $table) { + if ($this->db->tableExists($table)) { + $this->forge->dropTable($table, true); + } + } + + foreach ([ + 'idempotency_key', + 'is_void', + 'reversal_of_payment_id', + 'voided_at', + 'voided_by', + 'void_reason', + ] as $column) { + if ($this->db->fieldExists($column, 'payments')) { + $this->forge->dropColumn('payments', $column); + } + } + } + + private function createPaymentBackupTable(): void + { + if ($this->db->tableExists('payments_backup_20260718')) { + return; + } + + $this->db->query('CREATE TABLE `payments_backup_20260718` AS SELECT * FROM `payments`'); + } + + private function ensurePaymentAuditColumns(): void + { + $columns = []; + + if (!$this->db->fieldExists('idempotency_key', 'payments')) { + $columns['idempotency_key'] = [ + 'type' => 'CHAR', + 'constraint' => 36, + 'null' => true, + 'after' => 'transaction_id', + ]; + } + + if (!$this->db->fieldExists('is_void', 'payments')) { + $columns['is_void'] = [ + 'type' => 'TINYINT', + 'constraint' => 1, + 'default' => 0, + 'null' => false, + 'after' => 'status', + ]; + } + + if (!$this->db->fieldExists('reversal_of_payment_id', 'payments')) { + $columns['reversal_of_payment_id'] = [ + 'type' => 'INT', + 'constraint' => 10, + 'unsigned' => true, + 'null' => true, + 'after' => 'is_void', + ]; + } + + if (!$this->db->fieldExists('voided_at', 'payments')) { + $columns['voided_at'] = [ + 'type' => 'DATETIME', + 'null' => true, + 'after' => 'reversal_of_payment_id', + ]; + } + + if (!$this->db->fieldExists('voided_by', 'payments')) { + $columns['voided_by'] = [ + 'type' => 'INT', + 'constraint' => 10, + 'unsigned' => true, + 'null' => true, + 'after' => 'voided_at', + ]; + } + + if (!$this->db->fieldExists('void_reason', 'payments')) { + $columns['void_reason'] = [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => true, + 'after' => 'voided_by', + ]; + } + + if ($columns !== []) { + $this->forge->addColumn('payments', $columns); + } + } + + private function repairPaymentSequences(): void + { + if (!$this->db->fieldExists('installment_seq', 'payments')) { + $this->forge->addColumn('payments', [ + 'installment_seq' => [ + 'type' => 'INT', + 'constraint' => 11, + 'null' => true, + 'after' => 'number_of_installments', + ], + ]); + } + + $rows = $this->db->table('payments') + ->select('id, invoice_id, installment_seq') + ->orderBy('invoice_id', 'ASC') + ->orderBy('payment_date', 'ASC') + ->orderBy('id', 'ASC') + ->get() + ->getResultArray(); + + $sequenceByInvoice = []; + foreach ($rows as $row) { + $invoiceId = (int) ($row['invoice_id'] ?? 0); + $sequenceByInvoice[$invoiceId] = ($sequenceByInvoice[$invoiceId] ?? 0) + 1; + $expectedSeq = $sequenceByInvoice[$invoiceId]; + + if ((int) ($row['installment_seq'] ?? 0) === $expectedSeq) { + continue; + } + + $this->db->table('payments') + ->where('id', (int) $row['id']) + ->update(['installment_seq' => $expectedSeq]); + } + + if ($this->db->fieldExists('number_of_installments', 'payments')) { + $this->db->query( + "UPDATE `payments` p + JOIN ( + SELECT invoice_id, COUNT(*) AS installment_count + FROM `payments` + WHERE LOWER(COALESCE(status, '')) NOT IN ( + 'void', 'voided', 'refunded', 'failed', 'chargeback', + 'declined', 'reversed', 'canceled', 'cancelled' + ) + GROUP BY invoice_id + ) c ON c.invoice_id = p.invoice_id + SET p.`number_of_installments` = c.installment_count" + ); + } + } + + private function normalizePaymentsFromInvoices(): void + { + if (!$this->db->tableExists('invoices')) { + return; + } + + $this->db->query( + 'UPDATE `payments` p + JOIN `invoices` i ON i.`id` = p.`invoice_id` + SET p.`parent_id` = i.`parent_id`, + p.`school_year` = i.`school_year` + WHERE p.`parent_id` <> i.`parent_id` + OR NOT (p.`school_year` <=> i.`school_year`)' + ); + } + + private function ensureRepairTables(): void + { + foreach ([ + 'payment_repair_transition_review', + 'payment_repair_invoice_review', + 'payment_repair_analysis', + ] as $view) { + $this->db->query(sprintf('DROP VIEW IF EXISTS `%s`', $view)); + } + + $this->db->query( + "CREATE TABLE IF NOT EXISTS `invoice_adjustments` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `invoice_id` INT UNSIGNED NOT NULL, + `adjustment_type` ENUM('charge','discount','waiver','credit','reversal','correction') NOT NULL, + `amount` DECIMAL(10,2) NOT NULL, + `description` VARCHAR(255) NOT NULL, + `source_reference` VARCHAR(100) DEFAULT NULL, + `school_year` VARCHAR(9) NOT NULL, + `created_by` INT UNSIGNED DEFAULT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_invoice_adjustments_invoice` (`invoice_id`), + UNIQUE KEY `uniq_invoice_adjustment_source` (`invoice_id`, `source_reference`) + ) ENGINE=InnoDB" + ); + + $this->db->query( + "CREATE TABLE IF NOT EXISTS `invoice_opening_paid_balances` ( + `invoice_id` INT UNSIGNED NOT NULL, + `amount` DECIMAL(10,2) NOT NULL, + `effective_before_payment_id` INT UNSIGNED DEFAULT NULL, + `school_year` VARCHAR(9) NOT NULL, + `evidence_reference` VARCHAR(255) NOT NULL, + `approved_by` INT UNSIGNED NOT NULL, + `approved_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `notes` TEXT, + PRIMARY KEY (`invoice_id`) + ) ENGINE=InnoDB" + ); + + $this->db->query( + "CREATE TABLE IF NOT EXISTS `payment_repair_decisions` ( + `payment_id` INT UNSIGNED NOT NULL, + `invoice_id` INT UNSIGNED NOT NULL, + `decision` ENUM( + 'balance_is_wrong', + 'missing_payment', + 'missing_credit', + 'missing_charge', + 'missing_reversal', + 'leave_unchanged' + ) NOT NULL, + `correction_amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `evidence_reference` VARCHAR(255) NOT NULL, + `approved_by` INT UNSIGNED NOT NULL, + `approved_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `notes` TEXT, + PRIMARY KEY (`payment_id`) + ) ENGINE=InnoDB" + ); + + $this->db->query( + "CREATE TABLE IF NOT EXISTS `payment_repair_analysis` ( + `payment_id` INT UNSIGNED NOT NULL, + `invoice_id` INT UNSIGNED NOT NULL, + `paid_amount` DECIMAL(10,2) NOT NULL, + `balance` DECIMAL(10,2) DEFAULT NULL, + `payment_date` DATETIME NOT NULL, + `installment_seq` INT DEFAULT NULL, + `expected_installment_seq` INT NOT NULL, + `running_paid_total` DECIMAL(10,2) NOT NULL, + PRIMARY KEY (`payment_id`), + KEY `idx_payment_repair_analysis_invoice` (`invoice_id`) + ) ENGINE=InnoDB" + ); + + $this->db->query( + "CREATE TABLE IF NOT EXISTS `payment_repair_invoice_review` ( + `invoice_id` INT UNSIGNED NOT NULL, + `payment_count` INT NOT NULL, + `ledger_paid_total` DECIMAL(10,2) NOT NULL, + `sequence_matches` TINYINT(1) NOT NULL, + PRIMARY KEY (`invoice_id`) + ) ENGINE=InnoDB" + ); + + $this->db->query( + "CREATE TABLE IF NOT EXISTS `payment_repair_transition_review` ( + `payment_id` INT UNSIGNED NOT NULL, + `invoice_id` INT UNSIGNED NOT NULL, + `balance` DECIMAL(10,2) DEFAULT NULL, + `previous_balance` DECIMAL(10,2) DEFAULT NULL, + `paid_amount` DECIMAL(10,2) NOT NULL, + `expected_balance` DECIMAL(10,2) DEFAULT NULL, + `unexplained_change` DECIMAL(10,2) DEFAULT NULL, + PRIMARY KEY (`payment_id`), + KEY `idx_payment_repair_transition_invoice` (`invoice_id`) + ) ENGINE=InnoDB" + ); + + $this->refreshRepairTables(); + } + + private function refreshRepairTables(): void + { + $this->db->table('payment_repair_transition_review')->truncate(); + $this->db->table('payment_repair_invoice_review')->truncate(); + $this->db->table('payment_repair_analysis')->truncate(); + + $payments = $this->db->table('payments') + ->select('id, invoice_id, paid_amount, balance, payment_date, installment_seq') + ->orderBy('invoice_id', 'ASC') + ->orderBy('payment_date', 'ASC') + ->orderBy('id', 'ASC') + ->get() + ->getResultArray(); + + $sequenceByInvoice = []; + $runningByInvoice = []; + $invoiceReview = []; + $previousByInvoice = []; + + foreach ($payments as $payment) { + $invoiceId = (int) ($payment['invoice_id'] ?? 0); + $paidAmount = round((float) ($payment['paid_amount'] ?? 0), 2); + $balance = array_key_exists('balance', $payment) && $payment['balance'] !== null + ? round((float) $payment['balance'], 2) + : null; + + $sequenceByInvoice[$invoiceId] = ($sequenceByInvoice[$invoiceId] ?? 0) + 1; + $runningByInvoice[$invoiceId] = round(($runningByInvoice[$invoiceId] ?? 0.0) + $paidAmount, 2); + + $expectedSeq = $sequenceByInvoice[$invoiceId]; + $actualSeq = $payment['installment_seq'] !== null ? (int) $payment['installment_seq'] : null; + + $this->db->table('payment_repair_analysis')->insert([ + 'payment_id' => (int) $payment['id'], + 'invoice_id' => $invoiceId, + 'paid_amount' => $paidAmount, + 'balance' => $balance, + 'payment_date' => $payment['payment_date'], + 'installment_seq' => $actualSeq, + 'expected_installment_seq' => $expectedSeq, + 'running_paid_total' => $runningByInvoice[$invoiceId], + ]); + + if (!isset($invoiceReview[$invoiceId])) { + $invoiceReview[$invoiceId] = [ + 'payment_count' => 0, + 'ledger_paid_total' => 0.0, + 'sequence_matches' => 1, + ]; + } + + $invoiceReview[$invoiceId]['payment_count']++; + $invoiceReview[$invoiceId]['ledger_paid_total'] = round($invoiceReview[$invoiceId]['ledger_paid_total'] + $paidAmount, 2); + if ($actualSeq !== $expectedSeq) { + $invoiceReview[$invoiceId]['sequence_matches'] = 0; + } + + if (isset($previousByInvoice[$invoiceId])) { + $previous = $previousByInvoice[$invoiceId]; + $expectedBalance = $previous['balance'] !== null + ? round($previous['balance'] - $paidAmount, 2) + : null; + $unexplainedChange = ($balance !== null && $expectedBalance !== null) + ? round($balance - $expectedBalance, 2) + : null; + + if ($unexplainedChange !== null && abs($unexplainedChange) > 0.01) { + $this->db->table('payment_repair_transition_review')->insert([ + 'payment_id' => (int) $payment['id'], + 'invoice_id' => $invoiceId, + 'balance' => $balance, + 'previous_balance' => $previous['balance'], + 'paid_amount' => $paidAmount, + 'expected_balance' => $expectedBalance, + 'unexplained_change' => $unexplainedChange, + ]); + } + } + + $previousByInvoice[$invoiceId] = [ + 'balance' => $balance, + ]; + } + + foreach ($invoiceReview as $invoiceId => $review) { + $this->db->table('payment_repair_invoice_review')->insert([ + 'invoice_id' => $invoiceId, + 'payment_count' => $review['payment_count'], + 'ledger_paid_total' => $review['ledger_paid_total'], + 'sequence_matches' => $review['sequence_matches'], + ]); + } + } + + private function ensurePaymentIndexes(): void + { + $this->addIndexIfMissing('payments', 'idx_payments_reversal', ['reversal_of_payment_id']); + $this->addIndexIfMissing('payments', 'uniq_payments_idempotency_key', ['idempotency_key'], true); + + if (!$this->hasDuplicatePaymentSequences()) { + $this->addIndexIfMissing('payments', 'uniq_payments_invoice_sequence', ['invoice_id', 'installment_seq'], true); + } + } + + private function hasDuplicatePaymentSequences(): bool + { + $row = $this->db->table('payments') + ->select('invoice_id, installment_seq') + ->where('installment_seq IS NOT NULL', null, false) + ->groupBy('invoice_id, installment_seq') + ->having('COUNT(*) >', 1, false) + ->get(1) + ->getRowArray(); + + return $row !== null; + } + + private function addIndexIfMissing(string $table, string $indexName, array $columns, bool $unique = false): void + { + if (!$this->db->tableExists($table) || $this->indexExists($table, $indexName)) { + return; + } + + $quotedColumns = implode(', ', array_map(static fn (string $column): string => '`' . $column . '`', $columns)); + $type = $unique ? 'UNIQUE INDEX' : 'INDEX'; + $this->db->query(sprintf('ALTER TABLE `%s` ADD %s `%s` (%s)', $table, $type, $indexName, $quotedColumns)); + } + + private function dropIndexIfExists(string $table, string $indexName): void + { + if (!$this->db->tableExists($table) || !$this->indexExists($table, $indexName)) { + return; + } + + $this->db->query(sprintf('ALTER TABLE `%s` DROP INDEX `%s`', $table, $indexName)); + } + + private function indexExists(string $table, string $indexName): bool + { + if (!$this->db->tableExists($table)) { + return false; + } + + $rows = $this->db->query(sprintf('SHOW INDEX FROM `%s`', $table))->getResultArray(); + foreach ($rows as $row) { + if (($row['Key_name'] ?? null) === $indexName) { + return true; + } + } + + return false; + } +} diff --git a/app/Models/PaymentModel.php b/app/Models/PaymentModel.php index aa2407c..5c05c4e 100644 --- a/app/Models/PaymentModel.php +++ b/app/Models/PaymentModel.php @@ -34,12 +34,18 @@ class PaymentModel extends Model 'number_of_installments', 'installment_seq', 'transaction_id', + 'idempotency_key', 'check_file', 'check_number', 'payment_method', 'payment_date', 'school_year', 'status', + 'is_void', + 'reversal_of_payment_id', + 'voided_at', + 'voided_by', + 'void_reason', 'updated_by', ]; @@ -69,11 +75,17 @@ class PaymentModel extends Model 'number_of_installments' => 'permit_empty|integer|greater_than[0]', 'installment_seq' => 'permit_empty|integer|greater_than[0]', - 'transaction_id' => 'permit_empty|max_length[100]', + 'transaction_id' => 'required|max_length[100]', + 'idempotency_key' => 'permit_empty|max_length[36]', 'payment_method' => 'required|in_list[cash,check,card]', 'payment_date' => 'required|valid_date', 'school_year' => 'required|string|max_length[9]', 'status' => 'required|max_length[50]', + 'is_void' => 'permit_empty|in_list[0,1]', + 'reversal_of_payment_id' => 'permit_empty|integer', + 'voided_at' => 'permit_empty|valid_date', + 'voided_by' => 'permit_empty|integer', + 'void_reason' => 'permit_empty|max_length[255]', 'check_file' => 'permit_empty|max_length[255]', 'check_number' => 'permit_empty|max_length[100]', 'updated_by' => 'permit_empty|integer', @@ -124,10 +136,17 @@ class PaymentModel extends Model 'integer' => 'Number of installments must be an integer.', 'greater_than' => 'Number of installments must be greater than zero.', ], + 'transaction_id' => [ + 'required' => 'Transaction ID is required.', + 'max_length' => 'Transaction ID must not exceed 100 characters.', + ], 'installment_seq' => [ 'integer' => 'Installment sequence must be an integer.', 'greater_than' => 'Installment sequence must be greater than zero.', ], + 'idempotency_key' => [ + 'max_length' => 'Idempotency key must not exceed 36 characters.', + ], 'check_number' => [ 'max_length' => 'Check number must not exceed 100 characters.', ], diff --git a/app/Views/parent/payment_view.php b/app/Views/parent/payment_view.php index 6a82b9f..7443f4d 100644 --- a/app/Views/parent/payment_view.php +++ b/app/Views/parent/payment_view.php @@ -25,7 +25,7 @@