Initial commit - WASL Digital Wallet
This commit is contained in:
89
Backend/app/Models/AuditLog.php
Normal file
89
Backend/app/Models/AuditLog.php
Normal 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(),
|
||||
]));
|
||||
}
|
||||
}
|
||||
15
Backend/app/Models/BaseModel.php
Normal file
15
Backend/app/Models/BaseModel.php
Normal 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;
|
||||
}
|
||||
86
Backend/app/Models/OtpCode.php
Normal file
86
Backend/app/Models/OtpCode.php
Normal 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()]);
|
||||
}
|
||||
}
|
||||
46
Backend/app/Models/Traits/HasMinorUnits.php
Normal file
46
Backend/app/Models/Traits/HasMinorUnits.php
Normal 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;
|
||||
}
|
||||
}
|
||||
40
Backend/app/Models/Traits/HasUuid.php
Normal file
40
Backend/app/Models/Traits/HasUuid.php
Normal 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);
|
||||
}
|
||||
}
|
||||
154
Backend/app/Models/Transaction.php
Normal file
154
Backend/app/Models/Transaction.php
Normal 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;
|
||||
}
|
||||
}
|
||||
83
Backend/app/Models/TransactionEntry.php
Normal file
83
Backend/app/Models/TransactionEntry.php
Normal 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
181
Backend/app/Models/User.php
Normal 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);
|
||||
}
|
||||
}
|
||||
78
Backend/app/Models/UserDevice.php
Normal file
78
Backend/app/Models/UserDevice.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
103
Backend/app/Models/Wallet.php
Normal file
103
Backend/app/Models/Wallet.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user