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