@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class PaymentsLogicAndDataRepairSupport extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
if (!$this->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;
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
],
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Number of Installments</th>
|
||||
<td><?= esc($payment['number_of_installments']) ?></td>
|
||||
<td><?= esc($payment['installment_seq'] ?? $payment['number_of_installments'] ?? '') ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Semester</th>
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
?>
|
||||
</td>
|
||||
<td><?= !empty($payment['check_number']) ? esc($payment['check_number']) : '-' ?></td>
|
||||
<td><?= esc($payment['number_of_installments']) ?></td>
|
||||
<td><?= esc($payment['installment_seq'] ?? $payment['number_of_installments'] ?? '') ?></td>
|
||||
<td><?= esc($payment['status']) ?></td>
|
||||
<td>
|
||||
<?php if (!empty($payment['check_file'])): ?>
|
||||
|
||||
Reference in New Issue
Block a user