commit 7306c473680e5ea747a624d0568c85ba0689388d Author: Hamza-Ayed Date: Sat Jun 20 21:55:06 2026 +0300 Initial commit - WASL Digital Wallet diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0d7789b --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +/Backend/vendor/ +/Backend/.env +/Backend/.env.backup +/Backend/storage/*.key +/Backend/bootstrap/cache/* +/Backend/public/storage +/Backend/public/hot + +.DS_Store +.idea/ +.vscode/ +.env +.phpunit.result.cache +*.log diff --git a/Backend/Dockerfile b/Backend/Dockerfile new file mode 100644 index 0000000..2358c88 --- /dev/null +++ b/Backend/Dockerfile @@ -0,0 +1,110 @@ +# ───────────────────────────────────────────────────────────── +# WASL Digital Wallet — Production Dockerfile +# Multi-stage build: smaller final image, no dev tools in prod. +# Base: PHP 8.3 with Swoole, PDO_PGSQL, Redis, BCMath, OpenSS +# ───────────────────────────────────────────────────────────── + +# ---- Base stage: system + PHP extensions ---- +FROM php:8.3-fpm-alpine AS base + +# System dependencies +RUN apk add --no-cache \ + nginx \ + bash \ + curl \ + git \ + zip \ + unzip \ + libzip-dev \ + libpng-dev \ + libjpeg-turbo-dev \ + freetype-dev \ + libxml2-dev \ + oniguruma-dev \ + postgresql-dev \ + linux-headers \ + $PHPIZE_DEPS + +# PHP extensions required by WASL +RUN docker-php-ext-configure gd --with-freetype --with-jpeg \ + && docker-php-ext-install -j$(nproc) \ + pdo \ + pdo_pgsql \ + pgsql \ + gd \ + zip \ + bcmath \ + opcache \ + pcntl \ + intl \ + exif + +# Redis via PECL +RUN pecl install redis && docker-php-ext-enable redis + +# Swoole via PECL (required for Octane) +RUN pecl install swoole && docker-php-ext-enable swoole + +# Install Composer +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +WORKDIR /var/www/html + +# ───────────────────────────────────────────────────────────── +# Dependencies stage — caches vendor/ separately from source +# ───────────────────────────────────────────────────────────── +FROM base AS deps + +COPY composer.json composer.lock ./ +# If no lock file yet, composer will resolve; otherwise install exact versions +RUN composer install --no-interaction --no-scripts --no-autoloader --prefer-dist + +# ───────────────────────────────────────────────────────────── +# Development stage — full tooling, debug helpers +# ───────────────────────────────────────────────────────────── +FROM deps AS development + +RUN composer install --no-interaction --prefer-dist + +COPY . . +RUN composer dump-autoload --optimize + +# Xdebug and development PHP config +RUN pecl install xdebug && docker-php-ext-enable xdebug +COPY docker/php/dev.ini /usr/local/etc/php/conf.d/99-wasl-dev.ini + +# Supervisor for Octane + queue workers +RUN apk add --no-cache supervisor +COPY docker/supervisor/supervisord.conf /etc/supervisord.conf + +EXPOSE 8000 +CMD ["php", "artisan", "octane:start", "--server=swoole", "--host=0.0.0.0", "--port=8000"] + +# ───────────────────────────────────────────────────────────── +# Production stage — optimized, no dev dependencies, opcache +# ───────────────────────────────────────────────────────────── +FROM base AS production + +# Production php.ini +COPY docker/php/production.ini /usr/local/etc/php/conf.d/99-wasl.ini + +# Copy application source +COPY --chown=www-data:www-data . /var/www/html + +# Install ONLY production dependencies (no dev) +COPY composer.json composer.lock ./ +RUN composer install --no-interaction --no-dev --optimize-autoloader --no-scripts \ + && composer dump-autoload --no-dev --optimize + +# Ensure correct permissions for Laravel storage +RUN chown -R www-data:www-data /var/www/html \ + && chmod -R 775 storage bootstrap/cache + +# Supervisor to run Octane + queue workers within one container +RUN apk add --no-cache supervisor +COPY docker/supervisor/supervisord.conf /etc/supervisord.conf + +EXPOSE 8000 + +# Run supervisor (manages Octane + queue workers) +CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"] diff --git a/Backend/app/Casts/Encryptable.php b/Backend/app/Casts/Encryptable.php new file mode 100644 index 0000000..a870106 --- /dev/null +++ b/Backend/app/Casts/Encryptable.php @@ -0,0 +1,57 @@ + __('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; + } +} diff --git a/Backend/app/Enums/TransactionType.php b/Backend/app/Enums/TransactionType.php new file mode 100644 index 0000000..c7d8bf2 --- /dev/null +++ b/Backend/app/Enums/TransactionType.php @@ -0,0 +1,37 @@ + 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, + }; + } +} diff --git a/Backend/app/Enums/UserStatus.php b/Backend/app/Enums/UserStatus.php new file mode 100644 index 0000000..409e3f9 --- /dev/null +++ b/Backend/app/Enums/UserStatus.php @@ -0,0 +1,26 @@ + __('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]); + } +} diff --git a/Backend/app/Http/Controllers/Api/AuthController.php b/Backend/app/Http/Controllers/Api/AuthController.php new file mode 100644 index 0000000..4383983 --- /dev/null +++ b/Backend/app/Http/Controllers/Api/AuthController.php @@ -0,0 +1,324 @@ +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.', + ]); + } +} diff --git a/Backend/app/Http/Middleware/AuditRequestMiddleware.php b/Backend/app/Http/Middleware/AuditRequestMiddleware.php new file mode 100644 index 0000000..0705f84 --- /dev/null +++ b/Backend/app/Http/Middleware/AuditRequestMiddleware.php @@ -0,0 +1,58 @@ +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; + } +} diff --git a/Backend/app/Http/Middleware/IdempotencyMiddleware.php b/Backend/app/Http/Middleware/IdempotencyMiddleware.php new file mode 100644 index 0000000..96dc1fe --- /dev/null +++ b/Backend/app/Http/Middleware/IdempotencyMiddleware.php @@ -0,0 +1,79 @@ +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); + } + } +} diff --git a/Backend/app/Http/Middleware/JwtAuthenticate.php b/Backend/app/Http/Middleware/JwtAuthenticate.php new file mode 100644 index 0000000..5f9dd4d --- /dev/null +++ b/Backend/app/Http/Middleware/JwtAuthenticate.php @@ -0,0 +1,70 @@ +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); + } +} diff --git a/Backend/app/Http/Middleware/ThrottleSensitiveActions.php b/Backend/app/Http/Middleware/ThrottleSensitiveActions.php new file mode 100644 index 0000000..6d329d9 --- /dev/null +++ b/Backend/app/Http/Middleware/ThrottleSensitiveActions.php @@ -0,0 +1,63 @@ +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; + } +} diff --git a/Backend/app/Models/AuditLog.php b/Backend/app/Models/AuditLog.php new file mode 100644 index 0000000..bfb0538 --- /dev/null +++ b/Backend/app/Models/AuditLog.php @@ -0,0 +1,89 @@ + '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(), + ])); + } +} diff --git a/Backend/app/Models/BaseModel.php b/Backend/app/Models/BaseModel.php new file mode 100644 index 0000000..34e708f --- /dev/null +++ b/Backend/app/Models/BaseModel.php @@ -0,0 +1,15 @@ + '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()]); + } +} diff --git a/Backend/app/Models/Traits/HasMinorUnits.php b/Backend/app/Models/Traits/HasMinorUnits.php new file mode 100644 index 0000000..fd4ffcb --- /dev/null +++ b/Backend/app/Models/Traits/HasMinorUnits.php @@ -0,0 +1,46 @@ +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; + } +} diff --git a/Backend/app/Models/Traits/HasUuid.php b/Backend/app/Models/Traits/HasUuid.php new file mode 100644 index 0000000..4c1392b --- /dev/null +++ b/Backend/app/Models/Traits/HasUuid.php @@ -0,0 +1,40 @@ +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); + } +} diff --git a/Backend/app/Models/Transaction.php b/Backend/app/Models/Transaction.php new file mode 100644 index 0000000..86b86f7 --- /dev/null +++ b/Backend/app/Models/Transaction.php @@ -0,0 +1,154 @@ + 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; + } +} diff --git a/Backend/app/Models/TransactionEntry.php b/Backend/app/Models/TransactionEntry.php new file mode 100644 index 0000000..4ac2495 --- /dev/null +++ b/Backend/app/Models/TransactionEntry.php @@ -0,0 +1,83 @@ + 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; + } +} diff --git a/Backend/app/Models/User.php b/Backend/app/Models/User.php new file mode 100644 index 0000000..975ae18 --- /dev/null +++ b/Backend/app/Models/User.php @@ -0,0 +1,181 @@ + 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); + } +} diff --git a/Backend/app/Models/UserDevice.php b/Backend/app/Models/UserDevice.php new file mode 100644 index 0000000..7358d29 --- /dev/null +++ b/Backend/app/Models/UserDevice.php @@ -0,0 +1,78 @@ + '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]); + } +} diff --git a/Backend/app/Models/Wallet.php b/Backend/app/Models/Wallet.php new file mode 100644 index 0000000..50b601f --- /dev/null +++ b/Backend/app/Models/Wallet.php @@ -0,0 +1,103 @@ + '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); + } +} diff --git a/Backend/app/Services/JwtService.php b/Backend/app/Services/JwtService.php new file mode 100644 index 0000000..c874a0d --- /dev/null +++ b/Backend/app/Services/JwtService.php @@ -0,0 +1,56 @@ +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; + } + } +} diff --git a/Backend/app/Support/helpers.php b/Backend/app/Support/helpers.php new file mode 100644 index 0000000..502d426 --- /dev/null +++ b/Backend/app/Support/helpers.php @@ -0,0 +1,45 @@ +handleCommand(new Symfony\Component\Console\Input\ArgvInput); + +exit($status); diff --git a/Backend/bootstrap/app.php b/Backend/bootstrap/app.php new file mode 100644 index 0000000..8c48653 --- /dev/null +++ b/Backend/bootstrap/app.php @@ -0,0 +1,36 @@ +withRouting( + api: __DIR__.'/../routes/api.php', + commands: __DIR__.'/../routes/console.php', + health: '/up', + ) + ->withMiddleware(function (Middleware $middleware) { + // Global security middleware + $middleware->api(prepend: [ + \App\Http\Middleware\AuditRequestMiddleware::class, + ]); + + // Trust all proxies (behind nginx + Cloudflare) + $middleware->trustProxies(at: '*'); + + // Middleware aliases + $middleware->alias([ + 'role' => \Spatie\Permission\Middleware\RoleMiddleware::class, + 'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class, + 'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class, + 'audit' => \App\Http\Middleware\AuditRequestMiddleware::class, + 'idempotency' => \App\Http\Middleware\IdempotencyMiddleware::class, + 'throttle.actions' => \App\Http\Middleware\ThrottleSensitiveActions::class, + 'auth.jwt' => \App\Http\Middleware\JwtAuthenticate::class, + ]); + }) + ->withExceptions(function (Exceptions $exceptions) { + // + }) + ->create(); diff --git a/Backend/composer.json b/Backend/composer.json new file mode 100644 index 0000000..a825fcc --- /dev/null +++ b/Backend/composer.json @@ -0,0 +1,93 @@ +{ + "name": "wasl/wallet", + "type": "project", + "description": "WASL (وَصْل) — Bank-grade digital wallet for Syria. P2P payments, double-entry ledger, 6-layer security.", + "keywords": ["wallet", "fintech", "syria", "payments", "laravel", "wasl"], + "license": "proprietary", + "require": { + "php": "^8.3", + "laravel/framework": "^11.0", + "laravel/octane": "^2.0", + "spiral/roadrunner-cli": "^2.6", + "spiral/roadrunner-http": "^3.3", + "firebase/php-jwt": "^6.10", + "spatie/laravel-permission": "^6.4", + "spatie/laravel-activitylog": "^4.8", + "predis/predis": "^2.0", + "league/flysystem-aws-s3-v3": "^3.0", + "webpatser/laravel-uuid": "^4.0", + "dragonmantank/cron-expression": "^3.3", + "staudenmeir/laravel-adjacency-list": "^1.0", + "ext-pdo": "*", + "ext-pgsql": "*", + "ext-redis": "*", + "ext-bcmath": "*", + "ext-openssl": "*", + "ext-swoole": "*" + }, + "require-dev": { + "filament/filament": "^3.2", + "filament/tables": "^3.2", + "filament/forms": "^3.2", + "nunomaduro/collision": "^8.1", + "nunomaduro/larastan": "^2.9", + "phpunit/phpunit": "^11.0", + "mockery/mockery": "^1.6", + "larastan/larastan": "^2.9", + "barryvdh/laravel-ide-helper": "^3.0" + }, + "autoload": { + "psr-4": { + "App\\": "app/", + "Database\\Factories\\": "database/factories/", + "Database\\Seeders\\": "database/seeders/" + }, + "files": [ + "app/Support/helpers.php" + ] + }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" + } + }, + "scripts": { + "post-autoload-dump": [ + "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", + "@php artisan package:discover --ansi", + "@php artisan filament:upgrade" + ], + "post-create-project-cmd": [ + "@php artisan key:generate --ansi", + "@php artisan jwt:secret --force --ansi" + ], + "post-install-cmd": [ + "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"" + ], + "test": [ + "@php artisan test" + ], + "migrate": [ + "@php artisan migrate --force" + ], + "fresh": [ + "@php artisan migrate:fresh --seed --force" + ] + }, + "extra": { + "laravel": { + "dont-discover": [] + } + }, + "config": { + "optimize-autoloader": true, + "preferred-install": "dist", + "sort-packages": true, + "allow-plugins": { + "php-http/discovery": true, + "dealerdirect/phpcodesniffer-composer": true + } + }, + "minimum-stability": "stable", + "prefer-stable": true +} diff --git a/Backend/config/activitylog.php b/Backend/config/activitylog.php new file mode 100644 index 0000000..1824967 --- /dev/null +++ b/Backend/config/activitylog.php @@ -0,0 +1,40 @@ + env('ACTIVITY_LOGGER_ENABLED', true), + + /* + |------------------------------------------------------------------- + | Log deleted records + |------------------------------------------------------------------- + */ + + 'delete_records_on_delete' => false, + + 'default_log_name' => 'default', + + /* + |------------------------------------------------------------------- + | Default driver + |------------------------------------------------------------------- + */ + + 'driver' => \Spatie\Activitylog\ActivitylogServiceProvider::class, + + /* + |------------------------------------------------------------------- + | Show activity log model + |------------------------------------------------------------------- + */ + + 'activity_model' => \App\Models\Activitylog\Activity::class, + + /* + |------------------------------------------------------------------- + | The database connection used for activity logs. + |------------------------------------------------------------------- + */ + + 'database_connection' => env('ACTIVITYLOG_DB_CONNECTION', 'pgsql'), +]; diff --git a/Backend/config/app.php b/Backend/config/app.php new file mode 100644 index 0000000..14ce5de --- /dev/null +++ b/Backend/config/app.php @@ -0,0 +1,97 @@ + env('APP_NAME', 'WASL'), + 'short_name' => env('APP_SHORT_NAME', 'وَصْل'), + + /* + |-------------------------------------------------------------------------- + | Application Environment + |-------------------------------------------------------------------------- + */ + + 'env' => env('APP_ENV', 'production'), + + /* + |-------------------------------------------------------------------------- + | Application Debug Mode + |-------------------------------------------------------------------------- + | SECURITY: NEVER enable debug mode in production for a financial system. + */ + + 'debug' => (bool) env('APP_DEBUG', false), + + /* + |-------------------------------------------------------------------------- + | Application URL + |-------------------------------------------------------------------------- + */ + + 'url' => env('APP_URL', 'http://localhost'), + 'asset_url' => env('ASSET_URL'), + + /* + |-------------------------------------------------------------------------- + | Application Timezone + |-------------------------------------------------------------------------- + | Syria uses Asia/Damascus (UTC+03:00, no DST since 2022) + */ + + 'timezone' => 'Asia/Damascus', + + /* + |-------------------------------------------------------------------------- + | Application Locale Configuration + |-------------------------------------------------------------------------- + */ + + 'locale' => 'ar', + 'fallback_locale' => 'en', + 'faker_locale' => 'ar_SY', + + /* + |-------------------------------------------------------------------------- + | Encryption Key + |-------------------------------------------------------------------------- + */ + + 'key' => env('APP_KEY'), + 'cipher' => env('APP_CIPHER', 'aes-256-cbc'), + + /* + |-------------------------------------------------------------------------- + | Maintenance Mode Driver + |-------------------------------------------------------------------------- + */ + + 'maintenance' => [ + 'driver' => env('APP_MAINTENANCE_DRIVER', 'file'), + 'store' => env('APP_MAINTENANCE_STORE', 'db'), + ], + + /* + |-------------------------------------------------------------------------- + | WASL-specific config reference + |-------------------------------------------------------------------------- + */ + + 'wasl' => [ + 'country_code' => 'SY', + 'default_currency' => 'SYP', + 'minor_unit_decimals' => [ + 'SYP' => 2, // 1 SYP = 100 piasters → minor unit + 'USD' => 2, + 'EUR' => 2, + 'AED' => 2, + 'USDT' => 8, // crypto precision + 'BTC' => 8, + ], + ], +]; diff --git a/Backend/config/auth.php b/Backend/config/auth.php new file mode 100644 index 0000000..076e48b --- /dev/null +++ b/Backend/config/auth.php @@ -0,0 +1,40 @@ + [ + 'guard' => 'api', + ], + + 'guards' => [ + 'api' => [ + 'driver' => 'jwt', + 'provider' => 'users', + ], + + // Filament admin panel + 'web' => [ + 'driver' => 'session', + 'provider' => 'users', + ], + ], + + 'providers' => [ + 'users' => [ + 'driver' => 'eloquent', + 'model' => env('AUTH_MODEL', App\Models\User::class), + ], + ], + + 'passwords' => [ + 'users' => [ + 'provider' => 'users', + 'table' => 'password_reset_tokens', + 'expire' => 60, + 'throttle' => 60, + ], + ], + + 'password_timeout' => 10800, + +]; diff --git a/Backend/config/database.php b/Backend/config/database.php new file mode 100644 index 0000000..27b1985 --- /dev/null +++ b/Backend/config/database.php @@ -0,0 +1,123 @@ + env('DB_CONNECTION', 'pgsql'), + + /* + |-------------------------------------------------------------------------- + | Database Connections + |-------------------------------------------------------------------------- + */ + + 'connections' => [ + + 'pgsql' => [ + 'driver' => 'pgsql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '5432'), + 'database' => env('DB_DATABASE', 'wasl'), + 'username' => env('DB_USERNAME', 'wasl'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => 'utf8', + 'prefix' => '', + 'prefix_indexes' => true, + 'search_path' => 'public', + 'sslmode' => env('DB_SSL_MODE', 'prefer'), + 'sslcert' => env('DB_SSL_CERT'), + 'sslkey' => env('DB_SSL_KEY'), + 'sslrootcert' => env('DB_SSL_ROOT_CERT'), + 'schema' => 'public', + 'engine' => null, + ], + + 'testing' => [ + 'driver' => 'pgsql', + 'host' => env('DB_TEST_HOST', '127.0.0.1'), + 'port' => env('DB_TEST_PORT', '5432'), + 'database' => env('DB_TEST_DATABASE', 'wasl_testing'), + 'username' => env('DB_TEST_USERNAME', 'wasl'), + 'password' => env('DB_TEST_PASSWORD', ''), + 'charset' => 'utf8', + 'search_path' => 'public', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Migration Repository Table + |-------------------------------------------------------------------------- + */ + + 'migrations' => [ + 'table' => 'migrations', + 'update_date_on_publish' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Redis Databases / Connection + | Separate logical DBs isolate concerns: cache, queue, idempotency, + | throttling, sessions — so a flush of one never impacts the others. + |-------------------------------------------------------------------------- + */ + + 'redis' => [ + + 'client' => env('REDIS_CLIENT', 'predis'), + + 'options' => [ + 'cluster' => env('REDIS_CLUSTER', 'redis'), + 'prefix' => env('REDIS_PREFIX', 'wasl_'), + 'persistent' => env('REDIS_PERSISTENT', false), + ], + + 'default' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_DB', '0'), + ], + + 'cache' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_CACHE_DB', '1'), + ], + + // Separate DB for idempotency keys (short TTL) + 'idempotency' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_IDEMPOTENCY_DB', '2'), + ], + + // Separate DB for rate limiting / throttling + 'throttle' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_THROTTLE_DB', '3'), + ], + ], + +]; diff --git a/Backend/config/filesystems.php b/Backend/config/filesystems.php new file mode 100644 index 0000000..3663971 --- /dev/null +++ b/Backend/config/filesystems.php @@ -0,0 +1,62 @@ + env('FILESYSTEM_DISK', 'local'), + + 'disks' => [ + + 'local' => [ + 'driver' => 'local', + 'root' => storage_path('app/private'), + 'serve' => true, + 'throw' => true, + ], + + 'public' => [ + 'driver' => 'local', + 'root' => storage_path('app/public'), + 'url' => env('APP_URL').'/storage', + 'visibility' => 'public', + 'throw' => true, + ], + + // MinIO — used for KYC documents (encrypted before upload) + 's3' => [ + 'driver' => 's3', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'bucket' => env('AWS_BUCKET'), + 'url' => env('AWS_URL'), + 'endpoint' => env('AWS_ENDPOINT'), // MinIO endpoint + 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', true), + 'throw' => true, + ], + + // Dedicated bucket for sensitive KYC docs (private, encrypted-at-rest) + 'kyc' => [ + 'driver' => 's3', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'bucket' => env('WASL_KYC_BUCKET', 'wasl-kyc'), + 'url' => env('AWS_URL'), + 'endpoint' => env('AWS_ENDPOINT'), + 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', true), + 'throw' => true, + 'visibility' => 'private', + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Symbolic Links + |-------------------------------------------------------------------------- + */ + + 'links' => [ + public_path('storage') => storage_path('app/public'), + ], +]; diff --git a/Backend/config/jwt.php b/Backend/config/jwt.php new file mode 100644 index 0000000..db650b1 --- /dev/null +++ b/Backend/config/jwt.php @@ -0,0 +1,57 @@ + env('JWT_SECRET'), + + // Asymmetric keys (recommended for production) + 'keys' => [ + 'public' => env('JWT_PUBLIC_KEY'), + 'private' => env('JWT_PRIVATE_KEY'), + 'passphrase' => env('JWT_PASSPHRASE'), + ], + + 'ttl' => env('JWT_TTL', 15), // 15 minutes — short-lived access token + + 'refresh_ttl' => env('JWT_REFRESH_TTL', 20160), // 14 days + + 'algo' => env('JWT_ALGO', 'HS256'), + + 'required_claims' => [ + 'iss', + 'iat', + 'exp', + 'nbf', + 'sub', + 'jti', + ], + + 'persistent_claims' => [ + 'dev', // device_id — bound to the token + 'kyc', // kyc_level — embedded for authorization checks + ], + + 'lock_subject' => true, + + 'leeway' => env('JWT_LEEWAY', 0), + + 'blacklist_enabled' => env('JWT_BLACKLIST_ENABLED', true), + + 'blacklist_grace_period' => env('JWT_BLACKLIST_GRACE_PERIOD', 0), + + 'decrypt_cookies' => false, + + 'providers' => [ + 'jwt' => Tymon\JWTAuth\Providers\JWT\Lcobucci::class, + 'auth' => Tymon\JWTAuth\Providers\Auth\Illuminate::class, + 'storage' => Tymon\JWTAuth\Providers\Storage\Illuminate::class, + ], +]; diff --git a/Backend/config/logging.php b/Backend/config/logging.php new file mode 100644 index 0000000..a92572f --- /dev/null +++ b/Backend/config/logging.php @@ -0,0 +1,85 @@ + env('LOG_CHANNEL', 'stack'), + + 'deprecations' => [ + 'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'), + 'trace' => env('LOG_DEPRECATIONS_TRACE', false), + ], + + 'channels' => [ + + 'stack' => [ + 'driver' => 'stack', + 'channels' => explode(',', env('LOG_STACK', 'single')), + 'ignore_exceptions' => false, + ], + + 'single' => [ + 'driver' => 'single', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'daily' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'days' => env('LOG_DAILY_DAYS', 14), + 'replace_placeholders' => true, + ], + + // Separate channel for financial transaction logs (longer retention) + 'transactions' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/transactions.log'), + 'level' => 'info', + 'days' => 90, + 'replace_placeholders' => true, + ], + + // Security events (login, failed login, fraud alerts) — long retention + 'security' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/security.log'), + 'level' => 'info', + 'days' => 180, + 'replace_placeholders' => true, + ], + + 'stderr' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'info'), + 'handler' => Monolog\Handler\StreamHandler::class, + 'formatter' => env('LOG_STDERR_FORMATTER'), + 'with' => [ + 'stream' => 'php://stderr', + ], + 'processors' => [ + \App\Logging\MaskSensitiveProcessor::class, + ], + ], + + 'syslog' => [ + 'driver' => 'syslog', + 'level' => env('LOG_LEVEL', 'debug'), + ], + + 'errorlog' => [ + 'driver' => 'errorlog', + 'level' => env('LOG_LEVEL', 'debug'), + ], + + 'null' => [ + 'driver' => 'monolog', + 'handler' => Monolog\Handler\NullHandler::class, + ], + + 'emergency' => [ + 'path' => storage_path('logs/laravel.log'), + ], + ], +]; diff --git a/Backend/config/octane.php b/Backend/config/octane.php new file mode 100644 index 0000000..f4e0b7c --- /dev/null +++ b/Backend/config/octane.php @@ -0,0 +1,146 @@ + [ + // RoadRunner via RPC socket + 'roadrunner' => [ + 'rpc' => [ + 'tcp' => '127.0.0.1:7233', + ], + 'logs' => [ + 'mode' => env('OCTANE_LOGS_MODE', 'production' === env('APP_ENV') ? 'none' : 'dev'), + 'level' => env('OCTANE_LOGS_LEVEL', 'info'), + ], + 'reload' => [ + 'watch' => env('OCTANE_RELOAD_WATCH', 'app,config,database,resources/views,routes'), + 'interval' => env('OCTANE_RELOAD_INTERVAL', 2000), + ], + 'http2' => [ + 'enabled' => env('OCTANE_HTTP2_ENABLED', true), + ], + 'maxTotalWorkerMemory' => env('OCTANE_MAX_TOTAL_WORKER_MEMORY', 512), // MB — flush worker if exceeded + 'worker' => [ + 'maxRequestCount' => env('OCTANE_WORKER_MAX_REQUEST_COUNT', 10000), // recycle to clear leaks + 'maxMemoryMB' => env('OCTANE_WORKER_MAX_MEMORY_MB', 512), + ], + ], + + // Swoole driver — recommended for WASL (best performance) + 'swoole' => [ + 'mode' => env('OCTANE_MODE', 'SWOOLE_PROCESS'), + 'options' => [ + 'log_file' => storage_path('logs/swoole.log'), + 'package_max_length' => 8 * 1024 * 1024, // 8 MB + 'worker_num' => env('OCTANE_WORKER_NUM', swoole_cpu_num()), + 'task_worker_num' => env('OCTANE_TASK_WORKER_NUM', swoole_cpu_num()), + 'max_request' => env('OCTANE_MAX_REQUEST', 10000), + 'max_request_grace' => env('OCTANE_MAX_REQUEST_GRACE', 1000), + 'max_request_execution_time' => env('OCTANE_MAX_REQUEST_EXECUTION_TIME', 30), + 'reload_async' => true, + 'enable_coroutine' => true, + 'send_timeout' => 10, + ], + 'cache' => [ + 'rows' => env('OCTANE_CACHE_ROWS', 1000), + 'bytes' => env('OCTANE_CACHE_BYTES', 10485760), // 10 MB per worker + ], + 'websocket' => [ + 'enabled' => env('OCTANE_WEBSOCKET_ENABLED', false), + ], + 'hot' => [ + 'enable' => env('OCTANE_HOT_RELOAD', 'local' === env('APP_ENV')), + 'watch' => ['app', 'config', 'resources/views', 'routes'], + ], + ], + ], + + /* + |-------------------------------------------------------------------------- + | Octane Cache Table + |-------------------------------------------------------------------------- + */ + + 'cache' => [ + 'rows' => env('OCTANE_CACHE_ROWS', 1000), + ], + + /* + |-------------------------------------------------------------------------- + | Octane Watchers / Listeners + | Flush state between requests to prevent leaks across requests in the + | long-running Octane process (critical for financial correctness). + |-------------------------------------------------------------------------- + */ + + 'listeners' => [ + RequestReceived::class => [ + ...Octane::prepareApplicationForNextOperation(), + ...Octane::prepareApplicationForNextRequest(), + ], + RequestHandled::class => [ + // + ], + RequestTerminated::class => [ + // Clear request-scoped state between requests + ], + TaskReceived::class => [ + ...Octane::prepareApplicationForNextOperation(), + ], + TaskTerminated::class => [ + // + ], + TickReceived::class => [ + ...Octane::prepareApplicationForNextOperation(), + ], + TickTerminated::class => [ + // + ], + Operation::class => [ + // Octane::flushState(), + ], + ], + + /* + |-------------------------------------------------------------------------- + | Warm Specific Tags / Fifo + |-------------------------------------------------------------------------- + */ + + 'warm' => [ + ...Octane::defaultServicesToWarm(), + 'view' => fn ($app) => $app->make('view'), + ], + + /* + |-------------------------------------------------------------------------- + | Garbage Collection Threshold (prevent memory leaks) + |-------------------------------------------------------------------------- + */ + + 'garbage_collection_threshold' => env('OCTANE_GARBAGE_COLLECTION_THRESHOLD', 50), + + /* + |-------------------------------------------------------------------------- + | Maximum Execution Time (seconds) — financial ops should be fast + |-------------------------------------------------------------------------- + */ + + 'max_execution_time' => env('OCTANE_MAX_EXECUTION_TIME', 30), +]; diff --git a/Backend/config/permission.php b/Backend/config/permission.php new file mode 100644 index 0000000..ac4a87b --- /dev/null +++ b/Backend/config/permission.php @@ -0,0 +1,71 @@ + [ + + /* + * When using the "HasPermissions" trait from this package, we need to + * know which entity should be used to retrieve your permissions. + */ + + 'permission' => Spatie\Permission\Models\Permission::class, + + /* + * When using the "HasRoles" trait from this package, we need to + * know which entity should be used to retrieve your roles. + */ + + 'role' => Spatie\Permission\Models\Role::class, + + ], + + 'table_names' => [ + + 'roles' => 'roles', + 'permissions' => 'permissions', + 'model_has_permissions' => 'model_has_permissions', + 'model_has_roles' => 'model_has_roles', + 'role_has_permissions' => 'role_has_permissions', + ], + + 'column_names' => [ + 'role_pivot_key' => null, + 'permission_pivot_key' => null, + 'model_morph_key' => 'model_id', + 'team_foreign_key' => 'team_id', + ], + + /* + * When set to true, the method for checking permissions will be registered + * on the gate. Set to false to disable. + */ + + 'register_permission_check_method' => true, + + /* + * When set to true, required permissions will be checked on the model + * to determine access. + */ + + 'teams' => false, + + 'use_passport_client_credentials' => false, + + 'display_permission_in_exception' => (bool) env('APP_DEBUG', false), + + 'display_role_in_exception' => (bool) env('APP_DEBUG', false), + + 'enable_wildcard_permission' => false, + + 'cache' => [ + + 'expiration_time' => \DateInterval::createFromDateString('24 hours'), + + 'key' => 'spatie.permission.cache', + + 'store' => 'default', + ], +]; diff --git a/Backend/config/wasl.php b/Backend/config/wasl.php new file mode 100644 index 0000000..c213579 --- /dev/null +++ b/Backend/config/wasl.php @@ -0,0 +1,132 @@ + [ + 'default_currency' => env('WASL_DEFAULT_CURRENCY', 'SYP'), + 'supported_currencies' => ['SYP', 'USD', 'EUR', 'AED'], + + // Per-KYC-tier limits (in minor units of SYP) + // Tier 0: no KYC, Tier 1: phone verified, Tier 2: ID verified, Tier 3: full + 'limits' => [ + 0 => ['balance' => 0, 'daily_tx' => 0, 'monthly_tx' => 0], + 1 => ['balance' => 5000000, 'daily_tx' => 1000000, 'monthly_tx' => 20000000], // 50k / 10k / 200k SYP + 2 => ['balance' => 50000000, 'daily_tx' => 10000000, 'monthly_tx' => 200000000], // 500k / 100k / 2M SYP + 3 => ['balance' => 500000000, 'daily_tx' => 100000000, 'monthly_tx' => 2000000000], // 5M / 1M / 20M SYP + ], + + // Fees in minor units (basis points * amount, or flat) + 'fees' => [ + 'p2p' => [ + 'enabled' => env('WASL_FEE_P2P_ENABLED', false), + 'percent' => env('WASL_FEE_P2P_PERCENT', 0), // e.g. 0.5 = 0.5% + 'flat_minor' => env('WASL_FEE_P2P_FLAT', 0), + 'min_minor' => env('WASL_FEE_P2P_MIN', 0), + 'max_minor' => env('WASL_FEE_P2P_MAX', 0), + ], + ], + ], + + /* + |-------------------------------------------------------------------------- + | Security + |-------------------------------------------------------------------------- + */ + + 'security' => [ + 'pin' => [ + 'min_length' => 6, + 'max_attempts' => env('WASL_PIN_MAX_ATTEMPTS', 5), + 'lock_minutes' => env('WASL_PIN_LOCK_MINUTES', 30), + 'hash_algo' => 'argon2id', + ], + + 'otp' => [ + 'length' => env('WASL_OTP_LENGTH', 6), + 'ttl_seconds' => env('WASL_OTP_TTL', 300), // 5 minutes + 'max_attempts' => env('WASL_OTP_MAX_ATTEMPTS', 3), + 'resend_cooldown' => env('WASL_OTP_RESEND_COOLDOWN', 60), // 1 minute + ], + + 'login' => [ + 'max_attempts' => env('WASL_LOGIN_MAX_ATTEMPTS', 5), + 'lock_minutes' => env('WASL_LOGIN_LOCK_MINUTES', 30), + ], + + // Encryption keys for field-level encryption (phone, national_id, cards) + 'encryption' => [ + 'cipher' => env('WASL_ENC_CIPHER', 'aes-256-cbc'), + // Separate key from APP_KEY to allow key rotation without re-encrypting all data + 'field_key' => env('WASL_FIELD_ENCRYPTION_KEY'), + ], + + 'idempotency' => [ + 'enabled' => env('WASL_IDEMPOTENCY_ENABLED', true), + 'ttl_seconds' => env('WASL_IDEMPOTENCY_TTL', 86400), // 24h + ], + ], + + /* + |-------------------------------------------------------------------------- + | KYC + |-------------------------------------------------------------------------- + */ + + 'kyc' => [ + 'auto_approve_in_local' => env('WASL_KYC_AUTO_APPROVE_LOCAL', false), + 'max_document_size_mb' => env('WASL_KYC_MAX_DOC_MB', 5), + 'allowed_mime_types' => ['image/jpeg', 'image/png', 'application/pdf'], + ], + + /* + |-------------------------------------------------------------------------- + | Rate Limiting (per-IP / per-user) + |-------------------------------------------------------------------------- + */ + + 'throttle' => [ + 'login' => ['max' => 5, 'minutes' => 1], + 'otp_request' => ['max' => 3, 'minutes' => 1], + 'transfer' => ['max' => 10, 'minutes' => 60], + 'api' => ['max' => 60, 'minutes' => 1], + ], + + /* + |-------------------------------------------------------------------------- + | Reference Code Format + | P2P transfer reference codes visible to users. + |-------------------------------------------------------------------------- + */ + + 'reference' => [ + 'prefix' => env('WASL_REF_PREFIX', 'WASL'), + 'length' => env('WASL_REF_LENGTH', 8), // alphanumeric chars after prefix + ], + + /* + |-------------------------------------------------------------------------- + | Feature Flags + |-------------------------------------------------------------------------- + */ + + 'features' => [ + 'crypto' => env('WASL_FEATURE_CRYPTO', false), + 'cards' => env('WASL_FEATURE_CARDS', false), + 'international' => env('WASL_FEATURE_INTERNATIONAL', false), + 'merchant' => env('WASL_FEATURE_MERCHANT', true), + ], +]; diff --git a/Backend/database/migrations/2026_01_01_000001_enable_postgres_extensions.php b/Backend/database/migrations/2026_01_01_000001_enable_postgres_extensions.php new file mode 100644 index 0000000..4f2e00e --- /dev/null +++ b/Backend/database/migrations/2026_01_01_000001_enable_postgres_extensions.php @@ -0,0 +1,29 @@ +bigIncrements('id'); + + // UUID for external exposure (BIGINT PKs never exposed to clients) + $table->uuid('uuid')->unique()->default(DB::raw('uuid_generate_v4()')); + + // Identity + $table->string('full_name', 150); + + // Phone: stored AES-256 encrypted. phone_hash = SHA-256 for fast unique lookup. + // NEVER query WHERE phone_number = ? — always use phone_hash. + $table->string('phone_number', 255); // encrypted ciphertext (variable length) + $table->string('phone_hash', 64)->unique(); // sha256(normalized phone) + + $table->string('email', 255)->nullable(); + $table->string('national_id', 255)->nullable(); // encrypted + $table->string('national_id_hash', 64)->nullable()->unique(); + + // Credentials — bcrypt/argon2id hashes (Laravel convention: 'password') + $table->string('password', 255)->nullable(); + $table->string('pin_hash', 255)->nullable(); // argon2id — 6-digit PIN + + // Lifecycle / status + $table->string('status', 20)->default('pending'); // pending/active/suspended/banned + $table->unsignedSmallInteger('kyc_level')->default(0);// 0=none,1=phone,2=id,3=full + + // Locale / region + $table->string('language', 5)->default('ar'); + $table->char('country_code', 2)->default('SY'); + + // Brute-force protection + $table->unsignedSmallInteger('failed_login_count')->default(0); + $table->timestampTz('locked_until')->nullable(); + $table->timestampTz('last_login_at')->nullable(); + $table->ipAddress('last_login_ip')->nullable(); + + // Verification + $table->timestampTz('phone_verified_at')->nullable(); + $table->timestampTz('email_verified_at')->nullable(); + + $table->timestampsTz(); + $table->softDeletesTz(); + + $table->index('phone_hash'); + $table->index('status'); + $table->index('deleted_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('users'); + } +}; diff --git a/Backend/database/migrations/2026_01_01_000020_create_wallets_table.php b/Backend/database/migrations/2026_01_01_000020_create_wallets_table.php new file mode 100644 index 0000000..6bd8c7c --- /dev/null +++ b/Backend/database/migrations/2026_01_01_000020_create_wallets_table.php @@ -0,0 +1,44 @@ +bigIncrements('id'); + $table->uuid('uuid')->unique()->default(DB::raw('uuid_generate_v4()')); + + $table->unsignedBigInteger('user_id'); + $table->foreign('user_id')->references('id')->on('users')->onDelete('restrict'); + + $table->char('currency_code', 3)->default('SYP'); + + // BIGINT minor units ONLY — never FLOAT/DECIMAL for money. + // 1 SYP = 100 minor units; 1 USD = 100 cents; 1 USDT = 100_000_000 minor units. + $table->bigInteger('balance_minor')->default(0); + $table->bigInteger('balance_pending_minor')->default(0); + + $table->string('status', 20)->default('active'); // active/frozen/closed + + // Per-wallet limits (override the KYC-tier defaults if set) + $table->bigInteger('daily_limit_minor')->nullable(); + $table->bigInteger('monthly_limit_minor')->nullable(); + + $table->timestampsTz(); + $table->softDeletesTz(); + + // One wallet per (user, currency) + $table->unique(['user_id', 'currency_code']); + $table->index(['user_id', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('wallets'); + } +}; diff --git a/Backend/database/migrations/2026_01_01_000030_create_transactions_table.php b/Backend/database/migrations/2026_01_01_000030_create_transactions_table.php new file mode 100644 index 0000000..9c34515 --- /dev/null +++ b/Backend/database/migrations/2026_01_01_000030_create_transactions_table.php @@ -0,0 +1,76 @@ +bigIncrements('id'); + $table->uuid('uuid')->unique()->default(DB::raw('uuid_generate_v4()')); + + // WASL-XXXXXXXX — user-visible reference code + $table->string('reference_code', 20)->unique(); + + $table->string('type', 20); // transfer/deposit/withdraw/merchant_pay/refund/fee + $table->string('status', 20)->default('pending'); // pending/processing/completed/failed/reversed + + // debit = wallet the money leaves + // credit = wallet the money enters + // (deposit has only credit; withdraw has only debit; transfer has both) + $table->unsignedBigInteger('debit_wallet_id')->nullable(); + $table->unsignedBigInteger('credit_wallet_id')->nullable(); + $table->foreign('debit_wallet_id')->references('id')->on('wallets')->onDelete('restrict'); + $table->foreign('credit_wallet_id')->references('id')->on('wallets')->onDelete('restrict'); + + // BIGINT minor units ONLY + $table->bigInteger('amount_minor'); + $table->bigInteger('fee_minor')->default(0); + $table->char('currency_code', 3)->default('SYP'); + + $table->text('description')->nullable(); + $table->jsonb('metadata')->nullable(); + + // Idempotency: UUID sent by client. Duplicate = return cached result, no new tx. + $table->string('idempotency_key', 64)->unique(); + + // Audit snapshot + $table->ipAddress('initiator_ip')->nullable(); + $table->string('device_id', 255)->nullable(); + + $table->timestampTz('initiated_at')->useCurrent(); + $table->timestampTz('completed_at')->nullable(); + $table->text('failure_reason')->nullable(); + + $table->timestampsTz(); + + $table->index(['debit_wallet_id', 'created_at']); + $table->index(['credit_wallet_id', 'created_at']); + $table->index('status'); + $table->index('reference_code'); + }); + + // Partial index for fast reconciliation scans of in-flight transactions + DB::statement( + "CREATE INDEX transactions_in_flight_idx ". + "ON transactions (created_at) ". + "WHERE status IN ('pending','processing');" + ); + } + + public function down(): void + { + Schema::dropIfExists('transactions'); + } +}; diff --git a/Backend/database/migrations/2026_01_01_000040_create_transaction_entries_table.php b/Backend/database/migrations/2026_01_01_000040_create_transaction_entries_table.php new file mode 100644 index 0000000..855b1f0 --- /dev/null +++ b/Backend/database/migrations/2026_01_01_000040_create_transaction_entries_table.php @@ -0,0 +1,57 @@ +bigIncrements('id'); + + $table->unsignedBigInteger('transaction_id'); + $table->foreign('transaction_id')->references('id')->on('transactions')->onDelete('restrict'); + + $table->unsignedBigInteger('wallet_id'); + $table->foreign('wallet_id')->references('id')->on('wallets')->onDelete('restrict'); + + $table->enum('entry_type', ['debit', 'credit']); + + $table->bigInteger('amount_minor'); + + // Snapshot: wallet balance AFTER applying this entry + $table->bigInteger('balance_after_minor'); + + $table->timestampTz('created_at')->useCurrent(); + + $table->index(['wallet_id', 'created_at']); + $table->index('transaction_id'); + + // Composite index for fast "what happened to this wallet today?" queries + $table->index(['wallet_id', 'entry_type', 'created_at'], 'entries_wallet_type_time_idx'); + }); + + // Constraint: every transaction must have exactly 2 entries + // (enforced at application level in TransferService, this is documentation) + } + + public function down(): void + { + Schema::dropIfExists('transaction_entries'); + } +}; diff --git a/Backend/database/migrations/2026_01_01_000050_create_user_devices_table.php b/Backend/database/migrations/2026_01_01_000050_create_user_devices_table.php new file mode 100644 index 0000000..808662f --- /dev/null +++ b/Backend/database/migrations/2026_01_01_000050_create_user_devices_table.php @@ -0,0 +1,42 @@ +bigIncrements('id'); + + $table->unsignedBigInteger('user_id'); + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + + $table->string('device_fingerprint', 255); + $table->string('device_name', 255)->nullable(); + $table->string('platform', 20)->nullable(); // android/ios + $table->string('os_version', 20)->nullable(); + $table->string('app_version', 20)->nullable(); + + $table->boolean('is_trusted')->default(false); + + // Push notification tokens (FCM / APNs) + $table->text('push_token')->nullable(); + + $table->timestampTz('first_seen_at')->useCurrent(); + $table->timestampTz('last_seen_at')->nullable(); + + $table->timestampsTz(); + + $table->unique(['user_id', 'device_fingerprint'], 'user_device_unique'); + $table->index(['is_trusted', 'last_seen_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('user_devices'); + } +}; diff --git a/Backend/database/migrations/2026_01_01_000060_create_otp_codes_table.php b/Backend/database/migrations/2026_01_01_000060_create_otp_codes_table.php new file mode 100644 index 0000000..55539ea --- /dev/null +++ b/Backend/database/migrations/2026_01_01_000060_create_otp_codes_table.php @@ -0,0 +1,44 @@ +bigIncrements('id'); + + $table->unsignedBigInteger('user_id'); + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + + $table->string('purpose', 30); // login/transfer/pin_change/phone_change/kyc/registration + $table->string('code_hash', 255); // SHA-256 or bcrypt — NEVER plain text + + $table->string('channel', 20)->default('sms'); // sms/authenticator + + $table->unsignedSmallInteger('attempts')->default(0); + $table->timestampTz('expires_at'); // created_at + config('wasl.security.otp.ttl_seconds') + + $table->timestampTz('used_at')->nullable(); + $table->timestampsTz(); + + $table->index(['user_id', 'purpose', 'created_at'], 'otp_user_purpose_idx'); + $table->index('expires_at'); + }); + + // Auto-cleanup job hint: OTPs older than 24h can be purged + } + + public function down(): void + { + Schema::dropIfExists('otp_codes'); + } +}; diff --git a/Backend/database/migrations/2026_01_01_000070_create_audit_logs_table.php b/Backend/database/migrations/2026_01_01_000070_create_audit_logs_table.php new file mode 100644 index 0000000..079b814 --- /dev/null +++ b/Backend/database/migrations/2026_01_01_000070_create_audit_logs_table.php @@ -0,0 +1,51 @@ +bigIncrements('id'); + + // Who was affected (the subject of the action) + $table->unsignedBigInteger('user_id')->nullable()->index(); + // Who performed the action (admin, system, or same user) + $table->unsignedBigInteger('actor_id')->nullable(); + + $table->string('action', 50); // login/logout/transfer_create/pin_change/kyc_approved/wallet_freeze + $table->string('subject_type', 50)->nullable(); // App\Models\Wallet, App\Models\Transaction + $table->unsignedBigInteger('subject_id')->nullable(); + + // Change tracking (before/after snapshots for sensitive field updates) + $table->jsonb('old_values')->nullable(); + $table->jsonb('new_values')->nullable(); + + // Context + $table->ipAddress('ip_address')->nullable(); + $table->text('user_agent')->nullable(); + $table->string('device_id', 255)->nullable(); + + $table->timestampTz('created_at')->useCurrent(); + // NO updated_at — immutable log + + $table->index(['user_id', 'created_at'], 'audit_user_time_idx'); + $table->index(['action', 'created_at'], 'audit_action_time_idx'); + $table->index(['actor_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('audit_logs'); + } +}; diff --git a/Backend/database/migrations/2026_01_01_000080_create_kyc_documents_table.php b/Backend/database/migrations/2026_01_01_000080_create_kyc_documents_table.php new file mode 100644 index 0000000..25269e1 --- /dev/null +++ b/Backend/database/migrations/2026_01_01_000080_create_kyc_documents_table.php @@ -0,0 +1,45 @@ +bigIncrements('id'); + + $table->unsignedBigInteger('user_id'); + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + + $table->string('doc_type', 30); // national_id/passport/utility_bill/selfie + + // Path on MinIO (S3-compatible). File is encrypted BEFORE upload + // (application-level encryption, not just server-side encryption). + $table->text('file_path_encrypted'); + + // SHA-256 of the ORIGINAL unencrypted file — integrity check + $table->string('file_hash', 64); + + $table->string('status', 20)->default('pending'); // pending/approved/rejected + + // Reviewer (admin who approved/rejected) + $table->unsignedBigInteger('reviewed_by')->nullable(); + $table->foreign('reviewed_by')->references('id')->on('users')->onDelete('set null'); + + $table->timestampTz('reviewed_at')->nullable(); + $table->text('rejection_reason')->nullable(); + + $table->timestampsTz(); + + $table->index(['user_id', 'doc_type', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('kyc_documents'); + } +}; diff --git a/Backend/database/migrations/2026_01_01_000090_create_fraud_alerts_table.php b/Backend/database/migrations/2026_01_01_000090_create_fraud_alerts_table.php new file mode 100644 index 0000000..4f9d7ff --- /dev/null +++ b/Backend/database/migrations/2026_01_01_000090_create_fraud_alerts_table.php @@ -0,0 +1,46 @@ +bigIncrements('id'); + + $table->unsignedBigInteger('user_id')->nullable(); + $table->foreign('user_id')->references('id')->on('users')->onDelete('set null'); + + $table->unsignedBigInteger('transaction_id')->nullable(); + $table->foreign('transaction_id')->references('id')->on('transactions')->onDelete('set null'); + + $table->string('rule_triggered', 100); // e.g. velocity_5tx_10min, geo_impossible, new_device_large_tx + $table->unsignedSmallInteger('risk_score'); // 0-100 + + $table->string('status', 20)->default('open'); // open/investigated/closed + + $table->string('action_taken', 20)->nullable(); // blocked/reviewed/allowed + + $table->jsonb('details')->nullable(); // full context snapshot + + $table->timestampsTz(); + + $table->index(['status', 'created_at']); + $table->index(['risk_score', 'created_at']); + $table->index(['user_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('fraud_alerts'); + } +}; diff --git a/Backend/docker-compose.yml b/Backend/docker-compose.yml new file mode 100644 index 0000000..50b6bcb --- /dev/null +++ b/Backend/docker-compose.yml @@ -0,0 +1,204 @@ +# WASL Digital Wallet — Production-grade Docker setup +# Services on internal network; only nginx is exposed externally. +# Usage: +# docker compose up -d +# docker compose exec app composer install +# docker compose exec app php artisan migrate --force +# docker compose exec app php artisan octane:start --server=swoole --host=0.0.0.0 --port=8000 + +services: + + # ─────────────────────────────────────────────────────────────── + # Application (PHP 8.3 + Swoole + Octane) + # ─────────────────────────────────────────────────────────────── + app: + build: + context: . + dockerfile: Dockerfile + target: production + container_name: wasl-app + restart: unless-stopped + working_dir: /var/www/html + volumes: + - ./:/var/www/html + - app_storage:/var/www/html/storage + - app_bootstrap_cache:/var/www/html/bootstrap/cache + networks: + - wasl-internal + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + environment: + - "PHP_OCTANE_SERVER=swoole" + - "DB_HOST=postgres" + - "REDIS_HOST=redis" + healthcheck: + test: ["CMD-SHELL", "php artisan octane:status || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s + + # ─────────────────────────────────────────────────────────────── + # Queue Worker (supervisor-managed) + # ─────────────────────────────────────────────────────────────── + worker: + build: + context: . + dockerfile: Dockerfile + target: production + container_name: wasl-worker + restart: unless-stopped + working_dir: /var/www/html + volumes: + - ./:/var/www/html + - app_storage:/var/www/html/storage + networks: + - wasl-internal + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + command: ["php", "artisan", "queue:work", "redis", "--tries=3", "--backoff=10", "--max-time=3600"] + healthcheck: + test: ["CMD-SHELL", "php artisan horizon:status || php artisan queue:status || exit 0"] + interval: 60s + timeout: 10s + retries: 3 + + # ─────────────────────────────────────────────────────────────── + # Nginx (Reverse Proxy + Load Balancer) — the ONLY exposed service + # ─────────────────────────────────────────────────────────────── + nginx: + image: nginx:1.27-alpine + container_name: wasl-nginx + restart: unless-stopped + ports: + - "${NGINX_PORT:-8080}:80" + volumes: + - ./:/var/www/html + - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + - ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - nginx_logs:/var/log/nginx + networks: + - wasl-internal + depends_on: + - app + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost/up"] + interval: 30s + timeout: 5s + retries: 3 + + # ─────────────────────────────────────────────────────────────── + # PostgreSQL 16 — Primary financial database + # ─────────────────────────────────────────────────────────────── + postgres: + image: postgres:16-alpine + container_name: wasl-postgres + restart: unless-stopped + environment: + - "POSTGRES_DB=${DB_DATABASE:-wasl}" + - "POSTGRES_USER=${DB_USERNAME:-wasl}" + - "POSTGRES_PASSWORD=${DB_PASSWORD:-secret}" + - "POSTGRES_INITDB_ARGS=--encoding=UTF8 --locale=C" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro + networks: + - wasl-internal + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-wasl} -d ${DB_DATABASE:-wasl}"] + interval: 10s + timeout: 5s + retries: 5 + # SECURITY: no ports exposed — only reachable from internal network + + # ─────────────────────────────────────────────────────────────── + # Redis 7 — Cache + Queue + Sessions + Throttling + # ─────────────────────────────────────────────────────────────── + redis: + image: redis:7-alpine + container_name: wasl-redis + restart: unless-stopped + command: > + redis-server + --requirepass ${REDIS_PASSWORD:-secret} + --maxmemory 512mb + --maxmemory-policy allkeys-lru + --appendonly yes + --save 60 1000 + volumes: + - redis_data:/data + networks: + - wasl-internal + healthcheck: + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-secret}", "ping"] + interval: 10s + timeout: 5s + retries: 5 + # SECURITY: no ports exposed + + # ─────────────────────────────────────────────────────────────── + # MinIO — S3-compatible storage for KYC documents (encrypted at rest) + # ─────────────────────────────────────────────────────────────── + minio: + image: minio/minio:latest + container_name: wasl-minio + restart: unless-stopped + command: server /data --console-address ":9001" + environment: + - "MINIO_ROOT_USER=${AWS_ACCESS_KEY_ID:-minioadmin}" + - "MINIO_ROOT_PASSWORD=${AWS_SECRET_ACCESS_KEY:-minioadmin}" + volumes: + - minio_data:/data + networks: + - wasl-internal + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 30s + timeout: 20s + retries: 3 + # SECURITY: no ports exposed externally + + # ─────────────────────────────────────────────────────────────── + # MinIO Bootstrap — auto-create buckets on first run + # ─────────────────────────────────────────────────────────────── + minio-bootstrap: + image: minio/mc:latest + container_name: wasl-minio-init + depends_on: + minio: + condition: service_healthy + networks: + - wasl-internal + entrypoint: > + /bin/sh -c " + sleep 5; + mc alias set wasl http://minio:9000 ${AWS_ACCESS_KEY_ID:-minioadmin} ${AWS_SECRET_ACCESS_KEY:-minioadmin}; + mc mb wasl/${WASL_KYC_BUCKET:-wasl-kyc} --ignore-existing; + mc anonymous set none wasl/${WASL_KYC_BUCKET:-wasl-kyc}; + exit 0; + " + +networks: + wasl-internal: + driver: bridge + internal: false # set to `true` if you don't need outbound internet + +volumes: + postgres_data: + driver: local + redis_data: + driver: local + minio_data: + driver: local + app_storage: + driver: local + app_bootstrap_cache: + driver: local + nginx_logs: + driver: local diff --git a/Backend/docker/nginx/default.conf b/Backend/docker/nginx/default.conf new file mode 100644 index 0000000..5ff869d --- /dev/null +++ b/Backend/docker/nginx/default.conf @@ -0,0 +1,95 @@ +# ───────────────────────────────────────────────────────────── +# WASL — Nginx server block (API) +# Proxies to Laravel Octane (Swoole) on app:8000 +# ───────────────────────────────────────────────────────────── + +server { + listen 80 default_server; + listen [::]:80 default_server; + server_name _; + + root /var/www/html/public; + index index.php; + + charset utf-8; + + # ── Health check endpoint ── + location = /up { + access_log off; + proxy_pass http://wasl_octane; + proxy_http_version 1.1; + proxy_set_header Connection ""; + } + + # ── Block sensitive files ── + location ~ /\.(?!well-known).* { + deny all; + access_log off; + log_not_found off; + } + + location ~* ^/(?:storage|app|bootstrap|config|database|resources|routes|tests|vendor|docker)(/.*)?$ { + deny all; + access_log off; + log_not_found off; + } + + # ── Static assets (cached) ── + location ~* \.(?:css|js|jpg|jpeg|png|gif|ico|svg|woff|woff2|ttf|eot)$ { + access_log off; + log_not_found off; + expires 30d; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + # ── API rate limits (login & transfers throttled harder) ── + location ~ ^/api/(auth|otp|login) { + limit_req zone=login burst=5 nodelay; + limit_req_status 429; + try_files $uri $uri/ /index.php?$query_string; + } + + location ~ ^/api/(transfers|wallets/.*/transfer) { + limit_req zone=transfer burst=3 nodelay; + limit_req_status 429; + try_files $uri $uri/ /index.php?$query_string; + } + + # ── General API + app ── + location / { + limit_req zone=api burst=30 nodelay; + limit_req_status 429; + try_files $uri $uri/ /index.php?$query_string; + } + + # ── PHP via Octane (Swoole) ── + location ~ \.php$ { + proxy_pass http://wasl_octane; + proxy_http_version 1.1; + proxy_set_header Connection ""; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_read_timeout 60s; + proxy_send_timeout 60s; + proxy_connect_timeout 5s; + + proxy_buffering off; + proxy_request_buffering off; + } + + # ── Custom error pages ── + error_page 404 /index.php; + error_page 429 = @ratelimit; + location @ratelimit { + default_type application/json; + return 429 '{"success":false,"message":"Too many requests. Please slow down.","code":"RATE_LIMITED"}'; + } + + location = /favicon.ico { access_log off; log_not_found off; } + location = /robots.txt { access_log off; log_not_found off; } +} diff --git a/Backend/docker/nginx/nginx.conf b/Backend/docker/nginx/nginx.conf new file mode 100644 index 0000000..d8699ef --- /dev/null +++ b/Backend/docker/nginx/nginx.conf @@ -0,0 +1,74 @@ +# ───────────────────────────────────────────────────────────── +# WASL — Nginx main config +# Hardened reverse proxy in front of Laravel Octane (Swoole) +# ───────────────────────────────────────────────────────────── + +user nginx; +worker_processes auto; +worker_rlimit_nofile 65535; + +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 4096; + multi_accept on; + use epoll; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # ── Logging ── + log_format main '$remote_addr - $remote_user [$time_local] ' + '"$request" $status $body_bytes_sent ' + '"$http_referer" "$http_user_agent" ' + 'rt=$request_time urt=$upstream_response_time'; + access_log /var/log/nginx/access.log main; + + # ── Performance ── + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + keepalive_requests 1000; + types_hash_max_size 2048; + server_tokens off; + + # ── Client body limits (KYC docs up to ~10MB) ── + client_max_body_size 15m; + client_body_buffer_size 128k; + client_body_timeout 30s; + client_header_timeout 30s; + + # ── Gzip ── + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_proxied any; + gzip_comp_level 6; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + # ── Security headers ── + server_tokens off; + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; + + # ── Rate limiting zones ── + limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s; + limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m; + limit_req_zone $binary_remote_addr zone=transfer:10m rate=10r/m; + + # ── Upstream to Octane (Swoole) ── + upstream wasl_octane { + server app:8000 max_fails=3 fail_timeout=5s; + keepalive 64; + } + + include /etc/nginx/conf.d/*.conf; +} diff --git a/Backend/docker/php/dev.ini b/Backend/docker/php/dev.ini new file mode 100644 index 0000000..09342a1 --- /dev/null +++ b/Backend/docker/php/dev.ini @@ -0,0 +1,31 @@ +; ───────────────────────────────────────────────────────────── +; WASL — Development PHP settings +; ───────────────────────────────────────────────────────────── + +display_errors = On +display_startup_errors = On +log_errors = On +error_reporting = E_ALL + +memory_limit = 512M +max_execution_time = 120 +upload_max_filesize = 50M +post_max_size = 50M + +; OPcache (with validation for dev hot-reload) +opcache.enable = 1 +opcache.enable_cli = 1 +opcache.memory_consumption = 128 +opcache.max_accelerated_files = 10000 +opcache.validate_timestamps = 1 +opcache.revalidate_freq = 0 + +date.timezone = Asia/Damascus + +; Xdebug +[xdebug] +xdebug.mode = debug,develop,coverage +xdebug.start_with_request = trigger +xdebug.client_host = host.docker.internal +xdebug.client_port = 9003 +xdebug.log = /dev/stderr diff --git a/Backend/docker/php/production.ini b/Backend/docker/php/production.ini new file mode 100644 index 0000000..4103cf5 --- /dev/null +++ b/Backend/docker/php/production.ini @@ -0,0 +1,55 @@ +; ───────────────────────────────────────────────────────────── +; WASL — Production PHP settings +; Conservative limits, security-focused, opcache optimized +; ───────────────────────────────────────────────────────────── + +; Errors: NEVER display in production (financial system) +display_errors = Off +display_startup_errors = Off +log_errors = On +error_log = /dev/stderr +error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT + +; Performance +memory_limit = 512M +max_execution_time = 30 +max_input_time = 60 +max_input_vars = 3000 +post_max_size = 20M +upload_max_filesize = 10M + +; OPcache — critical for production performance +opcache.enable = 1 +opcache.enable_cli = 1 +opcache.memory_consumption = 256 +opcache.max_accelerated_files = 20000 +opcache.validate_timestamps = 0 +opcache.revalidate_freq = 2 +opcache.interned_strings_buffer = 32 +opcache.fast_shutdown = 1 +opcache.jit = tracing +opcache.jit_buffer_size = 64M + +; Realpath cache (improves file inclusion performance) +realpath_cache_size = 4096K +realpath_cache_ttl = 600 + +; Session hardening +session.cookie_httponly = 1 +session.cookie_secure = 1 +session.cookie_samesite = "Strict" +session.use_strict_mode = 1 +session.gc_maxlifetime = 1200 + +; Exposure +expose_php = Off + +; Date +date.timezone = Asia/Damascus + +; Swoole runtime config +swoole.enable_library = On +swoole.enable_preemptive_scheduler = On + +; Disable functions that could be abused +disable_functions = exec,passthru,shell_exec,system,proc_open,popen,curl_multi_exec,parse_ini_file,show_source diff --git a/Backend/docker/postgres/init.sql b/Backend/docker/postgres/init.sql new file mode 100644 index 0000000..1742879 --- /dev/null +++ b/Backend/docker/postgres/init.sql @@ -0,0 +1,19 @@ +-- ───────────────────────────────────────────────────────────── +-- WASL — PostgreSQL initialization +-- Runs once when the postgres container initializes the data dir. +-- ───────────────────────────────────────────────────────────── + +-- Ensure required extensions are enabled for the WASL database +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- gen_random_uuid() / uuid-ossp +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- encryption helpers (pgp_sym_encrypt) +CREATE EXTENSION IF NOT EXISTS "citext"; -- case-insensitive text (emails) +CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- trigram fuzzy search (phone lookup) +CREATE EXTENSION IF NOT EXISTS "btree_gin"; -- composite GIN indexes + +-- Create the test database if running in development +SELECT 'CREATE DATABASE wasl_testing OWNER wasl' +WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'wasl_testing')\gexec + +-- Set default search path +ALTER DATABASE wasl SET search_path TO public; +ALTER DATABASE wasl SET timezone TO 'Asia/Damascus'; diff --git a/Backend/docker/supervisor/supervisord.conf b/Backend/docker/supervisor/supervisord.conf new file mode 100644 index 0000000..db0182f --- /dev/null +++ b/Backend/docker/supervisor/supervisord.conf @@ -0,0 +1,65 @@ +; ───────────────────────────────────────────────────────────── +; WASL — Supervisor config +; Manages long-running processes: Octane server + queue workers +; ───────────────────────────────────────────────────────────── + +[supervisord] +nodaemon=true +logfile=/dev/stderr +logfile_maxbytes=0 +pidfile=/var/run/supervisord.pid + +[unix_http_server] +file=/var/run/supervisor.sock +chmod=0700 + +[rpcinterface:supervisor] +supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface + +[supervisorctl] +serverurl=unix:///var/run/supervisor.sock + +; ── Laravel Octane (Swoole) — main application server ── +[program:octane] +command=php artisan octane:start --server=swoole --host=0.0.0.0 --port=8000 --max-requests=10000 +directory=/var/www/html +autostart=true +autorestart=true +startretries=3 +user=www-data +redirect_stderr=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stopwaitsecs=15 +stopsignal=TERM +killasgroup=true +stopasgroup=true + +; ── Queue worker (processes async jobs: notifications, reconciliation) ── +[program:queue] +command=php artisan queue:work redis --tries=3 --backoff=10 --max-time=3600 --queue=default,transfers,notifications +directory=/var/www/html +autostart=true +autorestart=true +startretries=3 +user=www-data +numprocs=2 +process_name=%(program_name)s_%(process_num)02d +redirect_stderr=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stopwaitsecs=30 +stopsignal=TERM +killasgroup=true +stopasgroup=true + +; ── Scheduled task runner (cron-style Laravel scheduler) ── +[program:scheduler] +command=php artisan schedule:work +directory=/var/www/html +autostart=true +autorestart=true +user=www-data +redirect_stderr=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 diff --git a/Backend/public/index.php b/Backend/public/index.php new file mode 100644 index 0000000..4edd9a5 --- /dev/null +++ b/Backend/public/index.php @@ -0,0 +1,28 @@ +make(Illuminate\Contracts\Http\Kernel::class); + +$response = $kernel->handle( + $request = Illuminate\Http\Request::capture() +); + +$response->send(); + +$kernel->terminate($request, $response); diff --git a/Backend/public/robots.txt b/Backend/public/robots.txt new file mode 100644 index 0000000..c6742d8 --- /dev/null +++ b/Backend/public/robots.txt @@ -0,0 +1,2 @@ +User-Agent: * +Disallow: / diff --git a/Backend/routes/api.php b/Backend/routes/api.php new file mode 100644 index 0000000..902f7cf --- /dev/null +++ b/Backend/routes/api.php @@ -0,0 +1,30 @@ +middleware(['idempotency', 'throttle.actions:otp_request']); + +Route::post('/login', [AuthController::class, 'login']) + ->middleware(['throttle.actions:login']); + +Route::post('/otp/verify', [AuthController::class, 'verifyOtp']) + ->middleware(['throttle.actions:login']); + +// Authenticated Routes +Route::middleware(['auth.jwt', 'audit'])->group(function () { + Route::post('/pin/setup', [AuthController::class, 'setupPin']) + ->middleware(['idempotency']); +}); diff --git a/Backend/routes/console.php b/Backend/routes/console.php new file mode 100644 index 0000000..3c9adf1 --- /dev/null +++ b/Backend/routes/console.php @@ -0,0 +1,8 @@ +comment(Inspiring::quote()); +})->purpose('Display an inspiring quote'); diff --git a/Backend/tests/Feature/AuthTest.php b/Backend/tests/Feature/AuthTest.php new file mode 100644 index 0000000..90c6a62 --- /dev/null +++ b/Backend/tests/Feature/AuthTest.php @@ -0,0 +1,87 @@ +postJson('/api/register', [ + 'full_name' => 'Adnan Khoury', + 'phone_number' => '+963933111222', + 'password' => 'SecurePassword123', + ]); + + $response->assertStatus(201) + ->assertJsonStructure(['message', 'uuid', 'otp']); + + $uuid = $response->json('uuid'); + + $this->assertDatabaseHas('users', [ + 'uuid' => $uuid, + 'full_name' => 'Adnan Khoury', + ]); + + $user = User::where('uuid', $uuid)->first(); + + $this->assertDatabaseHas('wallets', [ + 'user_id' => $user->id, + 'currency_code' => 'SYP', + 'balance_minor' => 0, + ]); + } + + public function test_user_can_login_and_verify_otp() + { + // 1. Create a user + $user = User::create([ + 'full_name' => 'Samer Al-Ali', + 'phone_number' => '+963933222333', + 'phone_hash' => hash_phone('+963933222333'), + 'password' => Hash::make('password123'), + 'status' => \App\Enums\UserStatus::PENDING, + 'kyc_level' => 0, + ]); + + // 2. Request login + $response = $this->postJson('/api/login', [ + 'phone_number' => '+963933222333', + 'password' => 'password123', + ]); + + $response->assertStatus(200) + ->assertJsonStructure(['message', 'uuid', 'otp']); + + $otpCode = $response->json('otp'); + + // 3. Verify OTP + $verifyResponse = $this->postJson('/api/otp/verify', [ + 'uuid' => $user->uuid, + 'code' => $otpCode, + ]); + + $verifyResponse->assertStatus(200) + ->assertJsonStructure(['message', 'access_token', 'token_type', 'user']); + + $token = $verifyResponse->json('access_token'); + + // 4. Setup PIN + $pinResponse = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $token, + ])->postJson('/api/pin/setup', [ + 'pin' => '123456', + ]); + + $pinResponse->assertStatus(200) + ->assertJsonStructure(['message']); + } +} diff --git a/Backend/tests/TestCase.php b/Backend/tests/TestCase.php new file mode 100644 index 0000000..fe1ffc2 --- /dev/null +++ b/Backend/tests/TestCase.php @@ -0,0 +1,10 @@ +