Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
0.00% |
0 / 63 |
|
0.00% |
0 / 10 |
CRAP | |
0.00% |
0 / 1 |
| LoanService | |
0.00% |
0 / 63 |
|
0.00% |
0 / 10 |
306 | |
0.00% |
0 / 1 |
| __construct | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| checkEligibility | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| requestLoan | |
0.00% |
0 / 36 |
|
0.00% |
0 / 1 |
56 | |||
| calculateLoanTerms | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
2 | |||
| getLoan | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
| getInvestorLoans | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| getActiveLoans | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| getConfig | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| getPaymentSchedule | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| getValidTerms | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace App\Domain\Loan\Service; |
| 6 | |
| 7 | use App\Domain\Exception\NotFoundException; |
| 8 | use App\Domain\Loan\Data\LoanData; |
| 9 | use App\Domain\Loan\Data\LoanEligibilityData; |
| 10 | use App\Domain\Loan\Repository\LoanRepository; |
| 11 | use DateTimeImmutable; |
| 12 | use DomainException; |
| 13 | use 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 | */ |
| 23 | final 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 | } |