Initial commit - WASL Digital Wallet

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

View File

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

View File

@@ -0,0 +1,64 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->bigIncrements('id');
// UUID for external exposure (BIGINT PKs never exposed to clients)
$table->uuid('uuid')->unique()->default(DB::raw('uuid_generate_v4()'));
// Identity
$table->string('full_name', 150);
// Phone: stored AES-256 encrypted. phone_hash = SHA-256 for fast unique lookup.
// NEVER query WHERE phone_number = ? — always use phone_hash.
$table->string('phone_number', 255); // encrypted ciphertext (variable length)
$table->string('phone_hash', 64)->unique(); // sha256(normalized phone)
$table->string('email', 255)->nullable();
$table->string('national_id', 255)->nullable(); // encrypted
$table->string('national_id_hash', 64)->nullable()->unique();
// Credentials — bcrypt/argon2id hashes (Laravel convention: 'password')
$table->string('password', 255)->nullable();
$table->string('pin_hash', 255)->nullable(); // argon2id — 6-digit PIN
// Lifecycle / status
$table->string('status', 20)->default('pending'); // pending/active/suspended/banned
$table->unsignedSmallInteger('kyc_level')->default(0);// 0=none,1=phone,2=id,3=full
// Locale / region
$table->string('language', 5)->default('ar');
$table->char('country_code', 2)->default('SY');
// Brute-force protection
$table->unsignedSmallInteger('failed_login_count')->default(0);
$table->timestampTz('locked_until')->nullable();
$table->timestampTz('last_login_at')->nullable();
$table->ipAddress('last_login_ip')->nullable();
// Verification
$table->timestampTz('phone_verified_at')->nullable();
$table->timestampTz('email_verified_at')->nullable();
$table->timestampsTz();
$table->softDeletesTz();
$table->index('phone_hash');
$table->index('status');
$table->index('deleted_at');
});
}
public function down(): void
{
Schema::dropIfExists('users');
}
};

View File

@@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('wallets', function (Blueprint $table) {
$table->bigIncrements('id');
$table->uuid('uuid')->unique()->default(DB::raw('uuid_generate_v4()'));
$table->unsignedBigInteger('user_id');
$table->foreign('user_id')->references('id')->on('users')->onDelete('restrict');
$table->char('currency_code', 3)->default('SYP');
// BIGINT minor units ONLY — never FLOAT/DECIMAL for money.
// 1 SYP = 100 minor units; 1 USD = 100 cents; 1 USDT = 100_000_000 minor units.
$table->bigInteger('balance_minor')->default(0);
$table->bigInteger('balance_pending_minor')->default(0);
$table->string('status', 20)->default('active'); // active/frozen/closed
// Per-wallet limits (override the KYC-tier defaults if set)
$table->bigInteger('daily_limit_minor')->nullable();
$table->bigInteger('monthly_limit_minor')->nullable();
$table->timestampsTz();
$table->softDeletesTz();
// One wallet per (user, currency)
$table->unique(['user_id', 'currency_code']);
$table->index(['user_id', 'status']);
});
}
public function down(): void
{
Schema::dropIfExists('wallets');
}
};

View File

