155 lines
4.0 KiB
PHP
155 lines
4.0 KiB
PHP
<?php
|
|
|
|
namespace App\Models;
|
|
|
|
use App\Enums\TransactionStatus;
|
|
use App\Enums\TransactionType;
|
|
use App\Models\Traits\HasMinorUnits;
|
|
use App\Models\Traits\HasUuid;
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
|
|
/**
|
|
* transactions — IMMUTABLE LEDGER
|
|
*
|
|
* Once status = 'completed', this row is NEVER updated.
|
|
* Reversals are new rows with type = 'refund'.
|
|
*
|
|
* @property string $uuid
|
|
* @property string $reference_code WASL-XXXXXXXX (user-visible)
|
|
* @property string $type TransactionType enum
|
|
* @property string $status TransactionStatus enum
|
|
* @property int|null $debit_wallet_id
|
|
* @property int|null $credit_wallet_id
|
|
* @property int $amount_minor BIGINT — never float/decimal
|
|
* @property int $fee_minor BIGINT
|
|
* @property string $currency_code
|
|
* @property string|null $description
|
|
* @property array|null $metadata
|
|
* @property string $idempotency_key
|
|
* @property string|null $initiator_ip
|
|
* @property string|null $device_id
|
|
*/
|
|
class Transaction extends BaseModel
|
|
{
|
|
use HasFactory;
|
|
use HasUuid;
|
|
use HasMinorUnits;
|
|
|
|
protected $fillable = [
|
|
'uuid',
|
|
'reference_code',
|
|
'type',
|
|
'status',
|
|
'debit_wallet_id',
|
|
'credit_wallet_id',
|
|
'amount_minor',
|
|
'fee_minor',
|
|
'currency_code',
|
|
'description',
|
|
'metadata',
|
|
'idempotency_key',
|
|
'initiator_ip',
|
|
'device_id',
|
|
'initiated_at',
|
|
'completed_at',
|
|
'failure_reason',
|
|
];
|
|
|
|
protected $casts = [
|
|
'type' => TransactionType::class,
|
|
'status' => TransactionStatus::class,
|
|
'amount_minor' => 'integer',
|
|
'fee_minor' => 'integer',
|
|
'metadata' => 'array',
|
|
'initiated_at' => 'datetime',
|
|
'completed_at' => 'datetime',
|
|
];
|
|
|
|
// ── Relationships ──
|
|
|
|
public function debitWallet()
|
|
{
|
|
return $this->belongsTo(Wallet::class, 'debit_wallet_id');
|
|
}
|
|
|
|
public function creditWallet()
|
|
{
|
|
return $this->belongsTo(Wallet::class, 'credit_wallet_id');
|
|
}
|
|
|
|
public function entries()
|
|
{
|
|
return $this->hasMany(TransactionEntry::class);
|
|
}
|
|
|
|
public function fraudAlerts()
|
|
{
|
|
return $this->hasMany(FraudAlert::class);
|
|
}
|
|
|
|
// ── Scopes ──
|
|
|
|
public function scopePending($query)
|
|
{
|
|
return $query->where('status', TransactionStatus::PENDING);
|
|
}
|
|
|
|
public function scopeCompleted($query)
|
|
{
|
|
return $query->where('status', TransactionStatus::COMPLETED);
|
|
}
|
|
|
|
public function scopeFailed($query)
|
|
{
|
|
return $query->where('status', TransactionStatus::FAILED);
|
|
}
|
|
|
|
public function scopeForWallet($query, int $walletId)
|
|
{
|
|
return $query->where('debit_wallet_id', $walletId)
|
|
->orWhere('credit_wallet_id', $walletId);
|
|
}
|
|
|
|
public function scopeInFlight($query)
|
|
{
|
|
return $query->whereIn('status', [
|
|
TransactionStatus::PENDING,
|
|
TransactionStatus::PROCESSING,
|
|
]);
|
|
}
|
|
|
|
// ── Helpers ──
|
|
|
|
public function formattedAmount(): string
|
|
{
|
|
return self::formatMoney($this->amount_minor, $this->currency_code);
|
|
}
|
|
|
|
public function formattedFee(): string
|
|
{
|
|
return self::formatMoney($this->fee_minor, $this->currency_code);
|
|
}
|
|
|
|
public function isFinal(): bool
|
|
{
|
|
return $this->status->isFinal();
|
|
}
|
|
|
|
/**
|
|
* Generate a unique reference code: WASL-XXXXXXXX
|
|
* Uses random alphanumeric characters for human readability.
|
|
*/
|
|
public static function generateReferenceCode(): string
|
|
{
|
|
$prefix = config('wasl.reference.prefix', 'WASL');
|
|
$length = config('wasl.reference.length', 8);
|
|
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no 0/O/1/I to avoid confusion
|
|
|
|
do {
|
|
$code = $prefix . '-' . substr(str_shuffle($chars), 0, $length);
|
|
} while (self::where('reference_code', $code)->exists());
|
|
|
|
return $code;
|
|
}
|
|
}
|