Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
205 changes: 205 additions & 0 deletions migrations/2024_01_01_000001_improve_transactions_table.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

/**
* Improve the transactions table to serve as the platform-wide financial
* transaction primitive.
*
* Changes:
* - Rename owner_uuid/owner_type → subject_uuid/subject_type
* - Add payer_uuid/payer_type (who the money flows from)
* - Add payee_uuid/payee_type (who the money flows to)
* - Add initiator_uuid/initiator_type (what triggered the transaction)
* - Add context_uuid/context_type (related business object)
* - Add direction (credit|debit)
* - Add balance_after (running balance snapshot, wallet context)
* - Add fee_amount, tax_amount, net_amount (monetary breakdown)
* - Add exchange_rate, settled_currency, settled_amount (multi-currency)
* - Add reference (idempotency key, unique)
* - Add parent_transaction_uuid (refund/reversal/split linkage)
* - Add gateway_response JSON (raw gateway payload)
* - Add payment_method, payment_method_last4, payment_method_brand
* - Add ip_address (fraud/audit trail)
* - Add notes (internal operator annotation)
* - Add failure_reason, failure_code (structured failure info)
* - Add period (YYYY-MM accounting period, denormalised)
* - Add tags JSON (operator-defined categorisation)
* - Add settled_at, voided_at, reversed_at, expires_at timestamps
* - Make amount NOT NULL DEFAULT 0
* - Keep customer_uuid/customer_type as deprecated nullable aliases
* - Keep owner_uuid/owner_type as deprecated nullable aliases (backfilled)
*/
return new class extends Migration {
public function up(): void
{
Schema::table('transactions', function (Blueprint $table) {
// ----------------------------------------------------------------
// New polymorphic: subject (primary owner of the transaction record)
// ----------------------------------------------------------------
$table->char('subject_uuid', 36)->nullable()->after('owner_type');
$table->string('subject_type')->nullable()->after('subject_uuid');

// ----------------------------------------------------------------
// New polymorphic: payer (funds flow from)
// ----------------------------------------------------------------
$table->char('payer_uuid', 36)->nullable()->after('subject_type');
$table->string('payer_type')->nullable()->after('payer_uuid');

// ----------------------------------------------------------------
// New polymorphic: payee (funds flow to)
// ----------------------------------------------------------------
$table->char('payee_uuid', 36)->nullable()->after('payer_type');
$table->string('payee_type')->nullable()->after('payee_uuid');

// ----------------------------------------------------------------
// New polymorphic: initiator (what triggered the transaction)
// ----------------------------------------------------------------
$table->char('initiator_uuid', 36)->nullable()->after('payee_type');
$table->string('initiator_type')->nullable()->after('initiator_uuid');

// ----------------------------------------------------------------
// New polymorphic: context (related business object)
// ----------------------------------------------------------------
$table->char('context_uuid', 36)->nullable()->after('initiator_type');
$table->string('context_type')->nullable()->after('context_uuid');

// ----------------------------------------------------------------
// Direction and balance
// ----------------------------------------------------------------
$table->string('direction')->nullable()->after('status'); // credit | debit
$table->integer('balance_after')->nullable()->after('direction');

// ----------------------------------------------------------------
// Monetary breakdown (all in smallest currency unit / cents)
// ----------------------------------------------------------------
$table->integer('fee_amount')->default(0)->after('amount');
$table->integer('tax_amount')->default(0)->after('fee_amount');
$table->integer('net_amount')->default(0)->after('tax_amount');

// ----------------------------------------------------------------
// Multi-currency settlement
// ----------------------------------------------------------------
$table->decimal('exchange_rate', 18, 8)->default(1)->after('currency');
$table->string('settled_currency', 3)->nullable()->after('exchange_rate');
$table->integer('settled_amount')->nullable()->after('settled_currency');

// ----------------------------------------------------------------
// Idempotency and linkage
// ----------------------------------------------------------------
$table->string('reference', 191)->nullable()->unique()->after('description');
$table->char('parent_transaction_uuid', 36)->nullable()->after('reference');

// ----------------------------------------------------------------
// Gateway enrichment
// ----------------------------------------------------------------
$table->json('gateway_response')->nullable()->after('gateway_transaction_id');
$table->string('payment_method', 50)->nullable()->after('gateway_response');
$table->string('payment_method_last4', 4)->nullable()->after('payment_method');
$table->string('payment_method_brand', 50)->nullable()->after('payment_method_last4');

// ----------------------------------------------------------------
// Traceability and compliance
// ----------------------------------------------------------------
$table->string('ip_address', 45)->nullable()->after('meta');
$table->text('notes')->nullable()->after('ip_address');
$table->string('failure_reason', 191)->nullable()->after('notes');
$table->string('failure_code', 50)->nullable()->after('failure_reason');

// ----------------------------------------------------------------
// Reporting
// ----------------------------------------------------------------
$table->string('period', 7)->nullable()->after('failure_code'); // YYYY-MM
$table->json('tags')->nullable()->after('period');

// ----------------------------------------------------------------
// Lifecycle timestamps
// ----------------------------------------------------------------
$table->timestamp('settled_at')->nullable()->after('updated_at');
$table->timestamp('voided_at')->nullable()->after('settled_at');
$table->timestamp('reversed_at')->nullable()->after('voided_at');
$table->timestamp('expires_at')->nullable()->after('reversed_at');
});

// --------------------------------------------------------------------
// Backfill subject_* from owner_* (preserve existing data)
// --------------------------------------------------------------------
DB::statement('UPDATE transactions SET subject_uuid = owner_uuid, subject_type = owner_type WHERE owner_uuid IS NOT NULL');

// --------------------------------------------------------------------
// Backfill payer_* from customer_* (customer was semantically the payer)
// --------------------------------------------------------------------
DB::statement('UPDATE transactions SET payer_uuid = customer_uuid, payer_type = customer_type WHERE customer_uuid IS NOT NULL');

// --------------------------------------------------------------------
// Backfill period from created_at
// --------------------------------------------------------------------
DB::statement("UPDATE transactions SET period = DATE_FORMAT(created_at, '%Y-%m') WHERE created_at IS NOT NULL");

// --------------------------------------------------------------------
// Backfill net_amount = amount (no fees/tax in legacy records)
// --------------------------------------------------------------------
DB::statement('UPDATE transactions SET net_amount = COALESCE(amount, 0) WHERE net_amount = 0');

// --------------------------------------------------------------------
// Add indexes on new columns
// --------------------------------------------------------------------
Schema::table('transactions', function (Blueprint $table) {
$table->index(['subject_uuid', 'subject_type'], 'transactions_subject_index');
$table->index(['payer_uuid', 'payer_type'], 'transactions_payer_index');
$table->index(['payee_uuid', 'payee_type'], 'transactions_payee_index');
$table->index(['initiator_uuid', 'initiator_type'], 'transactions_initiator_index');
$table->index(['context_uuid', 'context_type'], 'transactions_context_index');
$table->index('direction', 'transactions_direction_index');
$table->index('period', 'transactions_period_index');
$table->index('parent_transaction_uuid', 'transactions_parent_index');
$table->index('payment_method', 'transactions_payment_method_index');
$table->index('settled_at', 'transactions_settled_at_index');
$table->index(['company_uuid', 'type'], 'transactions_company_type_index');
$table->index(['company_uuid', 'status'], 'transactions_company_status_index');
$table->index(['company_uuid', 'period'], 'transactions_company_period_index');
});
}

public function down(): void
{
Schema::table('transactions', function (Blueprint $table) {
// Drop indexes
$table->dropIndex('transactions_subject_index');
$table->dropIndex('transactions_payer_index');
$table->dropIndex('transactions_payee_index');
$table->dropIndex('transactions_initiator_index');
$table->dropIndex('transactions_context_index');
$table->dropIndex('transactions_direction_index');
$table->dropIndex('transactions_period_index');
$table->dropIndex('transactions_parent_index');
$table->dropIndex('transactions_payment_method_index');
$table->dropIndex('transactions_settled_at_index');
$table->dropIndex('transactions_company_type_index');
$table->dropIndex('transactions_company_status_index');
$table->dropIndex('transactions_company_period_index');
$table->dropUnique(['reference']);

// Drop new columns
$table->dropColumn([
'subject_uuid', 'subject_type',
'payer_uuid', 'payer_type',
'payee_uuid', 'payee_type',
'initiator_uuid', 'initiator_type',
'context_uuid', 'context_type',
'direction', 'balance_after',
'fee_amount', 'tax_amount', 'net_amount',
'exchange_rate', 'settled_currency', 'settled_amount',
'reference', 'parent_transaction_uuid',
'gateway_response',
'payment_method', 'payment_method_last4', 'payment_method_brand',
'ip_address', 'notes', 'failure_reason', 'failure_code',
'period', 'tags',
'settled_at', 'voided_at', 'reversed_at', 'expires_at',
]);
});
}
};
70 changes: 70 additions & 0 deletions migrations/2024_01_01_000002_improve_transaction_items_table.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

