Initial commit - WASL Digital Wallet

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

14
.gitignore vendored Normal file
View File

@@ -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

110
Backend/Dockerfile Normal file
View File

@@ -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"]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

17
Backend/artisan Normal file
View File

@@ -0,0 +1,17 @@
#!/usr/bin/env php
<?php
use Illuminate\Foundation\Application;
define('LARAVEL_START', microtime(true));
// Register the Composer autoloader...
require __DIR__.'/vendor/autoload.php';
// Create and boot the application...
/** @var Application $app */
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new Symfony\Component\Console\Input\ArgvInput);
exit($status);

36
Backend/bootstrap/app.php Normal file
View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->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();

93
Backend/composer.json Normal file
View File

@@ -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
}

View File

@@ -0,0 +1,40 @@
<?php
return [
'enabled' => 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'),
];

97
Backend/config/app.php Normal file
View File

@@ -0,0 +1,97 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Name
|--------------------------------------------------------------------------
*/
'name' => 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,
],
],
];

40
Backend/config/auth.php Normal file
View File

@@ -0,0 +1,40 @@
<?php
return [
'defaults' => [
'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,
];

123
Backend/config/database.php Normal file
View File

@@ -0,0 +1,123 @@
<?php
use Illuminate\Support\ServiceProvider;
return [
/*
|--------------------------------------------------------------------------
| Default Database Connection Name
|--------------------------------------------------------------------------
*/
'default' => 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'),
],
],
];

View File

@@ -0,0 +1,62 @@
<?php
return [
'default' => 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'),
],
];

57
Backend/config/jwt.php Normal file
View File

@@ -0,0 +1,57 @@
<?php
/*
* |--------------------------------------------------------------------------
* | JWT Auth (tymon/jwt-auth) configuration for WASL mobile API
* | Mobile clients receive short-lived JWT access tokens. Refresh tokens are
* | rotated and stored hashed server-side (see Security module).
* |--------------------------------------------------------------------------
*/
return [
'secret' => 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,
],
];

View File

@@ -0,0 +1,85 @@
<?php
return [
'default' => 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'),
],
],
];

146
Backend/config/octane.php Normal file
View File

