Files
alrahma_sunday_school/app/Database/Migrations/2026-07-19-000200_AddRefundSourcesAndPayouts.php
root 2be16553df
Tests / PHPUnit (push) Successful in 1m21s
fix test run issue
2026-07-18 23:18:49 -04:00

247 lines
10 KiB
PHP

<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddRefundSourcesAndPayouts extends Migration
{
public function up()
{
$this->ensureRefundSourceColumns();
$this->ensureRefundPayoutsTable();
$this->ensureRefundPayoutSchoolYearColumn();
$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],
'school_year' => ['type' => 'VARCHAR', 'constraint' => 9, '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 ensureRefundPayoutSchoolYearColumn(): void
{
if (!$this->db->tableExists('refund_payouts')) {
return;
}
if (!$this->db->fieldExists('school_year', 'refund_payouts')) {
$this->forge->addColumn('refund_payouts', [
'school_year' => [
'type' => 'VARCHAR',
'constraint' => 9,
'null' => true,
'after' => 'refund_id',
],
]);
}
if ($this->db->tableExists('refunds')) {
$this->db->query(
"UPDATE refund_payouts rp
INNER JOIN refunds r ON r.id = rp.refund_id
SET rp.school_year = r.school_year
WHERE rp.school_year IS NULL OR TRIM(rp.school_year) = ''"
);
}
$this->db->query("ALTER TABLE refund_payouts MODIFY school_year VARCHAR(9) NOT NULL");
}
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, school_year, 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,
'school_year' => (string)($refund['school_year'] ?? ''),
'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,
]);
}
}
}