WIP: billplz api integration testing; DONE: backend for module investment, investment UI for admin
This commit is contained in:
+11
-1
@@ -104,4 +104,14 @@ ONEWAYSMS_API_USERNAME=
|
||||
ONEWAYSMS_API_PASSWORD=
|
||||
ONEWAYSMS_SENDER_ID=
|
||||
|
||||
EXTERNAL_SYSTEM_SSO_ISSUER=mykopkb
|
||||
EXTERNAL_SYSTEM_SSO_ISSUER=mykopkb
|
||||
|
||||
# Billplz (Investment module) — use sandbox credentials locally
|
||||
BILLPLZ_API_KEY=
|
||||
BILLPLZ_X_SIGNATURE_KEY=
|
||||
BILLPLZ_COLLECTION_ID=
|
||||
BILLPLZ_BASE_URL=https://www.billplz-sandbox.com/api/v3
|
||||
# Optional overrides; defaults: {APP_URL}/v1/billplz/callback
|
||||
BILLPLZ_CALLBACK_URL=
|
||||
# Frontend return URL, investment_id query is appended automatically
|
||||
BILLPLZ_REDIRECT_URL=
|
||||
@@ -2,4 +2,13 @@
|
||||
|
||||
return [
|
||||
'name' => 'Investment',
|
||||
|
||||
'billplz' => [
|
||||
'api_key' => env('BILLPLZ_API_KEY'),
|
||||
'x_signature_key' => env('BILLPLZ_X_SIGNATURE_KEY'),
|
||||
'collection_id' => env('BILLPLZ_COLLECTION_ID'),
|
||||
'base_url' => rtrim(env('BILLPLZ_BASE_URL', 'https://www.billplz-sandbox.com/api/v3'), '/'),
|
||||
'callback_url' => env('BILLPLZ_CALLBACK_URL'),
|
||||
'redirect_url' => env('BILLPLZ_REDIRECT_URL'),
|
||||
],
|
||||
];
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<?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('investment_accounts', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
|
||||
$table->foreignUuid('user_id')->constrained('users')->restrictOnDelete();
|
||||
$table->foreignUuid('investment_type_id')->constrained('investment_types')->restrictOnDelete();
|
||||
|
||||
$table->decimal('current_balance', 15, 2)->default(0);
|
||||
$table->decimal('pending_balance', 15, 2)->default(0);
|
||||
|
||||
$table->string('status')->default('ACTIVE');
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['user_id', 'investment_type_id']);
|
||||
$table->index('status');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('investment_accounts');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
<?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('investments', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
|
||||
$table->string('reference_no')->unique();
|
||||
|
||||
$table->foreignUuid('user_id')->constrained('users')->restrictOnDelete();
|
||||
$table->foreignUuid('investment_account_id')->constrained('investment_accounts')->restrictOnDelete();
|
||||
$table->foreignUuid('investment_type_id')->constrained('investment_types')->restrictOnDelete();
|
||||
|
||||
$table->decimal('amount', 15, 2);
|
||||
|
||||
$table->string('source');
|
||||
$table->string('status');
|
||||
|
||||
$table->text('remarks')->nullable();
|
||||
|
||||
$table->foreignUuid('created_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->foreignUuid('approved_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
|
||||
$table->timestamp('approved_at')->nullable();
|
||||
$table->timestamp('completed_at')->nullable();
|
||||
$table->timestamp('cancelled_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('user_id');
|
||||
$table->index('investment_type_id');
|
||||
$table->index('source');
|
||||
$table->index('status');
|
||||
$table->index(['status', 'investment_type_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('investments');
|
||||
}
|
||||
};
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('investment_ledger', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
|
||||
$table->foreignUuid('investment_id')->nullable()->constrained('investments')->restrictOnDelete();
|
||||
$table->foreignUuid('investment_account_id')->constrained('investment_accounts')->restrictOnDelete();
|
||||
$table->foreignUuid('user_id')->constrained('users')->restrictOnDelete();
|
||||
$table->foreignUuid('investment_type_id')->constrained('investment_types')->restrictOnDelete();
|
||||
|
||||
$table->string('transaction_type');
|
||||
|
||||
$table->decimal('credit', 15, 2)->default(0);
|
||||
$table->decimal('debit', 15, 2)->default(0);
|
||||
$table->decimal('balance_after', 15, 2);
|
||||
|
||||
$table->foreignUuid('created_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
|
||||
$table->index('investment_account_id');
|
||||
$table->index('user_id');
|
||||
$table->index('investment_type_id');
|
||||
$table->index('transaction_type');
|
||||
$table->index('created_at');
|
||||
});
|
||||
|
||||
// One INVESTMENT credit post per investment (idempotency guard).
|
||||
// Other types (ADJUSTMENT / REVERSAL / REFUND) may repeat if needed.
|
||||
DB::statement("
|
||||
CREATE UNIQUE INDEX investment_ledger_investment_investment_unique
|
||||
ON investment_ledger (investment_id)
|
||||
WHERE transaction_type = 'INVESTMENT'
|
||||
AND investment_id IS NOT NULL
|
||||
");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('investment_ledger');
|
||||
}
|
||||
};
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
<?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('investment_fund_summary', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
|
||||
$table->foreignUuid('investment_type_id')->unique()->constrained('investment_types')->restrictOnDelete();
|
||||
|
||||
$table->decimal('total_invested', 15, 2)->default(0);
|
||||
$table->decimal('reserved_amount', 15, 2)->default(0);
|
||||
$table->decimal('remaining_capacity', 15, 2)->nullable();
|
||||
|
||||
$table->timestamp('updated_at')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('investment_fund_summary');
|
||||
}
|
||||
};
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
<?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('investment_limit_profiles', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
|
||||
$table->string('name')->unique();
|
||||
$table->text('description')->nullable();
|
||||
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('investment_limit_profiles');
|
||||
}
|
||||
};
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
<?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('profile_investment_limits', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
|
||||
$table->foreignUuid('profile_id')
|
||||
->constrained('investment_limit_profiles')
|
||||
->cascadeOnDelete();
|
||||
$table->foreignUuid('investment_type_id')
|
||||
->constrained('investment_types')
|
||||
->restrictOnDelete();
|
||||
|
||||
$table->decimal('max_investment_amount', 15, 2)->nullable();
|
||||
$table->decimal('max_transaction_amount', 15, 2)->nullable();
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['profile_id', 'investment_type_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('profile_investment_limits');
|
||||
}
|
||||
};
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('user_limit_profiles', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
|
||||
$table->foreignUuid('user_id')->constrained('users')->cascadeOnDelete();
|
||||
$table->foreignUuid('profile_id')
|
||||
->constrained('investment_limit_profiles')
|
||||
->restrictOnDelete();
|
||||
|
||||
$table->date('effective_date');
|
||||
$table->date('expiry_date')->nullable();
|
||||
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
|
||||
$table->index(['user_id', 'effective_date']);
|
||||
$table->index(['user_id', 'expiry_date']);
|
||||
});
|
||||
|
||||
// At most one currently active profile assignment per user.
|
||||
DB::statement('
|
||||
CREATE UNIQUE INDEX user_limit_profiles_user_active_unique
|
||||
ON user_limit_profiles (user_id)
|
||||
WHERE expiry_date IS NULL
|
||||
');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('user_limit_profiles');
|
||||
}
|
||||
};
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
<?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_investment_limits', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
|
||||
$table->foreignUuid('user_id')->constrained('users')->cascadeOnDelete();
|
||||
$table->foreignUuid('investment_type_id')
|
||||
->constrained('investment_types')
|
||||
->restrictOnDelete();
|
||||
|
||||
$table->decimal('max_investment_amount', 15, 2)->nullable();
|
||||
$table->decimal('max_transaction_amount', 15, 2)->nullable();
|
||||
|
||||
$table->date('effective_date');
|
||||
$table->date('expiry_date')->nullable();
|
||||
|
||||
$table->foreignUuid('created_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['user_id', 'investment_type_id']);
|
||||
$table->index(['user_id', 'investment_type_id', 'effective_date', 'expiry_date']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('user_investment_limits');
|
||||
}
|
||||
};
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
<?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('investment_payments', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
|
||||
$table->foreignUuid('investment_id')
|
||||
->unique()
|
||||
->constrained('investments')
|
||||
->restrictOnDelete();
|
||||
|
||||
$table->string('gateway')->default('BILLPLZ');
|
||||
|
||||
$table->decimal('amount', 15, 2);
|
||||
$table->string('currency', 3)->default('MYR');
|
||||
|
||||
$table->string('status')->default('PENDING');
|
||||
|
||||
$table->string('external_reference')->nullable();
|
||||
$table->text('failure_reason')->nullable();
|
||||
|
||||
$table->string('idempotency_key')->unique();
|
||||
|
||||
$table->text('redirect_url')->nullable();
|
||||
$table->text('callback_url')->nullable();
|
||||
|
||||
$table->timestamp('paid_at')->nullable();
|
||||
$table->timestamp('expired_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('status');
|
||||
$table->index('gateway');
|
||||
$table->index('external_reference');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('investment_payments');
|
||||
}
|
||||
};
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
<?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('billplz_transactions', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
|
||||
$table->foreignUuid('payment_id')
|
||||
->constrained('investment_payments')
|
||||
->restrictOnDelete();
|
||||
|
||||
$table->string('bill_id')->unique();
|
||||
$table->string('collection_id')->nullable();
|
||||
|
||||
$table->text('bill_url')->nullable();
|
||||
|
||||
$table->string('reference_1')->nullable();
|
||||
$table->string('reference_2')->nullable();
|
||||
|
||||
$table->decimal('paid_amount', 15, 2)->nullable();
|
||||
$table->string('state')->nullable();
|
||||
$table->string('transaction_status')->default('PENDING');
|
||||
|
||||
$table->timestamp('paid_at')->nullable();
|
||||
|
||||
$table->string('x_signature')->nullable();
|
||||
$table->boolean('signature_verified')->default(false);
|
||||
|
||||
$table->json('callback_payload')->nullable();
|
||||
$table->json('redirect_payload')->nullable();
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('payment_id');
|
||||
$table->index('transaction_status');
|
||||
$table->index('reference_1');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('billplz_transactions');
|
||||
}
|
||||
};
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
<?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('salary_contributions', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
|
||||
$table->foreignUuid('user_id')->constrained('users')->restrictOnDelete();
|
||||
$table->foreignUuid('investment_type_id')->constrained('investment_types')->restrictOnDelete();
|
||||
|
||||
$table->decimal('monthly_amount', 15, 2);
|
||||
|
||||
$table->date('start_date');
|
||||
$table->date('end_date')->nullable();
|
||||
|
||||
$table->string('status')->default('ACTIVE');
|
||||
|
||||
$table->foreignUuid('created_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['user_id', 'investment_type_id']);
|
||||
$table->index('status');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('salary_contributions');
|
||||
}
|
||||
};
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
<?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('salary_deduction_batches', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
|
||||
$table->string('batch_no')->unique();
|
||||
|
||||
$table->unsignedTinyInteger('month');
|
||||
$table->unsignedSmallInteger('year');
|
||||
|
||||
$table->unsignedInteger('total_records')->default(0);
|
||||
$table->decimal('total_amount', 15, 2)->default(0);
|
||||
|
||||
$table->string('status')->default('UPLOADED');
|
||||
|
||||
$table->foreignUuid('uploaded_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->foreignUuid('verified_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
|
||||
$table->timestamp('uploaded_at')->nullable();
|
||||
$table->timestamp('verified_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['month', 'year']);
|
||||
$table->index('status');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('salary_deduction_batches');
|
||||
}
|
||||
};
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
<?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('salary_deduction_batch_details', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
|
||||
$table->foreignUuid('batch_id')
|
||||
->constrained('salary_deduction_batches')
|
||||
->cascadeOnDelete();
|
||||
|
||||
$table->foreignUuid('user_id')->constrained('users')->restrictOnDelete();
|
||||
$table->foreignUuid('salary_contribution_id')
|
||||
->nullable()
|
||||
->constrained('salary_contributions')
|
||||
->nullOnDelete();
|
||||
$table->foreignUuid('investment_type_id')->constrained('investment_types')->restrictOnDelete();
|
||||
$table->foreignUuid('investment_id')
|
||||
->nullable()
|
||||
->constrained('investments')
|
||||
->nullOnDelete();
|
||||
|
||||
$table->decimal('amount', 15, 2);
|
||||
|
||||
$table->string('status')->default('PENDING');
|
||||
$table->text('remarks')->nullable();
|
||||
|
||||
$table->foreignUuid('verified_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->timestamp('verified_at')->nullable();
|
||||
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
|
||||
$table->index(['batch_id', 'status']);
|
||||
$table->index(['user_id', 'investment_type_id']);
|
||||
$table->index('investment_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('salary_deduction_batch_details');
|
||||
}
|
||||
};
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
<?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('salary_verification_logs', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
|
||||
$table->foreignUuid('batch_detail_id')
|
||||
->constrained('salary_deduction_batch_details')
|
||||
->cascadeOnDelete();
|
||||
|
||||
$table->string('old_status')->nullable();
|
||||
$table->string('new_status');
|
||||
|
||||
$table->foreignUuid('action_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
|
||||
$table->text('remarks')->nullable();
|
||||
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
|
||||
$table->index('batch_detail_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('salary_verification_logs');
|
||||
}
|
||||
};
|
||||
@@ -13,6 +13,9 @@ class InvestmentDatabaseSeeder extends Seeder
|
||||
{
|
||||
$this->call([
|
||||
InvestmentTypePermissionSeeder::class,
|
||||
InvestmentPermissionSeeder::class,
|
||||
InvestmentLimitPermissionSeeder::class,
|
||||
SalaryDeductionPermissionSeeder::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Database\Seeders;
|
||||
|
||||
use App\Models\Permission;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class InvestmentLimitPermissionSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$permissions = [
|
||||
'lihat had pelaburan',
|
||||
'tambah had pelaburan',
|
||||
'kemaskini had pelaburan',
|
||||
'hapus had pelaburan',
|
||||
];
|
||||
|
||||
foreach ($permissions as $name) {
|
||||
Permission::firstOrCreate(
|
||||
['name' => $name, 'guard_name' => 'api'],
|
||||
['name' => $name, 'guard_name' => 'api']
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Database\Seeders;
|
||||
|
||||
use App\Models\Permission;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class InvestmentPermissionSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$permissions = [
|
||||
'lihat pelaburan',
|
||||
'tambah pelaburan manual',
|
||||
'tambah pelaburan billplz',
|
||||
];
|
||||
|
||||
foreach ($permissions as $name) {
|
||||
Permission::firstOrCreate(
|
||||
['name' => $name, 'guard_name' => 'api'],
|
||||
['name' => $name, 'guard_name' => 'api']
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Database\Seeders;
|
||||
|
||||
use App\Models\Permission;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class SalaryDeductionPermissionSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$permissions = [
|
||||
'lihat potongan gaji',
|
||||
'tambah potongan gaji',
|
||||
'kemaskini potongan gaji',
|
||||
'hapus potongan gaji',
|
||||
'sahkan potongan gaji',
|
||||
];
|
||||
|
||||
foreach ($permissions as $name) {
|
||||
Permission::firstOrCreate(
|
||||
['name' => $name, 'guard_name' => 'api'],
|
||||
['name' => $name, 'guard_name' => 'api']
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Entities;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Modules\Investment\Enums\BillplzTransactionStatus;
|
||||
|
||||
class BillplzTransaction extends Model
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
protected $table = 'billplz_transactions';
|
||||
|
||||
protected $fillable = [
|
||||
'payment_id',
|
||||
'bill_id',
|
||||
'collection_id',
|
||||
'bill_url',
|
||||
'reference_1',
|
||||
'reference_2',
|
||||
'paid_amount',
|
||||
'state',
|
||||
'transaction_status',
|
||||
'paid_at',
|
||||
'x_signature',
|
||||
'signature_verified',
|
||||
'callback_payload',
|
||||
'redirect_payload',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'paid_amount' => 'decimal:2',
|
||||
'transaction_status' => BillplzTransactionStatus::class,
|
||||
'paid_at' => 'datetime',
|
||||
'signature_verified' => 'boolean',
|
||||
'callback_payload' => 'array',
|
||||
'redirect_payload' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function payment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InvestmentPayment::class, 'payment_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Entities;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Modules\Auth\Entities\User;
|
||||
use Modules\Investment\Enums\InvestmentSource;
|
||||
use Modules\Investment\Enums\InvestmentStatus;
|
||||
|
||||
class Investment extends Model
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
protected $table = 'investments';
|
||||
|
||||
protected $fillable = [
|
||||
'reference_no',
|
||||
'user_id',
|
||||
'investment_account_id',
|
||||
'investment_type_id',
|
||||
'amount',
|
||||
'source',
|
||||
'status',
|
||||
'remarks',
|
||||
'created_by',
|
||||
'approved_by',
|
||||
'approved_at',
|
||||
'completed_at',
|
||||
'cancelled_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'amount' => 'decimal:2',
|
||||
'source' => InvestmentSource::class,
|
||||
'status' => InvestmentStatus::class,
|
||||
'approved_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
'cancelled_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
public function account(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InvestmentAccount::class, 'investment_account_id');
|
||||
}
|
||||
|
||||
public function investmentType(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InvestmentType::class, 'investment_type_id');
|
||||
}
|
||||
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function approvedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'approved_by');
|
||||
}
|
||||
|
||||
public function ledgerEntries(): HasMany
|
||||
{
|
||||
return $this->hasMany(InvestmentLedger::class, 'investment_id');
|
||||
}
|
||||
|
||||
public function payment(): HasOne
|
||||
{
|
||||
return $this->hasOne(InvestmentPayment::class, 'investment_id');
|
||||
}
|
||||
|
||||
public function salaryDeductionDetail(): HasOne
|
||||
{
|
||||
return $this->hasOne(SalaryDeductionBatchDetail::class, 'investment_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Entities;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Modules\Auth\Entities\User;
|
||||
use Modules\Investment\Enums\InvestmentAccountStatus;
|
||||
|
||||
class InvestmentAccount extends Model
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
protected $table = 'investment_accounts';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'investment_type_id',
|
||||
'current_balance',
|
||||
'pending_balance',
|
||||
'status',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status' => InvestmentAccountStatus::class,
|
||||
'current_balance' => 'decimal:2',
|
||||
'pending_balance' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
public function investmentType(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InvestmentType::class, 'investment_type_id');
|
||||
}
|
||||
|
||||
public function investments(): HasMany
|
||||
{
|
||||
return $this->hasMany(Investment::class, 'investment_account_id');
|
||||
}
|
||||
|
||||
public function ledgerEntries(): HasMany
|
||||
{
|
||||
return $this->hasMany(InvestmentLedger::class, 'investment_account_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Entities;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class InvestmentFundSummary extends Model
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $table = 'investment_fund_summary';
|
||||
|
||||
protected $fillable = [
|
||||
'investment_type_id',
|
||||
'total_invested',
|
||||
'reserved_amount',
|
||||
'remaining_capacity',
|
||||
'updated_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'total_invested' => 'decimal:2',
|
||||
'reserved_amount' => 'decimal:2',
|
||||
'remaining_capacity' => 'decimal:2',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function investmentType(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InvestmentType::class, 'investment_type_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Entities;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Modules\Auth\Entities\User;
|
||||
use Modules\Investment\Enums\InvestmentLedgerTransactionType;
|
||||
|
||||
class InvestmentLedger extends Model
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $table = 'investment_ledger';
|
||||
|
||||
protected $fillable = [
|
||||
'investment_id',
|
||||
'investment_account_id',
|
||||
'user_id',
|
||||
'investment_type_id',
|
||||
'transaction_type',
|
||||
'credit',
|
||||
'debit',
|
||||
'balance_after',
|
||||
'created_by',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'transaction_type' => InvestmentLedgerTransactionType::class,
|
||||
'credit' => 'decimal:2',
|
||||
'debit' => 'decimal:2',
|
||||
'balance_after' => 'decimal:2',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function investment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Investment::class, 'investment_id');
|
||||
}
|
||||
|
||||
public function account(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InvestmentAccount::class, 'investment_account_id');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
public function investmentType(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InvestmentType::class, 'investment_type_id');
|
||||
}
|
||||
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Entities;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class InvestmentLimitProfile extends Model
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
protected $table = 'investment_limit_profiles';
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'description',
|
||||
];
|
||||
|
||||
public function profileLimits(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProfileInvestmentLimit::class, 'profile_id');
|
||||
}
|
||||
|
||||
public function userAssignments(): HasMany
|
||||
{
|
||||
return $this->hasMany(UserLimitProfile::class, 'profile_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Entities;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Modules\Investment\Enums\InvestmentPaymentGateway;
|
||||
use Modules\Investment\Enums\InvestmentPaymentStatus;
|
||||
|
||||
class InvestmentPayment extends Model
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
protected $table = 'investment_payments';
|
||||
|
||||
protected $fillable = [
|
||||
'investment_id',
|
||||
'gateway',
|
||||
'amount',
|
||||
'currency',
|
||||
'status',
|
||||
'external_reference',
|
||||
'failure_reason',
|
||||
'idempotency_key',
|
||||
'redirect_url',
|
||||
'callback_url',
|
||||
'paid_at',
|
||||
'expired_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'gateway' => InvestmentPaymentGateway::class,
|
||||
'status' => InvestmentPaymentStatus::class,
|
||||
'amount' => 'decimal:2',
|
||||
'paid_at' => 'datetime',
|
||||
'expired_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function investment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Investment::class, 'investment_id');
|
||||
}
|
||||
|
||||
public function billplzTransaction(): HasOne
|
||||
{
|
||||
return $this->hasOne(BillplzTransaction::class, 'payment_id');
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ namespace Modules\Investment\Entities;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Modules\Auth\Entities\User;
|
||||
use Modules\Investment\Enums\InvestmentTypeStatus;
|
||||
@@ -56,4 +58,40 @@ class InvestmentType extends Model
|
||||
{
|
||||
return $this->belongsTo(User::class, 'updated_by');
|
||||
}
|
||||
|
||||
public function accounts(): HasMany
|
||||
{
|
||||
return $this->hasMany(InvestmentAccount::class, 'investment_type_id');
|
||||
}
|
||||
|
||||
public function investments(): HasMany
|
||||
{
|
||||
return $this->hasMany(Investment::class, 'investment_type_id');
|
||||
}
|
||||
|
||||
public function ledgerEntries(): HasMany
|
||||
{
|
||||
return $this->hasMany(InvestmentLedger::class, 'investment_type_id');
|
||||
}
|
||||
|
||||
public function fundSummary(): HasOne
|
||||
{
|
||||
return $this->hasOne(InvestmentFundSummary::class, 'investment_type_id');
|
||||
}
|
||||
|
||||
public function profileInvestmentLimits(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProfileInvestmentLimit::class, 'investment_type_id');
|
||||
}
|
||||
|
||||
public function userInvestmentLimits(): HasMany
|
||||
{
|
||||
return $this->hasMany(UserInvestmentLimit::class, 'investment_type_id');
|
||||
}
|
||||
|
||||
public function salaryContributions(): HasMany
|
||||
{
|
||||
return $this->hasMany(SalaryContribution::class, 'investment_type_id');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Entities;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ProfileInvestmentLimit extends Model
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
protected $table = 'profile_investment_limits';
|
||||
|
||||
protected $fillable = [
|
||||
'profile_id',
|
||||
'investment_type_id',
|
||||
'max_investment_amount',
|
||||
'max_transaction_amount',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'max_investment_amount' => 'decimal:2',
|
||||
'max_transaction_amount' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
|
||||
public function profile(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InvestmentLimitProfile::class, 'profile_id');
|
||||
}
|
||||
|
||||
public function investmentType(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InvestmentType::class, 'investment_type_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Entities;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Modules\Auth\Entities\User;
|
||||
use Modules\Investment\Enums\SalaryContributionStatus;
|
||||
|
||||
class SalaryContribution extends Model
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
protected $table = 'salary_contributions';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'investment_type_id',
|
||||
'monthly_amount',
|
||||
'start_date',
|
||||
'end_date',
|
||||
'status',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'monthly_amount' => 'decimal:2',
|
||||
'start_date' => 'date',
|
||||
'end_date' => 'date',
|
||||
'status' => SalaryContributionStatus::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
public function investmentType(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InvestmentType::class, 'investment_type_id');
|
||||
}
|
||||
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function batchDetails(): HasMany
|
||||
{
|
||||
return $this->hasMany(SalaryDeductionBatchDetail::class, 'salary_contribution_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Entities;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Modules\Auth\Entities\User;
|
||||
use Modules\Investment\Enums\SalaryDeductionBatchStatus;
|
||||
|
||||
class SalaryDeductionBatch extends Model
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
protected $table = 'salary_deduction_batches';
|
||||
|
||||
protected $fillable = [
|
||||
'batch_no',
|
||||
'month',
|
||||
'year',
|
||||
'total_records',
|
||||
'total_amount',
|
||||
'status',
|
||||
'uploaded_by',
|
||||
'verified_by',
|
||||
'uploaded_at',
|
||||
'verified_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'month' => 'integer',
|
||||
'year' => 'integer',
|
||||
'total_records' => 'integer',
|
||||
'total_amount' => 'decimal:2',
|
||||
'status' => SalaryDeductionBatchStatus::class,
|
||||
'uploaded_at' => 'datetime',
|
||||
'verified_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function details(): HasMany
|
||||
{
|
||||
return $this->hasMany(SalaryDeductionBatchDetail::class, 'batch_id');
|
||||
}
|
||||
|
||||
public function uploadedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'uploaded_by');
|
||||
}
|
||||
|
||||
public function verifiedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'verified_by');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Entities;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Modules\Auth\Entities\User;
|
||||
use Modules\Investment\Enums\SalaryDeductionDetailStatus;
|
||||
|
||||
class SalaryDeductionBatchDetail extends Model
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $table = 'salary_deduction_batch_details';
|
||||
|
||||
protected $fillable = [
|
||||
'batch_id',
|
||||
'user_id',
|
||||
'salary_contribution_id',
|
||||
'investment_type_id',
|
||||
'investment_id',
|
||||
'amount',
|
||||
'status',
|
||||
'remarks',
|
||||
'verified_by',
|
||||
'verified_at',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'amount' => 'decimal:2',
|
||||
'status' => SalaryDeductionDetailStatus::class,
|
||||
'verified_at' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function batch(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SalaryDeductionBatch::class, 'batch_id');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
public function salaryContribution(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SalaryContribution::class, 'salary_contribution_id');
|
||||
}
|
||||
|
||||
public function investmentType(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InvestmentType::class, 'investment_type_id');
|
||||
}
|
||||
|
||||
public function investment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Investment::class, 'investment_id');
|
||||
}
|
||||
|
||||
public function verifiedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'verified_by');
|
||||
}
|
||||
|
||||
public function verificationLogs(): HasMany
|
||||
{
|
||||
return $this->hasMany(SalaryVerificationLog::class, 'batch_detail_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Entities;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Modules\Auth\Entities\User;
|
||||
|
||||
class SalaryVerificationLog extends Model
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $table = 'salary_verification_logs';
|
||||
|
||||
protected $fillable = [
|
||||
'batch_detail_id',
|
||||
'old_status',
|
||||
'new_status',
|
||||
'action_by',
|
||||
'remarks',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function batchDetail(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SalaryDeductionBatchDetail::class, 'batch_detail_id');
|
||||
}
|
||||
|
||||
public function actionBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'action_by');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Entities;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Modules\Auth\Entities\User;
|
||||
|
||||
class UserInvestmentLimit extends Model
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
protected $table = 'user_investment_limits';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'investment_type_id',
|
||||
'max_investment_amount',
|
||||
'max_transaction_amount',
|
||||
'effective_date',
|
||||
'expiry_date',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'max_investment_amount' => 'decimal:2',
|
||||
'max_transaction_amount' => 'decimal:2',
|
||||
'effective_date' => 'date',
|
||||
'expiry_date' => 'date',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
public function investmentType(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InvestmentType::class, 'investment_type_id');
|
||||
}
|
||||
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function scopeActive(Builder $query, $onDate = null): Builder
|
||||
{
|
||||
$date = ($onDate ?? now())->toDateString();
|
||||
|
||||
return $query
|
||||
->whereDate('effective_date', '<=', $date)
|
||||
->where(function (Builder $builder) use ($date) {
|
||||
$builder->whereNull('expiry_date')
|
||||
->orWhereDate('expiry_date', '>=', $date);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Entities;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Modules\Auth\Entities\User;
|
||||
|
||||
class UserLimitProfile extends Model
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $table = 'user_limit_profiles';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'profile_id',
|
||||
'effective_date',
|
||||
'expiry_date',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'effective_date' => 'date',
|
||||
'expiry_date' => 'date',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
public function profile(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InvestmentLimitProfile::class, 'profile_id');
|
||||
}
|
||||
|
||||
public function scopeActive(Builder $query, $onDate = null): Builder
|
||||
{
|
||||
$date = ($onDate ?? now())->toDateString();
|
||||
|
||||
return $query
|
||||
->whereDate('effective_date', '<=', $date)
|
||||
->where(function (Builder $builder) use ($date) {
|
||||
$builder->whereNull('expiry_date')
|
||||
->orWhereDate('expiry_date', '>=', $date);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Enums;
|
||||
|
||||
enum BillplzTransactionStatus: string
|
||||
{
|
||||
case Pending = 'PENDING';
|
||||
case Paid = 'PAID';
|
||||
case Failed = 'FAILED';
|
||||
case Deleted = 'DELETED';
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Enums;
|
||||
|
||||
enum InvestmentAccountStatus: string
|
||||
{
|
||||
case Active = 'ACTIVE';
|
||||
case Frozen = 'FROZEN';
|
||||
case Closed = 'CLOSED';
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Enums;
|
||||
|
||||
enum InvestmentLedgerTransactionType: string
|
||||
{
|
||||
case Investment = 'INVESTMENT';
|
||||
case Refund = 'REFUND';
|
||||
case Adjustment = 'ADJUSTMENT';
|
||||
case Reversal = 'REVERSAL';
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Enums;
|
||||
|
||||
enum InvestmentPaymentGateway: string
|
||||
{
|
||||
case Billplz = 'BILLPLZ';
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Enums;
|
||||
|
||||
enum InvestmentPaymentStatus: string
|
||||
{
|
||||
case Pending = 'PENDING';
|
||||
case Paid = 'PAID';
|
||||
case Failed = 'FAILED';
|
||||
case Cancelled = 'CANCELLED';
|
||||
case Expired = 'EXPIRED';
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Enums;
|
||||
|
||||
enum InvestmentSource: string
|
||||
{
|
||||
case Billplz = 'BILLPLZ';
|
||||
case SalaryDeduction = 'SALARY_DEDUCTION';
|
||||
case Manual = 'MANUAL';
|
||||
case Adjustment = 'ADJUSTMENT';
|
||||
case Refund = 'REFUND';
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Enums;
|
||||
|
||||
enum InvestmentStatus: string
|
||||
{
|
||||
case Draft = 'DRAFT';
|
||||
case PendingPayment = 'PENDING_PAYMENT';
|
||||
case PendingVerification = 'PENDING_VERIFICATION';
|
||||
case Completed = 'COMPLETED';
|
||||
case Rejected = 'REJECTED';
|
||||
case Cancelled = 'CANCELLED';
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Enums;
|
||||
|
||||
enum SalaryContributionStatus: string
|
||||
{
|
||||
case Active = 'ACTIVE';
|
||||
case Paused = 'PAUSED';
|
||||
case Ended = 'ENDED';
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Enums;
|
||||
|
||||
enum SalaryDeductionBatchStatus: string
|
||||
{
|
||||
case Uploaded = 'UPLOADED';
|
||||
case UnderReview = 'UNDER_REVIEW';
|
||||
case Verified = 'VERIFIED';
|
||||
case Rejected = 'REJECTED';
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Enums;
|
||||
|
||||
enum SalaryDeductionDetailStatus: string
|
||||
{
|
||||
case Pending = 'PENDING';
|
||||
case Verified = 'VERIFIED';
|
||||
case Rejected = 'REJECTED';
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Investment\Entities\Investment;
|
||||
use Modules\Investment\Http\Requests\BillplzInvestmentRequest;
|
||||
use Modules\Investment\Services\BillplzInvestmentService;
|
||||
use Modules\Investment\Transformers\InvestmentResource;
|
||||
|
||||
class BillplzInvestmentController extends Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public function __construct(
|
||||
protected BillplzInvestmentService $billplzInvestmentService,
|
||||
) {}
|
||||
|
||||
public function store(BillplzInvestmentRequest $request): JsonResponse
|
||||
{
|
||||
$this->authorize('createBillplz', Investment::class);
|
||||
|
||||
$validated = $request->validated();
|
||||
$result = $this->billplzInvestmentService->initiate(
|
||||
$request->user()->id,
|
||||
$validated['investment_type_id'],
|
||||
$validated['amount'],
|
||||
$validated['remarks'] ?? null,
|
||||
$request->user(),
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'investment' => new InvestmentResource($result['investment']),
|
||||
'bill_url' => $result['bill_url'],
|
||||
],
|
||||
'message' => 'Bil Billplz berjaya dijana',
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function callback(Request $request): JsonResponse
|
||||
{
|
||||
$investment = $this->billplzInvestmentService->handleCallback($request->all());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'investment_id' => $investment->id,
|
||||
'status' => $investment->status?->value ?? $investment->status,
|
||||
],
|
||||
'message' => 'Callback Billplz diterima',
|
||||
]);
|
||||
}
|
||||
|
||||
public function status(string $id): JsonResponse
|
||||
{
|
||||
$this->authorize('view', Investment::class);
|
||||
|
||||
$investment = $this->billplzInvestmentService->syncFromGateway($id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => new InvestmentResource(
|
||||
$investment->loadMissing(['account', 'investmentType', 'payment.billplzTransaction', 'ledgerEntries'])
|
||||
),
|
||||
'message' => 'Status pelaburan berjaya dimuatkan',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -3,54 +3,153 @@
|
||||
namespace Modules\Investment\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Investment\Entities\Investment;
|
||||
use Modules\Investment\Http\Requests\ManualInvestmentRequest;
|
||||
use Modules\Investment\Services\InvestmentPostingService;
|
||||
use Modules\Investment\Transformers\InvestmentResource;
|
||||
|
||||
class InvestmentController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*/
|
||||
public function index()
|
||||
use AuthorizesRequests;
|
||||
|
||||
public function __construct(
|
||||
protected InvestmentPostingService $postingService,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
return view('investment::index');
|
||||
$this->authorize('viewAny', Investment::class);
|
||||
|
||||
$perPage = min((int) $request->get('per_page', 10), 1000);
|
||||
$sortBy = (string) $request->get('sort_by', 'created_at');
|
||||
$sortOrder = strtolower((string) $request->get('sort_order', 'desc')) === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
$allowedSort = [
|
||||
'created_at',
|
||||
'amount',
|
||||
'status',
|
||||
'source',
|
||||
'reference_no',
|
||||
'completed_at',
|
||||
];
|
||||
if (! in_array($sortBy, $allowedSort, true)) {
|
||||
$sortBy = 'created_at';
|
||||
}
|
||||
|
||||
$query = Investment::query()
|
||||
->with([
|
||||
'user:id,name,email,ic_number,member_number',
|
||||
'investmentType:id,code,name',
|
||||
])
|
||||
->orderBy($sortBy, $sortOrder);
|
||||
|
||||
if ($search = trim((string) $request->get('search', ''))) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('reference_no', 'ILIKE', "%{$search}%")
|
||||
->orWhere('remarks', 'ILIKE', "%{$search}%")
|
||||
->orWhereHas('user', function ($userQuery) use ($search) {
|
||||
$userQuery->where('name', 'ILIKE', "%{$search}%")
|
||||
->orWhere('email', 'ILIKE', "%{$search}%")
|
||||
->orWhere('ic_number', 'ILIKE', "%{$search}%");
|
||||
})
|
||||
->orWhereHas('investmentType', function ($typeQuery) use ($search) {
|
||||
$typeQuery->where('name', 'ILIKE', "%{$search}%")
|
||||
->orWhere('code', 'ILIKE', "%{$search}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if ($status = $request->query('status')) {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
|
||||
if ($source = $request->query('source')) {
|
||||
$query->where('source', $source);
|
||||
}
|
||||
|
||||
if ($userId = $request->query('user_id')) {
|
||||
$query->where('user_id', $userId);
|
||||
}
|
||||
|
||||
if ($typeId = $request->query('investment_type_id')) {
|
||||
$query->where('investment_type_id', $typeId);
|
||||
}
|
||||
|
||||
$investments = $query->paginate($perPage);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => InvestmentResource::collection($investments->items()),
|
||||
'pagination' => [
|
||||
'current_page' => $investments->currentPage(),
|
||||
'per_page' => $investments->perPage(),
|
||||
'total' => $investments->total(),
|
||||
'last_page' => $investments->lastPage(),
|
||||
'from' => $investments->firstItem(),
|
||||
'to' => $investments->lastItem(),
|
||||
'has_more_pages' => $investments->hasMorePages(),
|
||||
],
|
||||
'message' => 'Pelaburan berjaya dimuatkan',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new resource.
|
||||
*/
|
||||
public function create()
|
||||
public function show(string $id): JsonResponse
|
||||
{
|
||||
return view('investment::create');
|
||||
$this->authorize('view', Investment::class);
|
||||
|
||||
$investment = Investment::query()
|
||||
->with([
|
||||
'user:id,name,email,ic_number,member_number',
|
||||
'account',
|
||||
'investmentType',
|
||||
'createdBy:id,name,email',
|
||||
'approvedBy:id,name,email',
|
||||
'ledgerEntries',
|
||||
'payment.billplzTransaction',
|
||||
])
|
||||
->find($id);
|
||||
|
||||
if (! $investment) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Pelaburan tidak dijumpai.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => new InvestmentResource($investment),
|
||||
'message' => 'Pelaburan berjaya dimuatkan',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*/
|
||||
public function store(Request $request) {}
|
||||
|
||||
/**
|
||||
* Show the specified resource.
|
||||
*/
|
||||
public function show($id)
|
||||
public function storeManual(ManualInvestmentRequest $request): JsonResponse
|
||||
{
|
||||
return view('investment::show');
|
||||
$this->authorize('createManual', Investment::class);
|
||||
|
||||
$validated = $request->validated();
|
||||
|
||||
$investment = $this->postingService->postManualInvestment(
|
||||
$validated['user_id'],
|
||||
$validated['investment_type_id'],
|
||||
$validated['amount'],
|
||||
$validated['remarks'] ?? null,
|
||||
$request->user(),
|
||||
);
|
||||
|
||||
$investment->loadMissing([
|
||||
'user:id,name,email,ic_number,member_number',
|
||||
'investmentType:id,code,name',
|
||||
'account',
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => new InvestmentResource($investment),
|
||||
'message' => 'Pelaburan manual berjaya direkodkan',
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified resource.
|
||||
*/
|
||||
public function edit($id)
|
||||
{
|
||||
return view('investment::edit');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*/
|
||||
public function update(Request $request, $id) {}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
*/
|
||||
public function destroy($id) {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\BaseCrudController;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Modules\Investment\Entities\InvestmentLimitProfile;
|
||||
use Modules\Investment\Http\Requests\InvestmentLimitProfileRequest;
|
||||
use Modules\Investment\Repositories\Contracts\InvestmentLimitProfileRepositoryInterface;
|
||||
use Modules\Investment\Transformers\InvestmentLimitProfileResource;
|
||||
|
||||
class InvestmentLimitProfileController extends BaseCrudController
|
||||
{
|
||||
protected $modelClass = InvestmentLimitProfile::class;
|
||||
|
||||
protected $resourceClass = InvestmentLimitProfileResource::class;
|
||||
|
||||
protected $requestClass = InvestmentLimitProfileRequest::class;
|
||||
|
||||
protected $resourceName = 'profil had pelaburan';
|
||||
|
||||
protected $resourceNamePlural = 'profil had pelaburan';
|
||||
|
||||
public function __construct(
|
||||
InvestmentLimitProfileRepositoryInterface $repository,
|
||||
) {
|
||||
parent::__construct($repository);
|
||||
}
|
||||
|
||||
protected function loadShowRelations($item)
|
||||
{
|
||||
return $item->load(['profileLimits.investmentType:id,code,name']);
|
||||
}
|
||||
|
||||
protected function getItemName($item): string
|
||||
{
|
||||
return $item->name ?? $item->id;
|
||||
}
|
||||
|
||||
protected function checkDependencies($item): ?JsonResponse
|
||||
{
|
||||
if ($item->userAssignments()->whereNull('expiry_date')->exists()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Profil had tidak boleh dipadam kerana masih ditugaskan kepada ahli.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Modules\Investment\Entities\InvestmentLimitProfile;
|
||||
use Modules\Investment\Entities\ProfileInvestmentLimit;
|
||||
use Modules\Investment\Http\Requests\ProfileInvestmentLimitRequest;
|
||||
use Modules\Investment\Transformers\ProfileInvestmentLimitResource;
|
||||
|
||||
class ProfileInvestmentLimitController extends Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public function index(string $investmentLimitProfile): JsonResponse
|
||||
{
|
||||
$this->authorize('viewAny', InvestmentLimitProfile::class);
|
||||
|
||||
$profile = InvestmentLimitProfile::query()->find($investmentLimitProfile);
|
||||
|
||||
if (! $profile) {
|
||||
return $this->notFound();
|
||||
}
|
||||
|
||||
$limits = $profile->profileLimits()
|
||||
->with('investmentType:id,code,name')
|
||||
->orderBy('created_at')
|
||||
->get();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => ProfileInvestmentLimitResource::collection($limits),
|
||||
'message' => 'Had profil berjaya dimuatkan',
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(ProfileInvestmentLimitRequest $request, string $investmentLimitProfile): JsonResponse
|
||||
{
|
||||
$this->authorize('create', InvestmentLimitProfile::class);
|
||||
|
||||
$profile = InvestmentLimitProfile::query()->find($investmentLimitProfile);
|
||||
|
||||
if (! $profile) {
|
||||
return $this->notFound();
|
||||
}
|
||||
|
||||
$limit = $profile->profileLimits()->create($request->validated());
|
||||
$limit->load('investmentType:id,code,name');
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => new ProfileInvestmentLimitResource($limit),
|
||||
'message' => 'Had profil berjaya ditambah',
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function update(
|
||||
ProfileInvestmentLimitRequest $request,
|
||||
string $investmentLimitProfile,
|
||||
string $limit,
|
||||
): JsonResponse {
|
||||
$this->authorize('update', InvestmentLimitProfile::class);
|
||||
|
||||
$item = ProfileInvestmentLimit::query()
|
||||
->where('profile_id', $investmentLimitProfile)
|
||||
->whereKey($limit)
|
||||
->first();
|
||||
|
||||
if (! $item) {
|
||||
return $this->notFound('Had profil tidak dijumpai.');
|
||||
}
|
||||
|
||||
$item->update($request->validated());
|
||||
$item->load('investmentType:id,code,name');
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => new ProfileInvestmentLimitResource($item),
|
||||
'message' => 'Had profil berjaya dikemaskini',
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(string $investmentLimitProfile, string $limit): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', InvestmentLimitProfile::class);
|
||||
|
||||
$item = ProfileInvestmentLimit::query()
|
||||
->where('profile_id', $investmentLimitProfile)
|
||||
->whereKey($limit)
|
||||
->first();
|
||||
|
||||
if (! $item) {
|
||||
return $this->notFound('Had profil tidak dijumpai.');
|
||||
}
|
||||
|
||||
$item->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Had profil berjaya dipadam',
|
||||
]);
|
||||
}
|
||||
|
||||
protected function notFound(string $message = 'Profil had tidak dijumpai.'): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $message,
|
||||
], 404);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\BaseCrudController;
|
||||
use Modules\Investment\Entities\SalaryContribution;
|
||||
use Modules\Investment\Http\Requests\SalaryContributionRequest;
|
||||
use Modules\Investment\Repositories\Contracts\SalaryContributionRepositoryInterface;
|
||||
use Modules\Investment\Transformers\SalaryContributionResource;
|
||||
|
||||
class SalaryContributionController extends BaseCrudController
|
||||
{
|
||||
protected $modelClass = SalaryContribution::class;
|
||||
|
||||
protected $resourceClass = SalaryContributionResource::class;
|
||||
|
||||
protected $requestClass = SalaryContributionRequest::class;
|
||||
|
||||
protected $resourceName = 'caruman potongan gaji';
|
||||
|
||||
protected $resourceNamePlural = 'caruman potongan gaji';
|
||||
|
||||
public function __construct(
|
||||
SalaryContributionRepositoryInterface $repository,
|
||||
) {
|
||||
parent::__construct($repository);
|
||||
}
|
||||
|
||||
protected function prepareStoreData(array $validated): array
|
||||
{
|
||||
$validated['created_by'] = auth()->id();
|
||||
|
||||
return $validated;
|
||||
}
|
||||
|
||||
protected function loadShowRelations($item)
|
||||
{
|
||||
return $item->load(['user:id,name,email', 'investmentType:id,code,name']);
|
||||
}
|
||||
|
||||
protected function getItemName($item): string
|
||||
{
|
||||
return $item->id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Investment\Entities\SalaryContribution;
|
||||
use Modules\Investment\Entities\SalaryDeductionBatch;
|
||||
use Modules\Investment\Entities\SalaryDeductionBatchDetail;
|
||||
use Modules\Investment\Http\Requests\GenerateSalaryDeductionBatchRequest;
|
||||
use Modules\Investment\Http\Requests\RejectSalaryDeductionRequest;
|
||||
use Modules\Investment\Http\Requests\UploadSalaryDeductionBatchRequest;
|
||||
use Modules\Investment\Http\Requests\VerifySalaryDeductionDetailRequest;
|
||||
use Modules\Investment\Services\SalaryDeductionService;
|
||||
use Modules\Investment\Transformers\SalaryDeductionBatchDetailResource;
|
||||
use Modules\Investment\Transformers\SalaryDeductionBatchResource;
|
||||
|
||||
class SalaryDeductionBatchController extends Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public function __construct(
|
||||
protected SalaryDeductionService $salaryDeductionService,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('viewAny', SalaryContribution::class);
|
||||
|
||||
$query = SalaryDeductionBatch::query()->orderByDesc('year')->orderByDesc('month');
|
||||
|
||||
if ($status = $request->query('status')) {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
|
||||
$batches = $query->paginate(min((int) $request->get('per_page', 10), 1000));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => SalaryDeductionBatchResource::collection($batches->items()),
|
||||
'pagination' => [
|
||||
'current_page' => $batches->currentPage(),
|
||||
'per_page' => $batches->perPage(),
|
||||
'total' => $batches->total(),
|
||||
'last_page' => $batches->lastPage(),
|
||||
'from' => $batches->firstItem(),
|
||||
'to' => $batches->lastItem(),
|
||||
'has_more_pages' => $batches->hasMorePages(),
|
||||
],
|
||||
'message' => 'Batch potongan gaji berjaya dimuatkan',
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(string $id): JsonResponse
|
||||
{
|
||||
$this->authorize('view', SalaryContribution::class);
|
||||
|
||||
$batch = SalaryDeductionBatch::query()
|
||||
->with([
|
||||
'details.user:id,name,email',
|
||||
'details.investmentType:id,code,name',
|
||||
'details.investment:id,reference_no,status,amount',
|
||||
'uploadedBy:id,name,email',
|
||||
'verifiedBy:id,name,email',
|
||||
])
|
||||
->find($id);
|
||||
|
||||
if (! $batch) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Batch potongan gaji tidak dijumpai.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => new SalaryDeductionBatchResource($batch),
|
||||
'message' => 'Batch potongan gaji berjaya dimuatkan',
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(UploadSalaryDeductionBatchRequest $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', SalaryContribution::class);
|
||||
|
||||
$validated = $request->validated();
|
||||
$batch = $this->salaryDeductionService->uploadBatch(
|
||||
(int) $validated['month'],
|
||||
(int) $validated['year'],
|
||||
$validated['details'],
|
||||
$request->user(),
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => new SalaryDeductionBatchResource($batch),
|
||||
'message' => 'Batch potongan gaji berjaya dimuat naik',
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function generate(GenerateSalaryDeductionBatchRequest $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', SalaryContribution::class);
|
||||
|
||||
$validated = $request->validated();
|
||||
$batch = $this->salaryDeductionService->generateBatchFromContributions(
|
||||
(int) $validated['month'],
|
||||
(int) $validated['year'],
|
||||
$request->user(),
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => new SalaryDeductionBatchResource($batch),
|
||||
'message' => 'Batch potongan gaji berjaya dijana daripada caruman aktif',
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function markUnderReview(string $id): JsonResponse
|
||||
{
|
||||
$this->authorize('verify', SalaryContribution::class);
|
||||
|
||||
$batch = SalaryDeductionBatch::query()->findOrFail($id);
|
||||
$batch = $this->salaryDeductionService->markUnderReview($batch);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => new SalaryDeductionBatchResource($batch),
|
||||
'message' => 'Batch dihantar untuk semakan',
|
||||
]);
|
||||
}
|
||||
|
||||
public function verifyDetail(
|
||||
VerifySalaryDeductionDetailRequest $request,
|
||||
string $id,
|
||||
string $detailId,
|
||||
): JsonResponse {
|
||||
$this->authorize('verify', SalaryContribution::class);
|
||||
|
||||
$detail = SalaryDeductionBatchDetail::query()
|
||||
->where('batch_id', $id)
|
||||
->whereKey($detailId)
|
||||
->firstOrFail();
|
||||
|
||||
$detail = $this->salaryDeductionService->verifyDetail(
|
||||
$detail,
|
||||
$request->user(),
|
||||
$request->validated('remarks'),
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => new SalaryDeductionBatchDetailResource($detail),
|
||||
'message' => 'Rekod potongan gaji berjaya disahkan',
|
||||
]);
|
||||
}
|
||||
|
||||
public function rejectDetail(
|
||||
RejectSalaryDeductionRequest $request,
|
||||
string $id,
|
||||
string $detailId,
|
||||
): JsonResponse {
|
||||
$this->authorize('verify', SalaryContribution::class);
|
||||
|
||||
$detail = SalaryDeductionBatchDetail::query()
|
||||
->where('batch_id', $id)
|
||||
->whereKey($detailId)
|
||||
->firstOrFail();
|
||||
|
||||
$detail = $this->salaryDeductionService->rejectDetail(
|
||||
$detail,
|
||||
$request->user(),
|
||||
$request->validated('remarks'),
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => new SalaryDeductionBatchDetailResource($detail),
|
||||
'message' => 'Rekod potongan gaji berjaya ditolak',
|
||||
]);
|
||||
}
|
||||
|
||||
public function verifyAll(string $id): JsonResponse
|
||||
{
|
||||
$this->authorize('verify', SalaryContribution::class);
|
||||
|
||||
$batch = SalaryDeductionBatch::query()->findOrFail($id);
|
||||
$batch = $this->salaryDeductionService->verifyAllPending($batch, request()->user());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => new SalaryDeductionBatchResource($batch),
|
||||
'message' => 'Semua rekod pending berjaya disahkan',
|
||||
]);
|
||||
}
|
||||
|
||||
public function rejectBatch(RejectSalaryDeductionRequest $request, string $id): JsonResponse
|
||||
{
|
||||
$this->authorize('verify', SalaryContribution::class);
|
||||
|
||||
$batch = SalaryDeductionBatch::query()->findOrFail($id);
|
||||
$batch = $this->salaryDeductionService->rejectBatch(
|
||||
$batch,
|
||||
$request->user(),
|
||||
$request->validated('remarks'),
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => new SalaryDeductionBatchResource($batch),
|
||||
'message' => 'Batch potongan gaji berjaya ditolak',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\BaseCrudController;
|
||||
use Modules\Investment\Entities\UserInvestmentLimit;
|
||||
use Modules\Investment\Http\Requests\UserInvestmentLimitRequest;
|
||||
use Modules\Investment\Repositories\Contracts\UserInvestmentLimitRepositoryInterface;
|
||||
use Modules\Investment\Transformers\UserInvestmentLimitResource;
|
||||
|
||||
class UserInvestmentLimitController extends BaseCrudController
|
||||
{
|
||||
protected $modelClass = UserInvestmentLimit::class;
|
||||
|
||||
protected $resourceClass = UserInvestmentLimitResource::class;
|
||||
|
||||
protected $requestClass = UserInvestmentLimitRequest::class;
|
||||
|
||||
protected $resourceName = 'had pelaburan ahli';
|
||||
|
||||
protected $resourceNamePlural = 'had pelaburan ahli';
|
||||
|
||||
public function __construct(
|
||||
UserInvestmentLimitRepositoryInterface $repository,
|
||||
) {
|
||||
parent::__construct($repository);
|
||||
}
|
||||
|
||||
protected function prepareStoreData(array $validated): array
|
||||
{
|
||||
$validated['created_by'] = auth()->id();
|
||||
|
||||
return $validated;
|
||||
}
|
||||
|
||||
protected function getItemName($item): string
|
||||
{
|
||||
return $item->id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Investment\Entities\InvestmentLimitProfile;
|
||||
use Modules\Investment\Entities\UserLimitProfile;
|
||||
use Modules\Investment\Http\Requests\AssignUserLimitProfileRequest;
|
||||
use Modules\Investment\Services\InvestmentLimitService;
|
||||
use Modules\Investment\Transformers\UserLimitProfileResource;
|
||||
|
||||
class UserLimitProfileController extends Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public function __construct(
|
||||
protected InvestmentLimitService $limitService,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('viewAny', InvestmentLimitProfile::class);
|
||||
|
||||
$query = UserLimitProfile::query()
|
||||
->with(['user:id,name,email', 'profile'])
|
||||
->orderByDesc('effective_date');
|
||||
|
||||
if ($userId = $request->query('user_id')) {
|
||||
$query->where('user_id', $userId);
|
||||
}
|
||||
|
||||
if ($request->boolean('active_only')) {
|
||||
$query->active();
|
||||
}
|
||||
|
||||
$assignments = $query->paginate(min((int) $request->get('per_page', 10), 1000));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => UserLimitProfileResource::collection($assignments->items()),
|
||||
'pagination' => [
|
||||
'current_page' => $assignments->currentPage(),
|
||||
'per_page' => $assignments->perPage(),
|
||||
'total' => $assignments->total(),
|
||||
'last_page' => $assignments->lastPage(),
|
||||
'from' => $assignments->firstItem(),
|
||||
'to' => $assignments->lastItem(),
|
||||
'has_more_pages' => $assignments->hasMorePages(),
|
||||
],
|
||||
'message' => 'Tugasan profil had berjaya dimuatkan',
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(AssignUserLimitProfileRequest $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', InvestmentLimitProfile::class);
|
||||
|
||||
$validated = $request->validated();
|
||||
|
||||
$assignment = $this->limitService->assignProfileToUser(
|
||||
$validated['user_id'],
|
||||
$validated['profile_id'],
|
||||
$validated['effective_date'],
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => new UserLimitProfileResource($assignment),
|
||||
'message' => 'Profil had berjaya ditugaskan kepada ahli',
|
||||
], 201);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class AssignUserLimitProfileRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => ['required', 'uuid', 'exists:users,id'],
|
||||
'profile_id' => ['required', 'uuid', 'exists:investment_limit_profiles,id'],
|
||||
'effective_date' => ['required', 'date'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'user_id.required' => 'Ahli diperlukan.',
|
||||
'user_id.exists' => 'Ahli tidak dijumpai.',
|
||||
'profile_id.required' => 'Profil had diperlukan.',
|
||||
'profile_id.exists' => 'Profil had tidak dijumpai.',
|
||||
'effective_date.required' => 'Tarikh berkuat kuasa diperlukan.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class BillplzInvestmentRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'investment_type_id' => ['required', 'uuid', 'exists:investment_types,id'],
|
||||
'amount' => ['required', 'numeric', 'gt:0'],
|
||||
'remarks' => ['nullable', 'string', 'max:1000'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'investment_type_id.required' => 'Jenis pelaburan diperlukan.',
|
||||
'investment_type_id.exists' => 'Jenis pelaburan tidak dijumpai.',
|
||||
'amount.required' => 'Jumlah pelaburan diperlukan.',
|
||||
'amount.gt' => 'Jumlah pelaburan mestilah lebih daripada 0.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class GenerateSalaryDeductionBatchRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'month' => ['required', 'integer', 'min:1', 'max:12'],
|
||||
'year' => ['required', 'integer', 'min:2000', 'max:2100'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class InvestmentLimitProfileRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$isCreate = $this->isMethod('POST');
|
||||
$isUpdate = $this->isMethod('PUT') || $this->isMethod('PATCH');
|
||||
|
||||
if (! $isCreate && ! $isUpdate) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$nameRule = Rule::unique('investment_limit_profiles', 'name');
|
||||
|
||||
if ($isUpdate) {
|
||||
$nameRule = $nameRule->ignore($this->route('investment_limit_profile'));
|
||||
}
|
||||
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:100', $nameRule],
|
||||
'description' => ['nullable', 'string'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'name.required' => 'Nama profil had diperlukan.',
|
||||
'name.unique' => 'Nama profil had sudah wujud.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ManualInvestmentRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => ['required', 'uuid', 'exists:users,id'],
|
||||
'investment_type_id' => ['required', 'uuid', 'exists:investment_types,id'],
|
||||
'amount' => ['required', 'numeric', 'gt:0'],
|
||||
'remarks' => ['nullable', 'string', 'max:1000'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'user_id.required' => 'Ahli diperlukan.',
|
||||
'user_id.exists' => 'Ahli tidak dijumpai.',
|
||||
'investment_type_id.required' => 'Jenis pelaburan diperlukan.',
|
||||
'investment_type_id.exists' => 'Jenis pelaburan tidak dijumpai.',
|
||||
'amount.required' => 'Jumlah pelaburan diperlukan.',
|
||||
'amount.gt' => 'Jumlah pelaburan mestilah lebih daripada 0.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ProfileInvestmentLimitRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$profileId = $this->route('investment_limit_profile');
|
||||
$limitId = $this->route('limit');
|
||||
|
||||
$typeRule = Rule::unique('profile_investment_limits', 'investment_type_id')
|
||||
->where(fn ($query) => $query->where('profile_id', $profileId));
|
||||
|
||||
if ($this->isMethod('PUT') || $this->isMethod('PATCH')) {
|
||||
$typeRule = $typeRule->ignore($limitId);
|
||||
}
|
||||
|
||||
return [
|
||||
'investment_type_id' => [
|
||||
'required',
|
||||
'uuid',
|
||||
'exists:investment_types,id',
|
||||
$typeRule,
|
||||
],
|
||||
'max_investment_amount' => ['nullable', 'numeric', 'min:0'],
|
||||
'max_transaction_amount' => ['nullable', 'numeric', 'min:0'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'investment_type_id.required' => 'Jenis pelaburan diperlukan.',
|
||||
'investment_type_id.exists' => 'Jenis pelaburan tidak dijumpai.',
|
||||
'investment_type_id.unique' => 'Had untuk jenis pelaburan ini sudah wujud dalam profil.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class RejectSalaryDeductionRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'remarks' => ['nullable', 'string', 'max:1000'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Modules\Investment\Enums\SalaryContributionStatus;
|
||||
|
||||
class SalaryContributionRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$isCreate = $this->isMethod('POST');
|
||||
$isUpdate = $this->isMethod('PUT') || $this->isMethod('PATCH');
|
||||
|
||||
if (! $isCreate && ! $isUpdate) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'user_id' => [$isCreate ? 'required' : 'sometimes', 'uuid', 'exists:users,id'],
|
||||
'investment_type_id' => [$isCreate ? 'required' : 'sometimes', 'uuid', 'exists:investment_types,id'],
|
||||
'monthly_amount' => ['required', 'numeric', 'gt:0'],
|
||||
'start_date' => ['required', 'date'],
|
||||
'end_date' => ['nullable', 'date', 'after_or_equal:start_date'],
|
||||
'status' => ['required', Rule::enum(SalaryContributionStatus::class)],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'user_id.required' => 'Ahli diperlukan.',
|
||||
'investment_type_id.required' => 'Jenis pelaburan diperlukan.',
|
||||
'monthly_amount.required' => 'Jumlah bulanan diperlukan.',
|
||||
'monthly_amount.gt' => 'Jumlah bulanan mestilah lebih daripada 0.',
|
||||
'start_date.required' => 'Tarikh mula diperlukan.',
|
||||
'status.required' => 'Status caruman diperlukan.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UploadSalaryDeductionBatchRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'month' => ['required', 'integer', 'min:1', 'max:12'],
|
||||
'year' => ['required', 'integer', 'min:2000', 'max:2100'],
|
||||
'details' => ['required', 'array', 'min:1'],
|
||||
'details.*.user_id' => ['required', 'uuid', 'exists:users,id'],
|
||||
'details.*.investment_type_id' => ['required', 'uuid', 'exists:investment_types,id'],
|
||||
'details.*.amount' => ['required', 'numeric', 'gt:0'],
|
||||
'details.*.salary_contribution_id' => ['nullable', 'uuid', 'exists:salary_contributions,id'],
|
||||
'details.*.remarks' => ['nullable', 'string', 'max:1000'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UserInvestmentLimitRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$isCreate = $this->isMethod('POST');
|
||||
$isUpdate = $this->isMethod('PUT') || $this->isMethod('PATCH');
|
||||
|
||||
if (! $isCreate && ! $isUpdate) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'user_id' => [$isCreate ? 'required' : 'sometimes', 'uuid', 'exists:users,id'],
|
||||
'investment_type_id' => [$isCreate ? 'required' : 'sometimes', 'uuid', 'exists:investment_types,id'],
|
||||
'max_investment_amount' => ['nullable', 'numeric', 'min:0'],
|
||||
'max_transaction_amount' => ['nullable', 'numeric', 'min:0'],
|
||||
'effective_date' => ['required', 'date'],
|
||||
'expiry_date' => ['nullable', 'date', 'after_or_equal:effective_date'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'user_id.required' => 'Ahli diperlukan.',
|
||||
'investment_type_id.required' => 'Jenis pelaburan diperlukan.',
|
||||
'effective_date.required' => 'Tarikh berkuat kuasa diperlukan.',
|
||||
'expiry_date.after_or_equal' => 'Tarikh tamat mestilah pada atau selepas tarikh berkuat kuasa.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class VerifySalaryDeductionDetailRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'remarks' => ['nullable', 'string', 'max:1000'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Policies;
|
||||
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
|
||||
class InvestmentLimitPolicy
|
||||
{
|
||||
use HandlesAuthorization;
|
||||
|
||||
public function viewAny($user): bool
|
||||
{
|
||||
return $user->hasPermissionTo('lihat had pelaburan');
|
||||
}
|
||||
|
||||
public function view($user, $model = null): bool
|
||||
{
|
||||
return $user->hasPermissionTo('lihat had pelaburan');
|
||||
}
|
||||
|
||||
public function create($user): bool
|
||||
{
|
||||
return $user->hasPermissionTo('tambah had pelaburan');
|
||||
}
|
||||
|
||||
public function update($user, $model = null): bool
|
||||
{
|
||||
return $user->hasPermissionTo('kemaskini had pelaburan');
|
||||
}
|
||||
|
||||
public function deleteAny($user): bool
|
||||
{
|
||||
return $user->hasPermissionTo('hapus had pelaburan');
|
||||
}
|
||||
|
||||
public function delete($user, $model = null): bool
|
||||
{
|
||||
return $user->hasPermissionTo('hapus had pelaburan');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Policies;
|
||||
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
use Modules\Investment\Entities\Investment;
|
||||
|
||||
class InvestmentPolicy
|
||||
{
|
||||
use HandlesAuthorization;
|
||||
|
||||
public function viewAny($user): bool
|
||||
{
|
||||
return $user->hasPermissionTo('lihat pelaburan');
|
||||
}
|
||||
|
||||
public function view($user, ?Investment $investment = null): bool
|
||||
{
|
||||
return $user->hasPermissionTo('lihat pelaburan');
|
||||
}
|
||||
|
||||
public function createManual($user): bool
|
||||
{
|
||||
return $user->hasPermissionTo('tambah pelaburan manual');
|
||||
}
|
||||
|
||||
public function createBillplz($user): bool
|
||||
{
|
||||
return $user->hasPermissionTo('tambah pelaburan billplz');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Policies;
|
||||
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
|
||||
class SalaryDeductionPolicy
|
||||
{
|
||||
use HandlesAuthorization;
|
||||
|
||||
public function viewAny($user): bool
|
||||
{
|
||||
return $user->hasPermissionTo('lihat potongan gaji');
|
||||
}
|
||||
|
||||
public function view($user, $model = null): bool
|
||||
{
|
||||
return $user->hasPermissionTo('lihat potongan gaji');
|
||||
}
|
||||
|
||||
public function create($user): bool
|
||||
{
|
||||
return $user->hasPermissionTo('tambah potongan gaji');
|
||||
}
|
||||
|
||||
public function update($user, $model = null): bool
|
||||
{
|
||||
return $user->hasPermissionTo('kemaskini potongan gaji');
|
||||
}
|
||||
|
||||
public function deleteAny($user): bool
|
||||
{
|
||||
return $user->hasPermissionTo('hapus potongan gaji');
|
||||
}
|
||||
|
||||
public function delete($user, $model = null): bool
|
||||
{
|
||||
return $user->hasPermissionTo('hapus potongan gaji');
|
||||
}
|
||||
|
||||
public function verify($user, $model = null): bool
|
||||
{
|
||||
return $user->hasPermissionTo('sahkan potongan gaji');
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,16 @@ namespace Modules\Investment\Providers;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Modules\Investment\Entities\Investment;
|
||||
use Modules\Investment\Entities\InvestmentLimitProfile;
|
||||
use Modules\Investment\Entities\InvestmentType;
|
||||
use Modules\Investment\Entities\SalaryContribution;
|
||||
use Modules\Investment\Entities\SalaryDeductionBatch;
|
||||
use Modules\Investment\Entities\UserInvestmentLimit;
|
||||
use Modules\Investment\Policies\InvestmentLimitPolicy;
|
||||
use Modules\Investment\Policies\InvestmentPolicy;
|
||||
use Modules\Investment\Policies\InvestmentTypePolicy;
|
||||
use Modules\Investment\Policies\SalaryDeductionPolicy;
|
||||
use Nwidart\Modules\Traits\PathNamespace;
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
@@ -45,11 +53,31 @@ class InvestmentServiceProvider extends ServiceProvider
|
||||
\Modules\Investment\Repositories\Contracts\InvestmentTypeRepositoryInterface::class,
|
||||
\Modules\Investment\Repositories\InvestmentTypeRepository::class
|
||||
);
|
||||
|
||||
$this->app->bind(
|
||||
\Modules\Investment\Repositories\Contracts\InvestmentLimitProfileRepositoryInterface::class,
|
||||
\Modules\Investment\Repositories\InvestmentLimitProfileRepository::class
|
||||
);
|
||||
|
||||
$this->app->bind(
|
||||
\Modules\Investment\Repositories\Contracts\UserInvestmentLimitRepositoryInterface::class,
|
||||
\Modules\Investment\Repositories\UserInvestmentLimitRepository::class
|
||||
);
|
||||
|
||||
$this->app->bind(
|
||||
\Modules\Investment\Repositories\Contracts\SalaryContributionRepositoryInterface::class,
|
||||
\Modules\Investment\Repositories\SalaryContributionRepository::class
|
||||
);
|
||||
}
|
||||
|
||||
protected function registerPolicies(): void
|
||||
{
|
||||
Gate::policy(InvestmentType::class, InvestmentTypePolicy::class);
|
||||
Gate::policy(Investment::class, InvestmentPolicy::class);
|
||||
Gate::policy(InvestmentLimitProfile::class, InvestmentLimitPolicy::class);
|
||||
Gate::policy(UserInvestmentLimit::class, InvestmentLimitPolicy::class);
|
||||
Gate::policy(SalaryContribution::class, SalaryDeductionPolicy::class);
|
||||
Gate::policy(SalaryDeductionBatch::class, SalaryDeductionPolicy::class);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Repositories\Contracts;
|
||||
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Modules\Investment\Entities\InvestmentLimitProfile;
|
||||
|
||||
interface InvestmentLimitProfileRepositoryInterface
|
||||
{
|
||||
public function getAllPaginated(int $perPage = 10, string $search = '');
|
||||
|
||||
public function getAllWithRelationsPaginated(
|
||||
int $perPage = 10,
|
||||
string $search = '',
|
||||
string $sortBy = 'name',
|
||||
string $sortOrder = 'asc'
|
||||
);
|
||||
|
||||
public function getAllWithRelations(string $search = ''): Collection;
|
||||
|
||||
public function create(array $data): InvestmentLimitProfile;
|
||||
|
||||
public function findById(string $id): ?InvestmentLimitProfile;
|
||||
|
||||
public function delete(string $id): bool;
|
||||
|
||||
public function all(string $search = ''): Collection;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Repositories\Contracts;
|
||||
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Modules\Investment\Entities\SalaryContribution;
|
||||
|
||||
interface SalaryContributionRepositoryInterface
|
||||
{
|
||||
public function getAllPaginated(int $perPage = 10, string $search = '');
|
||||
|
||||
public function getAllWithRelationsPaginated(
|
||||
int $perPage = 10,
|
||||
string $search = '',
|
||||
string $sortBy = 'created_at',
|
||||
string $sortOrder = 'desc'
|
||||
);
|
||||
|
||||
public function getAllWithRelations(string $search = ''): Collection;
|
||||
|
||||
public function create(array $data): SalaryContribution;
|
||||
|
||||
public function findById(string $id): ?SalaryContribution;
|
||||
|
||||
public function delete(string $id): bool;
|
||||
|
||||
public function all(string $search = ''): Collection;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Repositories\Contracts;
|
||||
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Modules\Investment\Entities\UserInvestmentLimit;
|
||||
|
||||
interface UserInvestmentLimitRepositoryInterface
|
||||
{
|
||||
public function getAllPaginated(int $perPage = 10, string $search = '');
|
||||
|
||||
public function getAllWithRelationsPaginated(
|
||||
int $perPage = 10,
|
||||
string $search = '',
|
||||
string $sortBy = 'created_at',
|
||||
string $sortOrder = 'desc'
|
||||
);
|
||||
|
||||
public function getAllWithRelations(string $search = ''): Collection;
|
||||
|
||||
public function create(array $data): UserInvestmentLimit;
|
||||
|
||||
public function findById(string $id): ?UserInvestmentLimit;
|
||||
|
||||
public function delete(string $id): bool;
|
||||
|
||||
public function all(string $search = ''): Collection;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Repositories;
|
||||
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Modules\Investment\Entities\InvestmentLimitProfile;
|
||||
use Modules\Investment\Repositories\Contracts\InvestmentLimitProfileRepositoryInterface;
|
||||
|
||||
class InvestmentLimitProfileRepository implements InvestmentLimitProfileRepositoryInterface
|
||||
{
|
||||
protected function baseQuery()
|
||||
{
|
||||
return InvestmentLimitProfile::query()->orderBy('name');
|
||||
}
|
||||
|
||||
protected function applySearch($query, string $search)
|
||||
{
|
||||
if ($search !== '') {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'ILIKE', "%{$search}%")
|
||||
->orWhere('description', 'ILIKE', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
public function getAllPaginated(int $perPage = 10, string $search = '')
|
||||
{
|
||||
$query = $this->baseQuery();
|
||||
$this->applySearch($query, $search);
|
||||
|
||||
return $query->paginate($perPage);
|
||||
}
|
||||
|
||||
public function getAllWithRelationsPaginated(
|
||||
int $perPage = 10,
|
||||
string $search = '',
|
||||
string $sortBy = 'name',
|
||||
string $sortOrder = 'asc'
|
||||
) {
|
||||
$allowedSortColumns = ['id', 'name', 'created_at'];
|
||||
$sortBy = in_array($sortBy, $allowedSortColumns, true) ? $sortBy : 'name';
|
||||
$sortOrder = strtolower($sortOrder) === 'desc' ? 'desc' : 'asc';
|
||||
|
||||
$query = InvestmentLimitProfile::query()
|
||||
->withCount('profileLimits')
|
||||
->orderBy($sortBy, $sortOrder);
|
||||
$this->applySearch($query, $search);
|
||||
|
||||
return $query->paginate($perPage);
|
||||
}
|
||||
|
||||
public function getAllWithRelations(string $search = ''): Collection
|
||||
{
|
||||
$query = $this->baseQuery()->with('profileLimits');
|
||||
$this->applySearch($query, $search);
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
public function create(array $data): InvestmentLimitProfile
|
||||
{
|
||||
return InvestmentLimitProfile::create($data);
|
||||
}
|
||||
|
||||
public function findById(string $id): ?InvestmentLimitProfile
|
||||
{
|
||||
return InvestmentLimitProfile::query()->find($id);
|
||||
}
|
||||
|
||||
public function delete(string $id): bool
|
||||
{
|
||||
$profile = $this->findById($id);
|
||||
|
||||
if ($profile) {
|
||||
return (bool) $profile->delete();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function all(string $search = ''): Collection
|
||||
{
|
||||
$query = $this->baseQuery();
|
||||
$this->applySearch($query, $search);
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Repositories;
|
||||
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Modules\Investment\Entities\SalaryContribution;
|
||||
use Modules\Investment\Repositories\Contracts\SalaryContributionRepositoryInterface;
|
||||
|
||||
class SalaryContributionRepository implements SalaryContributionRepositoryInterface
|
||||
{
|
||||
protected function baseQuery()
|
||||
{
|
||||
return SalaryContribution::query()
|
||||
->with(['user:id,name,email', 'investmentType:id,code,name'])
|
||||
->orderByDesc('created_at');
|
||||
}
|
||||
|
||||
protected function applySearch($query, string $search)
|
||||
{
|
||||
if ($search !== '') {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->whereHas('user', function ($userQuery) use ($search) {
|
||||
$userQuery->where('name', 'ILIKE', "%{$search}%")
|
||||
->orWhere('email', 'ILIKE', "%{$search}%")
|
||||
->orWhere('ic_number', 'ILIKE', "%{$search}%");
|
||||
})->orWhereHas('investmentType', function ($typeQuery) use ($search) {
|
||||
$typeQuery->where('name', 'ILIKE', "%{$search}%")
|
||||
->orWhere('code', 'ILIKE', "%{$search}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
public function getAllPaginated(int $perPage = 10, string $search = '')
|
||||
{
|
||||
$query = $this->baseQuery();
|
||||
$this->applySearch($query, $search);
|
||||
|
||||
return $query->paginate($perPage);
|
||||
}
|
||||
|
||||
public function getAllWithRelationsPaginated(
|
||||
int $perPage = 10,
|
||||
string $search = '',
|
||||
string $sortBy = 'created_at',
|
||||
string $sortOrder = 'desc'
|
||||
) {
|
||||
$allowedSortColumns = [
|
||||
'id',
|
||||
'monthly_amount',
|
||||
'start_date',
|
||||
'end_date',
|
||||
'status',
|
||||
'created_at',
|
||||
];
|
||||
$sortBy = in_array($sortBy, $allowedSortColumns, true) ? $sortBy : 'created_at';
|
||||
$sortOrder = strtolower($sortOrder) === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
$query = SalaryContribution::query()
|
||||
->with(['user:id,name,email', 'investmentType:id,code,name'])
|
||||
->orderBy($sortBy, $sortOrder);
|
||||
|
||||
$this->applySearch($query, $search);
|
||||
|
||||
if ($userId = request('user_id')) {
|
||||
$query->where('user_id', $userId);
|
||||
}
|
||||
|
||||
if ($status = request('status')) {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
|
||||
return $query->paginate($perPage);
|
||||
}
|
||||
|
||||
public function getAllWithRelations(string $search = ''): Collection
|
||||
{
|
||||
$query = $this->baseQuery();
|
||||
$this->applySearch($query, $search);
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
public function create(array $data): SalaryContribution
|
||||
{
|
||||
$contribution = SalaryContribution::create($data);
|
||||
|
||||
return $contribution->load(['user:id,name,email', 'investmentType:id,code,name']);
|
||||
}
|
||||
|
||||
public function findById(string $id): ?SalaryContribution
|
||||
{
|
||||
return SalaryContribution::query()
|
||||
->with(['user:id,name,email', 'investmentType:id,code,name'])
|
||||
->find($id);
|
||||
}
|
||||
|
||||
public function delete(string $id): bool
|
||||
{
|
||||
$item = SalaryContribution::query()->find($id);
|
||||
|
||||
if ($item) {
|
||||
return (bool) $item->delete();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function all(string $search = ''): Collection
|
||||
{
|
||||
$query = $this->baseQuery();
|
||||
$this->applySearch($query, $search);
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Repositories;
|
||||
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Modules\Investment\Entities\UserInvestmentLimit;
|
||||
use Modules\Investment\Repositories\Contracts\UserInvestmentLimitRepositoryInterface;
|
||||
|
||||
class UserInvestmentLimitRepository implements UserInvestmentLimitRepositoryInterface
|
||||
{
|
||||
protected function baseQuery()
|
||||
{
|
||||
return UserInvestmentLimit::query()
|
||||
->with(['user:id,name,email', 'investmentType:id,code,name'])
|
||||
->orderByDesc('created_at');
|
||||
}
|
||||
|
||||
protected function applySearch($query, string $search)
|
||||
{
|
||||
if ($search !== '') {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->whereHas('user', function ($userQuery) use ($search) {
|
||||
$userQuery->where('name', 'ILIKE', "%{$search}%")
|
||||
->orWhere('email', 'ILIKE', "%{$search}%")
|
||||
->orWhere('ic_number', 'ILIKE', "%{$search}%");
|
||||
})->orWhereHas('investmentType', function ($typeQuery) use ($search) {
|
||||
$typeQuery->where('name', 'ILIKE', "%{$search}%")
|
||||
->orWhere('code', 'ILIKE', "%{$search}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
public function getAllPaginated(int $perPage = 10, string $search = '')
|
||||
{
|
||||
$query = $this->baseQuery();
|
||||
$this->applySearch($query, $search);
|
||||
|
||||
return $query->paginate($perPage);
|
||||
}
|
||||
|
||||
public function getAllWithRelationsPaginated(
|
||||
int $perPage = 10,
|
||||
string $search = '',
|
||||
string $sortBy = 'created_at',
|
||||
string $sortOrder = 'desc'
|
||||
) {
|
||||
$allowedSortColumns = [
|
||||
'id',
|
||||
'effective_date',
|
||||
'expiry_date',
|
||||
'max_investment_amount',
|
||||
'max_transaction_amount',
|
||||
'created_at',
|
||||
];
|
||||
$sortBy = in_array($sortBy, $allowedSortColumns, true) ? $sortBy : 'created_at';
|
||||
$sortOrder = strtolower($sortOrder) === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
$query = UserInvestmentLimit::query()
|
||||
->with(['user:id,name,email', 'investmentType:id,code,name'])
|
||||
->orderBy($sortBy, $sortOrder);
|
||||
$this->applySearch($query, $search);
|
||||
|
||||
if ($userId = request('user_id')) {
|
||||
$query->where('user_id', $userId);
|
||||
}
|
||||
|
||||
if ($investmentTypeId = request('investment_type_id')) {
|
||||
$query->where('investment_type_id', $investmentTypeId);
|
||||
}
|
||||
|
||||
return $query->paginate($perPage);
|
||||
}
|
||||
|
||||
public function getAllWithRelations(string $search = ''): Collection
|
||||
{
|
||||
$query = $this->baseQuery();
|
||||
$this->applySearch($query, $search);
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
public function create(array $data): UserInvestmentLimit
|
||||
{
|
||||
return UserInvestmentLimit::create($data);
|
||||
}
|
||||
|
||||
public function findById(string $id): ?UserInvestmentLimit
|
||||
{
|
||||
return UserInvestmentLimit::query()
|
||||
->with(['user:id,name,email', 'investmentType:id,code,name'])
|
||||
->find($id);
|
||||
}
|
||||
|
||||
public function delete(string $id): bool
|
||||
{
|
||||
$limit = UserInvestmentLimit::query()->find($id);
|
||||
|
||||
if ($limit) {
|
||||
return (bool) $limit->delete();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function all(string $search = ''): Collection
|
||||
{
|
||||
$query = $this->baseQuery();
|
||||
$this->applySearch($query, $search);
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,83 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Investment\Http\Controllers\BillplzInvestmentController;
|
||||
use Modules\Investment\Http\Controllers\InvestmentController;
|
||||
use Modules\Investment\Http\Controllers\InvestmentLimitProfileController;
|
||||
use Modules\Investment\Http\Controllers\InvestmentTypeController;
|
||||
use Modules\Investment\Http\Controllers\ProfileInvestmentLimitController;
|
||||
use Modules\Investment\Http\Controllers\SalaryContributionController;
|
||||
use Modules\Investment\Http\Controllers\SalaryDeductionBatchController;
|
||||
use Modules\Investment\Http\Controllers\UserInvestmentLimitController;
|
||||
use Modules\Investment\Http\Controllers\UserLimitProfileController;
|
||||
|
||||
Route::prefix('v1')->group(function () {
|
||||
// Billplz server-to-server callback (no auth)
|
||||
Route::post('billplz/callback', [BillplzInvestmentController::class, 'callback'])
|
||||
->name('billplz.callback');
|
||||
});
|
||||
|
||||
Route::middleware(['auth:sanctum', 'single.session'])->prefix('v1')->group(function () {
|
||||
// Investment Types
|
||||
Route::apiResource('investment-types', InvestmentTypeController::class)->names('investment-type');
|
||||
|
||||
// Investments
|
||||
Route::get('investments', [InvestmentController::class, 'index'])
|
||||
->name('investment.index');
|
||||
Route::post('investments/manual', [InvestmentController::class, 'storeManual'])
|
||||
->name('investment.manual.store');
|
||||
Route::post('investments/billplz', [BillplzInvestmentController::class, 'store'])
|
||||
->name('investment.billplz.store');
|
||||
Route::get('investments/{id}/payment-status', [BillplzInvestmentController::class, 'status'])
|
||||
->name('investment.payment-status');
|
||||
Route::get('investments/{id}', [InvestmentController::class, 'show'])
|
||||
->name('investment.show');
|
||||
|
||||
// Investment Limit Profiles
|
||||
Route::apiResource('investment-limit-profiles', InvestmentLimitProfileController::class)
|
||||
->names('investment-limit-profile');
|
||||
|
||||
// Profile Investment Limits
|
||||
Route::get('investment-limit-profiles/{investment_limit_profile}/limits', [ProfileInvestmentLimitController::class, 'index'])
|
||||
->name('investment-limit-profile.limits.index');
|
||||
Route::post('investment-limit-profiles/{investment_limit_profile}/limits', [ProfileInvestmentLimitController::class, 'store'])
|
||||
->name('investment-limit-profile.limits.store');
|
||||
Route::put('investment-limit-profiles/{investment_limit_profile}/limits/{limit}', [ProfileInvestmentLimitController::class, 'update'])
|
||||
->name('investment-limit-profile.limits.update');
|
||||
Route::delete('investment-limit-profiles/{investment_limit_profile}/limits/{limit}', [ProfileInvestmentLimitController::class, 'destroy'])
|
||||
->name('investment-limit-profile.limits.destroy');
|
||||
|
||||
// User Limit Profiles
|
||||
Route::get('user-limit-profiles', [UserLimitProfileController::class, 'index'])
|
||||
->name('user-limit-profile.index');
|
||||
Route::post('user-limit-profiles', [UserLimitProfileController::class, 'store'])
|
||||
->name('user-limit-profile.store');
|
||||
|
||||
// User Investment Limits
|
||||
Route::apiResource('user-investment-limits', UserInvestmentLimitController::class)
|
||||
->names('user-investment-limit');
|
||||
|
||||
// Salary contributions (standing instructions)
|
||||
Route::apiResource('salary-contributions', SalaryContributionController::class)
|
||||
->names('salary-contribution');
|
||||
|
||||
// Salary deduction batches
|
||||
Route::get('salary-deduction-batches', [SalaryDeductionBatchController::class, 'index'])
|
||||
->name('salary-deduction-batch.index');
|
||||
Route::post('salary-deduction-batches', [SalaryDeductionBatchController::class, 'store'])
|
||||
->name('salary-deduction-batch.store');
|
||||
Route::post('salary-deduction-batches/generate', [SalaryDeductionBatchController::class, 'generate'])
|
||||
->name('salary-deduction-batch.generate');
|
||||
Route::get('salary-deduction-batches/{id}', [SalaryDeductionBatchController::class, 'show'])
|
||||
->name('salary-deduction-batch.show');
|
||||
Route::post('salary-deduction-batches/{id}/under-review', [SalaryDeductionBatchController::class, 'markUnderReview'])
|
||||
->name('salary-deduction-batch.under-review');
|
||||
Route::post('salary-deduction-batches/{id}/verify-all', [SalaryDeductionBatchController::class, 'verifyAll'])
|
||||
->name('salary-deduction-batch.verify-all');
|
||||
Route::post('salary-deduction-batches/{id}/reject', [SalaryDeductionBatchController::class, 'rejectBatch'])
|
||||
->name('salary-deduction-batch.reject');
|
||||
Route::post('salary-deduction-batches/{id}/details/{detailId}/verify', [SalaryDeductionBatchController::class, 'verifyDetail'])
|
||||
->name('salary-deduction-batch.detail.verify');
|
||||
Route::post('salary-deduction-batches/{id}/details/{detailId}/reject', [SalaryDeductionBatchController::class, 'rejectDetail'])
|
||||
->name('salary-deduction-batch.detail.reject');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Services\Billplz;
|
||||
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
|
||||
class BillplzClient
|
||||
{
|
||||
/**
|
||||
* @param array{
|
||||
* collection_id: string,
|
||||
* email: string,
|
||||
* name: string,
|
||||
* amount: int,
|
||||
* description: string,
|
||||
* callback_url: string,
|
||||
* redirect_url?: string|null,
|
||||
* reference_1?: string|null,
|
||||
* reference_2?: string|null,
|
||||
* mobile?: string|null
|
||||
* } $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function createBill(array $payload): array
|
||||
{
|
||||
$response = $this->http()
|
||||
->asForm()
|
||||
->post($this->baseUrl().'/bills', array_filter([
|
||||
'collection_id' => $payload['collection_id'],
|
||||
'email' => $payload['email'],
|
||||
'name' => $payload['name'],
|
||||
'amount' => $payload['amount'],
|
||||
'description' => $payload['description'],
|
||||
'callback_url' => $payload['callback_url'],
|
||||
'redirect_url' => $payload['redirect_url'] ?? null,
|
||||
'reference_1_label' => 'Reference',
|
||||
'reference_1' => $payload['reference_1'] ?? null,
|
||||
'reference_2_label' => 'Investment',
|
||||
'reference_2' => $payload['reference_2'] ?? null,
|
||||
'mobile' => $payload['mobile'] ?? null,
|
||||
], fn ($value) => $value !== null && $value !== ''));
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::error('Billplz create bill failed', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
|
||||
throw new RuntimeException('Gagal mencipta bil Billplz.');
|
||||
}
|
||||
|
||||
return $response->json();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getBill(string $billId): array
|
||||
{
|
||||
try {
|
||||
$response = $this->http()->get($this->baseUrl().'/bills/'.$billId)->throw();
|
||||
} catch (RequestException $exception) {
|
||||
Log::error('Billplz get bill failed', [
|
||||
'bill_id' => $billId,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
throw new RuntimeException('Gagal mendapatkan status bil Billplz.');
|
||||
}
|
||||
|
||||
return $response->json();
|
||||
}
|
||||
|
||||
protected function http()
|
||||
{
|
||||
$apiKey = (string) config('investment.billplz.api_key');
|
||||
|
||||
if ($apiKey === '') {
|
||||
throw new RuntimeException('Billplz API key belum dikonfigurasi.');
|
||||
}
|
||||
|
||||
return Http::timeout((int) config('http.timeout', 30))
|
||||
->connectTimeout((int) config('http.connect_timeout', 10))
|
||||
->withBasicAuth($apiKey, '');
|
||||
}
|
||||
|
||||
protected function baseUrl(): string
|
||||
{
|
||||
$baseUrl = (string) config('investment.billplz.base_url');
|
||||
|
||||
if ($baseUrl === '') {
|
||||
throw new RuntimeException('Billplz base URL belum dikonfigurasi.');
|
||||
}
|
||||
|
||||
return $baseUrl;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Services\Billplz;
|
||||
|
||||
class BillplzSignatureVerifier
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public function verify(array $payload, ?string $providedSignature = null): bool
|
||||
{
|
||||
$xSignatureKey = (string) config('investment.billplz.x_signature_key');
|
||||
|
||||
if ($xSignatureKey === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$data = $this->flatten($payload);
|
||||
$signature = $providedSignature ?? ($data['x_signature'] ?? null);
|
||||
|
||||
if (! is_string($signature) || $signature === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
unset($data['x_signature']);
|
||||
|
||||
$sourceString = $this->buildSourceString($data);
|
||||
$computed = hash_hmac('sha256', $sourceString, $xSignatureKey);
|
||||
|
||||
return hash_equals($computed, $signature);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function flatten(array $payload, string $prefix = ''): array
|
||||
{
|
||||
$flattened = [];
|
||||
|
||||
foreach ($payload as $key => $value) {
|
||||
$fullKey = $prefix === '' ? (string) $key : $prefix.$key;
|
||||
|
||||
if (is_array($value)) {
|
||||
$flattened = array_merge($flattened, $this->flatten($value, $fullKey));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_bool($value)) {
|
||||
$flattened[$fullKey] = $value ? 'true' : 'false';
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($value === null) {
|
||||
$flattened[$fullKey] = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
$flattened[$fullKey] = (string) $value;
|
||||
}
|
||||
|
||||
return $flattened;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $data
|
||||
*/
|
||||
protected function buildSourceString(array $data): string
|
||||
{
|
||||
uksort($data, fn (string $left, string $right) => strcasecmp($left, $right));
|
||||
|
||||
$parts = [];
|
||||
foreach ($data as $key => $value) {
|
||||
$parts[] = $key.$value;
|
||||
}
|
||||
|
||||
return implode('|', $parts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Services;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Modules\Auth\Entities\User;
|
||||
use Modules\Investment\Entities\BillplzTransaction;
|
||||
use Modules\Investment\Entities\Investment;
|
||||
use Modules\Investment\Entities\InvestmentPayment;
|
||||
use Modules\Investment\Entities\InvestmentType;
|
||||
use Modules\Investment\Enums\BillplzTransactionStatus;
|
||||
use Modules\Investment\Enums\InvestmentPaymentGateway;
|
||||
use Modules\Investment\Enums\InvestmentPaymentStatus;
|
||||
use Modules\Investment\Enums\InvestmentStatus;
|
||||
use Modules\Investment\Services\Billplz\BillplzClient;
|
||||
use Modules\Investment\Services\Billplz\BillplzSignatureVerifier;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class BillplzInvestmentService
|
||||
{
|
||||
public function __construct(
|
||||
protected InvestmentPostingService $postingService,
|
||||
protected BillplzClient $billplzClient,
|
||||
protected BillplzSignatureVerifier $signatureVerifier,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{investment: Investment, bill_url: string}
|
||||
*/
|
||||
public function initiate(
|
||||
string $userId,
|
||||
string $investmentTypeId,
|
||||
string|float|int $amount,
|
||||
?string $remarks,
|
||||
User $payer,
|
||||
): array {
|
||||
$investment = $this->postingService->createPendingBillplzInvestment(
|
||||
$userId,
|
||||
$investmentTypeId,
|
||||
$amount,
|
||||
$remarks,
|
||||
$payer,
|
||||
);
|
||||
|
||||
$callbackUrl = $this->callbackUrl();
|
||||
$redirectUrl = $this->redirectUrl($investment->id);
|
||||
$collectionId = $this->collectionIdFor($investment->investmentType);
|
||||
$idempotencyKey = (string) Str::uuid();
|
||||
|
||||
$payment = InvestmentPayment::query()->create([
|
||||
'investment_id' => $investment->id,
|
||||
'gateway' => InvestmentPaymentGateway::Billplz,
|
||||
'amount' => $investment->amount,
|
||||
'currency' => 'MYR',
|
||||
'status' => InvestmentPaymentStatus::Pending,
|
||||
'idempotency_key' => $idempotencyKey,
|
||||
'redirect_url' => $redirectUrl,
|
||||
'callback_url' => $callbackUrl,
|
||||
]);
|
||||
|
||||
try {
|
||||
$bill = $this->billplzClient->createBill([
|
||||
'collection_id' => $collectionId,
|
||||
'email' => $payer->email,
|
||||
'name' => $payer->name,
|
||||
'amount' => (int) round(((float) $investment->amount) * 100),
|
||||
'description' => Str::limit(
|
||||
'Pelaburan '.$investment->investmentType->name.' ('.$investment->reference_no.')',
|
||||
200,
|
||||
'',
|
||||
),
|
||||
'callback_url' => $callbackUrl,
|
||||
'redirect_url' => $redirectUrl,
|
||||
'reference_1' => $investment->reference_no,
|
||||
'reference_2' => $investment->id,
|
||||
'mobile' => $payer->phone_number,
|
||||
]);
|
||||
} catch (Throwable $exception) {
|
||||
$this->postingService->cancelPendingInvestment($investment);
|
||||
$payment->update([
|
||||
'status' => InvestmentPaymentStatus::Failed,
|
||||
'failure_reason' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'billplz' => ['Gagal memulakan pembayaran Billplz. Sila cuba semula.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$billId = (string) ($bill['id'] ?? '');
|
||||
$billUrl = (string) ($bill['url'] ?? '');
|
||||
|
||||
if ($billId === '' || $billUrl === '') {
|
||||
$this->postingService->cancelPendingInvestment($investment);
|
||||
$payment->update([
|
||||
'status' => InvestmentPaymentStatus::Failed,
|
||||
'failure_reason' => 'Respons Billplz tidak lengkap.',
|
||||
]);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'billplz' => ['Respons Billplz tidak lengkap.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$payment->update([
|
||||
'external_reference' => $billId,
|
||||
]);
|
||||
|
||||
BillplzTransaction::query()->create([
|
||||
'payment_id' => $payment->id,
|
||||
'bill_id' => $billId,
|
||||
'collection_id' => $bill['collection_id'] ?? $collectionId,
|
||||
'bill_url' => $billUrl,
|
||||
'reference_1' => $investment->reference_no,
|
||||
'reference_2' => $investment->id,
|
||||
'state' => $bill['state'] ?? 'due',
|
||||
'transaction_status' => BillplzTransactionStatus::Pending,
|
||||
]);
|
||||
|
||||
return [
|
||||
'investment' => $investment->fresh([
|
||||
'account',
|
||||
'investmentType',
|
||||
'payment.billplzTransaction',
|
||||
]),
|
||||
'bill_url' => $billUrl,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public function handleCallback(array $payload): Investment
|
||||
{
|
||||
if (! $this->signatureVerifier->verify($payload)) {
|
||||
Log::warning('Billplz callback signature invalid', ['payload' => $payload]);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'x_signature' => ['Tandatangan Billplz tidak sah.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$billId = (string) ($payload['id'] ?? '');
|
||||
if ($billId === '') {
|
||||
throw ValidationException::withMessages([
|
||||
'id' => ['Bill ID tidak dijumpai.'],
|
||||
]);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($payload, $billId) {
|
||||
$billplz = BillplzTransaction::query()
|
||||
->where('bill_id', $billId)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (! $billplz) {
|
||||
throw ValidationException::withMessages([
|
||||
'id' => ['Transaksi Billplz tidak dijumpai.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$payment = InvestmentPayment::query()
|
||||
->whereKey($billplz->payment_id)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
$investment = Investment::query()
|
||||
->whereKey($payment->investment_id)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
$billplz->update([
|
||||
'callback_payload' => $payload,
|
||||
'x_signature' => $payload['x_signature'] ?? null,
|
||||
'signature_verified' => true,
|
||||
'state' => $payload['state'] ?? $billplz->state,
|
||||
'paid_amount' => isset($payload['paid_amount'])
|
||||
? round(((float) $payload['paid_amount']) / 100, 2)
|
||||
: $billplz->paid_amount,
|
||||
'paid_at' => $this->parsePaidAt($payload['paid_at'] ?? null) ?? $billplz->paid_at,
|
||||
]);
|
||||
|
||||
$paid = filter_var($payload['paid'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
$state = strtolower((string) ($payload['state'] ?? ''));
|
||||
|
||||
if ($paid && $state === 'paid') {
|
||||
$this->assertAmountMatches($payment, $payload);
|
||||
|
||||
$payment->update([
|
||||
'status' => InvestmentPaymentStatus::Paid,
|
||||
'paid_at' => $billplz->fresh()->paid_at ?? now(),
|
||||
'external_reference' => $billId,
|
||||
]);
|
||||
|
||||
$billplz->update([
|
||||
'transaction_status' => BillplzTransactionStatus::Paid,
|
||||
]);
|
||||
|
||||
return $this->postingService->completeInvestment($investment);
|
||||
}
|
||||
|
||||
if ($state === 'deleted') {
|
||||
$payment->update([
|
||||
'status' => InvestmentPaymentStatus::Cancelled,
|
||||
'failure_reason' => 'Bill Billplz telah dipadam.',
|
||||
]);
|
||||
|
||||
$billplz->update([
|
||||
'transaction_status' => BillplzTransactionStatus::Deleted,
|
||||
]);
|
||||
|
||||
if ($investment->status === InvestmentStatus::PendingPayment) {
|
||||
return $this->postingService->cancelPendingInvestment($investment);
|
||||
}
|
||||
}
|
||||
|
||||
return $investment->fresh([
|
||||
'account',
|
||||
'investmentType',
|
||||
'payment.billplzTransaction',
|
||||
'ledgerEntries',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function syncFromGateway(string $investmentId): Investment
|
||||
{
|
||||
$investment = Investment::query()
|
||||
->with(['payment.billplzTransaction'])
|
||||
->find($investmentId);
|
||||
|
||||
if (! $investment || ! $investment->payment?->billplzTransaction) {
|
||||
throw ValidationException::withMessages([
|
||||
'investment' => ['Pelaburan Billplz tidak dijumpai.'],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($investment->status === InvestmentStatus::Completed) {
|
||||
return $investment;
|
||||
}
|
||||
|
||||
$bill = $this->billplzClient->getBill($investment->payment->billplzTransaction->bill_id);
|
||||
|
||||
$payload = [
|
||||
'id' => $bill['id'] ?? $investment->payment->billplzTransaction->bill_id,
|
||||
'collection_id' => $bill['collection_id'] ?? null,
|
||||
'paid' => $bill['paid'] ?? false,
|
||||
'state' => $bill['state'] ?? null,
|
||||
'amount' => $bill['amount'] ?? null,
|
||||
'paid_amount' => $bill['paid_amount'] ?? null,
|
||||
'paid_at' => $bill['paid_at'] ?? null,
|
||||
'name' => $bill['name'] ?? null,
|
||||
'email' => $bill['email'] ?? null,
|
||||
'mobile' => $bill['mobile'] ?? null,
|
||||
'url' => $bill['url'] ?? null,
|
||||
];
|
||||
|
||||
// Gateway sync is trusted via API auth; still persist payload without requiring callback signature.
|
||||
return DB::transaction(function () use ($investment, $payload) {
|
||||
$billplz = BillplzTransaction::query()
|
||||
->whereKey($investment->payment->billplzTransaction->id)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
$payment = InvestmentPayment::query()
|
||||
->whereKey($billplz->payment_id)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
$lockedInvestment = Investment::query()
|
||||
->whereKey($payment->investment_id)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
$billplz->update([
|
||||
'redirect_payload' => $payload,
|
||||
'state' => $payload['state'] ?? $billplz->state,
|
||||
'paid_amount' => isset($payload['paid_amount'])
|
||||
? round(((float) $payload['paid_amount']) / 100, 2)
|
||||
: $billplz->paid_amount,
|
||||
'paid_at' => $this->parsePaidAt($payload['paid_at'] ?? null) ?? $billplz->paid_at,
|
||||
]);
|
||||
|
||||
$paid = filter_var($payload['paid'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
$state = strtolower((string) ($payload['state'] ?? ''));
|
||||
|
||||
if ($paid && $state === 'paid') {
|
||||
$this->assertAmountMatches($payment, $payload);
|
||||
|
||||
$payment->update([
|
||||
'status' => InvestmentPaymentStatus::Paid,
|
||||
'paid_at' => $billplz->fresh()->paid_at ?? now(),
|
||||
]);
|
||||
|
||||
$billplz->update([
|
||||
'transaction_status' => BillplzTransactionStatus::Paid,
|
||||
]);
|
||||
|
||||
return $this->postingService->completeInvestment($lockedInvestment);
|
||||
}
|
||||
|
||||
if ($state === 'deleted' && $lockedInvestment->status === InvestmentStatus::PendingPayment) {
|
||||
$payment->update([
|
||||
'status' => InvestmentPaymentStatus::Cancelled,
|
||||
'failure_reason' => 'Bill Billplz telah dipadam.',
|
||||
]);
|
||||
$billplz->update([
|
||||
'transaction_status' => BillplzTransactionStatus::Deleted,
|
||||
]);
|
||||
|
||||
return $this->postingService->cancelPendingInvestment($lockedInvestment);
|
||||
}
|
||||
|
||||
return $lockedInvestment->fresh([
|
||||
'account',
|
||||
'investmentType',
|
||||
'payment.billplzTransaction',
|
||||
'ledgerEntries',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
protected function assertAmountMatches(InvestmentPayment $payment, array $payload): void
|
||||
{
|
||||
if (! isset($payload['amount'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$expectedSen = (int) round(((float) $payment->amount) * 100);
|
||||
$actualSen = (int) $payload['amount'];
|
||||
|
||||
if ($expectedSen !== $actualSen) {
|
||||
throw ValidationException::withMessages([
|
||||
'amount' => ['Jumlah bil Billplz tidak sepadan dengan pelaburan.'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function collectionIdFor(?InvestmentType $investmentType): string
|
||||
{
|
||||
$collectionId = $investmentType?->billplz_collection_id
|
||||
?: (string) config('investment.billplz.collection_id');
|
||||
|
||||
if ($collectionId === '') {
|
||||
throw new RuntimeException('Billplz collection ID belum dikonfigurasi.');
|
||||
}
|
||||
|
||||
return $collectionId;
|
||||
}
|
||||
|
||||
protected function callbackUrl(): string
|
||||
{
|
||||
$configured = (string) config('investment.billplz.callback_url');
|
||||
|
||||
if ($configured !== '') {
|
||||
return $configured;
|
||||
}
|
||||
|
||||
return rtrim((string) config('app.url'), '/').'/v1/billplz/callback';
|
||||
}
|
||||
|
||||
protected function redirectUrl(string $investmentId): ?string
|
||||
{
|
||||
$configured = (string) config('investment.billplz.redirect_url');
|
||||
|
||||
if ($configured === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$separator = str_contains($configured, '?') ? '&' : '?';
|
||||
|
||||
return $configured.$separator.'investment_id='.$investmentId;
|
||||
}
|
||||
|
||||
protected function parsePaidAt(mixed $value): ?\Illuminate\Support\Carbon
|
||||
{
|
||||
if (! is_string($value) || trim($value) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return \Illuminate\Support\Carbon::parse($value);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Services;
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Modules\Investment\Entities\Investment;
|
||||
use Modules\Investment\Entities\InvestmentAccount;
|
||||
use Modules\Investment\Entities\InvestmentLimitProfile;
|
||||
use Modules\Investment\Entities\ProfileInvestmentLimit;
|
||||
use Modules\Investment\Entities\UserInvestmentLimit;
|
||||
use Modules\Investment\Entities\UserLimitProfile;
|
||||
use Modules\Investment\Enums\InvestmentStatus;
|
||||
|
||||
class InvestmentLimitService
|
||||
{
|
||||
public function assignProfileToUser(
|
||||
string $userId,
|
||||
string $profileId,
|
||||
string $effectiveDate,
|
||||
): UserLimitProfile {
|
||||
if (! InvestmentLimitProfile::query()->whereKey($profileId)->exists()) {
|
||||
throw ValidationException::withMessages([
|
||||
'profile_id' => ['Profil had tidak dijumpai.'],
|
||||
]);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($userId, $profileId, $effectiveDate) {
|
||||
$effective = Carbon::parse($effectiveDate)->startOfDay();
|
||||
|
||||
UserLimitProfile::query()
|
||||
->where('user_id', $userId)
|
||||
->whereNull('expiry_date')
|
||||
->lockForUpdate()
|
||||
->get()
|
||||
->each(function (UserLimitProfile $assignment) use ($effective) {
|
||||
$assignment->update([
|
||||
'expiry_date' => $effective->copy()->subDay()->toDateString(),
|
||||
]);
|
||||
});
|
||||
|
||||
return UserLimitProfile::query()->create([
|
||||
'user_id' => $userId,
|
||||
'profile_id' => $profileId,
|
||||
'effective_date' => $effective->toDateString(),
|
||||
'expiry_date' => null,
|
||||
'created_at' => now(),
|
||||
])->load(['user:id,name,email', 'profile']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce user/profile investment limits for amount A.
|
||||
* Product constraints and fund capacity remain in InvestmentPostingService.
|
||||
*/
|
||||
public function assertCanInvest(string $userId, string $investmentTypeId, float $amount): void
|
||||
{
|
||||
$limits = $this->resolveLimits($userId, $investmentTypeId);
|
||||
|
||||
if ($limits['max_transaction_amount'] !== null && $amount > $limits['max_transaction_amount']) {
|
||||
throw ValidationException::withMessages([
|
||||
'amount' => [
|
||||
'Jumlah transaksi melebihi had maksima ('.$this->formatAmount($limits['max_transaction_amount']).').',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($limits['max_investment_amount'] === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$currentBalance = $this->currentBalance($userId, $investmentTypeId);
|
||||
$pendingAmount = $this->pendingAmount($userId, $investmentTypeId);
|
||||
$projected = round($currentBalance + $pendingAmount + $amount, 2);
|
||||
|
||||
if ($projected > $limits['max_investment_amount']) {
|
||||
throw ValidationException::withMessages([
|
||||
'amount' => [
|
||||
'Jumlah pelaburan melebihi had kumulatif ('.$this->formatAmount($limits['max_investment_amount']).').',
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{max_investment_amount: ?float, max_transaction_amount: ?float, source: ?string}
|
||||
*/
|
||||
public function resolveLimits(string $userId, string $investmentTypeId): array
|
||||
{
|
||||
$override = UserInvestmentLimit::query()
|
||||
->active()
|
||||
->where('user_id', $userId)
|
||||
->where('investment_type_id', $investmentTypeId)
|
||||
->orderByDesc('effective_date')
|
||||
->first();
|
||||
|
||||
if ($override) {
|
||||
return [
|
||||
'max_investment_amount' => $this->nullableAmount($override->max_investment_amount),
|
||||
'max_transaction_amount' => $this->nullableAmount($override->max_transaction_amount),
|
||||
'source' => 'user_override',
|
||||
];
|
||||
}
|
||||
|
||||
$assignment = UserLimitProfile::query()
|
||||
->active()
|
||||
->where('user_id', $userId)
|
||||
->orderByDesc('effective_date')
|
||||
->first();
|
||||
|
||||
if (! $assignment) {
|
||||
return [
|
||||
'max_investment_amount' => null,
|
||||
'max_transaction_amount' => null,
|
||||
'source' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$profileLimit = ProfileInvestmentLimit::query()
|
||||
->where('profile_id', $assignment->profile_id)
|
||||
->where('investment_type_id', $investmentTypeId)
|
||||
->first();
|
||||
|
||||
if (! $profileLimit) {
|
||||
return [
|
||||
'max_investment_amount' => null,
|
||||
'max_transaction_amount' => null,
|
||||
'source' => 'profile',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'max_investment_amount' => $this->nullableAmount($profileLimit->max_investment_amount),
|
||||
'max_transaction_amount' => $this->nullableAmount($profileLimit->max_transaction_amount),
|
||||
'source' => 'profile',
|
||||
];
|
||||
}
|
||||
|
||||
public function pendingAmount(string $userId, string $investmentTypeId): float
|
||||
{
|
||||
$total = Investment::query()
|
||||
->where('user_id', $userId)
|
||||
->where('investment_type_id', $investmentTypeId)
|
||||
->whereIn('status', [
|
||||
InvestmentStatus::PendingPayment->value,
|
||||
InvestmentStatus::PendingVerification->value,
|
||||
])
|
||||
->sum('amount');
|
||||
|
||||
return round((float) $total, 2);
|
||||
}
|
||||
|
||||
public function currentBalance(string $userId, string $investmentTypeId): float
|
||||
{
|
||||
$balance = InvestmentAccount::query()
|
||||
->where('user_id', $userId)
|
||||
->where('investment_type_id', $investmentTypeId)
|
||||
->value('current_balance');
|
||||
|
||||
return round((float) ($balance ?? 0), 2);
|
||||
}
|
||||
|
||||
protected function nullableAmount(mixed $amount): ?float
|
||||
{
|
||||
if ($amount === null || $amount === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return round((float) $amount, 2);
|
||||
}
|
||||
|
||||
protected function formatAmount(float $amount): string
|
||||
{
|
||||
return number_format($amount, 2, '.', ',');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Services;
|
||||
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Modules\Auth\Entities\User;
|
||||
use Modules\Investment\Entities\Investment;
|
||||
use Modules\Investment\Entities\InvestmentAccount;
|
||||
use Modules\Investment\Entities\InvestmentFundSummary;
|
||||
use Modules\Investment\Entities\InvestmentLedger;
|
||||
use Modules\Investment\Entities\InvestmentType;
|
||||
use Modules\Investment\Enums\InvestmentAccountStatus;
|
||||
use Modules\Investment\Enums\InvestmentLedgerTransactionType;
|
||||
use Modules\Investment\Enums\InvestmentSource;
|
||||
use Modules\Investment\Enums\InvestmentStatus;
|
||||
use Modules\Investment\Enums\InvestmentTypeStatus;
|
||||
|
||||
class InvestmentPostingService
|
||||
{
|
||||
public function __construct(
|
||||
protected InvestmentLimitService $limitService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Admin one-shot manual investment: create + complete + ledger in one transaction.
|
||||
*/
|
||||
public function postManualInvestment(
|
||||
string $userId,
|
||||
string $investmentTypeId,
|
||||
string|float|int $amount,
|
||||
?string $remarks,
|
||||
User $actor,
|
||||
): Investment {
|
||||
return $this->postCompletedInvestment(
|
||||
$userId,
|
||||
$investmentTypeId,
|
||||
$amount,
|
||||
$remarks,
|
||||
$actor,
|
||||
InvestmentSource::Manual,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Salary deduction verification: create + complete + ledger in one transaction.
|
||||
*/
|
||||
public function postSalaryInvestment(
|
||||
string $userId,
|
||||
string $investmentTypeId,
|
||||
string|float|int $amount,
|
||||
?string $remarks,
|
||||
User $actor,
|
||||
): Investment {
|
||||
return $this->postCompletedInvestment(
|
||||
$userId,
|
||||
$investmentTypeId,
|
||||
$amount,
|
||||
$remarks,
|
||||
$actor,
|
||||
InvestmentSource::SalaryDeduction,
|
||||
requireSalaryDeduction: true,
|
||||
);
|
||||
}
|
||||
|
||||
protected function postCompletedInvestment(
|
||||
string $userId,
|
||||
string $investmentTypeId,
|
||||
string|float|int $amount,
|
||||
?string $remarks,
|
||||
User $actor,
|
||||
InvestmentSource $source,
|
||||
bool $requireSalaryDeduction = false,
|
||||
): Investment {
|
||||
return DB::transaction(function () use (
|
||||
$userId,
|
||||
$investmentTypeId,
|
||||
$amount,
|
||||
$remarks,
|
||||
$actor,
|
||||
$source,
|
||||
$requireSalaryDeduction,
|
||||
) {
|
||||
$amount = $this->normalizeAmount($amount);
|
||||
$investmentType = $this->lockInvestmentType($investmentTypeId);
|
||||
|
||||
if ($requireSalaryDeduction && ! $investmentType->allow_salary_deduction) {
|
||||
throw ValidationException::withMessages([
|
||||
'investment_type_id' => ['Jenis pelaburan ini tidak membenarkan potongan gaji.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$this->validateProductConstraints($investmentType, $amount);
|
||||
$this->limitService->assertCanInvest($userId, $investmentTypeId, $amount);
|
||||
$this->validateFundCapacity($investmentType, $amount);
|
||||
|
||||
$account = $this->ensureAccount($userId, $investmentTypeId);
|
||||
$this->assertAccountAcceptsInvestment($account);
|
||||
|
||||
$now = now();
|
||||
|
||||
$investment = Investment::query()->create([
|
||||
'reference_no' => $this->generateReferenceNo(),
|
||||
'user_id' => $userId,
|
||||
'investment_account_id' => $account->id,
|
||||
'investment_type_id' => $investmentTypeId,
|
||||
'amount' => $amount,
|
||||
'source' => $source,
|
||||
'status' => InvestmentStatus::Completed,
|
||||
'remarks' => $remarks,
|
||||
'created_by' => $actor->id,
|
||||
'approved_by' => $actor->id,
|
||||
'approved_at' => $now,
|
||||
'completed_at' => $now,
|
||||
]);
|
||||
|
||||
$this->postInvestmentCredit($investment, $actor);
|
||||
|
||||
return $investment->fresh([
|
||||
'user',
|
||||
'account',
|
||||
'investmentType',
|
||||
'createdBy',
|
||||
'approvedBy',
|
||||
'ledgerEntries',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a PENDING_PAYMENT investment and reserve fund/user pending capacity.
|
||||
*/
|
||||
public function createPendingBillplzInvestment(
|
||||
string $userId,
|
||||
string $investmentTypeId,
|
||||
string|float|int $amount,
|
||||
?string $remarks,
|
||||
?User $actor = null,
|
||||
): Investment {
|
||||
return DB::transaction(function () use ($userId, $investmentTypeId, $amount, $remarks, $actor) {
|
||||
$amount = $this->normalizeAmount($amount);
|
||||
$investmentType = $this->lockInvestmentType($investmentTypeId);
|
||||
|
||||
if (! $investmentType->allow_billplz) {
|
||||
throw ValidationException::withMessages([
|
||||
'investment_type_id' => ['Jenis pelaburan ini tidak membenarkan pembayaran Billplz.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$this->validateProductConstraints($investmentType, $amount);
|
||||
$this->limitService->assertCanInvest($userId, $investmentTypeId, $amount);
|
||||
$this->validateFundCapacity($investmentType, $amount);
|
||||
|
||||
$account = $this->ensureAccount($userId, $investmentTypeId);
|
||||
$this->assertAccountAcceptsInvestment($account);
|
||||
|
||||
$investment = Investment::query()->create([
|
||||
'reference_no' => $this->generateReferenceNo(),
|
||||
'user_id' => $userId,
|
||||
'investment_account_id' => $account->id,
|
||||
'investment_type_id' => $investmentTypeId,
|
||||
'amount' => $amount,
|
||||
'source' => InvestmentSource::Billplz,
|
||||
'status' => InvestmentStatus::PendingPayment,
|
||||
'remarks' => $remarks,
|
||||
'created_by' => $actor?->id,
|
||||
]);
|
||||
|
||||
$this->reserveAmount($investment);
|
||||
|
||||
return $investment->fresh(['account', 'investmentType']);
|
||||
});
|
||||
}
|
||||
|
||||
public function cancelPendingInvestment(Investment $investment): Investment
|
||||
{
|
||||
return DB::transaction(function () use ($investment) {
|
||||
$locked = Investment::query()
|
||||
->whereKey($investment->id)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
if ($locked->status === InvestmentStatus::Cancelled) {
|
||||
return $locked->fresh(['account', 'investmentType', 'payment.billplzTransaction']);
|
||||
}
|
||||
|
||||
if (! in_array($locked->status, [
|
||||
InvestmentStatus::PendingPayment,
|
||||
InvestmentStatus::PendingVerification,
|
||||
InvestmentStatus::Draft,
|
||||
], true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'status' => ['Pelaburan ini tidak boleh dibatalkan pada status semasa.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$this->releaseReservation($locked);
|
||||
|
||||
$locked->update([
|
||||
'status' => InvestmentStatus::Cancelled,
|
||||
'cancelled_at' => now(),
|
||||
]);
|
||||
|
||||
return $locked->fresh(['account', 'investmentType', 'payment.billplzTransaction']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared completion path for MANUAL / BILLPLZ / SALARY once payment/verification succeeds.
|
||||
* Idempotent: already COMPLETED investments are returned as-is.
|
||||
*/
|
||||
public function completeInvestment(Investment $investment, ?User $actor = null): Investment
|
||||
{
|
||||
return DB::transaction(function () use ($investment, $actor) {
|
||||
$locked = Investment::query()
|
||||
->whereKey($investment->id)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
if ($locked->status === InvestmentStatus::Completed) {
|
||||
return $locked->fresh([
|
||||
'user',
|
||||
'account',
|
||||
'investmentType',
|
||||
'createdBy',
|
||||
'approvedBy',
|
||||
'ledgerEntries',
|
||||
]);
|
||||
}
|
||||
|
||||
if (! in_array($locked->status, [
|
||||
InvestmentStatus::PendingPayment,
|
||||
InvestmentStatus::PendingVerification,
|
||||
InvestmentStatus::Draft,
|
||||
], true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'status' => ['Pelaburan ini tidak boleh diselesaikan pada status semasa.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$now = now();
|
||||
$locked->update([
|
||||
'status' => InvestmentStatus::Completed,
|
||||
'approved_by' => $actor?->id ?? $locked->approved_by,
|
||||
'approved_at' => $locked->approved_at ?? $now,
|
||||
'completed_at' => $now,
|
||||
]);
|
||||
|
||||
$this->releaseReservation($locked);
|
||||
$this->postInvestmentCredit($locked->fresh(), $actor);
|
||||
|
||||
return $locked->fresh([
|
||||
'user',
|
||||
'account',
|
||||
'investmentType',
|
||||
'createdBy',
|
||||
'approvedBy',
|
||||
'ledgerEntries',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function ensureAccount(string $userId, string $investmentTypeId): InvestmentAccount
|
||||
{
|
||||
$account = InvestmentAccount::query()
|
||||
->where('user_id', $userId)
|
||||
->where('investment_type_id', $investmentTypeId)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($account) {
|
||||
return $account;
|
||||
}
|
||||
|
||||
try {
|
||||
return InvestmentAccount::query()->create([
|
||||
'user_id' => $userId,
|
||||
'investment_type_id' => $investmentTypeId,
|
||||
'current_balance' => 0,
|
||||
'pending_balance' => 0,
|
||||
'status' => InvestmentAccountStatus::Active,
|
||||
]);
|
||||
} catch (UniqueConstraintViolationException) {
|
||||
return InvestmentAccount::query()
|
||||
->where('user_id', $userId)
|
||||
->where('investment_type_id', $investmentTypeId)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
}
|
||||
}
|
||||
|
||||
protected function postInvestmentCredit(Investment $investment, ?User $actor = null): InvestmentLedger
|
||||
{
|
||||
$existing = InvestmentLedger::query()
|
||||
->where('investment_id', $investment->id)
|
||||
->where('transaction_type', InvestmentLedgerTransactionType::Investment)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$account = InvestmentAccount::query()
|
||||
->whereKey($investment->investment_account_id)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
$amount = $this->normalizeAmount($investment->amount);
|
||||
$balanceAfter = $this->normalizeAmount($account->current_balance) + $amount;
|
||||
|
||||
$ledger = InvestmentLedger::query()->create([
|
||||
'investment_id' => $investment->id,
|
||||
'investment_account_id' => $account->id,
|
||||
'user_id' => $investment->user_id,
|
||||
'investment_type_id' => $investment->investment_type_id,
|
||||
'transaction_type' => InvestmentLedgerTransactionType::Investment,
|
||||
'credit' => $amount,
|
||||
'debit' => 0,
|
||||
'balance_after' => $balanceAfter,
|
||||
'created_by' => $actor?->id,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$account->update([
|
||||
'current_balance' => $balanceAfter,
|
||||
]);
|
||||
|
||||
$this->increaseInvested($investment->investment_type_id, $amount);
|
||||
|
||||
return $ledger;
|
||||
}
|
||||
|
||||
protected function validateProductConstraints(InvestmentType $investmentType, float $amount): void
|
||||
{
|
||||
if ($investmentType->status !== InvestmentTypeStatus::Active) {
|
||||
throw ValidationException::withMessages([
|
||||
'investment_type_id' => ['Jenis pelaburan tidak aktif.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$today = now()->startOfDay();
|
||||
|
||||
if ($investmentType->start_date && $today->lt($investmentType->start_date->startOfDay())) {
|
||||
throw ValidationException::withMessages([
|
||||
'investment_type_id' => ['Jenis pelaburan belum dibuka.'],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($investmentType->end_date && $today->gt($investmentType->end_date->endOfDay())) {
|
||||
throw ValidationException::withMessages([
|
||||
'investment_type_id' => ['Jenis pelaburan telah tamat.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$minimum = $this->normalizeAmount($investmentType->minimum_amount);
|
||||
if ($amount < $minimum) {
|
||||
throw ValidationException::withMessages([
|
||||
'amount' => ["Jumlah minimum ialah {$minimum}."],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($investmentType->maximum_amount !== null) {
|
||||
$maximum = $this->normalizeAmount($investmentType->maximum_amount);
|
||||
if ($amount > $maximum) {
|
||||
throw ValidationException::withMessages([
|
||||
'amount' => ["Jumlah maksimum ialah {$maximum}."],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function validateFundCapacity(InvestmentType $investmentType, float $amount): void
|
||||
{
|
||||
if ($investmentType->total_fund_limit === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$summary = $this->lockOrCreateFundSummary($investmentType);
|
||||
$limit = $this->normalizeAmount($investmentType->total_fund_limit);
|
||||
$used = $this->normalizeAmount($summary->total_invested)
|
||||
+ $this->normalizeAmount($summary->reserved_amount);
|
||||
|
||||
if (($used + $amount) > $limit) {
|
||||
throw ValidationException::withMessages([
|
||||
'amount' => ['Had dana jenis pelaburan tidak mencukupi.'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function assertAccountAcceptsInvestment(InvestmentAccount $account): void
|
||||
{
|
||||
if ($account->status !== InvestmentAccountStatus::Active) {
|
||||
throw ValidationException::withMessages([
|
||||
'user_id' => ['Akaun pelaburan ahli tidak aktif.'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function lockInvestmentType(string $investmentTypeId): InvestmentType
|
||||
{
|
||||
$investmentType = InvestmentType::query()
|
||||
->whereKey($investmentTypeId)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (! $investmentType) {
|
||||
throw ValidationException::withMessages([
|
||||
'investment_type_id' => ['Jenis pelaburan tidak dijumpai.'],
|
||||
]);
|
||||
}
|
||||
|
||||
return $investmentType;
|
||||
}
|
||||
|
||||
protected function lockOrCreateFundSummary(InvestmentType $investmentType): InvestmentFundSummary
|
||||
{
|
||||
$summary = InvestmentFundSummary::query()
|
||||
->where('investment_type_id', $investmentType->id)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($summary) {
|
||||
return $summary;
|
||||
}
|
||||
|
||||
return InvestmentFundSummary::query()->create([
|
||||
'investment_type_id' => $investmentType->id,
|
||||
'total_invested' => 0,
|
||||
'reserved_amount' => 0,
|
||||
'remaining_capacity' => $investmentType->total_fund_limit,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function increaseInvested(string $investmentTypeId, float $amount): void
|
||||
{
|
||||
$investmentType = $this->lockInvestmentType($investmentTypeId);
|
||||
$summary = $this->lockOrCreateFundSummary($investmentType);
|
||||
|
||||
$totalInvested = $this->normalizeAmount($summary->total_invested) + $amount;
|
||||
$reserved = $this->normalizeAmount($summary->reserved_amount);
|
||||
$remaining = $investmentType->total_fund_limit === null
|
||||
? null
|
||||
: $this->normalizeAmount($investmentType->total_fund_limit) - $totalInvested - $reserved;
|
||||
|
||||
$summary->update([
|
||||
'total_invested' => $totalInvested,
|
||||
'remaining_capacity' => $remaining,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function reserveAmount(Investment $investment): void
|
||||
{
|
||||
$amount = $this->normalizeAmount($investment->amount);
|
||||
$investmentType = $this->lockInvestmentType($investment->investment_type_id);
|
||||
$summary = $this->lockOrCreateFundSummary($investmentType);
|
||||
|
||||
$typeReserved = $this->normalizeAmount($investmentType->reserved_amount) + $amount;
|
||||
$summaryReserved = $this->normalizeAmount($summary->reserved_amount) + $amount;
|
||||
|
||||
$investmentType->update([
|
||||
'reserved_amount' => $typeReserved,
|
||||
]);
|
||||
|
||||
$account = InvestmentAccount::query()
|
||||
->whereKey($investment->investment_account_id)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
$account->update([
|
||||
'pending_balance' => $this->normalizeAmount($account->pending_balance) + $amount,
|
||||
]);
|
||||
|
||||
$remaining = $investmentType->total_fund_limit === null
|
||||
? null
|
||||
: $this->normalizeAmount($investmentType->total_fund_limit)
|
||||
- $this->normalizeAmount($summary->total_invested)
|
||||
- $summaryReserved;
|
||||
|
||||
$summary->update([
|
||||
'reserved_amount' => $summaryReserved,
|
||||
'remaining_capacity' => $remaining,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserved for pending flows (Billplz / salary). Manual one-shot skips reservation.
|
||||
*/
|
||||
protected function releaseReservation(Investment $investment): void
|
||||
{
|
||||
$amount = $this->normalizeAmount($investment->amount);
|
||||
$investmentType = $this->lockInvestmentType($investment->investment_type_id);
|
||||
$summary = $this->lockOrCreateFundSummary($investmentType);
|
||||
|
||||
$typeReserved = max(0, $this->normalizeAmount($investmentType->reserved_amount) - $amount);
|
||||
$summaryReserved = max(0, $this->normalizeAmount($summary->reserved_amount) - $amount);
|
||||
|
||||
$investmentType->update([
|
||||
'reserved_amount' => $typeReserved,
|
||||
]);
|
||||
|
||||
$account = InvestmentAccount::query()
|
||||
->whereKey($investment->investment_account_id)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($account) {
|
||||
$account->update([
|
||||
'pending_balance' => max(0, $this->normalizeAmount($account->pending_balance) - $amount),
|
||||
]);
|
||||
}
|
||||
|
||||
$remaining = $investmentType->total_fund_limit === null
|
||||
? null
|
||||
: $this->normalizeAmount($investmentType->total_fund_limit)
|
||||
- $this->normalizeAmount($summary->total_invested)
|
||||
- $summaryReserved;
|
||||
|
||||
$summary->update([
|
||||
'reserved_amount' => $summaryReserved,
|
||||
'remaining_capacity' => $remaining,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function generateReferenceNo(): string
|
||||
{
|
||||
$year = now()->year;
|
||||
$prefix = "INV-{$year}-";
|
||||
|
||||
$latestNumber = Investment::query()
|
||||
->where('reference_no', 'like', "{$prefix}%")
|
||||
->orderByDesc('reference_no')
|
||||
->lockForUpdate()
|
||||
->value('reference_no');
|
||||
|
||||
$sequence = 1;
|
||||
if ($latestNumber && preg_match('/-(\d+)$/', $latestNumber, $matches)) {
|
||||
$sequence = ((int) $matches[1]) + 1;
|
||||
}
|
||||
|
||||
return sprintf('%s%05d', $prefix, $sequence);
|
||||
}
|
||||
|
||||
protected function normalizeAmount(string|float|int|null $amount): float
|
||||
{
|
||||
return round((float) $amount, 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Services;
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Modules\Auth\Entities\User;
|
||||
use Modules\Investment\Entities\SalaryContribution;
|
||||
use Modules\Investment\Entities\SalaryDeductionBatch;
|
||||
use Modules\Investment\Entities\SalaryDeductionBatchDetail;
|
||||
use Modules\Investment\Entities\SalaryVerificationLog;
|
||||
use Modules\Investment\Enums\SalaryContributionStatus;
|
||||
use Modules\Investment\Enums\SalaryDeductionBatchStatus;
|
||||
use Modules\Investment\Enums\SalaryDeductionDetailStatus;
|
||||
|
||||
class SalaryDeductionService
|
||||
{
|
||||
public function __construct(
|
||||
protected InvestmentPostingService $postingService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param list<array{
|
||||
* user_id: string,
|
||||
* investment_type_id: string,
|
||||
* amount: float|int|string,
|
||||
* salary_contribution_id?: string|null,
|
||||
* remarks?: string|null
|
||||
* }> $details
|
||||
*/
|
||||
public function uploadBatch(int $month, int $year, array $details, User $uploader): SalaryDeductionBatch
|
||||
{
|
||||
$this->assertValidMonthYear($month, $year);
|
||||
|
||||
if ($details === []) {
|
||||
throw ValidationException::withMessages([
|
||||
'details' => ['Sekurang-kurangnya satu rekod potongan diperlukan.'],
|
||||
]);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($month, $year, $details, $uploader) {
|
||||
if (SalaryDeductionBatch::query()->where('month', $month)->where('year', $year)->exists()) {
|
||||
throw ValidationException::withMessages([
|
||||
'month' => ['Batch potongan gaji untuk bulan/tahun ini sudah wujud.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$batch = SalaryDeductionBatch::query()->create([
|
||||
'batch_no' => $this->generateBatchNo($month, $year),
|
||||
'month' => $month,
|
||||
'year' => $year,
|
||||
'total_records' => 0,
|
||||
'total_amount' => 0,
|
||||
'status' => SalaryDeductionBatchStatus::Uploaded,
|
||||
'uploaded_by' => $uploader->id,
|
||||
'uploaded_at' => now(),
|
||||
]);
|
||||
|
||||
$totalAmount = 0;
|
||||
foreach ($details as $detail) {
|
||||
$amount = round((float) $detail['amount'], 2);
|
||||
$totalAmount += $amount;
|
||||
|
||||
$batch->details()->create([
|
||||
'user_id' => $detail['user_id'],
|
||||
'salary_contribution_id' => $detail['salary_contribution_id'] ?? null,
|
||||
'investment_type_id' => $detail['investment_type_id'],
|
||||
'amount' => $amount,
|
||||
'status' => SalaryDeductionDetailStatus::Pending,
|
||||
'remarks' => $detail['remarks'] ?? null,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$batch->update([
|
||||
'total_records' => count($details),
|
||||
'total_amount' => $totalAmount,
|
||||
]);
|
||||
|
||||
return $batch->fresh(['details.user', 'details.investmentType', 'uploadedBy']);
|
||||
});
|
||||
}
|
||||
|
||||
public function generateBatchFromContributions(int $month, int $year, User $uploader): SalaryDeductionBatch
|
||||
{
|
||||
$this->assertValidMonthYear($month, $year);
|
||||
|
||||
$periodStart = Carbon::create($year, $month, 1)->startOfDay();
|
||||
$periodEnd = $periodStart->copy()->endOfMonth();
|
||||
|
||||
$contributions = SalaryContribution::query()
|
||||
->where('status', SalaryContributionStatus::Active)
|
||||
->whereDate('start_date', '<=', $periodEnd->toDateString())
|
||||
->where(function ($query) use ($periodStart) {
|
||||
$query->whereNull('end_date')
|
||||
->orWhereDate('end_date', '>=', $periodStart->toDateString());
|
||||
})
|
||||
->get();
|
||||
|
||||
if ($contributions->isEmpty()) {
|
||||
throw ValidationException::withMessages([
|
||||
'contributions' => ['Tiada caruman potongan gaji aktif untuk tempoh ini.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$details = $contributions->map(fn (SalaryContribution $contribution) => [
|
||||
'user_id' => $contribution->user_id,
|
||||
'investment_type_id' => $contribution->investment_type_id,
|
||||
'salary_contribution_id' => $contribution->id,
|
||||
'amount' => $contribution->monthly_amount,
|
||||
])->all();
|
||||
|
||||
return $this->uploadBatch($month, $year, $details, $uploader);
|
||||
}
|
||||
|
||||
public function markUnderReview(SalaryDeductionBatch $batch): SalaryDeductionBatch
|
||||
{
|
||||
if ($batch->status !== SalaryDeductionBatchStatus::Uploaded) {
|
||||
throw ValidationException::withMessages([
|
||||
'status' => ['Hanya batch yang baru dimuat naik boleh dihantar untuk semakan.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$batch->update([
|
||||
'status' => SalaryDeductionBatchStatus::UnderReview,
|
||||
]);
|
||||
|
||||
return $batch->fresh(['details', 'uploadedBy']);
|
||||
}
|
||||
|
||||
public function verifyDetail(SalaryDeductionBatchDetail $detail, User $actor, ?string $remarks = null): SalaryDeductionBatchDetail
|
||||
{
|
||||
return DB::transaction(function () use ($detail, $actor, $remarks) {
|
||||
$locked = SalaryDeductionBatchDetail::query()
|
||||
->whereKey($detail->id)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
$batch = SalaryDeductionBatch::query()
|
||||
->whereKey($locked->batch_id)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
if (! in_array($batch->status, [
|
||||
SalaryDeductionBatchStatus::Uploaded,
|
||||
SalaryDeductionBatchStatus::UnderReview,
|
||||
], true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'status' => ['Batch ini tidak boleh disahkan pada status semasa.'],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($locked->status !== SalaryDeductionDetailStatus::Pending) {
|
||||
throw ValidationException::withMessages([
|
||||
'status' => ['Rekod ini sudah diproses.'],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($batch->status === SalaryDeductionBatchStatus::Uploaded) {
|
||||
$batch->update(['status' => SalaryDeductionBatchStatus::UnderReview]);
|
||||
}
|
||||
|
||||
$oldStatus = $locked->status;
|
||||
$investment = $this->postingService->postSalaryInvestment(
|
||||
$locked->user_id,
|
||||
$locked->investment_type_id,
|
||||
$locked->amount,
|
||||
$remarks ?? $locked->remarks,
|
||||
$actor,
|
||||
);
|
||||
|
||||
$locked->update([
|
||||
'status' => SalaryDeductionDetailStatus::Verified,
|
||||
'investment_id' => $investment->id,
|
||||
'remarks' => $remarks ?? $locked->remarks,
|
||||
'verified_by' => $actor->id,
|
||||
'verified_at' => now(),
|
||||
]);
|
||||
|
||||
$this->writeLog($locked, $oldStatus, SalaryDeductionDetailStatus::Verified, $actor, $remarks);
|
||||
$this->refreshBatchStatus($batch, $actor);
|
||||
|
||||
return $locked->fresh([
|
||||
'user',
|
||||
'investmentType',
|
||||
'investment',
|
||||
'salaryContribution',
|
||||
'verifiedBy',
|
||||
'verificationLogs',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function rejectDetail(SalaryDeductionBatchDetail $detail, User $actor, ?string $remarks = null): SalaryDeductionBatchDetail
|
||||
{
|
||||
return DB::transaction(function () use ($detail, $actor, $remarks) {
|
||||
$locked = SalaryDeductionBatchDetail::query()
|
||||
->whereKey($detail->id)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
$batch = SalaryDeductionBatch::query()
|
||||
->whereKey($locked->batch_id)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
if (! in_array($batch->status, [
|
||||
SalaryDeductionBatchStatus::Uploaded,
|
||||
SalaryDeductionBatchStatus::UnderReview,
|
||||
], true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'status' => ['Batch ini tidak boleh ditolak pada status semasa.'],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($locked->status !== SalaryDeductionDetailStatus::Pending) {
|
||||
throw ValidationException::withMessages([
|
||||
'status' => ['Rekod ini sudah diproses.'],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($batch->status === SalaryDeductionBatchStatus::Uploaded) {
|
||||
$batch->update(['status' => SalaryDeductionBatchStatus::UnderReview]);
|
||||
}
|
||||
|
||||
$oldStatus = $locked->status;
|
||||
$locked->update([
|
||||
'status' => SalaryDeductionDetailStatus::Rejected,
|
||||
'remarks' => $remarks ?? $locked->remarks,
|
||||
'verified_by' => $actor->id,
|
||||
'verified_at' => now(),
|
||||
]);
|
||||
|
||||
$this->writeLog($locked, $oldStatus, SalaryDeductionDetailStatus::Rejected, $actor, $remarks);
|
||||
$this->refreshBatchStatus($batch, $actor);
|
||||
|
||||
return $locked->fresh([
|
||||
'user',
|
||||
'investmentType',
|
||||
'salaryContribution',
|
||||
'verifiedBy',
|
||||
'verificationLogs',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function verifyAllPending(SalaryDeductionBatch $batch, User $actor): SalaryDeductionBatch
|
||||
{
|
||||
$pending = $batch->details()
|
||||
->where('status', SalaryDeductionDetailStatus::Pending)
|
||||
->get();
|
||||
|
||||
foreach ($pending as $detail) {
|
||||
$this->verifyDetail($detail, $actor);
|
||||
}
|
||||
|
||||
return $batch->fresh(['details.investment', 'details.user', 'verifiedBy', 'uploadedBy']);
|
||||
}
|
||||
|
||||
public function rejectBatch(SalaryDeductionBatch $batch, User $actor, ?string $remarks = null): SalaryDeductionBatch
|
||||
{
|
||||
return DB::transaction(function () use ($batch, $actor, $remarks) {
|
||||
$locked = SalaryDeductionBatch::query()
|
||||
->whereKey($batch->id)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
if (! in_array($locked->status, [
|
||||
SalaryDeductionBatchStatus::Uploaded,
|
||||
SalaryDeductionBatchStatus::UnderReview,
|
||||
], true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'status' => ['Batch ini tidak boleh ditolak pada status semasa.'],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($locked->details()->where('status', SalaryDeductionDetailStatus::Verified)->exists()) {
|
||||
throw ValidationException::withMessages([
|
||||
'status' => ['Batch yang sudah mempunyai rekod disahkan tidak boleh ditolak sepenuhnya.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$pending = $locked->details()
|
||||
->where('status', SalaryDeductionDetailStatus::Pending)
|
||||
->lockForUpdate()
|
||||
->get();
|
||||
|
||||
foreach ($pending as $detail) {
|
||||
$oldStatus = $detail->status;
|
||||
$detail->update([
|
||||
'status' => SalaryDeductionDetailStatus::Rejected,
|
||||
'remarks' => $remarks ?? $detail->remarks,
|
||||
'verified_by' => $actor->id,
|
||||
'verified_at' => now(),
|
||||
]);
|
||||
$this->writeLog($detail, $oldStatus, SalaryDeductionDetailStatus::Rejected, $actor, $remarks);
|
||||
}
|
||||
|
||||
$locked->update([
|
||||
'status' => SalaryDeductionBatchStatus::Rejected,
|
||||
'verified_by' => $actor->id,
|
||||
'verified_at' => now(),
|
||||
]);
|
||||
|
||||
return $locked->fresh(['details', 'verifiedBy', 'uploadedBy']);
|
||||
});
|
||||
}
|
||||
|
||||
protected function refreshBatchStatus(SalaryDeductionBatch $batch, User $actor): void
|
||||
{
|
||||
$hasPending = $batch->details()
|
||||
->where('status', SalaryDeductionDetailStatus::Pending)
|
||||
->exists();
|
||||
|
||||
if ($hasPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
$hasVerified = $batch->details()
|
||||
->where('status', SalaryDeductionDetailStatus::Verified)
|
||||
->exists();
|
||||
|
||||
$batch->update([
|
||||
'status' => $hasVerified
|
||||
? SalaryDeductionBatchStatus::Verified
|
||||
: SalaryDeductionBatchStatus::Rejected,
|
||||
'verified_by' => $actor->id,
|
||||
'verified_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function writeLog(
|
||||
SalaryDeductionBatchDetail $detail,
|
||||
SalaryDeductionDetailStatus|string|null $oldStatus,
|
||||
SalaryDeductionDetailStatus $newStatus,
|
||||
User $actor,
|
||||
?string $remarks,
|
||||
): void {
|
||||
SalaryVerificationLog::query()->create([
|
||||
'batch_detail_id' => $detail->id,
|
||||
'old_status' => $oldStatus instanceof SalaryDeductionDetailStatus
|
||||
? $oldStatus->value
|
||||
: $oldStatus,
|
||||
'new_status' => $newStatus->value,
|
||||
'action_by' => $actor->id,
|
||||
'remarks' => $remarks,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function generateBatchNo(int $month, int $year): string
|
||||
{
|
||||
$prefix = sprintf('SDB-%04d-%02d-', $year, $month);
|
||||
|
||||
$latest = SalaryDeductionBatch::query()
|
||||
->where('batch_no', 'like', $prefix.'%')
|
||||
->orderByDesc('batch_no')
|
||||
->lockForUpdate()
|
||||
->value('batch_no');
|
||||
|
||||
$sequence = 1;
|
||||
if ($latest && preg_match('/-(\d+)$/', $latest, $matches)) {
|
||||
$sequence = ((int) $matches[1]) + 1;
|
||||
}
|
||||
|
||||
return sprintf('%s%03d', $prefix, $sequence);
|
||||
}
|
||||
|
||||
protected function assertValidMonthYear(int $month, int $year): void
|
||||
{
|
||||
if ($month < 1 || $month > 12) {
|
||||
throw ValidationException::withMessages([
|
||||
'month' => ['Bulan tidak sah.'],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($year < 2000 || $year > 2100) {
|
||||
throw ValidationException::withMessages([
|
||||
'year' => ['Tahun tidak sah.'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Transformers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class InvestmentLimitProfileResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'description' => $this->description,
|
||||
'profile_limits_count' => $this->when(isset($this->profile_limits_count), $this->profile_limits_count),
|
||||
'profile_limits' => ProfileInvestmentLimitResource::collection($this->whenLoaded('profileLimits')),
|
||||
'created_at' => $this->created_at?->toISOString(),
|
||||
'updated_at' => $this->updated_at?->toISOString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Transformers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class InvestmentResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'reference_no' => $this->reference_no,
|
||||
'user_id' => $this->user_id,
|
||||
'investment_account_id' => $this->investment_account_id,
|
||||
'investment_type_id' => $this->investment_type_id,
|
||||
'amount' => $this->amount,
|
||||
'source' => $this->source?->value ?? $this->source,
|
||||
'status' => $this->status?->value ?? $this->status,
|
||||
'remarks' => $this->remarks,
|
||||
'created_by' => $this->created_by,
|
||||
'approved_by' => $this->approved_by,
|
||||
'approved_at' => $this->approved_at?->toISOString(),
|
||||
'completed_at' => $this->completed_at?->toISOString(),
|
||||
'cancelled_at' => $this->cancelled_at?->toISOString(),
|
||||
'created_at' => $this->created_at?->toISOString(),
|
||||
'updated_at' => $this->updated_at?->toISOString(),
|
||||
'user' => $this->whenLoaded('user', fn () => $this->user ? [
|
||||
'id' => $this->user->id,
|
||||
'name' => $this->user->name,
|
||||
'email' => $this->user->email,
|
||||
'ic_number' => $this->user->ic_number,
|
||||
'member_number' => $this->user->member_number,
|
||||
] : null),
|
||||
'account' => $this->whenLoaded('account', fn () => [
|
||||
'id' => $this->account->id,
|
||||
'current_balance' => $this->account->current_balance,
|
||||
'pending_balance' => $this->account->pending_balance,
|
||||
'status' => $this->account->status?->value ?? $this->account->status,
|
||||
]),
|
||||
'investment_type' => $this->whenLoaded('investmentType', fn () => [
|
||||
'id' => $this->investmentType->id,
|
||||
'code' => $this->investmentType->code,
|
||||
'name' => $this->investmentType->name,
|
||||
]),
|
||||
'created_by_user' => $this->whenLoaded('createdBy', fn () => $this->createdBy ? [
|
||||
'id' => $this->createdBy->id,
|
||||
'name' => $this->createdBy->name,
|
||||
'email' => $this->createdBy->email,
|
||||
] : null),
|
||||
'approved_by_user' => $this->whenLoaded('approvedBy', fn () => $this->approvedBy ? [
|
||||
'id' => $this->approvedBy->id,
|
||||
'name' => $this->approvedBy->name,
|
||||
'email' => $this->approvedBy->email,
|
||||
] : null),
|
||||
'ledger_entries' => $this->whenLoaded('ledgerEntries', function () {
|
||||
return $this->ledgerEntries->map(fn ($entry) => [
|
||||
'id' => $entry->id,
|
||||
'transaction_type' => $entry->transaction_type?->value ?? $entry->transaction_type,
|
||||
'credit' => $entry->credit,
|
||||
'debit' => $entry->debit,
|
||||
'balance_after' => $entry->balance_after,
|
||||
'created_at' => $entry->created_at?->toISOString(),
|
||||
]);
|
||||
}),
|
||||
'payment' => $this->whenLoaded('payment', function () {
|
||||
if (! $this->payment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$billplz = null;
|
||||
if ($this->payment->relationLoaded('billplzTransaction') && $this->payment->billplzTransaction) {
|
||||
$billplz = [
|
||||
'bill_id' => $this->payment->billplzTransaction->bill_id,
|
||||
'bill_url' => $this->payment->billplzTransaction->bill_url,
|
||||
'state' => $this->payment->billplzTransaction->state,
|
||||
'transaction_status' => $this->payment->billplzTransaction->transaction_status?->value
|
||||
?? $this->payment->billplzTransaction->transaction_status,
|
||||
'signature_verified' => (bool) $this->payment->billplzTransaction->signature_verified,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $this->payment->id,
|
||||
'gateway' => $this->payment->gateway?->value ?? $this->payment->gateway,
|
||||
'amount' => $this->payment->amount,
|
||||
'currency' => $this->payment->currency,
|
||||
'status' => $this->payment->status?->value ?? $this->payment->status,
|
||||
'external_reference' => $this->payment->external_reference,
|
||||
'paid_at' => $this->payment->paid_at?->toISOString(),
|
||||
'billplz' => $billplz,
|
||||
];
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Transformers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class ProfileInvestmentLimitResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'profile_id' => $this->profile_id,
|
||||
'investment_type_id' => $this->investment_type_id,
|
||||
'max_investment_amount' => $this->max_investment_amount,
|
||||
'max_transaction_amount' => $this->max_transaction_amount,
|
||||
'investment_type' => $this->whenLoaded('investmentType', fn () => [
|
||||
'id' => $this->investmentType->id,
|
||||
'code' => $this->investmentType->code,
|
||||
'name' => $this->investmentType->name,
|
||||
]),
|
||||
'created_at' => $this->created_at?->toISOString(),
|
||||
'updated_at' => $this->updated_at?->toISOString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Transformers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class SalaryContributionResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'user_id' => $this->user_id,
|
||||
'investment_type_id' => $this->investment_type_id,
|
||||
'monthly_amount' => $this->monthly_amount,
|
||||
'start_date' => $this->start_date?->toDateString(),
|
||||
'end_date' => $this->end_date?->toDateString(),
|
||||
'status' => $this->status?->value ?? $this->status,
|
||||
'created_by' => $this->created_by,
|
||||
'created_at' => $this->created_at?->toISOString(),
|
||||
'updated_at' => $this->updated_at?->toISOString(),
|
||||
'user' => $this->whenLoaded('user', fn () => [
|
||||
'id' => $this->user->id,
|
||||
'name' => $this->user->name,
|
||||
'email' => $this->user->email,
|
||||
]),
|
||||
'investment_type' => $this->whenLoaded('investmentType', fn () => [
|
||||
'id' => $this->investmentType->id,
|
||||
'code' => $this->investmentType->code,
|
||||
'name' => $this->investmentType->name,
|
||||
]),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Transformers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class SalaryDeductionBatchDetailResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'batch_id' => $this->batch_id,
|
||||
'user_id' => $this->user_id,
|
||||
'salary_contribution_id' => $this->salary_contribution_id,
|
||||
'investment_type_id' => $this->investment_type_id,
|
||||
'investment_id' => $this->investment_id,
|
||||
'amount' => $this->amount,
|
||||
'status' => $this->status?->value ?? $this->status,
|
||||
'remarks' => $this->remarks,
|
||||
'verified_by' => $this->verified_by,
|
||||
'verified_at' => $this->verified_at?->toISOString(),
|
||||
'created_at' => $this->created_at?->toISOString(),
|
||||
'user' => $this->whenLoaded('user', fn () => [
|
||||
'id' => $this->user->id,
|
||||
'name' => $this->user->name,
|
||||
'email' => $this->user->email,
|
||||
]),
|
||||
'investment_type' => $this->whenLoaded('investmentType', fn () => [
|
||||
'id' => $this->investmentType->id,
|
||||
'code' => $this->investmentType->code,
|
||||
'name' => $this->investmentType->name,
|
||||
]),
|
||||
'investment' => $this->whenLoaded('investment', fn () => $this->investment ? [
|
||||
'id' => $this->investment->id,
|
||||
'reference_no' => $this->investment->reference_no,
|
||||
'status' => $this->investment->status?->value ?? $this->investment->status,
|
||||
'amount' => $this->investment->amount,
|
||||
] : null),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Transformers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class SalaryDeductionBatchResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'batch_no' => $this->batch_no,
|
||||
'month' => $this->month,
|
||||
'year' => $this->year,
|
||||
'total_records' => $this->total_records,
|
||||
'total_amount' => $this->total_amount,
|
||||
'status' => $this->status?->value ?? $this->status,
|
||||
'uploaded_by' => $this->uploaded_by,
|
||||
'verified_by' => $this->verified_by,
|
||||
'uploaded_at' => $this->uploaded_at?->toISOString(),
|
||||
'verified_at' => $this->verified_at?->toISOString(),
|
||||
'created_at' => $this->created_at?->toISOString(),
|
||||
'updated_at' => $this->updated_at?->toISOString(),
|
||||
'uploaded_by_user' => $this->whenLoaded('uploadedBy', fn () => $this->uploadedBy ? [
|
||||
'id' => $this->uploadedBy->id,
|
||||
'name' => $this->uploadedBy->name,
|
||||
'email' => $this->uploadedBy->email,
|
||||
] : null),
|
||||
'verified_by_user' => $this->whenLoaded('verifiedBy', fn () => $this->verifiedBy ? [
|
||||
'id' => $this->verifiedBy->id,
|
||||
'name' => $this->verifiedBy->name,
|
||||
'email' => $this->verifiedBy->email,
|
||||
] : null),
|
||||
'details' => SalaryDeductionBatchDetailResource::collection($this->whenLoaded('details')),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Transformers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class UserInvestmentLimitResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'user_id' => $this->user_id,
|
||||
'investment_type_id' => $this->investment_type_id,
|
||||
'max_investment_amount' => $this->max_investment_amount,
|
||||
'max_transaction_amount' => $this->max_transaction_amount,
|
||||
'effective_date' => $this->effective_date?->toDateString(),
|
||||
'expiry_date' => $this->expiry_date?->toDateString(),
|
||||
'created_by' => $this->created_by,
|
||||
'created_at' => $this->created_at?->toISOString(),
|
||||
'updated_at' => $this->updated_at?->toISOString(),
|
||||
'user' => $this->whenLoaded('user', fn () => [
|
||||
'id' => $this->user->id,
|
||||
'name' => $this->user->name,
|
||||
'email' => $this->user->email,
|
||||
]),
|
||||
'investment_type' => $this->whenLoaded('investmentType', fn () => [
|
||||
'id' => $this->investmentType->id,
|
||||
'code' => $this->investmentType->code,
|
||||
'name' => $this->investmentType->name,
|
||||
]),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Transformers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class UserLimitProfileResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'user_id' => $this->user_id,
|
||||
'profile_id' => $this->profile_id,
|
||||
'effective_date' => $this->effective_date?->toDateString(),
|
||||
'expiry_date' => $this->expiry_date?->toDateString(),
|
||||
'created_at' => $this->created_at?->toISOString(),
|
||||
'user' => $this->whenLoaded('user', fn () => [
|
||||
'id' => $this->user->id,
|
||||
'name' => $this->user->name,
|
||||
'email' => $this->user->email,
|
||||
]),
|
||||
'profile' => $this->whenLoaded('profile', fn () => [
|
||||
'id' => $this->profile->id,
|
||||
'name' => $this->profile->name,
|
||||
'description' => $this->profile->description,
|
||||
]),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user