@@ -0,0 +1,146 @@
<?php
use Laravel\Octane\Contracts\Operation;
use Laravel\Octane\Events\RequestHandled;
use Laravel\Octane\Events\RequestReceived;
use Laravel\Octane\Events\RequestTerminated;
use Laravel\Octane\Events\TaskReceived;
use Laravel\Octane\Events\TaskTerminated;
use Laravel\Octane\Events\TickReceived;
use Laravel\Octane\Events\TickTerminated;
use Laravel\Octane\Octane;
return [
/*
|--------------------------------------------------------------------------
| Octane Servers
|--------------------------------------------------------------------------
*/
'servers' => [
// 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),
];

View File

@@ -0,0 +1,71 @@
<?php
use App\Enums\Role;
return [
'models' => [
/*
* 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',
],
];

132
Backend/config/wasl.php Normal file
View File

@@ -0,0 +1,132 @@
<?php
/*
* |--------------------------------------------------------------------------
* | WASL — Domain-specific configuration for the wallet platform
* | Centralized business rules, limits, and feature flags. Changing behavior
* | should NEVER require a code change — only an .env flip.
* |--------------------------------------------------------------------------
*/
return [
/*
|--------------------------------------------------------------------------
| Wallet & Money
|--------------------------------------------------------------------------
*/
'wallet' => [
'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),
],
];

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* WASL — extensions required by the financial schema.
* uuid-ossp : gen_random_uuid()
* pgcrypto : field-level encryption helpers
* citext : case-insensitive text (email lookups)
* pg_trgm : trigram fuzzy indexes (phone hashes, names)
*/
public function up(): void
{
DB::statement('CREATE EXTENSION IF NOT EXISTS "uuid-ossp";');
DB::statement('CREATE EXTENSION IF NOT EXISTS "pgcrypto";');
DB::statement('CREATE EXTENSION IF NOT EXISTS "citext";');
DB::statement('CREATE EXTENSION IF NOT EXISTS "pg_trgm";');
DB::statement('CREATE EXTENSION IF NOT EXISTS "btree_gin";');
}
public function down(): void
{
// Extensions left in place on rollback (shared across apps)
}
};

View File

@@ -0,0 +1,64 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('wallets', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,76 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* transactions — IMMUTABLE LEDGER
*
* RULE: once a transaction reaches status='completed', its row is NEVER updated.
* Reversals/refunds are recorded as SEPARATE rows (type='refund') linked via metadata.
* Only 'status', 'completed_at', 'failure_reason', 'updated_at' may change post-insert
* (during the pending → completed/failed transition).
*/
public function up(): void
{
Schema::create('transactions', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* transaction_entries — DOUBLE-ENTRY LEDGER
*
* Every financial transaction MUST produce exactly 2 entries:
* 1. DEBIT — money leaving a wallet
* 2. CREDIT — money entering a wallet
*
* INVARIANT: SUM(debit amounts) == SUM(credit amounts) across all entries
* for any given transaction_id (always).
*
* balance_after_minor is a SNAPSHOT of the wallet balance immediately
* after this entry was applied. Used for reconciliation and audit.
*/
public function up(): void
{
Schema::create('transaction_entries', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('user_devices', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* OTP codes for SMS/authenticator-based verification.
* NEVER store the plain OTP — only the hash.
* Max 3 attempts, 5 minute TTL, 60 second resend cooldown.
*/
public function up(): void
{
Schema::create('otp_codes', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,51 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* audit_logs — IMMUTABLE security & audit trail.
* NO updated_at column — logs are write-once, never modified, never deleted.
* Required by KYC/AML regulations and internal security review.
* Records: who (actor), what (action), when, where (IP/UA/device).
*/
public function up(): void
{
Schema::create('audit_logs', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('kyc_documents', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* fraud_alerts — triggered by the rules engine when suspicious activity is detected.
* Every alert is reviewed by compliance staff. Actions: blocked/reviewed/allowed.
* risk_score 0-100: higher = more suspicious.
*/
public function up(): void
{
Schema::create('fraud_alerts', function (Blueprint $table) {
$table->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');
}
};

204
Backend/docker-compose.yml Normal file
View File

@@ -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

View File

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

View File

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

View File

@@ -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

View File

@@ -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

View File

@@ -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';

View File

@@ -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

28
Backend/public/index.php Normal file
View File

@@ -0,0 +1,28 @@
<?php
/**
* |--------------------------------------------------------------------------
* | Public entry point for WASL API
* |--------------------------------------------------------------------------
* | Served by nginx (or Octane directly). For Octane, this file is only used
* | during non-Octane requests / artisan.
*/
define('LARAVL_START', microtime(true));
// Determine if running via Octane — Octane bypasses this file entirely,
// but we keep it for standard php-fpm fallback.
require __DIR__.'/../vendor/autoload.php';
$app = require_once __DIR__.'/../bootstrap/app.php';
$kernel = $app->make(Illuminate\Contracts\Http\Kernel::class);
$response = $kernel->handle(
$request = Illuminate\Http\Request::capture()
);
$response->send();
$kernel->terminate($request, $response);

View File

@@ -0,0 +1,2 @@
User-Agent: *
Disallow: /

30
Backend/routes/api.php Normal file
View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\AuthController;
/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
|
| Here is where you can register API routes for your application. These
| routes are loaded by the RouteServiceProvider or bootstrap/app.php.
|
*/
// Authentication and onboarding (Public, but throttled)
Route::post('/register', [AuthController::class, 'register'])
->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']);
});

View File

@@ -0,0 +1,8 @@
<?php
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
Artisan::command('inspire', function () {
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');

View File

@@ -0,0 +1,87 @@
<?php
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\User;
use App\Models\OtpCode;
use App\Models\Wallet;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
class AuthTest extends TestCase
{
use RefreshDatabase;
public function test_user_can_register()
{
$response = $this->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']);
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
//
}

20
deploy.sh Executable file
View File

@@ -0,0 +1,20 @@
#!/bin/bash
# Default commit message with current timestamp
COMMIT_MSG="Deploy: $(date '+%Y-%m-%d %H:%M:%S')"
# Use custom commit message if provided as argument
if [ ! -z "$1" ]; then
COMMIT_MSG="$1"
fi
echo "Adding all files..."
git add .
echo "Committing with message: '$COMMIT_MSG'"
git commit -m "$COMMIT_MSG"
echo "Pushing to origin main..."
git push origin main
echo "Done!"