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