From 068e73940897028dff0abb006869b45e4799917e Mon Sep 17 00:00:00 2001 From: root Date: Sat, 18 Jul 2026 19:12:13 -0400 Subject: [PATCH] fix payment issues --- app/Controllers/View/EventController.php | 53 +- app/Controllers/View/PaymentController.php | 68 +- ...0300_PaymentsLogicAndDataRepairSupport.php | 456 +++++++++++++ app/Models/PaymentModel.php | 21 +- app/Views/parent/payment_view.php | 2 +- app/Views/payment/manual_payment.php | 2 +- payments_logic_and_data_repair_plan.md | 629 ++++++++++++++++++ tests/app/Models/PaymentModelTest.php | 13 + 8 files changed, 1204 insertions(+), 40 deletions(-) create mode 100644 app/Database/Migrations/2026-07-18-000300_PaymentsLogicAndDataRepairSupport.php create mode 100644 payments_logic_and_data_repair_plan.md 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 @@ Number of Installments - + Semester diff --git a/app/Views/payment/manual_payment.php b/app/Views/payment/manual_payment.php index 08c1b66..1b4deb0 100644 --- a/app/Views/payment/manual_payment.php +++ b/app/Views/payment/manual_payment.php @@ -87,7 +87,7 @@ ?> - + diff --git a/payments_logic_and_data_repair_plan.md b/payments_logic_and_data_repair_plan.md new file mode 100644 index 0000000..6dac623 --- /dev/null +++ b/payments_logic_and_data_repair_plan.md @@ -0,0 +1,629 @@ +# Payments Logic and Data Repair Plan + +## 1. Purpose + +This plan fixes two separate problems: + +1. **Application logic** that creates inconsistent or misleading payment rows. +2. **Existing data** in the `payments` table without deleting financial evidence or inventing transactions. + +The repair must treat `payments` as an auditable ledger. A payment row should represent money actually received, not an invoice adjustment, fee assignment, discount, balance correction, or repeated attempt to make a balance reach zero. + +--- + +## 2. Findings from the supplied dump + +The dump contains **321 rows across 120 invoices**. + +Key findings: + +- `installment_seq` is `NULL` in all 321 rows. +- `number_of_installments` is actually being used as the payment sequence number. It matches chronological row order for every invoice. +- 65 invoices have more than one `total_amount` value across their payment history. +- 168 rows have `paid_amount = 10.00`, mostly recorded as cash during April and May 2026. +- 116 of 120 invoice histories are internally consistent once a fixed pre-existing paid amount or credit is allowed. +- 33 internally consistent invoices have a non-zero implied opening paid amount. This means the table is not a complete ledger for those invoices. +- Four invoice histories contain an unexplained balance jump: + +| Invoice | Payment row where jump begins | Unexplained change | +|---:|---:|---:| +| 25 | 201 | -590.00 | +| 65 | 304 | -573.75 | +| 75 | 92 | +90.00 | +| 96 | 232 | -20.00 | + +These four changes may represent missing payments, credits, reversals, adjustments, or incorrect stored balances. They must be reconciled against receipts, invoice history, audit logs, and bank or cash records before destructive correction. + +--- + +## 3. Target accounting rules + +After the repair, the following rules must always hold. + +### 3.1 Payment rules + +- One row in `payments` represents one actual receipt of money. +- A payment is immutable after posting, except for controlled metadata corrections. +- A mistaken payment is reversed with a linked reversal row. It is not deleted or silently overwritten. +- `paid_amount` must be greater than zero for a normal payment. +- `transaction_id` or an idempotency key must be unique and non-null. +- `parent_id`, `school_year`, and invoice ownership must come from the invoice, not from unchecked form input. +- Check-specific fields are required only for check payments. +- A payment cannot create a negative invoice balance unless explicit overpayment or account-credit logic is enabled. + +### 3.2 Invoice rules + +- `invoices.total_amount` is the current authoritative invoice total. +- `invoices.paid_amount` is the sum of valid posted payments plus approved opening credit or migrated paid balance. +- `invoices.balance = invoices.total_amount - invoices.paid_amount`. +- Invoice fees, discounts, waivers, penalties, and event charges are stored as adjustments, not payments. +- Invoice status is derived from the current balance instead of being independently guessed. + +### 3.3 Installment rules + +- `installment_seq` is the chronological payment sequence within one invoice. +- `number_of_installments` must not be used as both a sequence and a total count. +- Recommended final naming: + - `installment_seq`: this payment's sequence number. + - `installment_count`: total posted payment count, only if the application genuinely needs it. + +--- + +## 4. Correct application logic + +## 4.1 Record a payment atomically + +All operations must run inside one database transaction. The invoice row must be locked so two users cannot post against the same balance simultaneously. + +```text +BEGIN TRANSACTION + +1. Load invoice FOR UPDATE. +2. Reject missing, cancelled, or wrong-school-year invoice. +3. Read authoritative parent_id, school_year, total_amount, paid_amount, and balance from invoice. +4. Validate payment amount and payment method. +5. Reject duplicate idempotency key or transaction ID. +6. Reject amount greater than current balance unless explicit credit handling is enabled. +7. Calculate next installment_seq while the invoice is locked. +8. Insert one payment row. +9. Update invoice paid_amount, balance, and status. +10. Write an audit record. + +COMMIT +``` + +Representative MySQL logic: + +```sql +START TRANSACTION; + +SELECT + id, + parent_id, + total_amount, + paid_amount, + balance, + school_year, + status +INTO + @invoice_id, + @invoice_parent_id, + @invoice_total_amount, + @invoice_paid_amount, + @invoice_balance, + @invoice_school_year, + @invoice_status +FROM invoices +WHERE id = :invoice_id +FOR UPDATE; + +-- Application validations before continuing: +-- :paid_amount > 0 +-- :paid_amount <= invoice.balance unless credits are explicitly supported +-- invoice.school_year = active school year +-- invoice.status is not cancelled/void +-- transaction_id/idempotency_key does not already exist + +SELECT COALESCE(MAX(installment_seq), 0) + 1 +INTO @next_installment_seq +FROM payments +WHERE invoice_id = :invoice_id; + +SET @new_paid_amount = @invoice_paid_amount + :paid_amount; +SET @new_balance = @invoice_total_amount - @new_paid_amount; + +INSERT INTO payments ( + parent_id, + invoice_id, + paid_amount, + installment_seq, + transaction_id, + check_file, + check_number, + payment_method, + payment_date, + school_year, + status, + updated_by, + created_at, + updated_at +) VALUES ( + @invoice_parent_id, + @invoice_id, + :paid_amount, + @next_installment_seq, + :transaction_id, + :check_file, + :check_number, + LOWER(:payment_method), + :payment_date, + @invoice_school_year, + 'recorded', + :updated_by, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +); + +UPDATE invoices +SET paid_amount = @new_paid_amount, + balance = @new_balance, + status = CASE + WHEN @new_balance = 0 THEN 'paid' + WHEN @new_balance > 0 AND @new_paid_amount > 0 THEN 'partial' + WHEN @new_paid_amount = 0 THEN 'unpaid' + ELSE 'credit' + END, + updated_by = :updated_by, + updated_at = CURRENT_TIMESTAMP +WHERE id = :invoice_id; + +COMMIT; +``` + +The exact status strings must match the values supported by the application. Do not introduce `partial` or `credit` until reporting and validation code understands them. + +## 4.2 Record an invoice adjustment + +A fee, discount, waiver, event charge, or correction changes the invoice total. It does not create a payment. + +Create a dedicated adjustment table rather than forcing `payments` to perform accounting theatre: + +```sql +CREATE TABLE 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), + CONSTRAINT fk_invoice_adjustments_invoice + FOREIGN KEY (invoice_id) REFERENCES invoices(id), + CONSTRAINT chk_invoice_adjustment_amount + CHECK (amount <> 0) +) ENGINE=InnoDB; +``` + +Adjustment transaction: + +```text +1. Lock invoice FOR UPDATE. +2. Insert exactly one adjustment row using an idempotent source reference. +3. Recalculate total_amount from invoice base charges plus all adjustments. +4. Recalculate balance as total_amount minus paid_amount. +5. Update invoice status. +6. Commit. +``` + +This eliminates the likely cause of repeated `$10.00` payment rows when the system is actually adding charges or closing small balances. + +## 4.3 Prevent duplicate submissions + +Add an idempotency key generated once by the client or server per payment request. + +```sql +ALTER TABLE payments + ADD COLUMN idempotency_key CHAR(36) DEFAULT NULL AFTER transaction_id, + ADD UNIQUE KEY uniq_payments_idempotency_key (idempotency_key); +``` + +On retry, return the existing payment instead of inserting a second one. + +## 4.4 Reverse rather than delete + +Recommended columns: + +```sql +ALTER TABLE payments + ADD COLUMN reversal_of_payment_id INT UNSIGNED DEFAULT NULL, + ADD COLUMN voided_at DATETIME DEFAULT NULL, + ADD COLUMN voided_by INT UNSIGNED DEFAULT NULL, + ADD COLUMN void_reason VARCHAR(255) DEFAULT NULL, + ADD KEY idx_payments_reversal (reversal_of_payment_id); +``` + +A reversal should insert an explicit reversing transaction or mark the original row void while preserving the audit trail. The selected policy must be consistent across invoice totals, cash reports, and receipts. + +--- + +## 5. Existing-data repair strategy + +## 5.1 Repair principles + +The migration must not automatically: + +- Delete `$10.00` rows. +- Merge rows merely because they share a date or amount. +- Change `paid_amount` without receipt evidence. +- Convert negative balances to zero using `GREATEST()`. +- Recalculate every historical balance from the current invoice total. +- Assume the first stored payment is the first payment ever made. + +Those shortcuts make reports look tidy while making the ledger less truthful. Aesthetic consistency is not accounting integrity. + +## 5.2 Maintenance procedure + +1. Put payment creation and editing into maintenance mode. +2. Take a database backup and verify restoration on a separate database. +3. Copy `payments`, `invoices`, and relevant audit tables into dated backup tables. +4. Run the audit queries in the companion SQL script. +5. Reconcile the four unexplained transitions against external evidence. +6. Apply safe sequence and normalization updates. +7. Apply only approved balance corrections. +8. Rebuild invoice summaries. +9. Run all validation queries. +10. Deploy corrected application logic before reopening writes. + +--- + +## 6. Data-repair SQL design + +The companion SQL script creates these working tables: + +- `payments_backup_20260718`: exact pre-repair copy. +- `payment_repair_analysis`: row-level sequence, running totals, and implied opening paid amount. +- `payment_repair_invoice_review`: invoice-level consistency summary. +- `payment_repair_transition_review`: unexplained changes between consecutive rows. +- `payment_repair_decisions`: reviewed instructions for each anomalous transition. + +### 6.1 Sequence repair + +Safe automatic correction: + +```sql +UPDATE payments p +JOIN payment_repair_analysis a ON a.payment_id = p.id +SET p.installment_seq = a.expected_installment_seq +WHERE p.installment_seq IS NULL + OR p.installment_seq <> a.expected_installment_seq; +``` + +After the application has switched to `installment_seq`, repurpose the misleading field: + +```sql +UPDATE payments p +JOIN ( + SELECT invoice_id, COUNT(*) AS installment_count + FROM payments + WHERE status = 'recorded' + GROUP BY invoice_id +) c ON c.invoice_id = p.invoice_id +SET p.number_of_installments = c.installment_count; +``` + +Do not run the second update before confirming no application code still expects `number_of_installments` to be the current sequence. + +### 6.2 Parent and school-year normalization + +First inspect differences: + +```sql +SELECT + p.id, + p.invoice_id, + p.parent_id AS payment_parent_id, + i.parent_id AS invoice_parent_id, + p.school_year AS payment_school_year, + i.school_year AS invoice_school_year +FROM payments p +JOIN invoices i ON i.id = p.invoice_id +WHERE p.parent_id <> i.parent_id + OR NOT (p.school_year <=> i.school_year); +``` + +After review, normalize from the authoritative invoice: + +```sql +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); +``` + +### 6.3 Balance-transition audit + +For every row after the first payment on an invoice, the expected transition is: + +```text +new balance += previous balance ++ change in invoice total +- current payment ++ explicit non-payment adjustment not already reflected in total_amount +``` + +The supplied dump contains four unexplained transitions. The migration records a decision for each one: + +```sql +CREATE TABLE 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; +``` + +Rules: + +- `balance_is_wrong`: correct the balance at that row, then recalculate all later balances for the invoice. +- `missing_payment`: insert a real payment using receipt evidence, then resequence. +- `missing_credit`: insert an approved credit adjustment. +- `missing_charge`: insert an approved charge adjustment. +- `missing_reversal`: insert or link a reversal. +- `leave_unchanged`: permitted only with written justification. + +### 6.4 Preserve migrated opening paid balances + +The dump proves that some invoices were already partially or fully paid before their first surviving `payments` row. Do not invent cash, card, or check transactions to fill that gap. Preserve the migrated amount separately with approval evidence. + +```sql +CREATE TABLE 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), + CONSTRAINT fk_opening_paid_invoice + FOREIGN KEY (invoice_id) REFERENCES invoices(id), + CONSTRAINT chk_opening_paid_amount + CHECK (amount >= 0) +) ENGINE=InnoDB; +``` + +The internally consistent candidates are produced by: + +```sql +SELECT + invoice_id, + minimum_implied_opening_paid AS candidate_opening_paid +FROM payment_repair_invoice_review +WHERE is_internally_consistent = 1 + AND minimum_implied_opening_paid > 0.01 +ORDER BY invoice_id; +``` + +The supplied dump produces 33 candidates. Each amount still requires approval because internal consistency proves only that the arithmetic repeats consistently, not that the original transaction actually occurred. + +### 6.5 Correct a bad balance and recalculate forward + +For an approved bad-balance decision, calculate the corrected row from the previous row: + +```sql +UPDATE payments current_payment +JOIN payment_repair_transition_review review + ON review.payment_id = current_payment.id +JOIN payment_repair_decisions decision + ON decision.payment_id = current_payment.id +SET current_payment.balance = review.expected_balance +WHERE decision.decision = 'balance_is_wrong'; +``` + +Then rebuild later balances for that invoice in chronological order. The companion script creates a staged result table first, allowing review before the final update. + +Do not attempt this with an unordered multi-row variable update. SQL does not owe anyone deterministic behavior merely because the rows looked sorted in phpMyAdmin. + +### 6.6 Rebuild invoice summaries + +After payment, opening-balance, and adjustment reconciliation: + +```sql +UPDATE invoices i +LEFT JOIN ( + SELECT + invoice_id, + SUM(CASE + WHEN status = 'recorded' AND voided_at IS NULL + THEN paid_amount + ELSE 0 + END) AS ledger_paid + FROM payments + GROUP BY invoice_id +) p ON p.invoice_id = i.id +LEFT JOIN invoice_opening_paid_balances o + ON o.invoice_id = i.id +SET i.paid_amount = + COALESCE(o.amount, 0) + COALESCE(p.ledger_paid, 0), + i.balance = + i.total_amount + - (COALESCE(o.amount, 0) + COALESCE(p.ledger_paid, 0)), + i.status = CASE + WHEN i.total_amount + - (COALESCE(o.amount, 0) + COALESCE(p.ledger_paid, 0)) = 0 + THEN 'paid' + WHEN COALESCE(o.amount, 0) + COALESCE(p.ledger_paid, 0) = 0 + THEN 'unpaid' + WHEN i.total_amount + - (COALESCE(o.amount, 0) + COALESCE(p.ledger_paid, 0)) > 0 + THEN 'partial' + ELSE 'credit' + END, + i.updated_at = CURRENT_TIMESTAMP; +``` + +Discounts, waivers, and invoice credits must change `invoices.total_amount` through the adjustment ledger. They must not be counted as money paid. Opening paid balances are included only because they represent approved historical payments that predate the surviving ledger. + +--- + +## 7. Schema hardening after cleanup + +Apply only after data passes validation. + +```sql +ALTER TABLE payments + MODIFY transaction_id VARCHAR(100) NOT NULL, + MODIFY installment_seq INT NOT NULL, + MODIFY school_year VARCHAR(9) NOT NULL, + ADD UNIQUE KEY uniq_payments_invoice_sequence (invoice_id, installment_seq), + ADD CONSTRAINT chk_payments_paid_amount CHECK (paid_amount > 0), + ADD CONSTRAINT chk_payments_method CHECK ( + payment_method IN ('cash', 'card', 'check', 'bank_transfer', 'online') + ), + ADD CONSTRAINT chk_payments_check_fields CHECK ( + payment_method <> 'check' + OR check_number IS NOT NULL + ); +``` + +Recommended foreign key after confirming every reference is valid: + +```sql +ALTER TABLE payments + ADD CONSTRAINT fk_payments_invoice + FOREIGN KEY (invoice_id) REFERENCES invoices(id); +``` + +Do not add a parent foreign key until the signed/unsigned types of both columns match. + +--- + +## 8. Validation checklist + +The repair is complete only when all checks pass. + +### Row and amount preservation + +```sql +SELECT + (SELECT COUNT(*) FROM payments_backup_20260718) AS before_rows, + (SELECT COUNT(*) FROM payments) AS after_rows, + (SELECT SUM(paid_amount) FROM payments_backup_20260718) AS before_paid, + (SELECT SUM(paid_amount) FROM payments) AS after_paid; +``` + +Any difference must be explained by approved inserted reversals, recovered payments, or documented corrections. + +### Duplicate transaction IDs + +```sql +SELECT transaction_id, COUNT(*) +FROM payments +GROUP BY transaction_id +HAVING transaction_id IS NULL OR COUNT(*) > 1; +``` + +### Duplicate or missing installment sequences + +```sql +SELECT invoice_id, installment_seq, COUNT(*) +FROM payments +GROUP BY invoice_id, installment_seq +HAVING installment_seq IS NULL OR COUNT(*) > 1; +``` + +### Payment-to-invoice ownership mismatch + +```sql +SELECT p.id, p.invoice_id, p.parent_id, i.parent_id +FROM payments p +JOIN invoices i ON i.id = p.invoice_id +WHERE p.parent_id <> i.parent_id; +``` + +### Invoice summary mismatch + +```sql +SELECT + i.id AS invoice_id, + i.total_amount, + i.paid_amount, + i.balance, + SUM(CASE WHEN p.status = 'recorded' THEN p.paid_amount ELSE 0 END) AS ledger_paid +FROM invoices i +LEFT JOIN payments p ON p.invoice_id = i.id +GROUP BY i.id, i.total_amount, i.paid_amount, i.balance +HAVING ABS(i.balance - (i.total_amount - i.paid_amount)) > 0.01; +``` + +### Remaining unexplained transitions + +Recreate `payment_repair_transition_review` after corrections. It should return no unexplained transition unless the difference is linked to an explicit adjustment, credit, reversal, or approved migration record. + +--- + +## 9. Deployment order + +1. Add new nullable columns and adjustment/reversal structures. +2. Deploy application code that dual-writes the old and new installment fields. +3. Freeze payment writes. +4. Backup and run audit scripts. +5. Reconcile the four anomalous invoices and all opening paid balances. +6. Apply data repair. +7. Rebuild invoice summaries. +8. Run validations and business-report comparisons. +9. Deploy application code that reads only the corrected fields. +10. Add NOT NULL, UNIQUE, CHECK, and foreign-key constraints. +11. Reopen payment writes. +12. Monitor duplicate attempts, negative balances, and reconciliation failures. + +--- + +## 10. Required tests + +At minimum, automated tests must cover: + +- Full payment. +- Partial payment. +- Final installment. +- Concurrent payments on the same invoice. +- Duplicate browser submission. +- Check payment without a check number. +- Payment greater than balance. +- Invoice charge after partial payment. +- Invoice discount after payment. +- Payment reversal. +- School-year mismatch. +- Invoice transferred to another parent or corrected ownership. +- Repeated event-fee processing with the same source reference. +- Status transitions from unpaid to partial to paid. + +The concurrency test is non-negotiable. Without it, two perfectly valid requests can both read the same balance and produce one invalid ledger, because computers are extremely obedient even when asked to race into a wall. diff --git a/tests/app/Models/PaymentModelTest.php b/tests/app/Models/PaymentModelTest.php index 176890a..36d027a 100644 --- a/tests/app/Models/PaymentModelTest.php +++ b/tests/app/Models/PaymentModelTest.php @@ -4,9 +4,22 @@ namespace Tests\App\Models; use Tests\Support\ModelCrudTestCase; use App\Models\PaymentModel; +use CodeIgniter\Model; class PaymentModelTest extends ModelCrudTestCase { + protected function fabricatorOverrides(Model $model): array + { + $overrides = parent::fabricatorOverrides($model); + + if ($model instanceof PaymentModel) { + $overrides['transaction_id'] = uniqid('PAY-TEST-', true); + $overrides['payment_method'] = 'cash'; + $overrides['status'] = 'recorded'; + } + + return $overrides; + } public function testCanInsertAndRetrieve() {