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