WIP: billplz api integration testing; DONE: backend for module investment, investment UI for admin

This commit is contained in:
ISMAIL MASSERAN
2026-08-02 12:46:15 +08:00
parent 1cf08e4ae0
commit 51271b8e59
89 changed files with 5334 additions and 39 deletions
+11 -1
View File
@@ -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=
+9
View File
@@ -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'),
],
];
@@ -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');
}
};
@@ -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');
}
};
@@ -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');
}
};
@@ -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');
}
};
@@ -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');
}
};
@@ -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');
}
};
@@ -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');
}
};
@@ -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');
}
};
@@ -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');
}
};
@@ -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');
}
};
@@ -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');
}
};
@@ -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');
}
};
@@ -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);
}
/**
@@ -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();
}
}
+75
View File
@@ -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,
]),
];
}
}