Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.59% covered (success)
92.59%
100 / 108
33.33% covered (danger)
33.33%
4 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
AccountService
92.59% covered (success)
92.59%
100 / 108
33.33% covered (danger)
33.33%
4 / 12
45.82
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createAccountForInvestor
96.15% covered (success)
96.15%
25 / 26
0.00% covered (danger)
0.00%
0 / 1
11
 getAccount
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getAccountByInvestorId
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getAccountSummary
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
2.01
 getBalanceHistory
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 activateAccount
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
4.01
 freezeAccount
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 unfreezeAccount
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 closeAccount
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
5.02
 updateInterestRate
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
4.02
 updateLoanToValueRatio
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
4.02
1<?php
2
3declare(strict_types=1);
4
5namespace App\Domain\Account\Service;
6
7use App\Domain\Account\Data\AccountData;
8use App\Domain\Account\Data\AccountSummaryData;
9use App\Domain\Account\Data\BalanceHistoryPointData;
10use App\Domain\Account\Repository\AccountRepository;
11use App\Domain\Exception\ConflictException;
12use App\Domain\Exception\NotFoundException;
13use App\Domain\Exception\ValidationException;
14use App\Domain\Loan\Repository\LoanRepository;
15use App\Domain\Transaction\Repository\TransactionRepository;
16use Random\RandomException;
17use RuntimeException;
18
19use function sprintf;
20
21/**
22 * Handles operations related to managing investment accounts, including creation,
23 * retrieval, updates, and status changes. The service enforces validation rules
24 * and business logic for each operation.
25 */
26final class AccountService
27{
28    // Business rule constants
29    private const float MINIMUM_INVESTMENT = 25000.00;
30
31    private const float MAX_INTEREST_RATE = 100.0;
32
33    private const float MAX_LOAN_TO_VALUE_RATIO = 1.0;
34
35    public function __construct(
36        private readonly AccountRepository $repository,
37        private readonly TransactionRepository $transactionRepository,
38        private readonly LoanRepository $loanRepository,
39    ) {}
40
41    /**
42     * @param int $investorId
43     * @param float $initialBalance
44     * @param ?float $interestRate Per-account override as whole percentage (e.g. 8.00 for 8%).
45     *                             Null leaves the column NULL so the global account_yield_rate is used.
46     * @param float $loanToValueRatio
47     *
48     * @throws RandomException
49     *
50     * @return AccountData
51     */
52    public function createAccountForInvestor(
53        int $investorId,
54        float $initialBalance = 0.00,
55        ?float $interestRate = null,
56        float $loanToValueRatio = 0.80,
57    ): AccountData {
58        // Validate investor ID
59        if ($investorId <= 0) {
60            throw new ValidationException('Invalid investor ID');
61        }
62
63        // Validate initial balance
64        if ($initialBalance < 0) {
65            throw new ValidationException('Initial balance cannot be negative');
66        }
67
68        // Validate per-account override only when one is supplied; null falls
69        // through and the account inherits the global yield rate.
70        if ($interestRate !== null && ($interestRate < 0 || $interestRate > self::MAX_INTEREST_RATE)) {
71            throw new ValidationException(
72                'Interest rate must be between 0 and 100',
73            );
74        }
75
76        // Validate loan-to-value ratio
77        if ($loanToValueRatio < 0 || $loanToValueRatio > self::MAX_LOAN_TO_VALUE_RATIO) {
78            throw new ValidationException(
79                'Loan-to-value ratio must be between 0 and 1.0',
80            );
81        }
82
83        // Check if an investor already has an account
84        if ($this->repository->investorHasAccount($investorId)) {
85            throw new ConflictException('Investor already has an account');
86        }
87
88        // Determine status based on balance
89        $status = $initialBalance >= self::MINIMUM_INVESTMENT ? 'active' : 'pending';
90
91        // Create account - pass data as an array to match the repository pattern
92        $accountId = $this->repository->createAccount([
93            'investorId' => $investorId,
94            'balance' => $initialBalance,
95            'interestRate' => $interestRate,
96            'loanToValueRatio' => $loanToValueRatio,
97            'status' => $status,
98        ]);
99
100        // Return created account
101        $account = $this->repository->findAccountById($accountId);
102
103        if (!$account) {
104            throw new RuntimeException('Failed to create account');
105        }
106
107        return $account;
108    }
109
110    public function getAccount(int $accountId): AccountData
111    {
112        if ($accountId <= 0) {
113            throw new ValidationException('Invalid account ID');
114        }
115
116        $account = $this->repository->findAccountById($accountId);
117
118        if (!$account) {
119            throw new NotFoundException('Account not found');
120        }
121
122        return $account;
123    }
124
125    public function getAccountByInvestorId(int $investorId): AccountData
126    {
127        if ($investorId <= 0) {
128            throw new ValidationException('Invalid investor ID');
129        }
130
131        $account = $this->repository->findAccountByInvestorId($investorId);
132
133        if (!$account) {
134            throw new NotFoundException('No account found for this investor');
135        }
136
137        return $account;
138    }
139
140    public function getAccountSummary(int $accountId): AccountSummaryData
141    {
142        // Verify account exists
143        $this->getAccount($accountId);
144
145        // Get summary data from the repository (return array)
146        $summaryData = $this->repository->getAccountSummary($accountId);
147
148        if (!$summaryData) {
149            throw new RuntimeException('Failed to retrieve account summary');
150        }
151
152        // FSC-48: enrich with lock-up info so the UI can show the
153        // withdrawable breakdown without a second request.
154        $lockupDays = (int)($this->loanRepository->getConfig()['lockup_period_days'] ?? '90');
155        $summaryData['lockedBalance'] = $this->transactionRepository->getLockedInvestmentBalance($accountId, $lockupDays);
156        $summaryData['lockupPeriodDays'] = $lockupDays;
157
158        // Convert array to object
159        return new AccountSummaryData($summaryData);
160    }
161
162    /**
163     * Get balance history for an account.
164     *
165     * @param int $accountId The account ID
166     *
167     * @throws NotFoundException If account not found
168     *
169     * @return BalanceHistoryPointData[]
170     */
171    public function getBalanceHistory(int $accountId): array
172    {
173        $account = $this->repository->findAccountById($accountId);
174
175        if ($account === null) {
176            throw new NotFoundException('Account not found');
177        }
178
179        return $this->repository->getBalanceHistory($accountId);
180    }
181
182    public function activateAccount(int $accountId): AccountData
183    {
184        // Verify account exists
185        $account = $this->getAccount($accountId);
186
187        // Business rule: Can only activate pending accounts
188        if ($account->status !== 'pending') {
189            throw new ConflictException('Only pending accounts can be activated');
190        }
191
192        // Business rule: Must meet minimum balance requirement
193        // Convert string balance to float for comparison
194        if ((float)$account->balance < self::MINIMUM_INVESTMENT) {
195            throw new ValidationException(
196                sprintf(
197                    'Account must have minimum balance of $%.2f to activate',
198                    self::MINIMUM_INVESTMENT,
199                ),
200            );
201        }
202
203        // Update status
204        $result = $this->repository->updateAccountStatus($accountId, 'active');
205
206        if (!$result) {
207            throw new RuntimeException('Failed to activate account');
208        }
209
210        return $this->getAccount($accountId);
211    }
212
213    public function freezeAccount(int $accountId): AccountData
214    {
215        // Verify account exists
216        $account = $this->getAccount($accountId);
217
218        // Business rule: Can only freeze active accounts
219        if ($account->status !== 'active') {
220            throw new ConflictException('Only active accounts can be frozen');
221        }
222
223        // Update status
224        $result = $this->repository->updateAccountStatus($accountId, 'frozen');
225
226        if (!$result) {
227            throw new RuntimeException('Failed to freeze account');
228        }
229
230        return $this->getAccount($accountId);
231    }
232
233    public function unfreezeAccount(int $accountId): AccountData
234    {
235        // Verify account exists
236        $account = $this->getAccount($accountId);
237
238        // Business rule: Can only unfreeze frozen accounts
239        if ($account->status !== 'frozen') {
240            throw new ConflictException('Only frozen accounts can be unfrozen');
241        }
242
243        // Update status back to active
244        $result = $this->repository->updateAccountStatus($accountId, 'active');
245
246        if (!$result) {
247            throw new RuntimeException('Failed to unfreeze account');
248        }
249
250        return $this->getAccount($accountId);
251    }
252
253    public function closeAccount(int $accountId): AccountData
254    {
255        // Verify account exists
256        $account = $this->getAccount($accountId);
257
258        // Business rule: Cannot close an already-closed account
259        if ($account->status === 'closed') {
260            throw new ConflictException('Account is already closed');
261        }
262
263        // Business rule: Cannot close an account with balance or outstanding loans
264        // Convert strings to float for comparison
265        $balance = (float)$account->balance;
266        $availableBalance = (float)$account->availableBalance;
267
268        if ($balance > 0 || $availableBalance < 0) {
269            throw new ConflictException('Cannot close account with balance or active loans');
270        }
271
272        // Update status
273        $result = $this->repository->updateAccountStatus($accountId, 'closed');
274
275        if (!$result) {
276            throw new RuntimeException('Failed to close account');
277        }
278
279        return $this->getAccount($accountId);
280    }
281
282    public function updateInterestRate(int $accountId, float $newRate): AccountData
283    {
284        // Verify account exists
285        $this->getAccount($accountId);
286
287        // Validate rate (whole percentage, e.g. 8.00 for 8%)
288        if ($newRate < 0 || $newRate > self::MAX_INTEREST_RATE) {
289            throw new ValidationException(
290                'Interest rate must be between 0 and 100',
291            );
292        }
293
294        // Convert float to string for repository
295        $result = $this->repository->updateInterestRate($accountId, (string)$newRate);
296
297        if (!$result) {
298            throw new RuntimeException('Failed to update interest rate');
299        }
300
301        return $this->getAccount($accountId);
302    }
303
304    public function updateLoanToValueRatio(int $accountId, float $newRatio): AccountData
305    {
306        // Verify account exists
307        $this->getAccount($accountId);
308
309        // Validate ratio
310        if ($newRatio < 0 || $newRatio > self::MAX_LOAN_TO_VALUE_RATIO) {
311            throw new ValidationException(
312                'Loan-to-value ratio must be between 0 and 1.0',
313            );
314        }
315
316        // Convert float to string for repository
317        $result = $this->repository->updateLoanToValueRatio($accountId, (string)$newRatio);
318
319        if (!$result) {
320            throw new RuntimeException('Failed to update loan-to-value ratio');
321        }
322
323        return $this->getAccount($accountId);
324    }
325}