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,57 @@
<?php
namespace App\Casts;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Support\Facades\Crypt;
/**
* Encrypts a value using AES-256-CBC when writing to the database,
* and decrypts it when reading. Uses Laravel's APP_KEY cipher.
*
* Used for: phone_number, national_id, and any PII.
* NEVER log or expose encrypted fields.
*/
class Encryptable implements CastsAttributes
{
private const ALLOWED_HASH_CONTEXTS = ['search'];
/**
* @param \Illuminate\Database\Eloquent\Model $model
* @param string $key
* @param mixed $value
* @param array $attributes
* @return string|null
*/
public function get($model, $key, $value, $attributes)
{
if (is_null($value)) {
return null;
}
try {
return Crypt::decryptString($value);
} catch (\Throwable $e) {
report($e);
// If decryption fails, return masked value to prevent crash
return null;
}
}
/**
* @param \Illuminate\Database\Eloquent\Model $model
* @param string $key
* @param mixed $value
* @param array $attributes
* @return string|null
*/
public function set($model, $key, $value, $attributes)
{
if (is_null($value)) {
return null;
}
return Crypt::encryptString($value);
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace App\Enums;
enum WalletStatus: string
{
case ACTIVE = 'active';
case FROZEN = 'frozen';
case CLOSED = 'closed';
public function canReceive(): bool
{
return $this === self::ACTIVE;
}
public function canSend(): bool
{
return $this === self::ACTIVE;
}
}
enum EntryType: string
{
case DEBIT = 'debit';
case CREDIT = 'credit';
}
enum OtpPurpose: string
{
case REGISTRATION = 'registration';
case LOGIN = 'login';
case TRANSFER = 'transfer';
case PIN_CHANGE = 'pin_change';
case PHONE_CHANGE = 'phone_change';
case KYC = 'kyc';
}
enum OtpChannel: string
{
case SMS = 'sms';
case AUTHENTICATOR = 'authenticator';
}
enum FraudAction: string
{
case BLOCKED = 'blocked';
case REVIEWED = 'reviewed';
case ALLOWED = 'allowed';
}
enum FraudStatus: string
{
case OPEN = 'open';
case INVESTIGATED = 'investigated';
case CLOSED = 'closed';
}
enum DevicePlatform: string
{
case ANDROID = 'android';
case IOS = 'ios';
}
enum KycDocType: string
{
case NATIONAL_ID = 'national_id';
case PASSPORT = 'passport';
case UTILITY_BILL = 'utility_bill';
case SELFIE = 'selfie';
}
enum KycDocStatus: string
{
case PENDING = 'pending';
case APPROVED = 'approved';
case REJECTED = 'rejected';
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Enums;
enum KycLevel: int
{
case NONE = 0; // No KYC — cannot transact
case PHONE = 1; // Phone verified — limited
case ID = 2; // National ID verified — standard
case FULL = 3; // Full KYC (ID + address proof) — unlimited
public function label(): string
{
return match ($this) {
self::NONE => __('kyc.none'),
self::PHONE => __('kyc.phone'),
self::ID => __('kyc.id'),
self::FULL => __('kyc.full'),
};
}
public function limits(): array
{
return config("wasl.wallet.limits.{$this->value}", []);
}
public function maxBalanceMinor(): int
{
return $this->limits()['balance'] ?? 0;
}
public function dailyTxLimitMinor(): int
{
return $this->limits()['daily_tx'] ?? 0;
}
public function monthlyTxLimitMinor(): int
{
return $this->limits()['monthly_tx'] ?? 0;
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Enums;
enum TransactionType: string
{
case TRANSFER = 'transfer';
case DEPOSIT = 'deposit';
case WITHDRAW = 'withdraw';
case MERCHANT_PAY = 'merchant_pay';
case REFUND = 'refund';
case FEE = 'fee';
}
enum TransactionStatus: string
{
case PENDING = 'pending';
case PROCESSING = 'processing';
case COMPLETED = 'completed';
case FAILED = 'failed';
case REVERSED = 'reversed';
public function isFinal(): bool
{
return in_array($this, [self::COMPLETED, self::FAILED, self::REVERSED]);
}
public function canTransitionTo(self $new): bool
{
return match ($this) {
self::PENDING => in_array($new, [self::PROCESSING, self::COMPLETED, self::FAILED]),
self::PROCESSING => in_array($new, [self::COMPLETED, self::FAILED]),
self::COMPLETED => in_array($new, [self::REVERSED]),
default => false,
};
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Enums;
enum UserStatus: string
{
case PENDING = 'pending';
case ACTIVE = 'active';
case SUSPENDED = 'suspended';
case BANNED = 'banned';
public function label(): string
{
return match ($this) {
self::PENDING => __('status.pending'),
self::ACTIVE => __('status.active'),
self::SUSPENDED => __('status.suspended'),
self::BANNED => __('status.banned'),
};
}
public function canAuthenticate(): bool
{
return in_array($this, [self::ACTIVE, self::SUSPENDED]);
}
}

View File

@@ -0,0 +1,324 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\User;
use App\Models\Wallet;
use App\Models\OtpCode;
use App\Models\UserDevice;
use App\Models\AuditLog;
use App\Services\JwtService;
use App\Enums\UserStatus;
use App\Enums\WalletStatus;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Symfony\Component\HttpFoundation\Response;
class AuthController extends Controller
{
protected JwtService $jwtService;
public function __construct(JwtService $jwtService)
{
$this->jwtService = $jwtService;
}
/**
* POST /api/register
*/
public function register(Request $request)
{
$validator = Validator::make($request->all(), [
'full_name' => 'required|string|max:150',
'phone_number' => 'required|string',
'password' => 'required|string|min:8',
]);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$normalizedPhone = normalize_phone_number($request->phone_number);
$phoneHash = hash_phone($normalizedPhone);
// Check uniqueness
if (User::where('phone_hash', $phoneHash)->exists()) {
return response()->json([
'error' => 'Conflict',
'message' => 'A user with this phone number already exists.',
], Response::HTTP_CONFLICT);
}
$user = DB::transaction(function () use ($request, $normalizedPhone, $phoneHash) {
$user = User::create([
'full_name' => $request->full_name,
'phone_number' => $normalizedPhone,
'phone_hash' => $phoneHash,
'password' => Hash::make($request->password),
'status' => UserStatus::PENDING,
'kyc_level' => 0,
]);
// Create default wallet
Wallet::create([
'user_id' => $user->id,
'currency_code' => 'SYP',
'balance_minor' => 0,
'balance_pending_minor' => 0,
'status' => WalletStatus::ACTIVE,
]);
return $user;
});
// Generate Registration OTP
$otp = '123456'; // Default mockup code
if (app()->environment('production')) {
$otp = (string) rand(100000, 999999);
}
OtpCode::create([
'user_id' => $user->id,
'purpose' => 'login',
'code_hash' => Hash::make($otp),
'channel' => 'sms',
'attempts' => 0,
'expires_at' => now()->addMinutes(5),
]);
// Audit log registration
AuditLog::record([
'user_id' => $user->id,
'actor_id' => $user->id,
'action' => 'user_registered',
'subject_type' => User::class,
'subject_id' => $user->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
]);
return response()->json([
'message' => 'User registered successfully. Verification OTP sent.',
'uuid' => $user->uuid,
'otp' => app()->environment('production') ? null : $otp, // Return for testing
], Response::HTTP_CREATED);
}
/**
* POST /api/login
*/
public function login(Request $request)
{
$validator = Validator::make($request->all(), [
'phone_number' => 'required|string',
'password' => 'required|string',
]);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$phoneHash = hash_phone($request->phone_number);
$user = User::where('phone_hash', $phoneHash)->first();
if (!$user) {
return response()->json([
'error' => 'Unauthorized',
'message' => 'Invalid phone number or password.',
], Response::HTTP_UNAUTHORIZED);
}
// Brute-force protection
if ($user->isLocked()) {
return response()->json([
'error' => 'Locked',
'message' => 'Account is locked. Try again later.',
], Response::HTTP_LOCKED);
}
if (!Hash::check($request->password, $user->password)) {
$user->increment('failed_login_count');
if ($user->failed_login_count >= 5) {
$user->update(['locked_until' => now()->addMinutes(30)]);
}
return response()->json([
'error' => 'Unauthorized',
'message' => 'Invalid phone number or password.',
], Response::HTTP_UNAUTHORIZED);
}
// Reset failed count
$user->update([
'failed_login_count' => 0,
'locked_until' => null,
'last_login_at' => now(),
'last_login_ip' => $request->ip(),
]);
// Generate Login OTP
$otp = '123456';
if (app()->environment('production')) {
$otp = (string) rand(100000, 999999);
}
OtpCode::create([
'user_id' => $user->id,
'purpose' => 'login',
'code_hash' => Hash::make($otp),
'channel' => 'sms',
'attempts' => 0,
'expires_at' => now()->addMinutes(5),
]);
return response()->json([
'message' => 'Credentials verified. Verification OTP sent.',
'uuid' => $user->uuid,
'otp' => app()->environment('production') ? null : $otp,
]);
}
/**
* POST /api/otp/verify
*/
public function verifyOtp(Request $request)
{
$validator = Validator::make($request->all(), [
'uuid' => 'required|uuid',
'code' => 'required|string|size:6',
]);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$user = User::where('uuid', $request->uuid)->first();
if (!$user) {
return response()->json([
'error' => 'NotFound',
'message' => 'User not found.',
], Response::HTTP_NOT_FOUND);
}
$otp = OtpCode::where('user_id', $user->id)
->where('purpose', 'login')
->whereNull('used_at')
->orderBy('id', 'desc')
->first();
if (!$otp || $otp->isExpired()) {
return response()->json([
'error' => 'BadRequest',
'message' => 'OTP code is expired or invalid.',
], Response::HTTP_BAD_REQUEST);
}
if ($otp->attempts >= 3) {
$otp->markUsed(); // invalidate
return response()->json([
'error' => 'Locked',
'message' => 'Too many failed verification attempts. Please request a new OTP.',
], Response::HTTP_LOCKED);
}
$otp->incrementAttempts();
if (!Hash::check($request->code, $otp->code_hash)) {
return response()->json([
'error' => 'Unauthorized',
'message' => 'Invalid OTP code.',
], Response::HTTP_UNAUTHORIZED);
}
// Success
$otp->markUsed();
DB::transaction(function () use ($user) {
$updates = [];
if (is_null($user->phone_verified_at)) {
$updates['phone_verified_at'] = now();
}
if ($user->status === UserStatus::PENDING) {
$updates['status'] = UserStatus::ACTIVE;
}
if ($user->kyc_level === 0) {
$updates['kyc_level'] = 1; // phone verified kyc tier
}
if (!empty($updates)) {
$user->update($updates);
}
});
// Register device if device fingerprint exists
$deviceId = $request->header('X-Device-Id');
if ($deviceId) {
UserDevice::updateOrCreate(
['user_id' => $user->id, 'device_fingerprint' => $deviceId],
[
'device_name' => $request->header('User-Agent') ?? 'Unknown',
'platform' => str_contains(strtolower($request->header('User-Agent')), 'android') ? 'android' : 'ios',
'last_seen_at' => now(),
]
);
}
$token = $this->jwtService->generateToken($user, $deviceId);
// Audit log login
AuditLog::record([
'user_id' => $user->id,
'actor_id' => $user->id,
'action' => 'user_logged_in',
'subject_type' => User::class,
'subject_id' => $user->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'device_id' => $deviceId,
]);
return response()->json([
'message' => 'Verification successful.',
'access_token' => $token,
'token_type' => 'Bearer',
'user' => $user,
]);
}
/**
* POST /api/pin/setup
*/
public function setupPin(Request $request)
{
$validator = Validator::make($request->all(), [
'pin' => 'required|string|size:6|regex:/^[0-9]+$/',
]);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$user = auth()->user();
$user->update([
'pin_hash' => Hash::make($request->pin),
]);
AuditLog::record([
'user_id' => $user->id,
'actor_id' => $user->id,
'action' => 'pin_updated',
'subject_type' => User::class,
'subject_id' => $user->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
]);
return response()->json([
'message' => 'Security PIN updated successfully.',
]);
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use App\Models\AuditLog;
use Illuminate\Support\Facades\Auth;
class AuditRequestMiddleware
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure(\Illuminate\Http\Request): (\Illuminate\Http\Response) $next
* @return mixed
*/
public function handle(Request $request, Closure $next)
{
$response = $next($request);
// Audit only write/mutation operations or auth requests
$method = $request->method();
if (in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE']) || $request->routeIs('*.sensitive')) {
$user = Auth::user();
// Mask sensitive fields in request payload
$payload = $request->all();
$sensitiveKeys = ['password', 'password_confirmation', 'pin', 'pin_confirmation', 'pin_hash', 'code', 'token', 'key', 'national_id', 'card_number'];
foreach ($sensitiveKeys as $key) {
if (isset($payload[$key])) {
$payload[$key] = '********';
}
}
AuditLog::record([
'user_id' => $user?->id,
'actor_id' => $user?->id,
'action' => 'api_request_' . strtolower($method),
'subject_type' => 'Request',
'subject_id' => null,
'old_values' => null,
'new_values' => [
'url' => $request->fullUrl(),
'method' => $method,
'status' => $response->getStatusCode(),
'payload' => $payload,
],
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'device_id' => $request->header('X-Device-Id'),
]);
}
return $response;
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redis;
use Symfony\Component\HttpFoundation\Response;
class IdempotencyMiddleware
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure(\Illuminate\Http\Request): (\Illuminate\Http\Response) $next
* @return mixed
*/
public function handle(Request $request, Closure $next)
{
// Only run for mutations (POST, PUT, PATCH, DELETE)
if (!in_array($request->method(), ['POST', 'PUT', 'PATCH', 'DELETE'])) {
return $next($request);
}
$idempotencyKey = $request->header('Idempotency-Key') ?? $request->header('X-Idempotency-Key');
if (!$idempotencyKey) {
return $next($request);
}
// Clean key
$key = 'idempotency:' . hash('sha256', $idempotencyKey);
$lockKey = $key . ':lock';
$ttl = config('wasl.security.idempotency.ttl_seconds', 86400); // 24 hours default
// Acquire lock using Redis SETNX (Swoole safe)
$lockAcquired = Redis::set($lockKey, 'locked', 'EX', 10, 'NX');
if (!$lockAcquired) {
return response()->json([
'error' => 'Conflict',
'message' => 'A request with this Idempotency-Key is already in progress.',
], Response::HTTP_CONFLICT);
}
try {
// Check if we have a cached response
$cached = Redis::get($key);
if ($cached) {
$data = json_decode($cached, true);
// Release lock
Redis::del($lockKey);
return response($data['content'], $data['status'], $data['headers']);
}
// Execute request
$response = $next($request);
// Only cache successful or non-server-error responses (2xx and 4xx, exclude 5xx)
if ($response->getStatusCode() < 500) {
$cacheData = json_encode([
'status' => $response->getStatusCode(),
'headers' => collect($response->headers->all())->map(fn($val) => $val[0])->toArray(),
'content' => $response->getContent(),
]);
Redis::set($key, $cacheData, 'EX', $ttl);
}
return $response;
} finally {
// Always release lock
Redis::del($lockKey);
}
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use App\Services\JwtService;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class JwtAuthenticate
{
protected JwtService $jwtService;
public function __construct(JwtService $jwtService)
{
$this->jwtService = $jwtService;
}
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure(\Illuminate\Http\Request): (\Illuminate\Http\Response) $next
* @return mixed
*/
public function handle(Request $request, Closure $next)
{
$authorization = $request->header('Authorization');
if (!$authorization || !str_starts_with($authorization, 'Bearer ')) {
return response()->json([
'error' => 'Unauthorized',
'message' => 'Authorization token is missing or malformed.',
], Response::HTTP_UNAUTHORIZED);
}
$token = substr($authorization, 7);
$payload = $this->jwtService->validateToken($token);
if (!$payload) {
return response()->json([
'error' => 'Unauthorized',
'message' => 'Authorization token is invalid or expired.',
], Response::HTTP_UNAUTHORIZED);
}
$user = User::where('uuid', $payload['sub'])->first();
if (!$user) {
return response()->json([
'error' => 'Unauthorized',
'message' => 'User associated with this token does not exist.',
], Response::HTTP_UNAUTHORIZED);
}
if ($user->status === \App\Enums\UserStatus::BANNED || $user->status === \App\Enums\UserStatus::SUSPENDED) {
return response()->json([
'error' => 'Forbidden',
'message' => 'Your account has been ' . $user->status->value . '.',
], Response::HTTP_FORBIDDEN);
}
// Set authenticated user
Auth::setUser($user);
return $next($request);
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Symfony\Component\HttpFoundation\Response;
class ThrottleSensitiveActions
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure(\Illuminate\Http\Request): (\Illuminate\Http\Response) $next
* @param string $action
* @return mixed
*/
public function handle(Request $request, Closure $next, string $action)
{
$config = config("wasl.throttle.{$action}");
if (!$config) {
return $next($request);
}
$maxAttempts = $config['max'] ?? 5;
$decayMinutes = $config['minutes'] ?? 1;
// Generate key based on action, user_id (if logged in) or phone hash / IP
$userId = $request->user()?->id;
$ip = $request->ip();
$phoneHash = '';
if ($request->has('phone_number')) {
$phoneHash = hash_phone($request->input('phone_number'));
}
$key = 'throttle:' . $action . ':' . ($userId ?: ($phoneHash ?: $ip));
if (RateLimiter::tooManyAttempts($key, $maxAttempts)) {
$seconds = RateLimiter::availableIn($key);
return response()->json([
'error' => 'Too Many Requests',
'message' => "Too many attempts for {$action}. Please retry in {$seconds} seconds.",
'retry_after' => $seconds,
], Response::HTTP_TOO_MANY_REQUESTS);
}
RateLimiter::hit($key, $decayMinutes * 60);
$response = $next($request);
// Optional: clear the rate limit on successful authentication for login
if ($action === 'login' && $response->getStatusCode() === Response::HTTP_OK) {
RateLimiter::clear($key);
}
return $response;
}
}

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);
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Services;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use App\Models\User;
use Illuminate\Support\Str;
class JwtService
{
protected string $secret;
protected string $algo;
protected int $ttl;
public function __construct()
{
$this->secret = config('jwt.secret') ?? config('app.key') ?? 'default-secret-key-wasl';
$this->algo = config('jwt.algo', 'HS256');
$this->ttl = config('jwt.ttl', 15); // in minutes
}
/**
* Generate access token for a user.
*/
public function generateToken(User $user, ?string $deviceId = null): string
{
$now = time();
$payload = [
'iss' => config('app.url'),
'iat' => $now,
'nbf' => $now,
'exp' => $now + ($this->ttl * 60),
'sub' => $user->uuid,
'jti' => Str::random(16),
'dev' => $deviceId,
'kyc' => $user->kyc_level,
];
return JWT::encode($payload, $this->secret, $this->algo);
}
/**
* Validate and decode token.
*/
public function validateToken(string $token): ?array
{
try {
$decoded = JWT::decode($token, new Key($this->secret, $this->algo));
return (array) $decoded;
} catch (\Throwable $e) {
report($e);
return null;
}
}
}

View File

@@ -0,0 +1,45 @@
<?php
if (!function_exists('normalize_phone_number')) {
/**
* Normalize phone numbers to E.164 format or standard format.
* For Syria: +963xxxxxxxx or standard digits.
*/
function normalize_phone_number(string $phone): string
{
// Remove non-numeric characters except +
$cleaned = preg_replace('/[^\d+]/', '', $phone);
// Remove leading double zeros
if (str_starts_with($cleaned, '00')) {
$cleaned = '+' . substr($cleaned, 2);
}
// If it starts with local 09, convert to +9639
if (str_starts_with($cleaned, '09') && strlen($cleaned) === 10) {
$cleaned = '+963' . substr($cleaned, 1);
}
return $cleaned;
}
}
if (!function_exists('hash_phone')) {
/**
* Generate SHA-256 hash of a normalized phone number for fast lookup.
*/
function hash_phone(string $phone): string
{
return hash('sha256', normalize_phone_number($phone));
}
}
if (!function_exists('hash_national_id')) {
/**
* Generate SHA-256 hash of a national ID for fast lookup.
*/
function hash_national_id(string $nationalId): string
{
return hash('sha256', trim($nationalId));
}
}