Initial commit - WASL Digital Wallet
This commit is contained in:
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal 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
110
Backend/Dockerfile
Normal 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"]
|
||||
57
Backend/app/Casts/Encryptable.php
Normal file
57
Backend/app/Casts/Encryptable.php
Normal 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);
|
||||
}
|
||||
}
|
||||
77
Backend/app/Enums/AllEnums.php
Normal file
77
Backend/app/Enums/AllEnums.php
Normal 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';
|
||||
}
|
||||
41
Backend/app/Enums/KycLevel.php
Normal file
41
Backend/app/Enums/KycLevel.php
Normal 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;
|
||||
}
|
||||
}
|
||||
37
Backend/app/Enums/TransactionType.php
Normal file
37
Backend/app/Enums/TransactionType.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
26
Backend/app/Enums/UserStatus.php
Normal file
26
Backend/app/Enums/UserStatus.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
324
Backend/app/Http/Controllers/Api/AuthController.php
Normal file
324
Backend/app/Http/Controllers/Api/AuthController.php
Normal 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.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
58
Backend/app/Http/Middleware/AuditRequestMiddleware.php
Normal file
58
Backend/app/Http/Middleware/AuditRequestMiddleware.php
Normal 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;
|
||||
}
|
||||
}
|
||||
79
Backend/app/Http/Middleware/IdempotencyMiddleware.php
Normal file
79
Backend/app/Http/Middleware/IdempotencyMiddleware.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
70
Backend/app/Http/Middleware/JwtAuthenticate.php
Normal file
70
Backend/app/Http/Middleware/JwtAuthenticate.php
Normal 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);
|
||||
}
|
||||
}
|
||||
63
Backend/app/Http/Middleware/ThrottleSensitiveActions.php
Normal file
63
Backend/app/Http/Middleware/ThrottleSensitiveActions.php
Normal 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;
|
||||
}
|
||||
}
|
||||
89
Backend/app/Models/AuditLog.php
Normal file
89
Backend/app/Models/AuditLog.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
/**
|
||||
* audit_logs — IMMUTABLE security & audit trail.
|
||||
* NO updated_at column. Records are write-once, never modified or deleted.
|
||||
*
|
||||
* @property int|null $user_id The subject of the action
|
||||
* @property int|null $actor_id Who performed it (admin/system/user)
|
||||
* @property string $action login/transfer_create/pin_change/kyc_approved/wallet_freeze
|
||||
* @property string|null $subject_type App\Models\Wallet etc.
|
||||
* @property int|null $subject_id
|
||||
* @property array|null $old_values
|
||||
* @property array|null $new_values
|
||||
* @property string|null $ip_address
|
||||
* @property string|null $user_agent
|
||||
* @property string|null $device_id
|
||||
*/
|
||||
class AuditLog extends BaseModel
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
// NO timestamps trait — we only have created_at (manual)
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'actor_id',
|
||||
'action',
|
||||
'subject_type',
|
||||
'subject_id',
|
||||
'old_values',
|
||||
'new_values',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
'device_id',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'old_values' => 'array',
|
||||
'new_values' => 'array',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
// ── Relationships ──
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function actor()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'actor_id');
|
||||
}
|
||||
|
||||
// ── Scopes ──
|
||||
|
||||
public function scopeForUser($query, int $userId)
|
||||
{
|
||||
return $query->where('user_id', $userId);
|
||||
}
|
||||
|
||||
public function scopeForAction($query, string $action)
|
||||
{
|
||||
return $query->where('action', $action);
|
||||
}
|
||||
|
||||
public function scopeRecent($query, int $hours = 24)
|
||||
{
|
||||
return $query->where('created_at', '>=', now()->subHours($hours));
|
||||
}
|
||||
|
||||
// ── Static factory ──
|
||||
|
||||
/**
|
||||
* Log an audit event. Called by the AuditService.
|
||||
*/
|
||||
public static function record(array $data): self
|
||||
{
|
||||
return self::create(array_merge($data, [
|
||||
'created_at' => now(),
|
||||
]));
|
||||
}
|
||||
}
|
||||
15
Backend/app/Models/BaseModel.php
Normal file
15
Backend/app/Models/BaseModel.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Traits\HasUuid;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
/**
|
||||
* Abstract base model for all WASL models with standard behavior.
|
||||
* UUID generation, timestampz, soft deletes.
|
||||
*/
|
||||
abstract class BaseModel extends \Illuminate\Database\Eloquent\Model
|
||||
{
|
||||
use HasFactory;
|
||||
}
|
||||
86
Backend/app/Models/OtpCode.php
Normal file
86
Backend/app/Models/OtpCode.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
/**
|
||||
* @property string $purpose login/transfer/pin_change/phone_change/kyc/registration
|
||||
* @property string $code_hash NEVER plain text — always hash
|
||||
* @property string $channel sms/authenticator
|
||||
* @property int $attempts
|
||||
* @property \Illuminate\Support\Carbon $expires_at
|
||||
* @property \Illuminate\Support\Carbon|null $used_at
|
||||
*/
|
||||
class OtpCode extends BaseModel
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'purpose',
|
||||
'code_hash',
|
||||
'channel',
|
||||
'attempts',
|
||||
'expires_at',
|
||||
'used_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'attempts' => 'integer',
|
||||
'expires_at' => 'datetime',
|
||||
'used_at' => 'datetime',
|
||||
];
|
||||
|
||||
// ── Relationships ──
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
// ── Scopes ──
|
||||
|
||||
public function scopeValid($query)
|
||||
{
|
||||
return $query->where('expires_at', '>', now())
|
||||
->whereNull('used_at');
|
||||
}
|
||||
|
||||
public function scopeForPurpose($query, string $purpose)
|
||||
{
|
||||
return $query->where('purpose', $purpose);
|
||||
}
|
||||
|
||||
public function scopeForUser($query, int $userId)
|
||||
{
|
||||
return $query->where('user_id', $userId);
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
public function isExpired(): bool
|
||||
{
|
||||
return $this->expires_at->isPast();
|
||||
}
|
||||
|
||||
public function isUsed(): bool
|
||||
{
|
||||
return !is_null($this->used_at);
|
||||
}
|
||||
|
||||
public function hasAttemptsLeft(int $max): bool
|
||||
{
|
||||
return $this->attempts < $max;
|
||||
}
|
||||
|
||||
public function incrementAttempts(): bool
|
||||
{
|
||||
return $this->increment('attempts');
|
||||
}
|
||||
|
||||
public function markUsed(): bool
|
||||
{
|
||||
return $this->update(['used_at' => now()]);
|
||||
}
|
||||
}
|
||||
46
Backend/app/Models/Traits/HasMinorUnits.php
Normal file
46
Backend/app/Models/Traits/HasMinorUnits.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Traits;
|
||||
|
||||
/**
|
||||
* Provides minor-unit (cents/halala) money helpers.
|
||||
* All monetary amounts in WASL use BIGINT minor units.
|
||||
* This trait provides conversion methods.
|
||||
*
|
||||
* Usage: $wallet->formatBalance('SYP') → "150,000.00 SYP"
|
||||
*/
|
||||
trait HasMinorUnits
|
||||
{
|
||||
/**
|
||||
* Convert display amount (e.g., 1500.00) to minor units (150000).
|
||||
* Uses string math to avoid float precision issues.
|
||||
*/
|
||||
public static function toMinor(string|int|float $amount, int $decimals = 2): int
|
||||
{
|
||||
return (int) round((float) $amount * (10 ** $decimals));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert minor units back to display amount string.
|
||||
* Returns a string to preserve precision (e.g., "1500.00").
|
||||
*/
|
||||
public static function fromMinor(int $minor, int $decimals = 2): string
|
||||
{
|
||||
return number_format($minor / (10 ** $decimals), $decimals, '.', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a minor-unit value for user display.
|
||||
* e.g., formatMoney(150000, 'SYP', 2) → "150,000.00 SYP"
|
||||
*/
|
||||
public static function formatMoney(
|
||||
int $minor,
|
||||
string $currency = 'SYP',
|
||||
?int $decimals = null
|
||||
): string {
|
||||
$decimals = $decimals ?? config("wasl.wallet.minor_unit_decimals.{$currency}", 2);
|
||||
$amount = self::fromMinor($minor, $decimals);
|
||||
|
||||
return number_format((float) $amount, $decimals, '.', ',') . ' ' . $currency;
|
||||
}
|
||||
}
|
||||
40
Backend/app/Models/Traits/HasUuid.php
Normal file
40
Backend/app/Models/Traits/HasUuid.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Traits;
|
||||
|
||||
/**
|
||||
* Auto-generates a UUID on creation and provides uuid-based route binding.
|
||||
* Every WASL model exposed to the API MUST use this trait.
|
||||
*/
|
||||
trait HasUuid
|
||||
{
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function ($model) {
|
||||
if (empty($model->getAttribute('uuid'))) {
|
||||
$model->setAttribute('uuid', str()->uuid()->toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'uuid';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve by UUID instead of BIGINT primary key.
|
||||
*/
|
||||
public function resolveRouteBinding($value, $field = null)
|
||||
{
|
||||
return $this->where('uuid', $value)->firstOrFail();
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope: filter by UUID.
|
||||
*/
|
||||
public function scopeWhereUuid($query, string $uuid)
|
||||
{
|
||||
return $query->where('uuid', $uuid);
|
||||
}
|
||||
}
|
||||
154
Backend/app/Models/Transaction.php
Normal file
154
Backend/app/Models/Transaction.php
Normal file
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\TransactionStatus;
|
||||
use App\Enums\TransactionType;
|
||||
use App\Models\Traits\HasMinorUnits;
|
||||
use App\Models\Traits\HasUuid;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
/**
|
||||
* transactions — IMMUTABLE LEDGER
|
||||
*
|
||||
* Once status = 'completed', this row is NEVER updated.
|
||||
* Reversals are new rows with type = 'refund'.
|
||||
*
|
||||
* @property string $uuid
|
||||
* @property string $reference_code WASL-XXXXXXXX (user-visible)
|
||||
* @property string $type TransactionType enum
|
||||
* @property string $status TransactionStatus enum
|
||||
* @property int|null $debit_wallet_id
|
||||
* @property int|null $credit_wallet_id
|
||||
* @property int $amount_minor BIGINT — never float/decimal
|
||||
* @property int $fee_minor BIGINT
|
||||
* @property string $currency_code
|
||||
* @property string|null $description
|
||||
* @property array|null $metadata
|
||||
* @property string $idempotency_key
|
||||
* @property string|null $initiator_ip
|
||||
* @property string|null $device_id
|
||||
*/
|
||||
class Transaction extends BaseModel
|
||||
{
|
||||
use HasFactory;
|
||||
use HasUuid;
|
||||
use HasMinorUnits;
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'reference_code',
|
||||
'type',
|
||||
'status',
|
||||
'debit_wallet_id',
|
||||
'credit_wallet_id',
|
||||
'amount_minor',
|
||||
'fee_minor',
|
||||
'currency_code',
|
||||
'description',
|
||||
'metadata',
|
||||
'idempotency_key',
|
||||
'initiator_ip',
|
||||
'device_id',
|
||||
'initiated_at',
|
||||
'completed_at',
|
||||
'failure_reason',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'type' => TransactionType::class,
|
||||
'status' => TransactionStatus::class,
|
||||
'amount_minor' => 'integer',
|
||||
'fee_minor' => 'integer',
|
||||
'metadata' => 'array',
|
||||
'initiated_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
];
|
||||
|
||||
// ── Relationships ──
|
||||
|
||||
public function debitWallet()
|
||||
{
|
||||
return $this->belongsTo(Wallet::class, 'debit_wallet_id');
|
||||
}
|
||||
|
||||
public function creditWallet()
|
||||
{
|
||||
return $this->belongsTo(Wallet::class, 'credit_wallet_id');
|
||||
}
|
||||
|
||||
public function entries()
|
||||
{
|
||||
return $this->hasMany(TransactionEntry::class);
|
||||
}
|
||||
|
||||
public function fraudAlerts()
|
||||
{
|
||||
return $this->hasMany(FraudAlert::class);
|
||||
}
|
||||
|
||||
// ── Scopes ──
|
||||
|
||||
public function scopePending($query)
|
||||
{
|
||||
return $query->where('status', TransactionStatus::PENDING);
|
||||
}
|
||||
|
||||
public function scopeCompleted($query)
|
||||
{
|
||||
return $query->where('status', TransactionStatus::COMPLETED);
|
||||
}
|
||||
|
||||
public function scopeFailed($query)
|
||||
{
|
||||
return $query->where('status', TransactionStatus::FAILED);
|
||||
}
|
||||
|
||||
public function scopeForWallet($query, int $walletId)
|
||||
{
|
||||
return $query->where('debit_wallet_id', $walletId)
|
||||
->orWhere('credit_wallet_id', $walletId);
|
||||
}
|
||||
|
||||
public function scopeInFlight($query)
|
||||
{
|
||||
return $query->whereIn('status', [
|
||||
TransactionStatus::PENDING,
|
||||
TransactionStatus::PROCESSING,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
public function formattedAmount(): string
|
||||
{
|
||||
return self::formatMoney($this->amount_minor, $this->currency_code);
|
||||
}
|
||||
|
||||
public function formattedFee(): string
|
||||
{
|
||||
return self::formatMoney($this->fee_minor, $this->currency_code);
|
||||
}
|
||||
|
||||
public function isFinal(): bool
|
||||
{
|
||||
return $this->status->isFinal();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique reference code: WASL-XXXXXXXX
|
||||
* Uses random alphanumeric characters for human readability.
|
||||
*/
|
||||
public static function generateReferenceCode(): string
|
||||
{
|
||||
$prefix = config('wasl.reference.prefix', 'WASL');
|
||||
$length = config('wasl.reference.length', 8);
|
||||
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no 0/O/1/I to avoid confusion
|
||||
|
||||
do {
|
||||
$code = $prefix . '-' . substr(str_shuffle($chars), 0, $length);
|
||||
} while (self::where('reference_code', $code)->exists());
|
||||
|
||||
return $code;
|
||||
}
|
||||
}
|
||||
83
Backend/app/Models/TransactionEntry.php
Normal file
83
Backend/app/Models/TransactionEntry.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\EntryType;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
/**
|
||||
* transaction_entries — DOUBLE-ENTRY LEDGER
|
||||
*
|
||||
* Every transaction MUST have exactly 2 entries (debit + credit).
|
||||
* balance_after_minor is the wallet balance snapshot after this entry.
|
||||
* This table is the source of truth for all balance calculations.
|
||||
*
|
||||
* @property int $transaction_id
|
||||
* @property int $wallet_id
|
||||
* @property string $entry_type debit/credit
|
||||
* @property int $amount_minor BIGINT
|
||||
* @property int $balance_after_minor BIGINT — snapshot after this entry
|
||||
*/
|
||||
class TransactionEntry extends BaseModel
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'transaction_id',
|
||||
'wallet_id',
|
||||
'entry_type',
|
||||
'amount_minor',
|
||||
'balance_after_minor',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'entry_type' => EntryType::class,
|
||||
'amount_minor' => 'integer',
|
||||
'balance_after_minor' => 'integer',
|
||||
];
|
||||
|
||||
public $timestamps = false; // created_at only, set via useCurrent()
|
||||
|
||||
// ── Relationships ──
|
||||
|
||||
public function transaction()
|
||||
{
|
||||
return $this->belongsTo(Transaction::class);
|
||||
}
|
||||
|
||||
public function wallet()
|
||||
{
|
||||
return $this->belongsTo(Wallet::class);
|
||||
}
|
||||
|
||||
// ── Scopes ──
|
||||
|
||||
public function scopeDebits($query)
|
||||
{
|
||||
return $query->where('entry_type', EntryType::DEBIT);
|
||||
}
|
||||
|
||||
public function scopeCredits($query)
|
||||
{
|
||||
return $query->where('entry_type', EntryType::CREDIT);
|
||||
}
|
||||
|
||||
public function scopeForWallet($query, int $walletId)
|
||||
{
|
||||
return $query->where('wallet_id', $walletId);
|
||||
}
|
||||
|
||||
// ── Reconciliation helper ──
|
||||
|
||||
/**
|
||||
* Verify double-entry integrity: SUM(debits) must equal SUM(credits)
|
||||
* for the given transaction_id.
|
||||
*/
|
||||
public static function verifyIntegrity(int $transactionId): bool
|
||||
{
|
||||
$debits = self::where('transaction_id', $transactionId)->debits()->sum('amount_minor');
|
||||
$credits = self::where('transaction_id', $transactionId)->credits()->sum('amount_minor');
|
||||
|
||||
return $debits === $credits && $debits > 0;
|
||||
}
|
||||
}
|
||||
181
Backend/app/Models/User.php
Normal file
181
Backend/app/Models/User.php
Normal file
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Casts\Encryptable;
|
||||
use App\Enums\UserStatus;
|
||||
use App\Models\Traits\HasUuid;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Spatie\Permission\Traits\HasRoles;
|
||||
|
||||
/**
|
||||
* @property string $uuid
|
||||
* @property string $full_name
|
||||
* @property string $phone_number AES-256 encrypted — NEVER log/expose raw
|
||||
* @property string $phone_hash SHA-256 for lookup/search
|
||||
* @property string|null $email
|
||||
* @property string|null $national_id AES-256 encrypted
|
||||
* @property string|null $national_id_hash
|
||||
* @property string|null $password bcrypt/argon2id
|
||||
* @property string|null $pin_hash argon2id for 6-digit PIN
|
||||
* @property string $status UserStatus enum value
|
||||
* @property int $kyc_level 0=none, 1=phone, 2=id, 3=full
|
||||
* @property string $language
|
||||
* @property string $country_code
|
||||
* @property int $failed_login_count
|
||||
* @property \Illuminate\Support\Carbon|null $locked_until
|
||||
* @property \Illuminate\Support\Carbon|null $last_login_at
|
||||
* @property string|null $last_login_ip
|
||||
* @property \Illuminate\Support\Carbon|null $phone_verified_at
|
||||
* @property \Illuminate\Support\Carbon|null $email_verified_at
|
||||
*/
|
||||
class User extends Authenticatable
|
||||
{
|
||||
use HasFactory;
|
||||
use Notifiable;
|
||||
use HasRoles;
|
||||
use SoftDeletes;
|
||||
use HasUuid;
|
||||
|
||||
protected $table = 'users';
|
||||
|
||||
// Guarded — use $fillable explicitly
|
||||
protected $guarded = ['id', 'uuid', 'created_at', 'updated_at', 'deleted_at'];
|
||||
|
||||
protected $fillable = [
|
||||
'full_name',
|
||||
'phone_number',
|
||||
'phone_hash',
|
||||
'email',
|
||||
'national_id',
|
||||
'national_id_hash',
|
||||
'password',
|
||||
'pin_hash',
|
||||
'status',
|
||||
'kyc_level',
|
||||
'language',
|
||||
'country_code',
|
||||
'failed_login_count',
|
||||
'locked_until',
|
||||
'last_login_at',
|
||||
'last_login_ip',
|
||||
'phone_verified_at',
|
||||
'email_verified_at',
|
||||
];
|
||||
|
||||
// ── Encryption casts ── NEVER log or expose these fields raw ──
|
||||
protected $casts = [
|
||||
'phone_number' => Encryptable::class,
|
||||
'national_id' => Encryptable::class,
|
||||
'password' => 'hashed',
|
||||
'pin_hash' => 'hashed',
|
||||
'status' => UserStatus::class,
|
||||
'kyc_level' => 'integer',
|
||||
'failed_login_count' => 'integer',
|
||||
'locked_until' => 'datetime',
|
||||
'last_login_at' => 'datetime',
|
||||
'phone_verified_at' => 'datetime',
|
||||
'email_verified_at' => 'datetime',
|
||||
];
|
||||
|
||||
// ── Hide sensitive fields from API/array serialization ──
|
||||
protected $hidden = [
|
||||
'id',
|
||||
'password',
|
||||
'pin_hash',
|
||||
'phone_number', // encrypted ciphertext
|
||||
'national_id', // encrypted ciphertext
|
||||
'phone_hash', // internal hash
|
||||
'national_id_hash', // internal hash
|
||||
'failed_login_count',
|
||||
'locked_until',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'deleted_at',
|
||||
];
|
||||
|
||||
protected $visible = [
|
||||
'uuid',
|
||||
'full_name',
|
||||
'email',
|
||||
'status',
|
||||
'kyc_level',
|
||||
'language',
|
||||
'country_code',
|
||||
'phone_verified_at',
|
||||
'last_login_at',
|
||||
];
|
||||
|
||||
// ── Helper: get masked phone for display (e.g., +963 *** 456) ──
|
||||
public function maskedPhone(): ?string
|
||||
{
|
||||
$phone = $this->phone_number;
|
||||
if (!$phone || strlen($phone) < 6) {
|
||||
return null;
|
||||
}
|
||||
$len = strlen($phone);
|
||||
$maskLen = max(3, $len - 6);
|
||||
return substr($phone, 0, 3) . str_repeat('*', $maskLen) . substr($phone, -3);
|
||||
}
|
||||
|
||||
// ── Relationships ──
|
||||
|
||||
public function wallets()
|
||||
{
|
||||
return $this->hasMany(Wallet::class);
|
||||
}
|
||||
|
||||
public function devices()
|
||||
{
|
||||
return $this->hasMany(UserDevice::class);
|
||||
}
|
||||
|
||||
public function kycDocuments()
|
||||
{
|
||||
return $this->hasMany(KycDocument::class);
|
||||
}
|
||||
|
||||
public function transactions()
|
||||
{
|
||||
// Transactions where user is either sender or receiver (via wallets)
|
||||
return Transaction::whereIn('debit_wallet_id', $this->wallets()->select('id'))
|
||||
->orWhereIn('credit_wallet_id', $this->wallets()->select('id'));
|
||||
}
|
||||
|
||||
// ── Status helpers ──
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->status === UserStatus::ACTIVE;
|
||||
}
|
||||
|
||||
public function canTransact(): bool
|
||||
{
|
||||
return $this->isActive() && $this->kyc_level > 0;
|
||||
}
|
||||
|
||||
public function isLocked(): bool
|
||||
{
|
||||
return $this->locked_until && $this->locked_until->isFuture();
|
||||
}
|
||||
|
||||
public function isPhoneVerified(): bool
|
||||
{
|
||||
return !is_null($this->phone_verified_at);
|
||||
}
|
||||
|
||||
// ── Scopes ──
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('status', UserStatus::ACTIVE);
|
||||
}
|
||||
|
||||
public function scopeByPhoneHash($query, string $hash)
|
||||
{
|
||||
return $query->where('phone_hash', $hash);
|
||||
}
|
||||
}
|
||||
78
Backend/app/Models/UserDevice.php
Normal file
78
Backend/app/Models/UserDevice.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
/**
|
||||
* @property string $device_fingerprint
|
||||
* @property string|null $device_name
|
||||
* @property string|null $platform android/ios
|
||||
* @property string|null $os_version
|
||||
* @property string|null $app_version
|
||||
* @property bool $is_trusted
|
||||
* @property string|null $push_token
|
||||
*/
|
||||
class UserDevice extends BaseModel
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'device_fingerprint',
|
||||
'device_name',
|
||||
'platform',
|
||||
'os_version',
|
||||
'app_version',
|
||||
'is_trusted',
|
||||
'push_token',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_trusted' => 'boolean',
|
||||
'first_seen_at' => 'datetime',
|
||||
'last_seen_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'id',
|
||||
'user_id',
|
||||
'push_token',
|
||||
];
|
||||
|
||||
// ── Relationships ──
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
// ── Scopes ──
|
||||
|
||||
public function scopeTrusted($query)
|
||||
{
|
||||
return $query->where('is_trusted', true);
|
||||
}
|
||||
|
||||
public function scopeForFingerprint($query, string $fingerprint)
|
||||
{
|
||||
return $query->where('device_fingerprint', $fingerprint);
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
public function markSeen(): bool
|
||||
{
|
||||
return $this->update(['last_seen_at' => now()]);
|
||||
}
|
||||
|
||||
public function trust(): bool
|
||||
{
|
||||
return $this->update(['is_trusted' => true]);
|
||||
}
|
||||
|
||||
public function revoke(): bool
|
||||
{
|
||||
return $this->update(['is_trusted' => false]);
|
||||
}
|
||||
}
|
||||
103
Backend/app/Models/Wallet.php
Normal file
103
Backend/app/Models/Wallet.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\WalletStatus;
|
||||
use App\Models\Traits\HasMinorUnits;
|
||||
use App\Models\Traits\HasUuid;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
/**
|
||||
* @property string $uuid
|
||||
* @property int $user_id
|
||||
* @property string $currency_code
|
||||
* @property int $balance_minor BIGINT — never float/decimal
|
||||
* @property int $balance_pending_minor BIGINT — holds in-flight amounts
|
||||
* @property string $status WalletStatus enum value
|
||||
* @property int|null $daily_limit_minor
|
||||
* @property int|null $monthly_limit_minor
|
||||
*/
|
||||
class Wallet extends BaseModel
|
||||
{
|
||||
use HasFactory;
|
||||
use HasUuid;
|
||||
use HasMinorUnits;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'currency_code',
|
||||
'balance_minor',
|
||||
'balance_pending_minor',
|
||||
'status',
|
||||
'daily_limit_minor',
|
||||
'monthly_limit_minor',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'balance_minor' => 'integer',
|
||||
'balance_pending_minor' => 'integer',
|
||||
'status' => WalletStatus::class,
|
||||
'daily_limit_minor' => 'integer',
|
||||
'monthly_limit_minor' => 'integer',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'id',
|
||||
'user_id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'deleted_at',
|
||||
];
|
||||
|
||||
// ── Relationships ──
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function debitEntries()
|
||||
{
|
||||
return $this->hasMany(TransactionEntry::class, 'wallet_id')
|
||||
->where('entry_type', 'debit');
|
||||
}
|
||||
|
||||
public function creditEntries()
|
||||
{
|
||||
return $this->hasMany(TransactionEntry::class, 'wallet_id')
|
||||
->where('entry_type', 'credit');
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->status === WalletStatus::ACTIVE;
|
||||
}
|
||||
|
||||
public function canReceive(): bool
|
||||
{
|
||||
return $this->status->canReceive();
|
||||
}
|
||||
|
||||
public function canSend(): bool
|
||||
{
|
||||
return $this->status->canSend();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format balance for API response.
|
||||
* e.g., "150,000.00 SYP"
|
||||
*/
|
||||
public function formattedBalance(): string
|
||||
{
|
||||
return self::formatMoney($this->balance_minor, $this->currency_code);
|
||||
}
|
||||
|
||||
// ── Scopes ──
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('status', WalletStatus::ACTIVE);
|
||||
}
|
||||
}
|
||||
56
Backend/app/Services/JwtService.php
Normal file
56
Backend/app/Services/JwtService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
45
Backend/app/Support/helpers.php
Normal file
45
Backend/app/Support/helpers.php
Normal 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
17
Backend/artisan
Normal 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
36
Backend/bootstrap/app.php
Normal 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
93
Backend/composer.json
Normal 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
|
||||
}
|
||||
40
Backend/config/activitylog.php
Normal file
40
Backend/config/activitylog.php
Normal 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
97
Backend/config/app.php
Normal 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
40
Backend/config/auth.php
Normal 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
123
Backend/config/database.php
Normal 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'),
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
62
Backend/config/filesystems.php
Normal file
62
Backend/config/filesystems.php
Normal 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
57
Backend/config/jwt.php
Normal 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,
|
||||
],
|
||||
];
|
||||
85
Backend/config/logging.php
Normal file
85
Backend/config/logging.php
Normal 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
146
Backend/config/octane.php
Normal 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),
|
||||
];
|
||||
71
Backend/config/permission.php
Normal file
71
Backend/config/permission.php
Normal 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
132
Backend/config/wasl.php
Normal 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),
|
||||
],
|
||||
];
|
||||
@@ -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)
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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
204
Backend/docker-compose.yml
Normal 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
|
||||
95
Backend/docker/nginx/default.conf
Normal file
95
Backend/docker/nginx/default.conf
Normal 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; }
|
||||
}
|
||||
74
Backend/docker/nginx/nginx.conf
Normal file
74
Backend/docker/nginx/nginx.conf
Normal 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;
|
||||
}
|
||||
31
Backend/docker/php/dev.ini
Normal file
31
Backend/docker/php/dev.ini
Normal 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
|
||||
55
Backend/docker/php/production.ini
Normal file
55
Backend/docker/php/production.ini
Normal 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
|
||||
19
Backend/docker/postgres/init.sql
Normal file
19
Backend/docker/postgres/init.sql
Normal 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';
|
||||
65
Backend/docker/supervisor/supervisord.conf
Normal file
65
Backend/docker/supervisor/supervisord.conf
Normal 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
28
Backend/public/index.php
Normal 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);
|
||||
2
Backend/public/robots.txt
Normal file
2
Backend/public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-Agent: *
|
||||
Disallow: /
|
||||
30
Backend/routes/api.php
Normal file
30
Backend/routes/api.php
Normal 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']);
|
||||
});
|
||||
8
Backend/routes/console.php
Normal file
8
Backend/routes/console.php
Normal 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');
|
||||
87
Backend/tests/Feature/AuthTest.php
Normal file
87
Backend/tests/Feature/AuthTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
10
Backend/tests/TestCase.php
Normal file
10
Backend/tests/TestCase.php
Normal 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
20
deploy.sh
Executable 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!"
|
||||
Reference in New Issue
Block a user