@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user