fix invoice and enrollment fees
This commit is contained in:
@@ -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.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user