/**
* Improve the transaction_items table to align with the robust transaction
* primitive design.
*
* Changes:
* - Add public_id (HasPublicId support)
* - Fix amount column type: string → INT NOT NULL DEFAULT 0
* - Add quantity (INT DEFAULT 1)
* - Add unit_price (INT DEFAULT 0, cents)
* - Add tax_rate (DECIMAL(5,2) DEFAULT 0.00)
* - Add tax_amount (INT DEFAULT 0, cents)
* - Add description (longer text alternative to details)
* - Add sort_order (for ordered line item display)
*/
return new class extends Migration {
public function up(): void
{
Schema::table('transaction_items', function (Blueprint $table) {
// Add public_id for HasPublicId trait support
$table->string('public_id', 191)->nullable()->unique()->after('uuid');

// Add quantity and unit price for proper line-item accounting
$table->integer('quantity')->default(1)->after('transaction_uuid');
$table->integer('unit_price')->default(0)->after('quantity');

// Add tax columns
$table->decimal('tax_rate', 5, 2)->default(0.00)->after('currency');
$table->integer('tax_amount')->default(0)->after('tax_rate');

// Add description as a longer alternative to details (TEXT vs VARCHAR)
$table->text('description')->nullable()->after('details');

// Add sort order for ordered display of line items
$table->unsignedSmallInteger('sort_order')->default(0)->after('description');
});

// Fix amount column: string → integer
// First copy to a temp column, then drop and re-add as integer
DB::statement('ALTER TABLE transaction_items MODIFY COLUMN amount BIGINT NOT NULL DEFAULT 0');

// Backfill unit_price = amount for existing records (single-unit assumption)
DB::statement('UPDATE transaction_items SET unit_price = amount WHERE unit_price = 0 AND amount > 0');
}

public function down(): void
{
// Revert amount back to string (original type)
DB::statement('ALTER TABLE transaction_items MODIFY COLUMN amount VARCHAR(191) NULL');

Schema::table('transaction_items', function (Blueprint $table) {
$table->dropUnique(['public_id']);
$table->dropColumn([
'public_id',
'quantity',
'unit_price',
'tax_rate',
'tax_amount',
'description',
'sort_order',
]);
});
}
};
Loading