From 51271b8e59f717a0ede1fd4f50dc7d0bd19ded39 Mon Sep 17 00:00:00 2001 From: ISMAIL MASSERAN Date: Sun, 2 Aug 2026 12:46:15 +0800 Subject: [PATCH] WIP: billplz api integration testing; DONE: backend for module investment, investment UI for admin --- .env.example | 12 +- Modules/Investment/Config/config.php | 9 + ...20000_create_investment_accounts_table.php | 33 ++ ..._08_02_020100_create_investments_table.php | 47 ++ ..._020200_create_investment_ledger_table.php | 50 ++ ...0_create_investment_fund_summary_table.php | 28 + ...create_investment_limit_profiles_table.php | 25 + ...create_profile_investment_limits_table.php | 34 ++ ...30200_create_user_limit_profiles_table.php | 41 ++ ...00_create_user_investment_limits_table.php | 38 ++ ...40000_create_investment_payments_table.php | 48 ++ ...0100_create_billplz_transactions_table.php | 50 ++ ...0000_create_salary_contributions_table.php | 37 ++ ..._create_salary_deduction_batches_table.php | 40 ++ ...e_salary_deduction_batch_details_table.php | 49 ++ ..._create_salary_verification_logs_table.php | 35 ++ .../Seeders/InvestmentDatabaseSeeder.php | 3 + .../InvestmentLimitPermissionSeeder.php | 26 + .../Seeders/InvestmentPermissionSeeder.php | 25 + .../SalaryDeductionPermissionSeeder.php | 27 + .../Entities/BillplzTransaction.php | 49 ++ Modules/Investment/Entities/Investment.php | 87 +++ .../Investment/Entities/InvestmentAccount.php | 54 ++ .../Entities/InvestmentFundSummary.php | 39 ++ .../Investment/Entities/InvestmentLedger.php | 67 +++ .../Entities/InvestmentLimitProfile.php | 29 + .../Investment/Entities/InvestmentPayment.php | 53 ++ .../Investment/Entities/InvestmentType.php | 38 ++ .../Entities/ProfileInvestmentLimit.php | 39 ++ .../Entities/SalaryContribution.php | 57 ++ .../Entities/SalaryDeductionBatch.php | 58 ++ .../Entities/SalaryDeductionBatchDetail.php | 78 +++ .../Entities/SalaryVerificationLog.php | 43 ++ .../Entities/UserInvestmentLimit.php | 63 ++ .../Investment/Entities/UserLimitProfile.php | 57 ++ .../Enums/BillplzTransactionStatus.php | 11 + .../Enums/InvestmentAccountStatus.php | 10 + .../Enums/InvestmentLedgerTransactionType.php | 11 + .../Enums/InvestmentPaymentGateway.php | 8 + .../Enums/InvestmentPaymentStatus.php | 12 + Modules/Investment/Enums/InvestmentSource.php | 12 + Modules/Investment/Enums/InvestmentStatus.php | 13 + .../Enums/SalaryContributionStatus.php | 10 + .../Enums/SalaryDeductionBatchStatus.php | 11 + .../Enums/SalaryDeductionDetailStatus.php | 10 + .../BillplzInvestmentController.php | 73 +++ .../Http/Controllers/InvestmentController.php | 175 ++++-- .../InvestmentLimitProfileController.php | 51 ++ .../ProfileInvestmentLimitController.php | 113 ++++ .../SalaryContributionController.php | 45 ++ .../SalaryDeductionBatchController.php | 216 +++++++ .../UserInvestmentLimitController.php | 40 ++ .../UserLimitProfileController.php | 75 +++ .../AssignUserLimitProfileRequest.php | 33 ++ .../Requests/BillplzInvestmentRequest.php | 32 + .../GenerateSalaryDeductionBatchRequest.php | 21 + .../InvestmentLimitProfileRequest.php | 43 ++ .../Http/Requests/ManualInvestmentRequest.php | 35 ++ .../ProfileInvestmentLimitRequest.php | 47 ++ .../Requests/RejectSalaryDeductionRequest.php | 20 + .../Requests/SalaryContributionRequest.php | 46 ++ .../UploadSalaryDeductionBatchRequest.php | 27 + .../Requests/UserInvestmentLimitRequest.php | 42 ++ .../VerifySalaryDeductionDetailRequest.php | 20 + .../Policies/InvestmentLimitPolicy.php | 40 ++ .../Investment/Policies/InvestmentPolicy.php | 32 + .../Policies/SalaryDeductionPolicy.php | 45 ++ .../Providers/InvestmentServiceProvider.php | 28 + ...estmentLimitProfileRepositoryInterface.php | 28 + .../SalaryContributionRepositoryInterface.php | 28 + ...UserInvestmentLimitRepositoryInterface.php | 28 + .../InvestmentLimitProfileRepository.php | 90 +++ .../SalaryContributionRepository.php | 118 ++++ .../UserInvestmentLimitRepository.php | 115 ++++ Modules/Investment/Routes/api.php | 75 +++ .../Services/Billplz/BillplzClient.php | 100 ++++ .../Billplz/BillplzSignatureVerifier.php | 79 +++ .../Services/BillplzInvestmentService.php | 394 +++++++++++++ .../Services/InvestmentLimitService.php | 177 ++++++ .../Services/InvestmentPostingService.php | 552 ++++++++++++++++++ .../Services/SalaryDeductionService.php | 384 ++++++++++++ .../InvestmentLimitProfileResource.php | 22 + .../Transformers/InvestmentResource.php | 98 ++++ .../ProfileInvestmentLimitResource.php | 27 + .../SalaryContributionResource.php | 35 ++ .../SalaryDeductionBatchDetailResource.php | 43 ++ .../SalaryDeductionBatchResource.php | 39 ++ .../UserInvestmentLimitResource.php | 35 ++ .../Transformers/UserLimitProfileResource.php | 31 + 89 files changed, 5334 insertions(+), 39 deletions(-) create mode 100644 Modules/Investment/Database/Migrations/2026_08_02_020000_create_investment_accounts_table.php create mode 100644 Modules/Investment/Database/Migrations/2026_08_02_020100_create_investments_table.php create mode 100644 Modules/Investment/Database/Migrations/2026_08_02_020200_create_investment_ledger_table.php create mode 100644 Modules/Investment/Database/Migrations/2026_08_02_020300_create_investment_fund_summary_table.php create mode 100644 Modules/Investment/Database/Migrations/2026_08_02_030000_create_investment_limit_profiles_table.php create mode 100644 Modules/Investment/Database/Migrations/2026_08_02_030100_create_profile_investment_limits_table.php create mode 100644 Modules/Investment/Database/Migrations/2026_08_02_030200_create_user_limit_profiles_table.php create mode 100644 Modules/Investment/Database/Migrations/2026_08_02_030300_create_user_investment_limits_table.php create mode 100644 Modules/Investment/Database/Migrations/2026_08_02_040000_create_investment_payments_table.php create mode 100644 Modules/Investment/Database/Migrations/2026_08_02_040100_create_billplz_transactions_table.php create mode 100644 Modules/Investment/Database/Migrations/2026_08_02_050000_create_salary_contributions_table.php create mode 100644 Modules/Investment/Database/Migrations/2026_08_02_050100_create_salary_deduction_batches_table.php create mode 100644 Modules/Investment/Database/Migrations/2026_08_02_050200_create_salary_deduction_batch_details_table.php create mode 100644 Modules/Investment/Database/Migrations/2026_08_02_050300_create_salary_verification_logs_table.php create mode 100644 Modules/Investment/Database/Seeders/InvestmentLimitPermissionSeeder.php create mode 100644 Modules/Investment/Database/Seeders/InvestmentPermissionSeeder.php create mode 100644 Modules/Investment/Database/Seeders/SalaryDeductionPermissionSeeder.php create mode 100644 Modules/Investment/Entities/BillplzTransaction.php create mode 100644 Modules/Investment/Entities/Investment.php create mode 100644 Modules/Investment/Entities/InvestmentAccount.php create mode 100644 Modules/Investment/Entities/InvestmentFundSummary.php create mode 100644 Modules/Investment/Entities/InvestmentLedger.php create mode 100644 Modules/Investment/Entities/InvestmentLimitProfile.php create mode 100644 Modules/Investment/Entities/InvestmentPayment.php create mode 100644 Modules/Investment/Entities/ProfileInvestmentLimit.php create mode 100644 Modules/Investment/Entities/SalaryContribution.php create mode 100644 Modules/Investment/Entities/SalaryDeductionBatch.php create mode 100644 Modules/Investment/Entities/SalaryDeductionBatchDetail.php create mode 100644 Modules/Investment/Entities/SalaryVerificationLog.php create mode 100644 Modules/Investment/Entities/UserInvestmentLimit.php create mode 100644 Modules/Investment/Entities/UserLimitProfile.php create mode 100644 Modules/Investment/Enums/BillplzTransactionStatus.php create mode 100644 Modules/Investment/Enums/InvestmentAccountStatus.php create mode 100644 Modules/Investment/Enums/InvestmentLedgerTransactionType.php create mode 100644 Modules/Investment/Enums/InvestmentPaymentGateway.php create mode 100644 Modules/Investment/Enums/InvestmentPaymentStatus.php create mode 100644 Modules/Investment/Enums/InvestmentSource.php create mode 100644 Modules/Investment/Enums/InvestmentStatus.php create mode 100644 Modules/Investment/Enums/SalaryContributionStatus.php create mode 100644 Modules/Investment/Enums/SalaryDeductionBatchStatus.php create mode 100644 Modules/Investment/Enums/SalaryDeductionDetailStatus.php create mode 100644 Modules/Investment/Http/Controllers/BillplzInvestmentController.php create mode 100644 Modules/Investment/Http/Controllers/InvestmentLimitProfileController.php create mode 100644 Modules/Investment/Http/Controllers/ProfileInvestmentLimitController.php create mode 100644 Modules/Investment/Http/Controllers/SalaryContributionController.php create mode 100644 Modules/Investment/Http/Controllers/SalaryDeductionBatchController.php create mode 100644 Modules/Investment/Http/Controllers/UserInvestmentLimitController.php create mode 100644 Modules/Investment/Http/Controllers/UserLimitProfileController.php create mode 100644 Modules/Investment/Http/Requests/AssignUserLimitProfileRequest.php create mode 100644 Modules/Investment/Http/Requests/BillplzInvestmentRequest.php create mode 100644 Modules/Investment/Http/Requests/GenerateSalaryDeductionBatchRequest.php create mode 100644 Modules/Investment/Http/Requests/InvestmentLimitProfileRequest.php create mode 100644 Modules/Investment/Http/Requests/ManualInvestmentRequest.php create mode 100644 Modules/Investment/Http/Requests/ProfileInvestmentLimitRequest.php create mode 100644 Modules/Investment/Http/Requests/RejectSalaryDeductionRequest.php create mode 100644 Modules/Investment/Http/Requests/SalaryContributionRequest.php create mode 100644 Modules/Investment/Http/Requests/UploadSalaryDeductionBatchRequest.php create mode 100644 Modules/Investment/Http/Requests/UserInvestmentLimitRequest.php create mode 100644 Modules/Investment/Http/Requests/VerifySalaryDeductionDetailRequest.php create mode 100644 Modules/Investment/Policies/InvestmentLimitPolicy.php create mode 100644 Modules/Investment/Policies/InvestmentPolicy.php create mode 100644 Modules/Investment/Policies/SalaryDeductionPolicy.php create mode 100644 Modules/Investment/Repositories/Contracts/InvestmentLimitProfileRepositoryInterface.php create mode 100644 Modules/Investment/Repositories/Contracts/SalaryContributionRepositoryInterface.php create mode 100644 Modules/Investment/Repositories/Contracts/UserInvestmentLimitRepositoryInterface.php create mode 100644 Modules/Investment/Repositories/InvestmentLimitProfileRepository.php create mode 100644 Modules/Investment/Repositories/SalaryContributionRepository.php create mode 100644 Modules/Investment/Repositories/UserInvestmentLimitRepository.php create mode 100644 Modules/Investment/Services/Billplz/BillplzClient.php create mode 100644 Modules/Investment/Services/Billplz/BillplzSignatureVerifier.php create mode 100644 Modules/Investment/Services/BillplzInvestmentService.php create mode 100644 Modules/Investment/Services/InvestmentLimitService.php create mode 100644 Modules/Investment/Services/InvestmentPostingService.php create mode 100644 Modules/Investment/Services/SalaryDeductionService.php create mode 100644 Modules/Investment/Transformers/InvestmentLimitProfileResource.php create mode 100644 Modules/Investment/Transformers/InvestmentResource.php create mode 100644 Modules/Investment/Transformers/ProfileInvestmentLimitResource.php create mode 100644 Modules/Investment/Transformers/SalaryContributionResource.php create mode 100644 Modules/Investment/Transformers/SalaryDeductionBatchDetailResource.php create mode 100644 Modules/Investment/Transformers/SalaryDeductionBatchResource.php create mode 100644 Modules/Investment/Transformers/UserInvestmentLimitResource.php create mode 100644 Modules/Investment/Transformers/UserLimitProfileResource.php diff --git a/.env.example b/.env.example index 1bca928..87330c6 100644 --- a/.env.example +++ b/.env.example @@ -104,4 +104,14 @@ ONEWAYSMS_API_USERNAME= ONEWAYSMS_API_PASSWORD= ONEWAYSMS_SENDER_ID= -EXTERNAL_SYSTEM_SSO_ISSUER=mykopkb \ No newline at end of file +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= \ No newline at end of file diff --git a/Modules/Investment/Config/config.php b/Modules/Investment/Config/config.php index 8b6dd5a..b511d97 100644 --- a/Modules/Investment/Config/config.php +++ b/Modules/Investment/Config/config.php @@ -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'), + ], ]; diff --git a/Modules/Investment/Database/Migrations/2026_08_02_020000_create_investment_accounts_table.php b/Modules/Investment/Database/Migrations/2026_08_02_020000_create_investment_accounts_table.php new file mode 100644 index 0000000..c6fba65 --- /dev/null +++ b/Modules/Investment/Database/Migrations/2026_08_02_020000_create_investment_accounts_table.php @@ -0,0 +1,33 @@ +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'); + } +}; diff --git a/Modules/Investment/Database/Migrations/2026_08_02_020100_create_investments_table.php b/Modules/Investment/Database/Migrations/2026_08_02_020100_create_investments_table.php new file mode 100644 index 0000000..f69f946 --- /dev/null +++ b/Modules/Investment/Database/Migrations/2026_08_02_020100_create_investments_table.php @@ -0,0 +1,47 @@ +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'); + } +}; diff --git a/Modules/Investment/Database/Migrations/2026_08_02_020200_create_investment_ledger_table.php b/Modules/Investment/Database/Migrations/2026_08_02_020200_create_investment_ledger_table.php new file mode 100644 index 0000000..235416b --- /dev/null +++ b/Modules/Investment/Database/Migrations/2026_08_02_020200_create_investment_ledger_table.php @@ -0,0 +1,50 @@ +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'); + } +}; diff --git a/Modules/Investment/Database/Migrations/2026_08_02_020300_create_investment_fund_summary_table.php b/Modules/Investment/Database/Migrations/2026_08_02_020300_create_investment_fund_summary_table.php new file mode 100644 index 0000000..263ea67 --- /dev/null +++ b/Modules/Investment/Database/Migrations/2026_08_02_020300_create_investment_fund_summary_table.php @@ -0,0 +1,28 @@ +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'); + } +}; diff --git a/Modules/Investment/Database/Migrations/2026_08_02_030000_create_investment_limit_profiles_table.php b/Modules/Investment/Database/Migrations/2026_08_02_030000_create_investment_limit_profiles_table.php new file mode 100644 index 0000000..9e894c8 --- /dev/null +++ b/Modules/Investment/Database/Migrations/2026_08_02_030000_create_investment_limit_profiles_table.php @@ -0,0 +1,25 @@ +uuid('id')->primary(); + + $table->string('name')->unique(); + $table->text('description')->nullable(); + + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('investment_limit_profiles'); + } +}; diff --git a/Modules/Investment/Database/Migrations/2026_08_02_030100_create_profile_investment_limits_table.php b/Modules/Investment/Database/Migrations/2026_08_02_030100_create_profile_investment_limits_table.php new file mode 100644 index 0000000..c521f92 --- /dev/null +++ b/Modules/Investment/Database/Migrations/2026_08_02_030100_create_profile_investment_limits_table.php @@ -0,0 +1,34 @@ +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'); + } +}; diff --git a/Modules/Investment/Database/Migrations/2026_08_02_030200_create_user_limit_profiles_table.php b/Modules/Investment/Database/Migrations/2026_08_02_030200_create_user_limit_profiles_table.php new file mode 100644 index 0000000..7681447 --- /dev/null +++ b/Modules/Investment/Database/Migrations/2026_08_02_030200_create_user_limit_profiles_table.php @@ -0,0 +1,41 @@ +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'); + } +}; diff --git a/Modules/Investment/Database/Migrations/2026_08_02_030300_create_user_investment_limits_table.php b/Modules/Investment/Database/Migrations/2026_08_02_030300_create_user_investment_limits_table.php new file mode 100644 index 0000000..cc69457 --- /dev/null +++ b/Modules/Investment/Database/Migrations/2026_08_02_030300_create_user_investment_limits_table.php @@ -0,0 +1,38 @@ +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'); + } +}; diff --git a/Modules/Investment/Database/Migrations/2026_08_02_040000_create_investment_payments_table.php b/Modules/Investment/Database/Migrations/2026_08_02_040000_create_investment_payments_table.php new file mode 100644 index 0000000..2d324e9 --- /dev/null +++ b/Modules/Investment/Database/Migrations/2026_08_02_040000_create_investment_payments_table.php @@ -0,0 +1,48 @@ +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'); + } +}; diff --git a/Modules/Investment/Database/Migrations/2026_08_02_040100_create_billplz_transactions_table.php b/Modules/Investment/Database/Migrations/2026_08_02_040100_create_billplz_transactions_table.php new file mode 100644 index 0000000..e0156c9 --- /dev/null +++ b/Modules/Investment/Database/Migrations/2026_08_02_040100_create_billplz_transactions_table.php @@ -0,0 +1,50 @@ +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'); + } +}; diff --git a/Modules/Investment/Database/Migrations/2026_08_02_050000_create_salary_contributions_table.php b/Modules/Investment/Database/Migrations/2026_08_02_050000_create_salary_contributions_table.php new file mode 100644 index 0000000..4a4413a --- /dev/null +++ b/Modules/Investment/Database/Migrations/2026_08_02_050000_create_salary_contributions_table.php @@ -0,0 +1,37 @@ +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'); + } +}; diff --git a/Modules/Investment/Database/Migrations/2026_08_02_050100_create_salary_deduction_batches_table.php b/Modules/Investment/Database/Migrations/2026_08_02_050100_create_salary_deduction_batches_table.php new file mode 100644 index 0000000..ec6c251 --- /dev/null +++ b/Modules/Investment/Database/Migrations/2026_08_02_050100_create_salary_deduction_batches_table.php @@ -0,0 +1,40 @@ +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'); + } +}; diff --git a/Modules/Investment/Database/Migrations/2026_08_02_050200_create_salary_deduction_batch_details_table.php b/Modules/Investment/Database/Migrations/2026_08_02_050200_create_salary_deduction_batch_details_table.php new file mode 100644 index 0000000..d932522 --- /dev/null +++ b/Modules/Investment/Database/Migrations/2026_08_02_050200_create_salary_deduction_batch_details_table.php @@ -0,0 +1,49 @@ +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'); + } +}; diff --git a/Modules/Investment/Database/Migrations/2026_08_02_050300_create_salary_verification_logs_table.php b/Modules/Investment/Database/Migrations/2026_08_02_050300_create_salary_verification_logs_table.php new file mode 100644 index 0000000..fd76fcd --- /dev/null +++ b/Modules/Investment/Database/Migrations/2026_08_02_050300_create_salary_verification_logs_table.php @@ -0,0 +1,35 @@ +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'); + } +}; diff --git a/Modules/Investment/Database/Seeders/InvestmentDatabaseSeeder.php b/Modules/Investment/Database/Seeders/InvestmentDatabaseSeeder.php index e055842..9b54da0 100644 --- a/Modules/Investment/Database/Seeders/InvestmentDatabaseSeeder.php +++ b/Modules/Investment/Database/Seeders/InvestmentDatabaseSeeder.php @@ -13,6 +13,9 @@ class InvestmentDatabaseSeeder extends Seeder { $this->call([ InvestmentTypePermissionSeeder::class, + InvestmentPermissionSeeder::class, + InvestmentLimitPermissionSeeder::class, + SalaryDeductionPermissionSeeder::class, ]); } } diff --git a/Modules/Investment/Database/Seeders/InvestmentLimitPermissionSeeder.php b/Modules/Investment/Database/Seeders/InvestmentLimitPermissionSeeder.php new file mode 100644 index 0000000..52fae21 --- /dev/null +++ b/Modules/Investment/Database/Seeders/InvestmentLimitPermissionSeeder.php @@ -0,0 +1,26 @@ + $name, 'guard_name' => 'api'], + ['name' => $name, 'guard_name' => 'api'] + ); + } + } +} diff --git a/Modules/Investment/Database/Seeders/InvestmentPermissionSeeder.php b/Modules/Investment/Database/Seeders/InvestmentPermissionSeeder.php new file mode 100644 index 0000000..b2078ca --- /dev/null +++ b/Modules/Investment/Database/Seeders/InvestmentPermissionSeeder.php @@ -0,0 +1,25 @@ + $name, 'guard_name' => 'api'], + ['name' => $name, 'guard_name' => 'api'] + ); + } + } +} diff --git a/Modules/Investment/Database/Seeders/SalaryDeductionPermissionSeeder.php b/Modules/Investment/Database/Seeders/SalaryDeductionPermissionSeeder.php new file mode 100644 index 0000000..bb9e09b --- /dev/null +++ b/Modules/Investment/Database/Seeders/SalaryDeductionPermissionSeeder.php @@ -0,0 +1,27 @@ + $name, 'guard_name' => 'api'], + ['name' => $name, 'guard_name' => 'api'] + ); + } + } +} diff --git a/Modules/Investment/Entities/BillplzTransaction.php b/Modules/Investment/Entities/BillplzTransaction.php new file mode 100644 index 0000000..31747f8 --- /dev/null +++ b/Modules/Investment/Entities/BillplzTransaction.php @@ -0,0 +1,49 @@ + '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'); + } +} diff --git a/Modules/Investment/Entities/Investment.php b/Modules/Investment/Entities/Investment.php new file mode 100644 index 0000000..4861bf6 --- /dev/null +++ b/Modules/Investment/Entities/Investment.php @@ -0,0 +1,87 @@ + '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'); + } +} diff --git a/Modules/Investment/Entities/InvestmentAccount.php b/Modules/Investment/Entities/InvestmentAccount.php new file mode 100644 index 0000000..89b1292 --- /dev/null +++ b/Modules/Investment/Entities/InvestmentAccount.php @@ -0,0 +1,54 @@ + 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'); + } +} diff --git a/Modules/Investment/Entities/InvestmentFundSummary.php b/Modules/Investment/Entities/InvestmentFundSummary.php new file mode 100644 index 0000000..01d4a47 --- /dev/null +++ b/Modules/Investment/Entities/InvestmentFundSummary.php @@ -0,0 +1,39 @@ + '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'); + } +} diff --git a/Modules/Investment/Entities/InvestmentLedger.php b/Modules/Investment/Entities/InvestmentLedger.php new file mode 100644 index 0000000..c55b5d0 --- /dev/null +++ b/Modules/Investment/Entities/InvestmentLedger.php @@ -0,0 +1,67 @@ + 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'); + } +} diff --git a/Modules/Investment/Entities/InvestmentLimitProfile.php b/Modules/Investment/Entities/InvestmentLimitProfile.php new file mode 100644 index 0000000..4273701 --- /dev/null +++ b/Modules/Investment/Entities/InvestmentLimitProfile.php @@ -0,0 +1,29 @@ +hasMany(ProfileInvestmentLimit::class, 'profile_id'); + } + + public function userAssignments(): HasMany + { + return $this->hasMany(UserLimitProfile::class, 'profile_id'); + } +} diff --git a/Modules/Investment/Entities/InvestmentPayment.php b/Modules/Investment/Entities/InvestmentPayment.php new file mode 100644 index 0000000..92d85d4 --- /dev/null +++ b/Modules/Investment/Entities/InvestmentPayment.php @@ -0,0 +1,53 @@ + 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'); + } +} diff --git a/Modules/Investment/Entities/InvestmentType.php b/Modules/Investment/Entities/InvestmentType.php index 4463e32..036c9f0 100644 --- a/Modules/Investment/Entities/InvestmentType.php +++ b/Modules/Investment/Entities/InvestmentType.php @@ -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'); + } } + diff --git a/Modules/Investment/Entities/ProfileInvestmentLimit.php b/Modules/Investment/Entities/ProfileInvestmentLimit.php new file mode 100644 index 0000000..aad8304 --- /dev/null +++ b/Modules/Investment/Entities/ProfileInvestmentLimit.php @@ -0,0 +1,39 @@ + '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'); + } +} diff --git a/Modules/Investment/Entities/SalaryContribution.php b/Modules/Investment/Entities/SalaryContribution.php new file mode 100644 index 0000000..b627c4d --- /dev/null +++ b/Modules/Investment/Entities/SalaryContribution.php @@ -0,0 +1,57 @@ + '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'); + } +} diff --git a/Modules/Investment/Entities/SalaryDeductionBatch.php b/Modules/Investment/Entities/SalaryDeductionBatch.php new file mode 100644 index 0000000..7dc75d0 --- /dev/null +++ b/Modules/Investment/Entities/SalaryDeductionBatch.php @@ -0,0 +1,58 @@ + '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'); + } +} diff --git a/Modules/Investment/Entities/SalaryDeductionBatchDetail.php b/Modules/Investment/Entities/SalaryDeductionBatchDetail.php new file mode 100644 index 0000000..abc0d5a --- /dev/null +++ b/Modules/Investment/Entities/SalaryDeductionBatchDetail.php @@ -0,0 +1,78 @@ + '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'); + } +} diff --git a/Modules/Investment/Entities/SalaryVerificationLog.php b/Modules/Investment/Entities/SalaryVerificationLog.php new file mode 100644 index 0000000..9c7e1bf --- /dev/null +++ b/Modules/Investment/Entities/SalaryVerificationLog.php @@ -0,0 +1,43 @@ + 'datetime', + ]; + } + + public function batchDetail(): BelongsTo + { + return $this->belongsTo(SalaryDeductionBatchDetail::class, 'batch_detail_id'); + } + + public function actionBy(): BelongsTo + { + return $this->belongsTo(User::class, 'action_by'); + } +} diff --git a/Modules/Investment/Entities/UserInvestmentLimit.php b/Modules/Investment/Entities/UserInvestmentLimit.php new file mode 100644 index 0000000..ac2e332 --- /dev/null +++ b/Modules/Investment/Entities/UserInvestmentLimit.php @@ -0,0 +1,63 @@ + '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); + }); + } +} diff --git a/Modules/Investment/Entities/UserLimitProfile.php b/Modules/Investment/Entities/UserLimitProfile.php new file mode 100644 index 0000000..415e411 --- /dev/null +++ b/Modules/Investment/Entities/UserLimitProfile.php @@ -0,0 +1,57 @@ + '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); + }); + } +} diff --git a/Modules/Investment/Enums/BillplzTransactionStatus.php b/Modules/Investment/Enums/BillplzTransactionStatus.php new file mode 100644 index 0000000..136300b --- /dev/null +++ b/Modules/Investment/Enums/BillplzTransactionStatus.php @@ -0,0 +1,11 @@ +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', + ]); + } +} diff --git a/Modules/Investment/Http/Controllers/InvestmentController.php b/Modules/Investment/Http/Controllers/InvestmentController.php index 65aa7b3..a311cb4 100644 --- a/Modules/Investment/Http/Controllers/InvestmentController.php +++ b/Modules/Investment/Http/Controllers/InvestmentController.php @@ -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) {} } diff --git a/Modules/Investment/Http/Controllers/InvestmentLimitProfileController.php b/Modules/Investment/Http/Controllers/InvestmentLimitProfileController.php new file mode 100644 index 0000000..89fc15f --- /dev/null +++ b/Modules/Investment/Http/Controllers/InvestmentLimitProfileController.php @@ -0,0 +1,51 @@ +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; + } +} diff --git a/Modules/Investment/Http/Controllers/ProfileInvestmentLimitController.php b/Modules/Investment/Http/Controllers/ProfileInvestmentLimitController.php new file mode 100644 index 0000000..bb8f6d8 --- /dev/null +++ b/Modules/Investment/Http/Controllers/ProfileInvestmentLimitController.php @@ -0,0 +1,113 @@ +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); + } +} diff --git a/Modules/Investment/Http/Controllers/SalaryContributionController.php b/Modules/Investment/Http/Controllers/SalaryContributionController.php new file mode 100644 index 0000000..66270dc --- /dev/null +++ b/Modules/Investment/Http/Controllers/SalaryContributionController.php @@ -0,0 +1,45 @@ +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; + } +} diff --git a/Modules/Investment/Http/Controllers/SalaryDeductionBatchController.php b/Modules/Investment/Http/Controllers/SalaryDeductionBatchController.php new file mode 100644 index 0000000..5051b9e --- /dev/null +++ b/Modules/Investment/Http/Controllers/SalaryDeductionBatchController.php @@ -0,0 +1,216 @@ +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', + ]); + } +} diff --git a/Modules/Investment/Http/Controllers/UserInvestmentLimitController.php b/Modules/Investment/Http/Controllers/UserInvestmentLimitController.php new file mode 100644 index 0000000..10b0fa2 --- /dev/null +++ b/Modules/Investment/Http/Controllers/UserInvestmentLimitController.php @@ -0,0 +1,40 @@ +id(); + + return $validated; + } + + protected function getItemName($item): string + { + return $item->id; + } +} diff --git a/Modules/Investment/Http/Controllers/UserLimitProfileController.php b/Modules/Investment/Http/Controllers/UserLimitProfileController.php new file mode 100644 index 0000000..7bcfc09 --- /dev/null +++ b/Modules/Investment/Http/Controllers/UserLimitProfileController.php @@ -0,0 +1,75 @@ +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); + } +} diff --git a/Modules/Investment/Http/Requests/AssignUserLimitProfileRequest.php b/Modules/Investment/Http/Requests/AssignUserLimitProfileRequest.php new file mode 100644 index 0000000..5bb111f --- /dev/null +++ b/Modules/Investment/Http/Requests/AssignUserLimitProfileRequest.php @@ -0,0 +1,33 @@ + ['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.', + ]; + } +} diff --git a/Modules/Investment/Http/Requests/BillplzInvestmentRequest.php b/Modules/Investment/Http/Requests/BillplzInvestmentRequest.php new file mode 100644 index 0000000..2293af3 --- /dev/null +++ b/Modules/Investment/Http/Requests/BillplzInvestmentRequest.php @@ -0,0 +1,32 @@ + ['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.', + ]; + } +} diff --git a/Modules/Investment/Http/Requests/GenerateSalaryDeductionBatchRequest.php b/Modules/Investment/Http/Requests/GenerateSalaryDeductionBatchRequest.php new file mode 100644 index 0000000..768dae7 --- /dev/null +++ b/Modules/Investment/Http/Requests/GenerateSalaryDeductionBatchRequest.php @@ -0,0 +1,21 @@ + ['required', 'integer', 'min:1', 'max:12'], + 'year' => ['required', 'integer', 'min:2000', 'max:2100'], + ]; + } +} diff --git a/Modules/Investment/Http/Requests/InvestmentLimitProfileRequest.php b/Modules/Investment/Http/Requests/InvestmentLimitProfileRequest.php new file mode 100644 index 0000000..2ea8117 --- /dev/null +++ b/Modules/Investment/Http/Requests/InvestmentLimitProfileRequest.php @@ -0,0 +1,43 @@ +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.', + ]; + } +} diff --git a/Modules/Investment/Http/Requests/ManualInvestmentRequest.php b/Modules/Investment/Http/Requests/ManualInvestmentRequest.php new file mode 100644 index 0000000..e7ffca6 --- /dev/null +++ b/Modules/Investment/Http/Requests/ManualInvestmentRequest.php @@ -0,0 +1,35 @@ + ['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.', + ]; + } +} diff --git a/Modules/Investment/Http/Requests/ProfileInvestmentLimitRequest.php b/Modules/Investment/Http/Requests/ProfileInvestmentLimitRequest.php new file mode 100644 index 0000000..e939ee5 --- /dev/null +++ b/Modules/Investment/Http/Requests/ProfileInvestmentLimitRequest.php @@ -0,0 +1,47 @@ +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.', + ]; + } +} diff --git a/Modules/Investment/Http/Requests/RejectSalaryDeductionRequest.php b/Modules/Investment/Http/Requests/RejectSalaryDeductionRequest.php new file mode 100644 index 0000000..0138c3b --- /dev/null +++ b/Modules/Investment/Http/Requests/RejectSalaryDeductionRequest.php @@ -0,0 +1,20 @@ + ['nullable', 'string', 'max:1000'], + ]; + } +} diff --git a/Modules/Investment/Http/Requests/SalaryContributionRequest.php b/Modules/Investment/Http/Requests/SalaryContributionRequest.php new file mode 100644 index 0000000..7f44c2e --- /dev/null +++ b/Modules/Investment/Http/Requests/SalaryContributionRequest.php @@ -0,0 +1,46 @@ +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.', + ]; + } +} diff --git a/Modules/Investment/Http/Requests/UploadSalaryDeductionBatchRequest.php b/Modules/Investment/Http/Requests/UploadSalaryDeductionBatchRequest.php new file mode 100644 index 0000000..6f76aaa --- /dev/null +++ b/Modules/Investment/Http/Requests/UploadSalaryDeductionBatchRequest.php @@ -0,0 +1,27 @@ + ['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'], + ]; + } +} diff --git a/Modules/Investment/Http/Requests/UserInvestmentLimitRequest.php b/Modules/Investment/Http/Requests/UserInvestmentLimitRequest.php new file mode 100644 index 0000000..b0e722e --- /dev/null +++ b/Modules/Investment/Http/Requests/UserInvestmentLimitRequest.php @@ -0,0 +1,42 @@ +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.', + ]; + } +} diff --git a/Modules/Investment/Http/Requests/VerifySalaryDeductionDetailRequest.php b/Modules/Investment/Http/Requests/VerifySalaryDeductionDetailRequest.php new file mode 100644 index 0000000..fc185a3 --- /dev/null +++ b/Modules/Investment/Http/Requests/VerifySalaryDeductionDetailRequest.php @@ -0,0 +1,20 @@ + ['nullable', 'string', 'max:1000'], + ]; + } +} diff --git a/Modules/Investment/Policies/InvestmentLimitPolicy.php b/Modules/Investment/Policies/InvestmentLimitPolicy.php new file mode 100644 index 0000000..9458741 --- /dev/null +++ b/Modules/Investment/Policies/InvestmentLimitPolicy.php @@ -0,0 +1,40 @@ +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'); + } +} diff --git a/Modules/Investment/Policies/InvestmentPolicy.php b/Modules/Investment/Policies/InvestmentPolicy.php new file mode 100644 index 0000000..4ce280a --- /dev/null +++ b/Modules/Investment/Policies/InvestmentPolicy.php @@ -0,0 +1,32 @@ +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'); + } +} + diff --git a/Modules/Investment/Policies/SalaryDeductionPolicy.php b/Modules/Investment/Policies/SalaryDeductionPolicy.php new file mode 100644 index 0000000..fce101b --- /dev/null +++ b/Modules/Investment/Policies/SalaryDeductionPolicy.php @@ -0,0 +1,45 @@ +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'); + } +} diff --git a/Modules/Investment/Providers/InvestmentServiceProvider.php b/Modules/Investment/Providers/InvestmentServiceProvider.php index 27ff94e..7daca8d 100644 --- a/Modules/Investment/Providers/InvestmentServiceProvider.php +++ b/Modules/Investment/Providers/InvestmentServiceProvider.php @@ -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); } /** diff --git a/Modules/Investment/Repositories/Contracts/InvestmentLimitProfileRepositoryInterface.php b/Modules/Investment/Repositories/Contracts/InvestmentLimitProfileRepositoryInterface.php new file mode 100644 index 0000000..1d4fd6f --- /dev/null +++ b/Modules/Investment/Repositories/Contracts/InvestmentLimitProfileRepositoryInterface.php @@ -0,0 +1,28 @@ +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(); + } +} diff --git a/Modules/Investment/Repositories/SalaryContributionRepository.php b/Modules/Investment/Repositories/SalaryContributionRepository.php new file mode 100644 index 0000000..5c29422 --- /dev/null +++ b/Modules/Investment/Repositories/SalaryContributionRepository.php @@ -0,0 +1,118 @@ +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(); + } +} diff --git a/Modules/Investment/Repositories/UserInvestmentLimitRepository.php b/Modules/Investment/Repositories/UserInvestmentLimitRepository.php new file mode 100644 index 0000000..9923ff8 --- /dev/null +++ b/Modules/Investment/Repositories/UserInvestmentLimitRepository.php @@ -0,0 +1,115 @@ +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(); + } +} diff --git a/Modules/Investment/Routes/api.php b/Modules/Investment/Routes/api.php index 607d811..ca27521 100644 --- a/Modules/Investment/Routes/api.php +++ b/Modules/Investment/Routes/api.php @@ -1,8 +1,83 @@ 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'); }); diff --git a/Modules/Investment/Services/Billplz/BillplzClient.php b/Modules/Investment/Services/Billplz/BillplzClient.php new file mode 100644 index 0000000..93daccd --- /dev/null +++ b/Modules/Investment/Services/Billplz/BillplzClient.php @@ -0,0 +1,100 @@ + + */ + 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 + */ + 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; + } +} diff --git a/Modules/Investment/Services/Billplz/BillplzSignatureVerifier.php b/Modules/Investment/Services/Billplz/BillplzSignatureVerifier.php new file mode 100644 index 0000000..5565d57 --- /dev/null +++ b/Modules/Investment/Services/Billplz/BillplzSignatureVerifier.php @@ -0,0 +1,79 @@ + $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 $payload + * @return array + */ + 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 $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); + } +} diff --git a/Modules/Investment/Services/BillplzInvestmentService.php b/Modules/Investment/Services/BillplzInvestmentService.php new file mode 100644 index 0000000..d5b1dcb --- /dev/null +++ b/Modules/Investment/Services/BillplzInvestmentService.php @@ -0,0 +1,394 @@ +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 $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 $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; + } + } +} diff --git a/Modules/Investment/Services/InvestmentLimitService.php b/Modules/Investment/Services/InvestmentLimitService.php new file mode 100644 index 0000000..ccc12f5 --- /dev/null +++ b/Modules/Investment/Services/InvestmentLimitService.php @@ -0,0 +1,177 @@ +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, '.', ','); + } +} diff --git a/Modules/Investment/Services/InvestmentPostingService.php b/Modules/Investment/Services/InvestmentPostingService.php new file mode 100644 index 0000000..e93c283 --- /dev/null +++ b/Modules/Investment/Services/InvestmentPostingService.php @@ -0,0 +1,552 @@ +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); + } +} diff --git a/Modules/Investment/Services/SalaryDeductionService.php b/Modules/Investment/Services/SalaryDeductionService.php new file mode 100644 index 0000000..dc5f0d0 --- /dev/null +++ b/Modules/Investment/Services/SalaryDeductionService.php @@ -0,0 +1,384 @@ + $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.'], + ]); + } + } +} diff --git a/Modules/Investment/Transformers/InvestmentLimitProfileResource.php b/Modules/Investment/Transformers/InvestmentLimitProfileResource.php new file mode 100644 index 0000000..cafd482 --- /dev/null +++ b/Modules/Investment/Transformers/InvestmentLimitProfileResource.php @@ -0,0 +1,22 @@ + $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(), + ]; + } +} diff --git a/Modules/Investment/Transformers/InvestmentResource.php b/Modules/Investment/Transformers/InvestmentResource.php new file mode 100644 index 0000000..b64a69f --- /dev/null +++ b/Modules/Investment/Transformers/InvestmentResource.php @@ -0,0 +1,98 @@ + $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, + ]; + }), + ]; + } +} + diff --git a/Modules/Investment/Transformers/ProfileInvestmentLimitResource.php b/Modules/Investment/Transformers/ProfileInvestmentLimitResource.php new file mode 100644 index 0000000..f93ac1a --- /dev/null +++ b/Modules/Investment/Transformers/ProfileInvestmentLimitResource.php @@ -0,0 +1,27 @@ + $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(), + ]; + } +} diff --git a/Modules/Investment/Transformers/SalaryContributionResource.php b/Modules/Investment/Transformers/SalaryContributionResource.php new file mode 100644 index 0000000..89608f0 --- /dev/null +++ b/Modules/Investment/Transformers/SalaryContributionResource.php @@ -0,0 +1,35 @@ + $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, + ]), + ]; + } +} diff --git a/Modules/Investment/Transformers/SalaryDeductionBatchDetailResource.php b/Modules/Investment/Transformers/SalaryDeductionBatchDetailResource.php new file mode 100644 index 0000000..859eb34 --- /dev/null +++ b/Modules/Investment/Transformers/SalaryDeductionBatchDetailResource.php @@ -0,0 +1,43 @@ + $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), + ]; + } +} diff --git a/Modules/Investment/Transformers/SalaryDeductionBatchResource.php b/Modules/Investment/Transformers/SalaryDeductionBatchResource.php new file mode 100644 index 0000000..636cc6d --- /dev/null +++ b/Modules/Investment/Transformers/SalaryDeductionBatchResource.php @@ -0,0 +1,39 @@ + $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')), + ]; + } +} diff --git a/Modules/Investment/Transformers/UserInvestmentLimitResource.php b/Modules/Investment/Transformers/UserInvestmentLimitResource.php new file mode 100644 index 0000000..90218b2 --- /dev/null +++ b/Modules/Investment/Transformers/UserInvestmentLimitResource.php @@ -0,0 +1,35 @@ + $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, + ]), + ]; + } +} diff --git a/Modules/Investment/Transformers/UserLimitProfileResource.php b/Modules/Investment/Transformers/UserLimitProfileResource.php new file mode 100644 index 0000000..cccb972 --- /dev/null +++ b/Modules/Investment/Transformers/UserLimitProfileResource.php @@ -0,0 +1,31 @@ + $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, + ]), + ]; + } +}