fix financials
Tests / PHPUnit (push) Failing after 1m21s

This commit is contained in:
root
2026-07-18 22:57:40 -04:00
parent 068e739408
commit a30c1398a1
61 changed files with 10908 additions and 1775 deletions
@@ -69,11 +69,28 @@ class PaymentsLogicAndDataRepairSupport extends Migration
if (!$this->db->fieldExists('idempotency_key', 'payments')) {
$columns['idempotency_key'] = [
'type' => 'CHAR',
'constraint' => 36,
'type' => 'VARCHAR',
'constraint' => 100,
'null' => true,
'after' => 'transaction_id',
];
} else {
$this->forge->modifyColumn('payments', [
'idempotency_key' => [
'type' => 'VARCHAR',
'constraint' => 100,
'null' => true,
],
]);
}
if (!$this->db->fieldExists('request_fingerprint_hash', 'payments')) {
$columns['request_fingerprint_hash'] = [
'type' => 'CHAR',
'constraint' => 64,
'null' => true,
'after' => 'idempotency_key',
];
}
if (!$this->db->fieldExists('is_void', 'payments')) {
@@ -86,6 +103,24 @@ class PaymentsLogicAndDataRepairSupport extends Migration
];
}
if (!$this->db->fieldExists('evidence_status', 'payments')) {
$columns['evidence_status'] = [
'type' => 'VARCHAR',
'constraint' => 30,
'null' => true,
'after' => 'check_file',
];
}
if (!$this->db->fieldExists('evidence_failure_message', 'payments')) {
$columns['evidence_failure_message'] = [
'type' => 'VARCHAR',
'constraint' => 255,
'null' => true,
'after' => 'evidence_status',
];
}
if (!$this->db->fieldExists('reversal_of_payment_id', 'payments')) {
$columns['reversal_of_payment_id'] = [
'type' => 'INT',
@@ -0,0 +1,167 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
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,
],
'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->backfillLegacyInvoiceLines();
}
public function down()
{
if ($this->db->tableExists('invoice_lines')) {
$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, 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,
'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,
]);
}
}
}
@@ -0,0 +1,214 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddRefundSourcesAndPayouts extends Migration
{
public function up()
{
$this->ensureRefundSourceColumns();
$this->ensureRefundPayoutsTable();
$this->ensureRefundPayoutFingerprintColumns();
$this->backfillRefundSources();
$this->backfillLegacyPayouts();
}
public function down()
{
if ($this->db->tableExists('refund_payouts')) {
$this->forge->dropTable('refund_payouts', true);
}
if ($this->db->tableExists('refunds')) {
foreach (['source_type', 'source_id', 'requested_amount_cents', 'approved_amount_cents', 'currency'] as $column) {
if ($this->db->fieldExists($column, 'refunds')) {
$this->forge->dropColumn('refunds', $column);
}
}
}
}
private function ensureRefundSourceColumns(): void
{
if (!$this->db->tableExists('refunds')) {
return;
}
$columns = [];
if (!$this->db->fieldExists('source_type', 'refunds')) {
$columns['source_type'] = ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true, 'after' => 'request'];
}
if (!$this->db->fieldExists('source_id', 'refunds')) {
$columns['source_id'] = ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true, 'after' => 'source_type'];
}
if (!$this->db->fieldExists('requested_amount_cents', 'refunds')) {
$columns['requested_amount_cents'] = ['type' => 'INT', 'constraint' => 11, 'null' => true, 'after' => 'refund_amount'];
}
if (!$this->db->fieldExists('approved_amount_cents', 'refunds')) {
$columns['approved_amount_cents'] = ['type' => 'INT', 'constraint' => 11, 'null' => true, 'after' => 'requested_amount_cents'];
}
if (!$this->db->fieldExists('currency', 'refunds')) {
$columns['currency'] = ['type' => 'CHAR', 'constraint' => 3, 'null' => false, 'default' => 'USD', 'after' => 'approved_amount_cents'];
}
if ($columns !== []) {
$this->forge->addColumn('refunds', $columns);
}
}
private function ensureRefundPayoutsTable(): void
{
if ($this->db->tableExists('refund_payouts')) {
return;
}
$this->forge->addField([
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
'refund_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
'amount_cents' => ['type' => 'INT', 'constraint' => 11, 'null' => false],
'currency' => ['type' => 'CHAR', 'constraint' => 3, 'null' => false, 'default' => 'USD'],
'payout_type' => ['type' => 'VARCHAR', 'constraint' => 30, 'null' => false, 'default' => 'cash_out'],
'payment_method' => ['type' => 'VARCHAR', 'constraint' => 30, 'null' => true],
'status' => ['type' => 'VARCHAR', 'constraint' => 30, 'null' => false],
'external_reference' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true],
'check_number' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true],
'check_date' => ['type' => 'DATE', 'null' => true],
'evidence_path' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true],
'idempotency_key' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => false],
'operation_type' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true],
'request_fingerprint_hash' => ['type' => 'CHAR', 'constraint' => 64, 'null' => true],
'processed_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'processed_at' => ['type' => 'DATETIME', 'null' => true],
'reversed_payout_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'failure_code' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true],
'failure_message' => ['type' => 'TEXT', 'null' => true],
'created_at' => ['type' => 'DATETIME', 'null' => false],
'updated_at' => ['type' => 'DATETIME', 'null' => false],
]);
$this->forge->addKey('id', true);
$this->forge->addKey('refund_id');
$this->forge->addKey('reversed_payout_id');
$this->forge->addUniqueKey('idempotency_key', 'uniq_refund_payouts_idempotency_key');
$this->forge->createTable('refund_payouts', true);
}
private function ensureRefundPayoutFingerprintColumns(): void
{
if (!$this->db->tableExists('refund_payouts')) {
return;
}
$columns = [];
if (!$this->db->fieldExists('operation_type', 'refund_payouts')) {
$columns['operation_type'] = [
'type' => 'VARCHAR',
'constraint' => 50,
'null' => true,
'after' => 'idempotency_key',
];
}
if (!$this->db->fieldExists('request_fingerprint_hash', 'refund_payouts')) {
$columns['request_fingerprint_hash'] = [
'type' => 'CHAR',
'constraint' => 64,
'null' => true,
'after' => 'operation_type',
];
}
if ($columns !== []) {
$this->forge->addColumn('refund_payouts', $columns);
}
}
private function backfillRefundSources(): void
{
if (!$this->db->tableExists('refunds')) {
return;
}
$this->db->query(
"UPDATE `refunds`
SET `requested_amount_cents` = ROUND(COALESCE(`refund_amount`, 0) * 100),
`approved_amount_cents` = ROUND(COALESCE(`refund_amount`, 0) * 100),
`currency` = COALESCE(NULLIF(`currency`, ''), 'USD')
WHERE `requested_amount_cents` IS NULL OR `approved_amount_cents` IS NULL"
);
if ($this->db->fieldExists('source_type', 'refunds')) {
$this->db->query(
"UPDATE `refunds`
SET `source_type` = CASE
WHEN `invoice_id` IS NOT NULL THEN 'invoice_overpayment'
WHEN LOWER(COALESCE(`request`, '')) = 'duplicate' THEN 'payment_duplicate'
ELSE 'administrative_credit'
END,
`source_id` = CASE
WHEN `invoice_id` IS NOT NULL THEN `invoice_id`
ELSE `source_id`
END
WHERE `source_type` IS NULL"
);
}
}
private function backfillLegacyPayouts(): void
{
if (!$this->db->tableExists('refunds') || !$this->db->tableExists('refund_payouts')) {
return;
}
$refunds = $this->db->table('refunds')
->select('id, refund_paid_amount, currency, refund_method, check_nbr, check_file, refunded_at, updated_by, created_at, updated_at')
->where('refund_paid_amount >', 0)
->get()
->getResultArray();
$now = date('Y-m-d H:i:s');
foreach ($refunds as $refund) {
$refundId = (int) ($refund['id'] ?? 0);
if ($refundId <= 0) {
continue;
}
$exists = $this->db->table('refund_payouts')
->where('refund_id', $refundId)
->where('idempotency_key', 'legacy-refund-' . $refundId)
->countAllResults();
if ($exists > 0) {
continue;
}
$this->db->table('refund_payouts')->insert([
'refund_id' => $refundId,
'amount_cents' => (int) round(((float) ($refund['refund_paid_amount'] ?? 0)) * 100),
'currency' => (string) ($refund['currency'] ?? 'USD') ?: 'USD',
'payout_type' => 'cash_out',
'payment_method' => $refund['refund_method'] ?? null,
'status' => 'completed',
'external_reference' => 'legacy_import',
'check_number' => $refund['check_nbr'] ?? null,
'check_date' => null,
'evidence_path' => $refund['check_file'] ?? null,
'idempotency_key' => 'legacy-refund-' . $refundId,
'operation_type' => 'refund_payout',
'request_fingerprint_hash' => hash('sha256', json_encode([
'operation_type' => 'refund_payout',
'refund_id' => $refundId,
'amount_cents' => (int) round(((float) ($refund['refund_paid_amount'] ?? 0)) * 100),
'payment_method' => $refund['refund_method'] ?? null,
'currency' => (string) ($refund['currency'] ?? 'USD') ?: 'USD',
'external_reference' => 'legacy_import',
], JSON_UNESCAPED_SLASHES)),
'processed_by' => $refund['updated_by'] ?? null,
'processed_at' => $refund['refunded_at'] ?? $refund['updated_at'] ?? $now,
'reversed_payout_id' => null,
'failure_code' => null,
'failure_message' => null,
'created_at' => $refund['created_at'] ?? $now,
'updated_at' => $refund['updated_at'] ?? $now,
]);
}
}
}
@@ -0,0 +1,56 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddRefundReconciliationFields extends Migration
{
public function up()
{
if (!$this->db->tableExists('refunds')) {
return;
}
$columns = [];
if (!$this->db->fieldExists('reconciliation_status', 'refunds')) {
$columns['reconciliation_status'] = [
'type' => 'VARCHAR',
'constraint' => 30,
'null' => true,
'after' => 'approved_amount_cents',
];
}
if (!$this->db->fieldExists('reconciliation_reason', 'refunds')) {
$columns['reconciliation_reason'] = [
'type' => 'TEXT',
'null' => true,
'after' => 'reconciliation_status',
];
}
if (!$this->db->fieldExists('reconciliation_required_at', 'refunds')) {
$columns['reconciliation_required_at'] = [
'type' => 'DATETIME',
'null' => true,
'after' => 'reconciliation_reason',
];
}
if ($columns !== []) {
$this->forge->addColumn('refunds', $columns);
}
}
public function down()
{
if (!$this->db->tableExists('refunds')) {
return;
}
foreach (['reconciliation_status', 'reconciliation_reason', 'reconciliation_required_at'] as $column) {
if ($this->db->fieldExists($column, 'refunds')) {
$this->forge->dropColumn('refunds', $column);
}
}
}
}
@@ -0,0 +1,191 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class FinancialWorkflowHardening extends Migration
{
public function up()
{
$this->ensureDiscountUsageColumns();
$this->ensureDiscountUsageUniqueness();
$this->ensureAdditionalChargeLedgerColumns();
$this->ensureInvoiceLineActiveSourceKey();
$this->expandAdditionalChargeStatus();
}
public function down()
{
if ($this->db->tableExists('discount_usages')) {
foreach (['requested_discount_cents', 'eligible_base_cents', 'eligible_base_before_cents', 'applied_discount_cents', 'application_order'] as $column) {
if ($this->db->fieldExists($column, 'discount_usages')) {
$this->forge->dropColumn('discount_usages', $column);
}
}
}
}
private function ensureDiscountUsageColumns(): void
{
if (!$this->db->tableExists('discount_usages')) {
return;
}
$columns = [];
if (!$this->db->fieldExists('requested_discount_cents', 'discount_usages')) {
$columns['requested_discount_cents'] = ['type' => 'INT', 'constraint' => 11, 'null' => true, 'after' => 'discount_amount'];
}
if (!$this->db->fieldExists('eligible_base_cents', 'discount_usages')) {
$columns['eligible_base_cents'] = ['type' => 'INT', 'constraint' => 11, 'null' => true, 'after' => 'requested_discount_cents'];
}
if (!$this->db->fieldExists('eligible_base_before_cents', 'discount_usages')) {
$columns['eligible_base_before_cents'] = ['type' => 'INT', 'constraint' => 11, 'null' => true, 'after' => 'eligible_base_cents'];
}
if (!$this->db->fieldExists('applied_discount_cents', 'discount_usages')) {
$columns['applied_discount_cents'] = ['type' => 'INT', 'constraint' => 11, 'null' => true, 'after' => 'eligible_base_before_cents'];
}
if (!$this->db->fieldExists('application_order', 'discount_usages')) {
$columns['application_order'] = ['type' => 'INT', 'constraint' => 11, 'null' => true, 'after' => 'applied_discount_cents'];
}
if ($columns !== []) {
$this->forge->addColumn('discount_usages', $columns);
}
$this->db->query(
"UPDATE discount_usages
SET requested_discount_cents = COALESCE(requested_discount_cents, ROUND(COALESCE(discount_amount, 0) * 100)),
eligible_base_cents = COALESCE(eligible_base_cents, ROUND(COALESCE(discount_amount, 0) * 100)),
eligible_base_before_cents = COALESCE(eligible_base_before_cents, eligible_base_cents, ROUND(COALESCE(discount_amount, 0) * 100)),
applied_discount_cents = COALESCE(applied_discount_cents, ROUND(COALESCE(discount_amount, 0) * 100))
WHERE requested_discount_cents IS NULL
OR eligible_base_cents IS NULL
OR eligible_base_before_cents IS NULL
OR applied_discount_cents IS NULL"
);
$this->db->query(
"UPDATE discount_usages du
JOIN (
SELECT id, ROW_NUMBER() OVER (PARTITION BY invoice_id ORDER BY COALESCE(used_at, created_at), id) AS rn
FROM discount_usages
) ordered ON ordered.id = du.id
SET du.application_order = COALESCE(du.application_order, ordered.rn)
WHERE du.application_order IS NULL"
);
}
private function ensureDiscountUsageUniqueness(): void
{
if (!$this->db->tableExists('discount_usages')) {
return;
}
$indexes = $this->db->query('SHOW INDEX FROM discount_usages')->getResultArray();
foreach ($indexes as $index) {
if (($index['Key_name'] ?? '') === 'uniq_discount_usage_voucher_invoice') {
return;
}
}
$this->db->query(
'CREATE UNIQUE INDEX uniq_discount_usage_voucher_invoice ON discount_usages (voucher_id, invoice_id)'
);
$this->assertIndexExists('discount_usages', 'uniq_discount_usage_voucher_invoice');
}
private function ensureAdditionalChargeLedgerColumns(): void
{
if (!$this->db->tableExists('additional_charges')) {
return;
}
$columns = [];
if (!$this->db->fieldExists('applied_invoice_line_id', 'additional_charges')) {
$columns['applied_invoice_line_id'] = ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true, 'after' => 'status'];
}
if (!$this->db->fieldExists('applied_by', 'additional_charges')) {
$columns['applied_by'] = ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true, 'after' => 'applied_invoice_line_id'];
}
if (!$this->db->fieldExists('applied_at', 'additional_charges')) {
$columns['applied_at'] = ['type' => 'DATETIME', 'null' => true, 'after' => 'applied_by'];
}
if (!$this->db->fieldExists('voided_by', 'additional_charges')) {
$columns['voided_by'] = ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true, 'after' => 'applied_at'];
}
if (!$this->db->fieldExists('voided_at', 'additional_charges')) {
$columns['voided_at'] = ['type' => 'DATETIME', 'null' => true, 'after' => 'voided_by'];
}
if (!$this->db->fieldExists('void_reason', 'additional_charges')) {
$columns['void_reason'] = ['type' => 'TEXT', 'null' => true, 'after' => 'voided_at'];
}
if ($columns !== []) {
$this->forge->addColumn('additional_charges', $columns);
}
}
private function ensureInvoiceLineActiveSourceKey(): void
{
if (!$this->db->tableExists('invoice_lines')) {
return;
}
if (!$this->db->fieldExists('active_source_key', 'invoice_lines')) {
$this->forge->addColumn('invoice_lines', [
'active_source_key' => [
'type' => 'VARCHAR',
'constraint' => 120,
'null' => true,
'after' => 'source_id',
],
]);
}
$indexes = $this->db->query('SHOW INDEX FROM invoice_lines')->getResultArray();
foreach ($indexes as $index) {
if (($index['Key_name'] ?? '') === 'uniq_invoice_lines_active_source_key') {
return;
}
}
$this->db->query(
'CREATE UNIQUE INDEX uniq_invoice_lines_active_source_key ON invoice_lines (active_source_key)'
);
$this->assertIndexExists('invoice_lines', 'uniq_invoice_lines_active_source_key');
}
private function expandAdditionalChargeStatus(): void
{
if (!$this->db->tableExists('additional_charges')) {
return;
}
try {
$this->forge->modifyColumn('additional_charges', [
'status' => [
'type' => 'ENUM',
'constraint' => ['pending', 'approved', 'applied', 'rejected', 'voided', 'reversed'],
'default' => 'pending',
'null' => false,
],
]);
} catch (\Throwable $e) {
throw new \RuntimeException('Could not expand additional charge statuses: ' . $e->getMessage(), 0, $e);
}
}
private function assertIndexExists(string $table, string $indexName): void
{
$indexes = $this->db->query('SHOW INDEX FROM ' . $this->db->escapeIdentifiers($table))->getResultArray();
foreach ($indexes as $index) {
if (($index['Key_name'] ?? '') === $indexName) {
return;
}
}
throw new \RuntimeException("Required index {$indexName} was not created on {$table}.");
}
}
@@ -0,0 +1,153 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class FinancialReimbursementPoHardening extends Migration
{
public function up()
{
$this->ensureReimbursementInvariants();
$this->ensurePurchaseOrderInvariants();
}
public function down()
{
if ($this->db->tableExists('reimbursements')) {
$this->dropIndexIfExists('reimbursements', 'uniq_reimbursement_active_expense');
if ($this->db->fieldExists('active_expense_id', 'reimbursements')) {
$this->forge->dropColumn('reimbursements', 'active_expense_id');
}
}
$this->dropIndexIfExists('reimbursement_batches', 'uniq_reimbursement_batch_year_sequence');
$this->dropIndexIfExists('reimbursement_batch_items', 'uniq_reimbursement_batch_item_active_expense');
}
private function ensureReimbursementInvariants(): void
{
if (!$this->db->tableExists('reimbursements')) {
return;
}
try {
$this->forge->modifyColumn('reimbursements', [
'status' => [
'type' => 'ENUM',
'constraint' => ['pending', 'approved', 'paid', 'rejected', 'reversed', 'Pending', 'Approved', 'Paid', 'Rejected', 'Reversed'],
'default' => 'pending',
'null' => false,
],
]);
} catch (\Throwable $e) {
throw new \RuntimeException('Could not normalize reimbursement statuses: ' . $e->getMessage(), 0, $e);
}
if (!$this->db->fieldExists('active_expense_id', 'reimbursements')) {
try {
$this->db->query(
"ALTER TABLE reimbursements
ADD active_expense_id INT
GENERATED ALWAYS AS (
CASE
WHEN expense_id IS NOT NULL
AND LOWER(status) NOT IN ('reversed','rejected','cancelled','canceled','voided')
THEN expense_id
ELSE NULL
END
) STORED"
);
} catch (\Throwable $e) {
throw new \RuntimeException('Could not add reimbursement active_expense_id generated column: ' . $e->getMessage(), 0, $e);
}
}
$this->createIndexIfMissing('reimbursements', 'uniq_reimbursement_active_expense', 'CREATE UNIQUE INDEX uniq_reimbursement_active_expense ON reimbursements (active_expense_id)');
$this->createIndexIfMissing('reimbursement_batches', 'uniq_reimbursement_batch_year_sequence', 'CREATE UNIQUE INDEX uniq_reimbursement_batch_year_sequence ON reimbursement_batches (school_year, yearly_batch_number)');
$this->createIndexIfMissing('reimbursement_batch_items', 'uniq_reimbursement_batch_item_active_expense', 'CREATE UNIQUE INDEX uniq_reimbursement_batch_item_active_expense ON reimbursement_batch_items (batch_id, expense_id)');
}
private function ensurePurchaseOrderInvariants(): void
{
if (!$this->db->tableExists('purchase_order_items')) {
return;
}
try {
$this->forge->modifyColumn('purchase_order_items', [
'quantity' => [
'type' => 'INT',
'constraint' => 11,
'null' => false,
],
'received_qty' => [
'type' => 'INT',
'constraint' => 11,
'default' => 0,
'null' => false,
],
'unit_cost' => [
'type' => 'DECIMAL',
'constraint' => '10,2',
'default' => 0,
'null' => false,
],
]);
} catch (\Throwable $e) {
throw new \RuntimeException('Could not tighten purchase order item columns: ' . $e->getMessage(), 0, $e);
}
try {
$this->db->query(
'ALTER TABLE purchase_order_items
ADD CONSTRAINT chk_po_item_quantities
CHECK (quantity > 0 AND received_qty >= 0 AND received_qty <= quantity AND unit_cost >= 0)'
);
} catch (\Throwable $e) {
throw new \RuntimeException('Could not add purchase order quantity check: ' . $e->getMessage(), 0, $e);
}
}
private function createIndexIfMissing(string $table, string $indexName, string $sql): void
{
if (!$this->db->tableExists($table)) {
return;
}
$indexes = $this->db->query('SHOW INDEX FROM ' . $this->db->escapeIdentifiers($table))->getResultArray();
foreach ($indexes as $index) {
if (($index['Key_name'] ?? '') === $indexName) {
return;
}
}
$this->db->query($sql);
$indexes = $this->db->query('SHOW INDEX FROM ' . $this->db->escapeIdentifiers($table))->getResultArray();
foreach ($indexes as $index) {
if (($index['Key_name'] ?? '') === $indexName) {
return;
}
}
throw new \RuntimeException('Required index ' . $indexName . ' was not created.');
}
private function dropIndexIfExists(string $table, string $indexName): void
{
if (!$this->db->tableExists($table)) {
return;
}
try {
$indexes = $this->db->query('SHOW INDEX FROM ' . $this->db->escapeIdentifiers($table))->getResultArray();
foreach ($indexes as $index) {
if (($index['Key_name'] ?? '') === $indexName) {
$this->db->query('DROP INDEX ' . $this->db->escapeIdentifiers($indexName) . ' ON ' . $this->db->escapeIdentifiers($table));
return;
}
}
} catch (\Throwable $e) {
log_message('warning', 'Could not drop index ' . $indexName . ': ' . $e->getMessage());
}
}
}
@@ -0,0 +1,55 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreatePaymentCorrections extends Migration
{
public function up()
{
if ($this->db->tableExists('payment_corrections')) {
return;
}
$this->forge->addField([
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
'payment_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
'invoice_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
'parent_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
'correction_type' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => false],
'approved_refundable_cents' => ['type' => 'INT', 'constraint' => 11, 'null' => false],
'status' => ['type' => 'VARCHAR', 'constraint' => 30, 'null' => false],
'reason' => ['type' => 'TEXT', 'null' => true],
'approved_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'approved_at' => ['type' => 'DATETIME', 'null' => true],
'created_at' => ['type' => 'DATETIME', 'null' => false],
'updated_at' => ['type' => 'DATETIME', 'null' => false],
]);
$this->forge->addKey('id', true);
$this->forge->createTable('payment_corrections', true);
$this->db->query('CREATE INDEX idx_payment_corrections_payment_status ON payment_corrections (payment_id, status)');
$this->db->query('CREATE INDEX idx_payment_corrections_invoice_status ON payment_corrections (invoice_id, status)');
$this->assertIndexExists('payment_corrections', 'idx_payment_corrections_payment_status');
}
public function down()
{
if ($this->db->tableExists('payment_corrections')) {
$this->forge->dropTable('payment_corrections', true);
}
}
private function assertIndexExists(string $table, string $indexName): void
{
$indexes = $this->db->query('SHOW INDEX FROM ' . $this->db->escapeIdentifiers($table))->getResultArray();
foreach ($indexes as $index) {
if (($index['Key_name'] ?? '') === $indexName) {
return;
}
}
throw new \RuntimeException("Required index {$indexName} was not created on {$table}.");
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateReimbursementReversals extends Migration
{
public function up()
{
if ($this->db->tableExists('reimbursement_reversals')) {
return;
}
$this->forge->addField([
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
'reimbursement_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
'amount_cents' => ['type' => 'INT', 'constraint' => 11, 'null' => false],
'reason' => ['type' => 'TEXT', 'null' => false],
'reversed_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'reversed_at' => ['type' => 'DATETIME', 'null' => false],
'created_at' => ['type' => 'DATETIME', 'null' => false],
]);
$this->forge->addKey('id', true);
$this->forge->addKey('reimbursement_id');
$this->forge->createTable('reimbursement_reversals', true);
}
public function down()
{
if ($this->db->tableExists('reimbursement_reversals')) {
$this->forge->dropTable('reimbursement_reversals', true);
}
}
}
@@ -0,0 +1,54 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateInventoryReceiptOperations extends Migration
{
public function up()
{
if (!$this->db->tableExists('inventory_receipt_operations')) {
$this->forge->addField([
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
'idempotency_key' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => false],
'purchase_order_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
'request_fingerprint_hash' => ['type' => 'CHAR', 'constraint' => 64, 'null' => false],
'status' => ['type' => 'VARCHAR', 'constraint' => 30, 'null' => false],
'actor_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'created_at' => ['type' => 'DATETIME', 'null' => false],
'updated_at' => ['type' => 'DATETIME', 'null' => false],
]);
$this->forge->addKey('id', true);
$this->forge->addUniqueKey('idempotency_key', 'uniq_inventory_receipt_operation_key');
$this->forge->createTable('inventory_receipt_operations', true);
}
if (!$this->db->tableExists('inventory_receipt_lines')) {
$this->forge->addField([
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
'operation_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
'purchase_order_item_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
'quantity' => ['type' => 'INT', 'constraint' => 11, 'null' => false],
'movement_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'reversal_movement_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'reversed_quantity' => ['type' => 'INT', 'constraint' => 11, 'null' => false, 'default' => 0],
'created_at' => ['type' => 'DATETIME', 'null' => false],
]);
$this->forge->addKey('id', true);
$this->forge->addKey('operation_id');
$this->forge->addKey('purchase_order_item_id');
$this->forge->createTable('inventory_receipt_lines', true);
}
}
public function down()
{
if ($this->db->tableExists('inventory_receipt_lines')) {
$this->forge->dropTable('inventory_receipt_lines', true);
}
if ($this->db->tableExists('inventory_receipt_operations')) {
$this->forge->dropTable('inventory_receipt_operations', true);
}
}
}