Initial commit - WASL Digital Wallet
This commit is contained in:
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user