Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.78% covered (success)
94.78%
109 / 115
73.33% covered (warning)
73.33%
11 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
LoanService
94.78% covered (success)
94.78%
109 / 115
73.33% covered (warning)
73.33%
11 / 15
37.19
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
 checkEligibility
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 requestLoan
96.55% covered (success)
96.55%
28 / 29
0.00% covered (danger)
0.00%
0 / 1
7
 getLoan
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getInvestorLoans
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPendingLoans
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getActiveLoans
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getApprovedLoans
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 approveLoan
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
8.04
 activateLoan
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 denyLoan
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 getConfig
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPaymentSchedule
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 makePayment
92.68% covered (success)
92.68%
38 / 41
0.00% covered (danger)
0.00%
0 / 1
6.01
 getValidTerms
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace App\Domain\Loan\Service;
6
7use App\Domain\Exception\ConflictException;
8use App\Domain\Exception\NotFoundException;
9use App\Domain\Exception\ValidationException;
10use App\Domain\Loan\Data\LoanData;
11use App\Domain\Loan\Data\LoanEligibilityData;
12use App\Domain\Loan\Repository\LoanRepository;
13use App\Domain\Stripe\Service\InvestorStripeService;
14use App\Domain\Transaction\Service\TransactionService;
15use App\Support\Row;
16
17/**
18 * Handles the creation, management, and eligibility checking for loans.
19 *
20 * This service implements key operations for managing loans, such as
21 * checking investor eligibility, requesting loans, and retrieving
22 * loan details or payment schedules. It validates loan parameters
23 * and calculates loan terms using simple interest.
24 */
25final readonly class LoanService
26{
27    private const array VALID_TERMS = [6, 12, 24, 36, 48, 60];
28
29    public function __construct(
30        private LoanRepository $repository,
31        private TransactionService $transactionService,
32        private InvestorStripeService $investorStripe,
33    ) {}
34
35    /**
36     * Checks the eligibility of an investor for a loan.
37     *
38     * @param int $investorId The unique identifier of the investor.
39     * @return LoanEligibilityData Returns the eligibility data for the investor, including details such as eligibility status, maximum loan amount, and reasons for ineligibility if applicable.
40     */
41    public function checkEligibility(int $investorId): LoanEligibilityData
42    {
43        return $this->repository->checkEligibility($investorId);
44    }
45
46    /**
47     * Request a loan — creates in 'requested' status pending admin approval.
48     *
49     * @param int $investorId
50     * @param string $amount
51     * @param int $termMonths
52     * @return LoanData
53     */
54    public function requestLoan(int $investorId, string $amount, int $termMonths): LoanData
55    {
56        if (!in_array($termMonths, self::VALID_TERMS, true)) {
57            throw new ValidationException(sprintf(
58                'Invalid term. Must be one of: %s months',
59                implode(', ', self::VALID_TERMS)
60            ));
61        }
62
63        if (!is_numeric($amount) || (float)$amount <= 0) {
64            throw new ValidationException('Amount must be a positive number');
65        }
66
67        $eligibility = $this->repository->checkEligibility($investorId);
68        if (!$eligibility->eligible) {
69            throw new ValidationException($eligibility->reason);
70        }
71
72        if ((float)$amount > (float)$eligibility->maxLoanAmount) {
73            throw new ValidationException(sprintf(
74                'Requested amount exceeds maximum allowed ($%s)',
75                number_format((float)$eligibility->maxLoanAmount, 2)
76            ));
77        }
78
79        $accountId = $this->repository->getInvestorAccountId($investorId);
80        if ($accountId === null) {
81            throw new NotFoundException('No account found for this investor');
82        }
83
84        // Lazy-create the Stripe Customer the first time an investor takes a
85        // loan. We don't store the returned ID locally here — the service
86        // persists it on the investors row. Failing here fails the loan
87        // request, which is preferred over creating a loan we have no way
88        // to charge against later.
89        $this->investorStripe->ensureCustomerExists($investorId);
90
91        $config = $this->repository->getConfig();
92        $interestRate = $config['default_interest_rate'] ?? '8.00';
93
94        $loanId = $this->repository->createLoanRequest(
95            investorId: $investorId,
96            accountId: $accountId,
97            amount: $amount,
98            termMonths: $termMonths,
99            interestRate: $interestRate,
100        );
101
102        return $this->getLoan($loanId);
103    }
104
105    public function getLoan(int $loanId): LoanData
106    {
107        $loan = $this->repository->findById($loanId);
108        if ($loan === null) {
109            throw new NotFoundException('Loan not found');
110        }
111        return $loan;
112    }
113
114    /** @return array<LoanData> */
115    public function getInvestorLoans(int $investorId): array
116    {
117        return $this->repository->findByInvestorId($investorId);
118    }
119
120    /** @return array<LoanData> */
121    public function getPendingLoans(): array
122    {
123        return $this->repository->findPending();
124    }
125
126    /** @return array<LoanData> */
127    public function getActiveLoans(): array
128    {
129        return $this->repository->findActive();
130    }
131
132    /** @return array<LoanData> */
133    public function getApprovedLoans(): array
134    {
135        return $this->repository->findApproved();
136    }
137
138    public function approveLoan(
139        int $loanId,
140        int $adminUserId,
141        ?string $amount = null,
142        ?int $termMonths = null,
143        ?string $interestRate = null,
144        ?string $notes = null,
145    ): LoanData {
146        $loan = $this->getLoan($loanId);
147
148        if ($loan->status !== 'requested' && $loan->status !== 'under_review') {
149            throw new ConflictException(sprintf('Cannot approve a loan with status "%s"', $loan->status));
150        }
151
152        if ($termMonths !== null && !in_array($termMonths, self::VALID_TERMS, true)) {
153            throw new ValidationException(sprintf(
154                'Invalid term. Must be one of: %s months',
155                implode(', ', self::VALID_TERMS)
156            ));
157        }
158
159        if ($amount !== null && (!is_numeric($amount) || (float)$amount <= 0)) {
160            throw new ValidationException('Approved amount must be a positive number');
161        }
162
163        $this->repository->approveLoan($loanId, $adminUserId, $amount, $termMonths, $interestRate, $notes);
164
165        return $this->getLoan($loanId);
166    }
167
168    /**
169     * @param int $loanId
170     * @param int $adminUserId
171     * @return array{loan: LoanData, paymentSchedule: list<array<mixed>>}
172     */
173    public function activateLoan(int $loanId, int $adminUserId): array
174    {
175        $loan = $this->getLoan($loanId);
176
177        if ($loan->status !== 'approved') {
178            throw new ConflictException(sprintf('Cannot disburse a loan with status "%s" — must be approved first', $loan->status));
179        }
180
181        // The DB trigger on_loan_activated handles:
182        // - Setting activated_at, monthly_payment, total_interest, total_repayment
183        // - Calculating maturity_date, next_payment_due, outstanding_balance
184        $this->repository->activateLoan($loanId);
185
186        // Generate payment schedule now that loan is disbursed
187        $this->repository->generatePaymentSchedule($loanId);
188
189        // Create disbursement transaction — credits the investor's account
190        $this->transactionService->createTransaction(
191            accountId: (int)$loan->accountId,
192            transactionType: 'loan_disbursement',
193            amount: (string)$loan->principleAmount,
194            description: sprintf('Loan #%d disbursement', $loanId),
195        );
196
197        $disbursedLoan = $this->getLoan($loanId);
198        $schedule = $this->getPaymentSchedule($loanId);
199
200        return ['loan' => $disbursedLoan, 'paymentSchedule' => $schedule];
201    }
202
203    public function denyLoan(int $loanId, int $adminUserId, string $reason): LoanData
204    {
205        $loan = $this->getLoan($loanId);
206
207        if ($loan->status !== 'requested' && $loan->status !== 'under_review') {
208            throw new ConflictException(sprintf('Cannot deny a loan with status "%s"', $loan->status));
209        }
210
211        $this->repository->denyLoan($loanId, $adminUserId, $reason);
212
213        return $this->getLoan($loanId);
214    }
215
216    /** @return array<string, string> */
217    public function getConfig(): array
218    {
219        return $this->repository->getConfig();
220    }
221
222    /** @return list<array<mixed>> */
223    public function getPaymentSchedule(int $loanId): array
224    {
225        $this->getLoan($loanId); // Verify exists
226        return $this->repository->getPaymentSchedule($loanId);
227    }
228
229    /**
230     * @param int $loanId
231     * @param int $investorId
232     * @return array{loan: LoanData, paymentId: int, amountPaid: string}
233     */
234    public function makePayment(int $loanId, int $investorId): array
235    {
236        $loan = $this->getLoan($loanId);
237
238        if ((int)$loan->investorId !== $investorId) {
239            // Hide existence of loans belonging to other investors.
240            throw new NotFoundException('Loan not found');
241        }
242
243        if ($loan->status !== 'active' && $loan->status !== 'disbursed') {
244            throw new ConflictException(sprintf('Cannot make payment on a loan with status "%s"', $loan->status));
245        }
246
247        $scheduleEntry = $this->repository->getNextUnpaidScheduleEntry($loanId);
248        if ($scheduleEntry === null) {
249            throw new ConflictException('No payments remaining on this loan');
250        }
251
252        $scheduleId = Row::int($scheduleEntry, 'scheduleId');
253        $paymentNumber = Row::int($scheduleEntry, 'paymentNumber');
254        $amountPaid = Row::string($scheduleEntry, 'expectedAmount');
255        $principalPortion = Row::string($scheduleEntry, 'principalPortion');
256        $interestPortion = Row::string($scheduleEntry, 'interestPortion');
257
258        // Charge the investor's default payment method via Stripe.
259        // Replaces the old "deduct from account balance" model — the loan
260        // payment now flows from card to FlowState's bank, not from the
261        // investor's pooled balance. No transactions row is written.
262        $charge = $this->investorStripe->chargeLoanPayment(
263            investorId: $investorId,
264            amountUsd: $amountPaid,
265            description: sprintf('Loan #%d payment %d', $loanId, $paymentNumber),
266            metadata: [
267                'loan_id' => (string)$loanId,
268                'schedule_id' => (string)$scheduleId,
269                'payment_number' => (string)$paymentNumber,
270            ],
271        );
272
273        if ($charge->status !== 'succeeded') {
274            throw new ConflictException(
275                $charge->declineMessage ?? 'Payment was declined. Please try a different card.',
276            );
277        }
278
279        // Record the payment and mark schedule entry as paid
280        $paymentId = $this->repository->recordPayment(
281            loanId: $loanId,
282            scheduleId: $scheduleId,
283            principalPaid: $principalPortion,
284            interestPaid: $interestPortion,
285            amountPaid: $amountPaid,
286            paymentMethod: 'card',
287            stripePaymentIntentId: $charge->id,
288        );
289
290        return [
291            'loan' => $this->getLoan($loanId),
292            'paymentId' => $paymentId,
293            'amountPaid' => $amountPaid,
294        ];
295    }
296
297    /** @return array<int> */
298    public static function getValidTerms(): array
299    {
300        return self::VALID_TERMS;
301    }
302}