Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 63
0.00% covered (danger)
0.00%
0 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
LoanService
0.00% covered (danger)
0.00%
0 / 63
0.00% covered (danger)
0.00%
0 / 10
306
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 checkEligibility
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 requestLoan
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
56
 calculateLoanTerms
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
2
 getLoan
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getInvestorLoans
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getActiveLoans
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getConfig
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getPaymentSchedule
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getValidTerms
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace App\Domain\Loan\Service;
6
7use App\Domain\Exception\NotFoundException;
8use App\Domain\Loan\Data\LoanData;
9use App\Domain\Loan\Data\LoanEligibilityData;
10use App\Domain\Loan\Repository\LoanRepository;
11use DateTimeImmutable;
12use DomainException;
13use InvalidArgumentException;
14
15/**
16 * Handles the creation, management, and eligibility checking for loans.
17 *
18 * This service implements key operations for managing loans, such as
19 * checking investor eligibility, requesting loans, and retrieving
20 * loan details or payment schedules. It validates loan parameters
21 * and calculates loan terms using simple interest.
22 */
23final readonly class LoanService
24{
25    private const array VALID_TERMS = [6, 12, 24, 36, 48, 60];
26
27    public function __construct(private LoanRepository $repository) {}
28
29    /**
30     * Checks the eligibility of an investor for a loan.
31     *
32     * @param int $investorId The unique identifier of the investor.
33     * @return LoanEligibilityData Returns the eligibility data for the investor, including details such as eligibility status, maximum loan amount, and reasons for ineligibility if applicable.
34     */
35    public function checkEligibility(int $investorId): LoanEligibilityData
36    {
37        return $this->repository->checkEligibility($investorId);
38    }
39
40    /**
41     * Request a loan - auto-approves and activates if within LTV.
42     *
43     * @param int $investorId
44     * @param string $amount
45     * @param int $termMonths
46     * @return array{loan: LoanData, paymentSchedule: array<int, array<string, mixed>>}
47     */
48    public function requestLoan(int $investorId, string $amount, int $termMonths): array
49    {
50        if (!in_array($termMonths, self::VALID_TERMS, true)) {
51            throw new InvalidArgumentException(sprintf(
52                'Invalid term. Must be one of: %s months',
53                implode(', ', self::VALID_TERMS)
54            ));
55        }
56
57        if (!is_numeric($amount) || (float)$amount <= 0) {
58            throw new InvalidArgumentException('Amount must be a positive number');
59        }
60
61        $eligibility = $this->repository->checkEligibility($investorId);
62        if (!$eligibility->eligible) {
63            throw new DomainException($eligibility->reason);
64        }
65
66        if ((float)$amount > (float)$eligibility->maxLoanAmount) {
67            throw new InvalidArgumentException(sprintf(
68                'Requested amount exceeds maximum allowed ($%s)',
69                number_format((float)$eligibility->maxLoanAmount, 2)
70            ));
71        }
72
73        $accountId = $this->repository->getInvestorAccountId($investorId);
74        if ($accountId === null) {
75            throw new DomainException('No account found for this investor');
76        }
77
78        $config = $this->repository->getConfig();
79        $interestRate = $config['default_interest_rate'] ?? '8.00';
80
81        // Calculate loan terms
82        $calculation = $this->calculateLoanTerms($amount, $termMonths, $interestRate);
83
84        // Create loan directly as active with all calculated values
85        $loanId = $this->repository->createActiveLoan(
86            investorId: $investorId,
87            accountId: $accountId,
88            principleAmount: $amount,
89            termMonths: $termMonths,
90            interestRate: $interestRate,
91            monthlyPayment: $calculation['monthlyPayment'],
92            totalInterest: $calculation['totalInterest'],
93            totalRepayment: $calculation['totalRepayment'],
94            maturityDate: $calculation['maturityDate'],
95        );
96
97        // Generate payment schedule
98        $this->repository->generatePaymentSchedule($loanId);
99
100        $loan = $this->getLoan($loanId);
101        $schedule = $this->getPaymentSchedule($loanId);
102
103        return ['loan' => $loan, 'paymentSchedule' => $schedule];
104    }
105
106    /**
107     * Calculate loan terms using simple interest.
108     *
109     * @param string $principal
110     * @param int $termMonths
111     * @param string $interestRate
112     * @return array{monthlyPayment: string, totalInterest: string, totalRepayment: string, maturityDate: string}
113     */
114    private function calculateLoanTerms(string $principal, int $termMonths, string $interestRate): array
115    {
116        $principalFloat = (float)$principal;
117        $rateDecimal = (float)$interestRate / 100;
118        $termYears = $termMonths / 12;
119
120        // Simple interest: I = P × r × t
121        $totalInterest = $principalFloat * $rateDecimal * $termYears;
122        $totalRepayment = $principalFloat + $totalInterest;
123        $monthlyPayment = $totalRepayment / $termMonths;
124
125        // Calculate maturity date
126        $maturityDate = (new DateTimeImmutable())
127            ->modify("+{$termMonths} months")
128            ->format('Y-m-d');
129
130        return [
131            'monthlyPayment' => number_format($monthlyPayment, 2, '.', ''),
132            'totalInterest' => number_format($totalInterest, 2, '.', ''),
133            'totalRepayment' => number_format($totalRepayment, 2, '.', ''),
134            'maturityDate' => $maturityDate,
135        ];
136    }
137
138    public function getLoan(int $loanId): LoanData
139    {
140        $loan = $this->repository->findById($loanId);
141        if ($loan === null) {
142            throw new NotFoundException('Loan not found');
143        }
144        return $loan;
145    }
146
147    /** @return array<LoanData> */
148    public function getInvestorLoans(int $investorId): array
149    {
150        return $this->repository->findByInvestorId($investorId);
151    }
152
153    /** @return array<LoanData> */
154    public function getActiveLoans(): array
155    {
156        return $this->repository->findActive();
157    }
158
159    /** @return array<string, string> */
160    public function getConfig(): array
161    {
162        return $this->repository->getConfig();
163    }
164
165    /** @return array<int, array<string, mixed>> */
166    public function getPaymentSchedule(int $loanId): array
167    {
168        $this->getLoan($loanId); // Verify exists
169        return $this->repository->getPaymentSchedule($loanId);
170    }
171
172    /** @return array<int> */
173    public static function getValidTerms(): array
174    {
175        return self::VALID_TERMS;
176    }
177}