553 lines
19 KiB
PHP
553 lines
19 KiB
PHP
<?php
|
|
|
|
namespace Modules\Investment\Services;
|
|
|
|
use Illuminate\Database\UniqueConstraintViolationException;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Modules\Auth\Entities\User;
|
|
use Modules\Investment\Entities\Investment;
|
|
use Modules\Investment\Entities\InvestmentAccount;
|
|
use Modules\Investment\Entities\InvestmentFundSummary;
|
|
use Modules\Investment\Entities\InvestmentLedger;
|
|
use Modules\Investment\Entities\InvestmentType;
|
|
use Modules\Investment\Enums\InvestmentAccountStatus;
|
|
use Modules\Investment\Enums\InvestmentLedgerTransactionType;
|
|
use Modules\Investment\Enums\InvestmentSource;
|
|
use Modules\Investment\Enums\InvestmentStatus;
|
|
use Modules\Investment\Enums\InvestmentTypeStatus;
|
|
|
|
class InvestmentPostingService
|
|
{
|
|
public function __construct(
|
|
protected InvestmentLimitService $limitService,
|
|
) {}
|
|
|
|
/**
|
|
* Admin one-shot manual investment: create + complete + ledger in one transaction.
|
|
*/
|
|
public function postManualInvestment(
|
|
string $userId,
|
|
string $investmentTypeId,
|
|
string|float|int $amount,
|
|
?string $remarks,
|
|
User $actor,
|
|
): Investment {
|
|
return $this->postCompletedInvestment(
|
|
$userId,
|
|
$investmentTypeId,
|
|
$amount,
|
|
$remarks,
|
|
$actor,
|
|
InvestmentSource::Manual,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Salary deduction verification: create + complete + ledger in one transaction.
|
|
*/
|
|
public function postSalaryInvestment(
|
|
string $userId,
|
|
string $investmentTypeId,
|
|
string|float|int $amount,
|
|
?string $remarks,
|
|
User $actor,
|
|
): Investment {
|
|
return $this->postCompletedInvestment(
|
|
$userId,
|
|
$investmentTypeId,
|
|
$amount,
|
|
$remarks,
|
|
$actor,
|
|
InvestmentSource::SalaryDeduction,
|
|
requireSalaryDeduction: true,
|
|
);
|
|
}
|
|
|
|
protected function postCompletedInvestment(
|
|
string $userId,
|
|
string $investmentTypeId,
|
|
string|float|int $amount,
|
|
?string $remarks,
|
|
User $actor,
|
|
InvestmentSource $source,
|
|
bool $requireSalaryDeduction = false,
|
|
): Investment {
|
|
return DB::transaction(function () use (
|
|
$userId,
|
|
$investmentTypeId,
|
|
$amount,
|
|
$remarks,
|
|
$actor,
|
|
$source,
|
|
$requireSalaryDeduction,
|
|
) {
|
|
$amount = $this->normalizeAmount($amount);
|
|
$investmentType = $this->lockInvestmentType($investmentTypeId);
|
|
|
|
if ($requireSalaryDeduction && ! $investmentType->allow_salary_deduction) {
|
|
throw ValidationException::withMessages([
|
|
'investment_type_id' => ['Jenis pelaburan ini tidak membenarkan potongan gaji.'],
|
|
]);
|
|
}
|
|
|
|
$this->validateProductConstraints($investmentType, $amount);
|
|
$this->limitService->assertCanInvest($userId, $investmentTypeId, $amount);
|
|
$this->validateFundCapacity($investmentType, $amount);
|
|
|
|
$account = $this->ensureAccount($userId, $investmentTypeId);
|
|
$this->assertAccountAcceptsInvestment($account);
|
|
|
|
$now = now();
|
|
|
|
$investment = Investment::query()->create([
|
|
'reference_no' => $this->generateReferenceNo(),
|
|
'user_id' => $userId,
|
|
'investment_account_id' => $account->id,
|
|
'investment_type_id' => $investmentTypeId,
|
|
'amount' => $amount,
|
|
'source' => $source,
|
|
'status' => InvestmentStatus::Completed,
|
|
'remarks' => $remarks,
|
|
'created_by' => $actor->id,
|
|
'approved_by' => $actor->id,
|
|
'approved_at' => $now,
|
|
'completed_at' => $now,
|
|
]);
|
|
|
|
$this->postInvestmentCredit($investment, $actor);
|
|
|
|
return $investment->fresh([
|
|
'user',
|
|
'account',
|
|
'investmentType',
|
|
'createdBy',
|
|
'approvedBy',
|
|
'ledgerEntries',
|
|
]);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Create a PENDING_PAYMENT investment and reserve fund/user pending capacity.
|
|
*/
|
|
public function createPendingBillplzInvestment(
|
|
string $userId,
|
|
string $investmentTypeId,
|
|
string|float|int $amount,
|
|
?string $remarks,
|
|
?User $actor = null,
|
|
): Investment {
|
|
return DB::transaction(function () use ($userId, $investmentTypeId, $amount, $remarks, $actor) {
|
|
$amount = $this->normalizeAmount($amount);
|
|
$investmentType = $this->lockInvestmentType($investmentTypeId);
|
|
|
|
if (! $investmentType->allow_billplz) {
|
|
throw ValidationException::withMessages([
|
|
'investment_type_id' => ['Jenis pelaburan ini tidak membenarkan pembayaran Billplz.'],
|
|
]);
|
|
}
|
|
|
|
$this->validateProductConstraints($investmentType, $amount);
|
|
$this->limitService->assertCanInvest($userId, $investmentTypeId, $amount);
|
|
$this->validateFundCapacity($investmentType, $amount);
|
|
|
|
$account = $this->ensureAccount($userId, $investmentTypeId);
|
|
$this->assertAccountAcceptsInvestment($account);
|
|
|
|
$investment = Investment::query()->create([
|
|
'reference_no' => $this->generateReferenceNo(),
|
|
'user_id' => $userId,
|
|
'investment_account_id' => $account->id,
|
|
'investment_type_id' => $investmentTypeId,
|
|
'amount' => $amount,
|
|
'source' => InvestmentSource::Billplz,
|
|
'status' => InvestmentStatus::PendingPayment,
|
|
'remarks' => $remarks,
|
|
'created_by' => $actor?->id,
|
|
]);
|
|
|
|
$this->reserveAmount($investment);
|
|
|
|
return $investment->fresh(['account', 'investmentType']);
|
|
});
|
|
}
|
|
|
|
public function cancelPendingInvestment(Investment $investment): Investment
|
|
{
|
|
return DB::transaction(function () use ($investment) {
|
|
$locked = Investment::query()
|
|
->whereKey($investment->id)
|
|
->lockForUpdate()
|
|
->firstOrFail();
|
|
|
|
if ($locked->status === InvestmentStatus::Cancelled) {
|
|
return $locked->fresh(['account', 'investmentType', 'payment.billplzTransaction']);
|
|
}
|
|
|
|
if (! in_array($locked->status, [
|
|
InvestmentStatus::PendingPayment,
|
|
InvestmentStatus::PendingVerification,
|
|
InvestmentStatus::Draft,
|
|
], true)) {
|
|
throw ValidationException::withMessages([
|
|
'status' => ['Pelaburan ini tidak boleh dibatalkan pada status semasa.'],
|
|
]);
|
|
}
|
|
|
|
$this->releaseReservation($locked);
|
|
|
|
$locked->update([
|
|
'status' => InvestmentStatus::Cancelled,
|
|
'cancelled_at' => now(),
|
|
]);
|
|
|
|
return $locked->fresh(['account', 'investmentType', 'payment.billplzTransaction']);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Shared completion path for MANUAL / BILLPLZ / SALARY once payment/verification succeeds.
|
|
* Idempotent: already COMPLETED investments are returned as-is.
|
|
*/
|
|
public function completeInvestment(Investment $investment, ?User $actor = null): Investment
|
|
{
|
|
return DB::transaction(function () use ($investment, $actor) {
|
|
$locked = Investment::query()
|
|
->whereKey($investment->id)
|
|
->lockForUpdate()
|
|
->firstOrFail();
|
|
|
|
if ($locked->status === InvestmentStatus::Completed) {
|
|
return $locked->fresh([
|
|
'user',
|
|
'account',
|
|
'investmentType',
|
|
'createdBy',
|
|
'approvedBy',
|
|
'ledgerEntries',
|
|
]);
|
|
}
|
|
|
|
if (! in_array($locked->status, [
|
|
InvestmentStatus::PendingPayment,
|
|
InvestmentStatus::PendingVerification,
|
|
InvestmentStatus::Draft,
|
|
], true)) {
|
|
throw ValidationException::withMessages([
|
|
'status' => ['Pelaburan ini tidak boleh diselesaikan pada status semasa.'],
|
|
]);
|
|
}
|
|
|
|
$now = now();
|
|
$locked->update([
|
|
'status' => InvestmentStatus::Completed,
|
|
'approved_by' => $actor?->id ?? $locked->approved_by,
|
|
'approved_at' => $locked->approved_at ?? $now,
|
|
'completed_at' => $now,
|
|
]);
|
|
|
|
$this->releaseReservation($locked);
|
|
$this->postInvestmentCredit($locked->fresh(), $actor);
|
|
|
|
return $locked->fresh([
|
|
'user',
|
|
'account',
|
|
'investmentType',
|
|
'createdBy',
|
|
'approvedBy',
|
|
'ledgerEntries',
|
|
]);
|
|
});
|
|
}
|
|
|
|
public function ensureAccount(string $userId, string $investmentTypeId): InvestmentAccount
|
|
{
|
|
$account = InvestmentAccount::query()
|
|
->where('user_id', $userId)
|
|
->where('investment_type_id', $investmentTypeId)
|
|
->lockForUpdate()
|
|
->first();
|
|
|
|
if ($account) {
|
|
return $account;
|
|
}
|
|
|
|
try {
|
|
return InvestmentAccount::query()->create([
|
|
'user_id' => $userId,
|
|
'investment_type_id' => $investmentTypeId,
|
|
'current_balance' => 0,
|
|
'pending_balance' => 0,
|
|
'status' => InvestmentAccountStatus::Active,
|
|
]);
|
|
} catch (UniqueConstraintViolationException) {
|
|
return InvestmentAccount::query()
|
|
->where('user_id', $userId)
|
|
->where('investment_type_id', $investmentTypeId)
|
|
->lockForUpdate()
|
|
->firstOrFail();
|
|
}
|
|
}
|
|
|
|
protected function postInvestmentCredit(Investment $investment, ?User $actor = null): InvestmentLedger
|
|
{
|
|
$existing = InvestmentLedger::query()
|
|
->where('investment_id', $investment->id)
|
|
->where('transaction_type', InvestmentLedgerTransactionType::Investment)
|
|
->first();
|
|
|
|
if ($existing) {
|
|
return $existing;
|
|
}
|
|
|
|
$account = InvestmentAccount::query()
|
|
->whereKey($investment->investment_account_id)
|
|
->lockForUpdate()
|
|
->firstOrFail();
|
|
|
|
$amount = $this->normalizeAmount($investment->amount);
|
|
$balanceAfter = $this->normalizeAmount($account->current_balance) + $amount;
|
|
|
|
$ledger = InvestmentLedger::query()->create([
|
|
'investment_id' => $investment->id,
|
|
'investment_account_id' => $account->id,
|
|
'user_id' => $investment->user_id,
|
|
'investment_type_id' => $investment->investment_type_id,
|
|
'transaction_type' => InvestmentLedgerTransactionType::Investment,
|
|
'credit' => $amount,
|
|
'debit' => 0,
|
|
'balance_after' => $balanceAfter,
|
|
'created_by' => $actor?->id,
|
|
'created_at' => now(),
|
|
]);
|
|
|
|
$account->update([
|
|
'current_balance' => $balanceAfter,
|
|
]);
|
|
|
|
$this->increaseInvested($investment->investment_type_id, $amount);
|
|
|
|
return $ledger;
|
|
}
|
|
|
|
protected function validateProductConstraints(InvestmentType $investmentType, float $amount): void
|
|
{
|
|
if ($investmentType->status !== InvestmentTypeStatus::Active) {
|
|
throw ValidationException::withMessages([
|
|
'investment_type_id' => ['Jenis pelaburan tidak aktif.'],
|
|
]);
|
|
}
|
|
|
|
$today = now()->startOfDay();
|
|
|
|
if ($investmentType->start_date && $today->lt($investmentType->start_date->startOfDay())) {
|
|
throw ValidationException::withMessages([
|
|
'investment_type_id' => ['Jenis pelaburan belum dibuka.'],
|
|
]);
|
|
}
|
|
|
|
if ($investmentType->end_date && $today->gt($investmentType->end_date->endOfDay())) {
|
|
throw ValidationException::withMessages([
|
|
'investment_type_id' => ['Jenis pelaburan telah tamat.'],
|
|
]);
|
|
}
|
|
|
|
$minimum = $this->normalizeAmount($investmentType->minimum_amount);
|
|
if ($amount < $minimum) {
|
|
throw ValidationException::withMessages([
|
|
'amount' => ["Jumlah minimum ialah {$minimum}."],
|
|
]);
|
|
}
|
|
|
|
if ($investmentType->maximum_amount !== null) {
|
|
$maximum = $this->normalizeAmount($investmentType->maximum_amount);
|
|
if ($amount > $maximum) {
|
|
throw ValidationException::withMessages([
|
|
'amount' => ["Jumlah maksimum ialah {$maximum}."],
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
protected function validateFundCapacity(InvestmentType $investmentType, float $amount): void
|
|
{
|
|
if ($investmentType->total_fund_limit === null) {
|
|
return;
|
|
}
|
|
|
|
$summary = $this->lockOrCreateFundSummary($investmentType);
|
|
$limit = $this->normalizeAmount($investmentType->total_fund_limit);
|
|
$used = $this->normalizeAmount($summary->total_invested)
|
|
+ $this->normalizeAmount($summary->reserved_amount);
|
|
|
|
if (($used + $amount) > $limit) {
|
|
throw ValidationException::withMessages([
|
|
'amount' => ['Had dana jenis pelaburan tidak mencukupi.'],
|
|
]);
|
|
}
|
|
}
|
|
|
|
protected function assertAccountAcceptsInvestment(InvestmentAccount $account): void
|
|
{
|
|
if ($account->status !== InvestmentAccountStatus::Active) {
|
|
throw ValidationException::withMessages([
|
|
'user_id' => ['Akaun pelaburan ahli tidak aktif.'],
|
|
]);
|
|
}
|
|
}
|
|
|
|
protected function lockInvestmentType(string $investmentTypeId): InvestmentType
|
|
{
|
|
$investmentType = InvestmentType::query()
|
|
->whereKey($investmentTypeId)
|
|
->lockForUpdate()
|
|
->first();
|
|
|
|
if (! $investmentType) {
|
|
throw ValidationException::withMessages([
|
|
'investment_type_id' => ['Jenis pelaburan tidak dijumpai.'],
|
|
]);
|
|
}
|
|
|
|
return $investmentType;
|
|
}
|
|
|
|
protected function lockOrCreateFundSummary(InvestmentType $investmentType): InvestmentFundSummary
|
|
{
|
|
$summary = InvestmentFundSummary::query()
|
|
->where('investment_type_id', $investmentType->id)
|
|
->lockForUpdate()
|
|
->first();
|
|
|
|
if ($summary) {
|
|
return $summary;
|
|
}
|
|
|
|
return InvestmentFundSummary::query()->create([
|
|
'investment_type_id' => $investmentType->id,
|
|
'total_invested' => 0,
|
|
'reserved_amount' => 0,
|
|
'remaining_capacity' => $investmentType->total_fund_limit,
|
|
'updated_at' => now(),
|
|
]);
|
|
}
|
|
|
|
protected function increaseInvested(string $investmentTypeId, float $amount): void
|
|
{
|
|
$investmentType = $this->lockInvestmentType($investmentTypeId);
|
|
$summary = $this->lockOrCreateFundSummary($investmentType);
|
|
|
|
$totalInvested = $this->normalizeAmount($summary->total_invested) + $amount;
|
|
$reserved = $this->normalizeAmount($summary->reserved_amount);
|
|
$remaining = $investmentType->total_fund_limit === null
|
|
? null
|
|
: $this->normalizeAmount($investmentType->total_fund_limit) - $totalInvested - $reserved;
|
|
|
|
$summary->update([
|
|
'total_invested' => $totalInvested,
|
|
'remaining_capacity' => $remaining,
|
|
'updated_at' => now(),
|
|
]);
|
|
}
|
|
|
|
protected function reserveAmount(Investment $investment): void
|
|
{
|
|
$amount = $this->normalizeAmount($investment->amount);
|
|
$investmentType = $this->lockInvestmentType($investment->investment_type_id);
|
|
$summary = $this->lockOrCreateFundSummary($investmentType);
|
|
|
|
$typeReserved = $this->normalizeAmount($investmentType->reserved_amount) + $amount;
|
|
$summaryReserved = $this->normalizeAmount($summary->reserved_amount) + $amount;
|
|
|
|
$investmentType->update([
|
|
'reserved_amount' => $typeReserved,
|
|
]);
|
|
|
|
$account = InvestmentAccount::query()
|
|
->whereKey($investment->investment_account_id)
|
|
->lockForUpdate()
|
|
->firstOrFail();
|
|
|
|
$account->update([
|
|
'pending_balance' => $this->normalizeAmount($account->pending_balance) + $amount,
|
|
]);
|
|
|
|
$remaining = $investmentType->total_fund_limit === null
|
|
? null
|
|
: $this->normalizeAmount($investmentType->total_fund_limit)
|
|
- $this->normalizeAmount($summary->total_invested)
|
|
- $summaryReserved;
|
|
|
|
$summary->update([
|
|
'reserved_amount' => $summaryReserved,
|
|
'remaining_capacity' => $remaining,
|
|
'updated_at' => now(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Reserved for pending flows (Billplz / salary). Manual one-shot skips reservation.
|
|
*/
|
|
protected function releaseReservation(Investment $investment): void
|
|
{
|
|
$amount = $this->normalizeAmount($investment->amount);
|
|
$investmentType = $this->lockInvestmentType($investment->investment_type_id);
|
|
$summary = $this->lockOrCreateFundSummary($investmentType);
|
|
|
|
$typeReserved = max(0, $this->normalizeAmount($investmentType->reserved_amount) - $amount);
|
|
$summaryReserved = max(0, $this->normalizeAmount($summary->reserved_amount) - $amount);
|
|
|
|
$investmentType->update([
|
|
'reserved_amount' => $typeReserved,
|
|
]);
|
|
|
|
$account = InvestmentAccount::query()
|
|
->whereKey($investment->investment_account_id)
|
|
->lockForUpdate()
|
|
->first();
|
|
|
|
if ($account) {
|
|
$account->update([
|
|
'pending_balance' => max(0, $this->normalizeAmount($account->pending_balance) - $amount),
|
|
]);
|
|
}
|
|
|
|
$remaining = $investmentType->total_fund_limit === null
|
|
? null
|
|
: $this->normalizeAmount($investmentType->total_fund_limit)
|
|
- $this->normalizeAmount($summary->total_invested)
|
|
- $summaryReserved;
|
|
|
|
$summary->update([
|
|
'reserved_amount' => $summaryReserved,
|
|
'remaining_capacity' => $remaining,
|
|
'updated_at' => now(),
|
|
]);
|
|
}
|
|
|
|
protected function generateReferenceNo(): string
|
|
{
|
|
$year = now()->year;
|
|
$prefix = "INV-{$year}-";
|
|
|
|
$latestNumber = Investment::query()
|
|
->where('reference_no', 'like', "{$prefix}%")
|
|
->orderByDesc('reference_no')
|
|
->lockForUpdate()
|
|
->value('reference_no');
|
|
|
|
$sequence = 1;
|
|
if ($latestNumber && preg_match('/-(\d+)$/', $latestNumber, $matches)) {
|
|
$sequence = ((int) $matches[1]) + 1;
|
|
}
|
|
|
|
return sprintf('%s%05d', $prefix, $sequence);
|
|
}
|
|
|
|
protected function normalizeAmount(string|float|int|null $amount): float
|
|
{
|
|
return round((float) $amount, 2);
|
|
}
|
|
}
|