Initial commit - WASL Digital Wallet

This commit is contained in:
Hamza-Ayed
2026-06-20 21:55:06 +03:00
commit 7306c47368
61 changed files with 4157 additions and 0 deletions

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
/**
* audit_logs — IMMUTABLE security & audit trail.
* NO updated_at column. Records are write-once, never modified or deleted.
*
* @property int|null $user_id The subject of the action
* @property int|null $actor_id Who performed it (admin/system/user)
* @property string $action login/transfer_create/pin_change/kyc_approved/wallet_freeze
* @property string|null $subject_type App\Models\Wallet etc.
* @property int|null $subject_id
* @property array|null $old_values
* @property array|null $new_values
* @property string|null $ip_address
* @property string|null $user_agent
* @property string|null $device_id
*/
class AuditLog extends BaseModel
{
use HasFactory;
// NO timestamps trait — we only have created_at (manual)
public $timestamps = false;
protected $fillable = [
'user_id',
'actor_id',
'action',
'subject_type',
'subject_id',
'old_values',
'new_values',
'ip_address',
'user_agent',
'device_id',
'created_at',
];
protected $casts = [
'old_values' => 'array',
'new_values' => 'array',
'created_at' => 'datetime',
];
// ── Relationships ──
public function user()
{
return $this->belongsTo(User::class);
}
public function actor()
{
return $this->belongsTo(User::class, 'actor_id');
}
// ── Scopes ──
public function scopeForUser($query, int $userId)
{
return $query->where('user_id', $userId);
}
public function scopeForAction($query, string $action)
{
return $query->where('action', $action);
}
public function scopeRecent($query, int $hours = 24)
{
return $query->where('created_at', '>=', now()->subHours($hours));
}
// ── Static factory ──
/**
* Log an audit event. Called by the AuditService.
*/
public static function record(array $data): self
{
return self::create(array_merge($data, [
'created_at' => now(),
]));
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Models;
use App\Models\Traits\HasUuid;
use Illuminate\Database\Eloquent\Factories\HasFactory;
/**
* Abstract base model for all WASL models with standard behavior.
* UUID generation, timestampz, soft deletes.
*/
abstract class BaseModel extends \Illuminate\Database\Eloquent\Model
{
use HasFactory;
}

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
/**
* @property string $purpose login/transfer/pin_change/phone_change/kyc/registration
* @property string $code_hash NEVER plain text — always hash
* @property string $channel sms/authenticator
* @property int $attempts
* @property \Illuminate\Support\Carbon $expires_at
* @property \Illuminate\Support\Carbon|null $used_at
*/
class OtpCode extends BaseModel
{
use HasFactory;
protected $fillable = [
'user_id',
'purpose',
'code_hash',
'channel',
'attempts',
'expires_at',
'used_at',
];
protected $casts = [
'attempts' => 'integer',
'expires_at' => 'datetime',
'used_at' => 'datetime',
];
// ── Relationships ──
public function user()
{
return $this->belongsTo(User::class);
}
// ── Scopes ──
public function scopeValid($query)
{
return $query->where('expires_at', '>', now())
->whereNull('used_at');
}
public function scopeForPurpose($query, string $purpose)
{
return $query->where('purpose', $purpose);
}
public function scopeForUser($query, int $userId)
{
return $query->where('user_id', $userId);
}
// ── Helpers ──
public function isExpired(): bool
{
return $this->expires_at->isPast();
}
public function isUsed(): bool
{
return !is_null($this->used_at);
}
public function hasAttemptsLeft(int $max): bool
{
return $this->attempts < $max;
}
public function incrementAttempts(): bool
{
return $this->increment('attempts');
}
public function markUsed(): bool
{
return $this->update(['used_at' => now()]);
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Models\Traits;
/**
* Provides minor-unit (cents/halala) money helpers.
* All monetary amounts in WASL use BIGINT minor units.
* This trait provides conversion methods.
*
* Usage: $wallet->formatBalance('SYP') → "150,000.00 SYP"
*/
trait HasMinorUnits
{
/**
* Convert display amount (e.g., 1500.00) to minor units (150000).
* Uses string math to avoid float precision issues.
*/
public static function toMinor(string|int|float $amount, int $decimals = 2): int
{
return (int) round((float) $amount * (10 ** $decimals));
}
/**
* Convert minor units back to display amount string.
* Returns a string to preserve precision (e.g., "1500.00").
*/
public static function fromMinor(int $minor, int $decimals = 2): string
{
return number_format($minor / (10 ** $decimals), $decimals, '.', '');
}
/**
* Format a minor-unit value for user display.
* e.g., formatMoney(150000, 'SYP', 2) → "150,000.00 SYP"
*/
public static function formatMoney(
int $minor,
string $currency = 'SYP',
?int $decimals = null
): string {
$decimals = $decimals ?? config("wasl.wallet.minor_unit_decimals.{$currency}", 2);
$amount = self::fromMinor($minor, $decimals);
return number_format((float) $amount, $decimals, '.', ',') . ' ' . $currency;
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Models\Traits;
/**
* Auto-generates a UUID on creation and provides uuid-based route binding.
* Every WASL model exposed to the API MUST use this trait.
*/
trait HasUuid
{
protected static function booted(): void
{
static::creating(function ($model) {
if (empty($model->getAttribute('uuid'))) {
$model->setAttribute('uuid', str()->uuid()->toString());
}
});
}
public function getRouteKeyName(): string
{
return 'uuid';
}
/**
* Resolve by UUID instead of BIGINT primary key.
*/
public function resolveRouteBinding($value, $field = null)
{
return $this->where('uuid', $value)->firstOrFail();
}
/**
* Scope: filter by UUID.
*/
public function scopeWhereUuid($query, string $uuid)
{
return $query->where('uuid', $uuid);
}
}

View File

@@ -0,0 +1,154 @@
<?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;
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace App\Models;
use App\Enums\EntryType;
use Illuminate\Database\Eloquent\Factories\HasFactory;
/**
* transaction_entries — DOUBLE-ENTRY LEDGER
*
* Every transaction MUST have exactly 2 entries (debit + credit).
* balance_after_minor is the wallet balance snapshot after this entry.
* This table is the source of truth for all balance calculations.
*
* @property int $transaction_id
* @property int $wallet_id
* @property string $entry_type debit/credit
* @property int $amount_minor BIGINT
* @property int $balance_after_minor BIGINT — snapshot after this entry
*/
class TransactionEntry extends BaseModel
{
use HasFactory;
protected $fillable = [
'transaction_id',
'wallet_id',
'entry_type',
'amount_minor',
'balance_after_minor',
];
protected $casts = [
'entry_type' => EntryType::class,
'amount_minor' => 'integer',
'balance_after_minor' => 'integer',
];
public $timestamps = false; // created_at only, set via useCurrent()
// ── Relationships ──
public function transaction()
{
return $this->belongsTo(Transaction::class);
}
public function wallet()
{
return $this->belongsTo(Wallet::class);
}
// ── Scopes ──
public function scopeDebits($query)
{
return $query->where('entry_type', EntryType::DEBIT);
}
public function scopeCredits($query)
{
return $query->where('entry_type', EntryType::CREDIT);
}
public function scopeForWallet($query, int $walletId)
{
return $query->where('wallet_id', $walletId);
}
// ── Reconciliation helper ──
/**
* Verify double-entry integrity: SUM(debits) must equal SUM(credits)
* for the given transaction_id.
*/
public static function verifyIntegrity(int $transactionId): bool
{
$debits = self::where('transaction_id', $transactionId)->debits()->sum('amount_minor');
$credits = self::where('transaction_id', $transactionId)->credits()->sum('amount_minor');
return $debits === $credits && $debits > 0;
}
}

181
Backend/app/Models/User.php Normal file
View File

@@ -0,0 +1,181 @@
<?php
namespace App\Models;
use App\Casts\Encryptable;
use App\Enums\UserStatus;
use App\Models\Traits\HasUuid;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Spatie\Permission\Traits\HasRoles;
/**
* @property string $uuid
* @property string $full_name
* @property string $phone_number AES-256 encrypted — NEVER log/expose raw
* @property string $phone_hash SHA-256 for lookup/search
* @property string|null $email
* @property string|null $national_id AES-256 encrypted
* @property string|null $national_id_hash
* @property string|null $password bcrypt/argon2id
* @property string|null $pin_hash argon2id for 6-digit PIN
* @property string $status UserStatus enum value
* @property int $kyc_level 0=none, 1=phone, 2=id, 3=full
* @property string $language
* @property string $country_code
* @property int $failed_login_count
* @property \Illuminate\Support\Carbon|null $locked_until
* @property \Illuminate\Support\Carbon|null $last_login_at
* @property string|null $last_login_ip
* @property \Illuminate\Support\Carbon|null $phone_verified_at
* @property \Illuminate\Support\Carbon|null $email_verified_at
*/
class User extends Authenticatable
{
use HasFactory;
use Notifiable;
use HasRoles;
use SoftDeletes;
use HasUuid;
protected $table = 'users';
// Guarded — use $fillable explicitly
protected $guarded = ['id', 'uuid', 'created_at', 'updated_at', 'deleted_at'];
protected $fillable = [
'full_name',
'phone_number',
'phone_hash',
'email',
'national_id',
'national_id_hash',
'password',
'pin_hash',
'status',
'kyc_level',
'language',
'country_code',
'failed_login_count',
'locked_until',
'last_login_at',
'last_login_ip',
'phone_verified_at',
'email_verified_at',
];
// ── Encryption casts ── NEVER log or expose these fields raw ──
protected $casts = [
'phone_number' => Encryptable::class,
'national_id' => Encryptable::class,
'password' => 'hashed',
'pin_hash' => 'hashed',
'status' => UserStatus::class,
'kyc_level' => 'integer',
'failed_login_count' => 'integer',
'locked_until' => 'datetime',
'last_login_at' => 'datetime',
'phone_verified_at' => 'datetime',
'email_verified_at' => 'datetime',
];
// ── Hide sensitive fields from API/array serialization ──
protected $hidden = [
'id',
'password',
'pin_hash',
'phone_number', // encrypted ciphertext
'national_id', // encrypted ciphertext
'phone_hash', // internal hash
'national_id_hash', // internal hash
'failed_login_count',
'locked_until',
'created_at',
'updated_at',
'deleted_at',
];
protected $visible = [
'uuid',
'full_name',
'email',
'status',
'kyc_level',
'language',
'country_code',
'phone_verified_at',
'last_login_at',
];
// ── Helper: get masked phone for display (e.g., +963 *** 456) ──
public function maskedPhone(): ?string
{
$phone = $this->phone_number;
if (!$phone || strlen($phone) < 6) {
return null;
}
$len = strlen($phone);
$maskLen = max(3, $len - 6);
return substr($phone, 0, 3) . str_repeat('*', $maskLen) . substr($phone, -3);
}
// ── Relationships ──
public function wallets()
{
return $this->hasMany(Wallet::class);
}
public function devices()
{
return $this->hasMany(UserDevice::class);
}
public function kycDocuments()
{
return $this->hasMany(KycDocument::class);
}
public function transactions()
{
// Transactions where user is either sender or receiver (via wallets)
return Transaction::whereIn('debit_wallet_id', $this->wallets()->select('id'))
->orWhereIn('credit_wallet_id', $this->wallets()->select('id'));
}
// ── Status helpers ──
public function isActive(): bool
{
return $this->status === UserStatus::ACTIVE;
}
public function canTransact(): bool
{
return $this->isActive() && $this->kyc_level > 0;
}
public function isLocked(): bool
{
return $this->locked_until && $this->locked_until->isFuture();
}
public function isPhoneVerified(): bool
{
return !is_null($this->phone_verified_at);
}
// ── Scopes ──
public function scopeActive($query)
{
return $query->where('status', UserStatus::ACTIVE);
}
public function scopeByPhoneHash($query, string $hash)
{
return $query->where('phone_hash', $hash);
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
/**
* @property string $device_fingerprint
* @property string|null $device_name
* @property string|null $platform android/ios
* @property string|null $os_version
* @property string|null $app_version
* @property bool $is_trusted
* @property string|null $push_token
*/
class UserDevice extends BaseModel
{
use HasFactory;
protected $fillable = [
'user_id',
'device_fingerprint',
'device_name',
'platform',
'os_version',
'app_version',
'is_trusted',
'push_token',
];
protected $casts = [
'is_trusted' => 'boolean',
'first_seen_at' => 'datetime',
'last_seen_at' => 'datetime',
];
protected $hidden = [
'id',
'user_id',
'push_token',
];
// ── Relationships ──
public function user()
{
return $this->belongsTo(User::class);
}
// ── Scopes ──
public function scopeTrusted($query)
{
return $query->where('is_trusted', true);
}
public function scopeForFingerprint($query, string $fingerprint)
{
return $query->where('device_fingerprint', $fingerprint);
}
// ── Helpers ──
public function markSeen(): bool
{
return $this->update(['last_seen_at' => now()]);
}
public function trust(): bool
{
return $this->update(['is_trusted' => true]);
}
public function revoke(): bool
{
return $this->update(['is_trusted' => false]);
}
}

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Models;
use App\Enums\WalletStatus;
use App\Models\Traits\HasMinorUnits;
use App\Models\Traits\HasUuid;
use Illuminate\Database\Eloquent\Factories\HasFactory;
/**
* @property string $uuid
* @property int $user_id
* @property string $currency_code
* @property int $balance_minor BIGINT — never float/decimal
* @property int $balance_pending_minor BIGINT — holds in-flight amounts
* @property string $status WalletStatus enum value
* @property int|null $daily_limit_minor
* @property int|null $monthly_limit_minor
*/
class Wallet extends BaseModel
{
use HasFactory;
use HasUuid;
use HasMinorUnits;
protected $fillable = [
'user_id',
'currency_code',
'balance_minor',
'balance_pending_minor',
'status',
'daily_limit_minor',
'monthly_limit_minor',
];
protected $casts = [
'balance_minor' => 'integer',
'balance_pending_minor' => 'integer',
'status' => WalletStatus::class,
'daily_limit_minor' => 'integer',
'monthly_limit_minor' => 'integer',
];
protected $hidden = [
'id',
'user_id',
'created_at',
'updated_at',
'deleted_at',
];
// ── Relationships ──
public function user()
{
return $this->belongsTo(User::class);
}
public function debitEntries()
{
return $this->hasMany(TransactionEntry::class, 'wallet_id')
->where('entry_type', 'debit');
}
public function creditEntries()
{
return $this->hasMany(TransactionEntry::class, 'wallet_id')
->where('entry_type', 'credit');
}
// ── Helpers ──
public function isActive(): bool
{
return $this->status === WalletStatus::ACTIVE;
}
public function canReceive(): bool
{
return $this->status->canReceive();
}
public function canSend(): bool
{
return $this->status->canSend();
}
/**
* Format balance for API response.
* e.g., "150,000.00 SYP"
*/
public function formattedBalance(): string
{
return self::formatMoney($this->balance_minor, $this->currency_code);
}
// ── Scopes ──
public function scopeActive($query)
{
return $query->where('status', WalletStatus::ACTIVE);
}
}