@@ -0,0 +1,76 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* transactions — IMMUTABLE LEDGER
*
* RULE: once a transaction reaches status='completed', its row is NEVER updated.
* Reversals/refunds are recorded as SEPARATE rows (type='refund') linked via metadata.
* Only 'status', 'completed_at', 'failure_reason', 'updated_at' may change post-insert
* (during the pending → completed/failed transition).
*/
public function up(): void
{
Schema::create('transactions', function (Blueprint $table) {
$table->bigIncrements('id');
$table->uuid('uuid')->unique()->default(DB::raw('uuid_generate_v4()'));
// WASL-XXXXXXXX — user-visible reference code
$table->string('reference_code', 20)->unique();
$table->string('type', 20); // transfer/deposit/withdraw/merchant_pay/refund/fee
$table->string('status', 20)->default('pending'); // pending/processing/completed/failed/reversed
// debit = wallet the money leaves
// credit = wallet the money enters
// (deposit has only credit; withdraw has only debit; transfer has both)
$table->unsignedBigInteger('debit_wallet_id')->nullable();
$table->unsignedBigInteger('credit_wallet_id')->nullable();
$table->foreign('debit_wallet_id')->references('id')->on('wallets')->onDelete('restrict');
$table->foreign('credit_wallet_id')->references('id')->on('wallets')->onDelete('restrict');
// BIGINT minor units ONLY
$table->bigInteger('amount_minor');
$table->bigInteger('fee_minor')->default(0);
$table->char('currency_code', 3)->default('SYP');
$table->text('description')->nullable();
$table->jsonb('metadata')->nullable();
// Idempotency: UUID sent by client. Duplicate = return cached result, no new tx.
$table->string('idempotency_key', 64)->unique();
// Audit snapshot
$table->ipAddress('initiator_ip')->nullable();
$table->string('device_id', 255)->nullable();
$table->timestampTz('initiated_at')->useCurrent();
$table->timestampTz('completed_at')->nullable();
$table->text('failure_reason')->nullable();
$table->timestampsTz();
$table->index(['debit_wallet_id', 'created_at']);
$table->index(['credit_wallet_id', 'created_at']);
$table->index('status');
$table->index('reference_code');
});
// Partial index for fast reconciliation scans of in-flight transactions
DB::statement(
"CREATE INDEX transactions_in_flight_idx ".
"ON transactions (created_at) ".
"WHERE status IN ('pending','processing');"
);
}
public function down(): void
{
Schema::dropIfExists('transactions');
}
};

View File

@@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* transaction_entries — DOUBLE-ENTRY LEDGER
*
* Every financial transaction MUST produce exactly 2 entries:
* 1. DEBIT — money leaving a wallet
* 2. CREDIT — money entering a wallet
*
* INVARIANT: SUM(debit amounts) == SUM(credit amounts) across all entries
* for any given transaction_id (always).
*
* balance_after_minor is a SNAPSHOT of the wallet balance immediately
* after this entry was applied. Used for reconciliation and audit.
*/
public function up(): void
{
Schema::create('transaction_entries', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedBigInteger('transaction_id');
$table->foreign('transaction_id')->references('id')->on('transactions')->onDelete('restrict');
$table->unsignedBigInteger('wallet_id');
$table->foreign('wallet_id')->references('id')->on('wallets')->onDelete('restrict');
$table->enum('entry_type', ['debit', 'credit']);
$table->bigInteger('amount_minor');
// Snapshot: wallet balance AFTER applying this entry
$table->bigInteger('balance_after_minor');
$table->timestampTz('created_at')->useCurrent();
$table->index(['wallet_id', 'created_at']);
$table->index('transaction_id');
// Composite index for fast "what happened to this wallet today?" queries
$table->index(['wallet_id', 'entry_type', 'created_at'], 'entries_wallet_type_time_idx');
});
// Constraint: every transaction must have exactly 2 entries
// (enforced at application level in TransferService, this is documentation)
}
public function down(): void
{
Schema::dropIfExists('transaction_entries');
}
};

View File

@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('user_devices', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedBigInteger('user_id');
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
$table->string('device_fingerprint', 255);
$table->string('device_name', 255)->nullable();
$table->string('platform', 20)->nullable(); // android/ios
$table->string('os_version', 20)->nullable();
$table->string('app_version', 20)->nullable();
$table->boolean('is_trusted')->default(false);
// Push notification tokens (FCM / APNs)
$table->text('push_token')->nullable();
$table->timestampTz('first_seen_at')->useCurrent();
$table->timestampTz('last_seen_at')->nullable();
$table->timestampsTz();
$table->unique(['user_id', 'device_fingerprint'], 'user_device_unique');
$table->index(['is_trusted', 'last_seen_at']);
});
}
public function down(): void
{
Schema::dropIfExists('user_devices');
}
};

View File

@@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* OTP codes for SMS/authenticator-based verification.
* NEVER store the plain OTP — only the hash.
* Max 3 attempts, 5 minute TTL, 60 second resend cooldown.
*/
public function up(): void
{
Schema::create('otp_codes', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedBigInteger('user_id');
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
$table->string('purpose', 30); // login/transfer/pin_change/phone_change/kyc/registration
$table->string('code_hash', 255); // SHA-256 or bcrypt — NEVER plain text
$table->string('channel', 20)->default('sms'); // sms/authenticator
$table->unsignedSmallInteger('attempts')->default(0);
$table->timestampTz('expires_at'); // created_at + config('wasl.security.otp.ttl_seconds')
$table->timestampTz('used_at')->nullable();
$table->timestampsTz();
$table->index(['user_id', 'purpose', 'created_at'], 'otp_user_purpose_idx');
$table->index('expires_at');
});
// Auto-cleanup job hint: OTPs older than 24h can be purged
}
public function down(): void
{
Schema::dropIfExists('otp_codes');
}
};

