fix invoice and enrollment fees
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 48s
Tests / PHPUnit (push) Failing after 1m19s

This commit is contained in:
root
2026-08-27 18:34:36 -04:00
parent 0ae9993d82
commit 38644b32ae
29 changed files with 1836 additions and 1555 deletions
@@ -31,7 +31,6 @@ class PaymentsLogicAndDataRepairSupport extends Migration
'payment_repair_transition_review',
'payment_repair_invoice_review',
'payment_repair_analysis',
'invoice_opening_paid_balances',
'invoice_adjustments',
'payments_backup_20260718',
] as $table) {
@@ -259,20 +258,6 @@ class PaymentsLogicAndDataRepairSupport extends Migration
) 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,
@@ -8,106 +8,8 @@ class CreateInvoiceLines extends Migration
{
public function up()
{
if (!$this->db->tableExists('invoice_lines')) {
$this->forge->addField([
'id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
'auto_increment' => true,
],
'invoice_id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
'null' => false,
],
'school_year' => [
'type' => 'VARCHAR',
'constraint' => 9,
'null' => false,
],
'line_type' => [
'type' => 'VARCHAR',
'constraint' => 50,
'null' => false,
],
'source_type' => [
'type' => 'VARCHAR',
'constraint' => 50,
'null' => true,
],
'source_id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
'null' => true,
],
'active_source_key' => [
'type' => 'VARCHAR',
'constraint' => 120,
'null' => true,
],
'description' => [
'type' => 'VARCHAR',
'constraint' => 255,
'null' => false,
],
'quantity' => [
'type' => 'DECIMAL',
'constraint' => '10,2',
'null' => false,
'default' => '1.00',
],
'unit_amount_cents' => [
'type' => 'INT',
'constraint' => 11,
'null' => false,
'default' => 0,
],
'line_amount_cents' => [
'type' => 'INT',
'constraint' => 11,
'null' => false,
'default' => 0,
],
'discount_eligible' => [
'type' => 'TINYINT',
'constraint' => 1,
'null' => false,
'default' => 1,
],
'calculation_version' => [
'type' => 'VARCHAR',
'constraint' => 50,
'null' => true,
],
'metadata_json' => [
'type' => 'TEXT',
'null' => true,
],
'created_at' => [
'type' => 'DATETIME',
'null' => false,
],
'updated_at' => [
'type' => 'DATETIME',
'null' => false,
],
'voided_at' => [
'type' => 'DATETIME',
'null' => true,
],
]);
$this->forge->addKey('id', true);
$this->forge->addKey('invoice_id');
$this->forge->addKey(['source_type', 'source_id']);
$this->forge->addUniqueKey('active_source_key', 'uniq_invoice_lines_active_source_key');
$this->forge->createTable('invoice_lines', true);
}
$this->ensureSchoolYearColumn();
$this->backfillLegacyInvoiceLines();
// invoice_lines was removed from the financial model. Keep this
// migration class as a no-op so older migration histories remain valid.
}
public function down()
@@ -116,88 +18,4 @@ class CreateInvoiceLines extends Migration
$this->forge->dropTable('invoice_lines', true);
}
}
private function backfillLegacyInvoiceLines(): void
{
if (!$this->db->tableExists('invoices') || !$this->db->tableExists('invoice_lines')) {
return;
}
$existingRows = $this->db->table('invoice_lines')
->select('invoice_id')
->groupBy('invoice_id')
->get()
->getResultArray();
$existing = array_fill_keys(array_map(static fn ($row) => (int) ($row['invoice_id'] ?? 0), $existingRows), true);
$invoices = $this->db->table('invoices')
->select('id, total_amount, invoice_number, school_year, created_at, updated_at')
->orderBy('id', 'ASC')
->get()
->getResultArray();
$now = date('Y-m-d H:i:s');
foreach ($invoices as $invoice) {
$invoiceId = (int) ($invoice['id'] ?? 0);
if ($invoiceId <= 0 || isset($existing[$invoiceId])) {
continue;
}
$amountCents = (int) round(((float) ($invoice['total_amount'] ?? 0)) * 100);
$createdAt = $invoice['created_at'] ?? $now;
$updatedAt = $invoice['updated_at'] ?? $createdAt;
$this->db->table('invoice_lines')->insert([
'invoice_id' => $invoiceId,
'school_year' => (string)($invoice['school_year'] ?? ''),
'line_type' => 'legacy_invoice_total',
'source_type' => 'legacy_invoice',
'source_id' => $invoiceId,
'active_source_key' => null,
'description' => 'Legacy invoice total preserved from invoice ' . (string) ($invoice['invoice_number'] ?? $invoiceId),
'quantity' => '1.00',
'unit_amount_cents' => $amountCents,
'line_amount_cents' => $amountCents,
'discount_eligible' => 0,
'calculation_version' => 'legacy_import',
'metadata_json' => json_encode([
'source' => 'invoices.total_amount',
'legacy_discount_eligible_base_cents' => 0,
'reconciliation_required' => true,
], JSON_UNESCAPED_SLASHES),
'created_at' => $createdAt ?: $now,
'updated_at' => $updatedAt ?: $now,
'voided_at' => null,
]);
}
}
private function ensureSchoolYearColumn(): void
{
if (!$this->db->tableExists('invoice_lines')) {
return;
}
if (!$this->db->fieldExists('school_year', 'invoice_lines')) {
$this->forge->addColumn('invoice_lines', [
'school_year' => [
'type' => 'VARCHAR',
'constraint' => 9,
'null' => true,
'after' => 'invoice_id',
],
]);
}
if ($this->db->tableExists('invoices')) {
$this->db->query(
"UPDATE invoice_lines il
INNER JOIN invoices i ON i.id = il.invoice_id
SET il.school_year = i.school_year
WHERE il.school_year IS NULL OR TRIM(il.school_year) = ''"
);
}
$this->db->query("ALTER TABLE invoice_lines MODIFY school_year VARCHAR(9) NOT NULL");
}
}
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
final class RemoveSemesterFromInvoiceStudentsList extends Migration
{
public function up(): void
{
if (! $this->db->tableExists('invoice_students_list')
|| ! $this->db->fieldExists('semester', 'invoice_students_list')) {
return;
}
foreach ($this->indexesContainingColumn('invoice_students_list', 'semester') as $indexName) {
$this->dropIndexIfExists('invoice_students_list', $indexName);
}
$this->forge->dropColumn('invoice_students_list', 'semester');
$this->db->resetDataCache();
}
public function down(): void
{
if (! $this->db->tableExists('invoice_students_list')
|| $this->db->fieldExists('semester', 'invoice_students_list')) {
return;
}
$this->forge->addColumn('invoice_students_list', [
'semester' => [
'type' => 'VARCHAR',
'constraint' => 10,
'null' => true,
'after' => 'school_year',
],
]);
$this->db->resetDataCache();
}
/**
* @return list<string>
*/
private function indexesContainingColumn(string $table, string $column): array
{
$rows = $this->db->query(
'SELECT DISTINCT INDEX_NAME
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = ?
AND COLUMN_NAME = ?
AND INDEX_NAME <> \'PRIMARY\'',
[$table, $column]
)->getResultArray();
return array_values(array_filter(array_map(
static fn (array $row): string => (string) ($row['INDEX_NAME'] ?? ''),
$rows
)));
}
private function dropIndexIfExists(string $table, string $index): void
{
if ($index === '') {
return;
}
$exists = $this->db->query(
'SELECT COUNT(*) AS aggregate
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = ?
AND INDEX_NAME = ?',
[$table, $index]
)->getRow();
if ((int) ($exists->aggregate ?? 0) === 0) {
return;
}
$this->db->query(sprintf(
'ALTER TABLE %s DROP INDEX %s',
$this->db->escapeIdentifiers($table),
$this->db->escapeIdentifiers($index)
));
}
}
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
final class DropUnusedInvoiceOpeningPaidBalances extends Migration
{
public function up(): void
{
if ($this->db->tableExists('invoice_opening_paid_balances')) {
$this->forge->dropTable('invoice_opening_paid_balances', true);
$this->db->resetDataCache();
}
}
public function down(): void
{
if ($this->db->tableExists('invoice_opening_paid_balances')) {
return;
}
$this->db->query(
"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`)
) ENGINE=InnoDB"
);
$this->db->resetDataCache();
}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
final class DropUnusedInvoiceLines extends Migration
{
public function up(): void
{
if ($this->db->tableExists('invoice_lines')) {
$this->forge->dropTable('invoice_lines', true);
$this->db->resetDataCache();
}
}
public function down(): void
{
// invoice_lines was intentionally removed from the financial model.
}
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
final class AddTuitionFeeToInvoiceStudentsList extends Migration
{
public function up(): void
{
if (! $this->db->tableExists('invoice_students_list')
|| $this->db->fieldExists('tuition_fee', 'invoice_students_list')) {
return;
}
$this->forge->addColumn('invoice_students_list', [
'tuition_fee' => [
'type' => 'DECIMAL',
'constraint' => '10,2',
'null' => false,
'default' => '0.00',
'after' => 'enrolled',
],
]);
$this->db->resetDataCache();
}
public function down(): void
{
if ($this->db->tableExists('invoice_students_list')
&& $this->db->fieldExists('tuition_fee', 'invoice_students_list')) {
$this->forge->dropColumn('invoice_students_list', 'tuition_fee');
$this->db->resetDataCache();
}
}
}
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
final class BackfillInvoiceStudentsList extends Migration
{
public function up(): void
{
foreach (['invoice_students_list', 'invoices', 'enrollments', 'students', 'student_class'] as $table) {
if (! $this->db->tableExists($table)) {
return;
}
}
$invoiceFilters = [];
if ($this->db->fieldExists('invoice_number', 'invoices')) {
$invoiceFilters[] = "COALESCE(i.invoice_number, '') NOT LIKE 'CF-%'";
}
if ($this->db->fieldExists('semester', 'invoices')) {
$invoiceFilters[] = "LOWER(TRIM(COALESCE(i.semester, ''))) <> 'opening balance'";
}
if ($this->db->fieldExists('description', 'invoices')) {
$invoiceFilters[] = "LOWER(COALESCE(i.description, '')) NOT LIKE '%carried over%'";
$invoiceFilters[] = "LOWER(COALESCE(i.description, '')) NOT LIKE '%carry-forward%'";
$invoiceFilters[] = "LOWER(COALESCE(i.description, '')) NOT LIKE '%carry over%'";
$invoiceFilters[] = "LOWER(COALESCE(i.description, '')) NOT LIKE '%previous school year%'";
}
$nonEventFilter = $this->db->fieldExists('is_event_only', 'student_class')
? 'AND COALESCE(sc.is_event_only, 0) = 0'
: '';
$where = $invoiceFilters === [] ? '' : 'AND ' . implode("\n AND ", $invoiceFilters);
$this->db->query(
"INSERT INTO invoice_students_list (
invoice_id,
student_id,
student_firstname,
student_lastname,
school_id,
enrolled,
tuition_fee,
school_year,
created_at,
updated_at
)
SELECT
i.id AS invoice_id,
e.student_id,
MIN(COALESCE(s.firstname, '')) AS student_firstname,
MIN(COALESCE(s.lastname, '')) AS student_lastname,
MIN(COALESCE(s.school_id, 0)) AS school_id,
MAX(CASE
WHEN LOWER(TRIM(COALESCE(e.enrollment_status, ''))) IN ('enrolled', 'payment pending') THEN 1
ELSE 0
END) AS enrolled,
0.00 AS tuition_fee,
i.school_year,
UTC_TIMESTAMP(),
UTC_TIMESTAMP()
FROM invoices i
INNER JOIN enrollments e
ON e.parent_id = i.parent_id
AND e.school_year = i.school_year
INNER JOIN students s
ON s.id = e.student_id
WHERE LOWER(TRIM(COALESCE(e.enrollment_status, ''))) IN (
'enrolled',
'payment pending',
'withdrawn',
'refund pending',
'withdraw under review'
)
AND EXISTS (
SELECT 1
FROM student_class sc
WHERE sc.student_id = e.student_id
AND sc.school_year = i.school_year
{$nonEventFilter}
)
AND NOT EXISTS (
SELECT 1
FROM invoice_students_list isl
WHERE isl.invoice_id = i.id
AND isl.student_id = e.student_id
)
{$where}
GROUP BY i.id, e.student_id, i.school_year"
);
}
public function down(): void
{
// Backfilled invoice snapshots are intentionally retained.
}
}