Files
mykopkb-be/Modules/Investment/Services/InvestmentPostingService.php
T

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);
}
}