View File

@@ -0,0 +1,51 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* audit_logs — IMMUTABLE security & audit trail.
* NO updated_at column — logs are write-once, never modified, never deleted.
* Required by KYC/AML regulations and internal security review.
* Records: who (actor), what (action), when, where (IP/UA/device).
*/
public function up(): void
{
Schema::create('audit_logs', function (Blueprint $table) {
$table->bigIncrements('id');
// Who was affected (the subject of the action)
$table->unsignedBigInteger('user_id')->nullable()->index();
// Who performed the action (admin, system, or same user)
$table->unsignedBigInteger('actor_id')->nullable();
$table->string('action', 50); // login/logout/transfer_create/pin_change/kyc_approved/wallet_freeze
$table->string('subject_type', 50)->nullable(); // App\Models\Wallet, App\Models\Transaction
$table->unsignedBigInteger('subject_id')->nullable();
// Change tracking (before/after snapshots for sensitive field updates)
$table->jsonb('old_values')->nullable();
$table->jsonb('new_values')->nullable();
// Context
$table->ipAddress('ip_address')->nullable();
$table->text('user_agent')->nullable();
$table->string('device_id', 255)->nullable();
$table->timestampTz('created_at')->useCurrent();
// NO updated_at — immutable log
$table->index(['user_id', 'created_at'], 'audit_user_time_idx');
$table->index(['action', 'created_at'], 'audit_action_time_idx');
$table->index(['actor_id', 'created_at']);
});
}
public function down(): void
{
Schema::dropIfExists('audit_logs');
}
};

View File

@@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('kyc_documents', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedBigInteger('user_id');
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
$table->string('doc_type', 30); // national_id/passport/utility_bill/selfie
// Path on MinIO (S3-compatible). File is encrypted BEFORE upload
// (application-level encryption, not just server-side encryption).
$table->text('file_path_encrypted');
// SHA-256 of the ORIGINAL unencrypted file — integrity check
$table->string('file_hash', 64);
$table->string('status', 20)->default('pending'); // pending/approved/rejected
// Reviewer (admin who approved/rejected)
$table->unsignedBigInteger('reviewed_by')->nullable();
$table->foreign('reviewed_by')->references('id')->on('users')->onDelete('set null');
$table->timestampTz('reviewed_at')->nullable();
$table->text('rejection_reason')->nullable();
$table->timestampsTz();
$table->index(['user_id', 'doc_type', 'status']);
});
}
public function down(): void
{
Schema::dropIfExists('kyc_documents');
}
};

View File

@@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* fraud_alerts — triggered by the rules engine when suspicious activity is detected.
* Every alert is reviewed by compliance staff. Actions: blocked/reviewed/allowed.
* risk_score 0-100: higher = more suspicious.
*/
public function up(): void
{
Schema::create('fraud_alerts', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedBigInteger('user_id')->nullable();
$table->foreign('user_id')->references('id')->on('users')->onDelete('set null');
$table->unsignedBigInteger('transaction_id')->nullable();
$table->foreign('transaction_id')->references('id')->on('transactions')->onDelete('set null');
$table->string('rule_triggered', 100); // e.g. velocity_5tx_10min, geo_impossible, new_device_large_tx
$table->unsignedSmallInteger('risk_score'); // 0-100
$table->string('status', 20)->default('open'); // open/investigated/closed
$table->string('action_taken', 20)->nullable(); // blocked/reviewed/allowed
$table->jsonb('details')->nullable(); // full context snapshot
$table->timestampsTz();
$table->index(['status', 'created_at']);
$table->index(['risk_score', 'created_at']);
$table->index(['user_id', 'created_at']);
});
}
public function down(): void
{
Schema::dropIfExists('fraud_alerts');
}
};