Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.57% covered (success)
92.57%
162 / 175
64.71% covered (warning)
64.71%
11 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
LoanService
92.57% covered (success)
92.57%
162 / 175
64.71% covered (warning)
64.71%
11 / 17
53.11
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
93.75% covered (success)
93.75%
30 / 32
0.00% covered (danger)
0.00%
0 / 1
7.01
 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
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
8.02
 acceptLoan
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 declineLoan
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
4.01
 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
93.02% covered (success)
93.02%
40 / 43
0.00% covered (danger)
0.00%
0 / 1
6.01
 makeAdditionalPayment
87.18% covered (warning)
87.18%
34 / 39
0.00% covered (danger)
0.00%
0 / 1
10.21
 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\Disbursement\Service\DisbursementService;
8use App\Domain\Exception\ConflictException;
9use App\Domain\Exception\NotFoundException;
10use App\Domain\Exception\ValidationException;
11use App\Domain\Loan\Data\LoanData;
12use App\Domain\Loan\Data\LoanEligibilityData;
13use App\Domain\Loan\Repository\LoanRepository;
14use App\Domain\Stripe\Service\InvestorStripeService;
15use App\Support\Row;
16use RuntimeException;
17
18/**
19 * Handles the creation, management, and eligibility checking for loans.
20 *
21 * This service implements key operations for managing loans, such as
22 * checking investor eligibility, requesting loans, and retrieving
23 * loan details or payment schedules. It validates loan parameters
24 * and calculates loan terms using standard amortization.
25 */
26final readonly class LoanService
27{
28    private const array VALID_TERMS = [6, 12, 24, 36, 48, 60];
29
30    public function __construct(
31        private LoanRepository $repository,
32        private DisbursementService $disbursementService,
33        private InvestorStripeService $investorStripe,
34    ) {}
35
36    /**
37     * Checks the eligibility of an investor for a loan.
38     *
39     * @param int $investorId The unique identifier of the investor.
40     * @return LoanEligibilityData Returns the eligibility data for the investor, including details such as eligibility status, maximum loan amount, and reasons for ineligibility if applicable.
41     */
42    public function checkEligibility(int $investorId): LoanEligibilityData
43    {
44        return $this->repository->checkEligibility($investorId);
45    }
46
47    /**
48     * Request a loan — creates in 'requested' status pending admin approval.
49     *
50     * @param int $investorId
51     * @param string $amount
52     * @param int $termMonths
53     * @return LoanData
54     */
55    public function requestLoan(int $investorId, string $amount, int $termMonths): LoanData
56    {
57        if (!in_array($termMonths, self::VALID_TERMS, true)) {
58            throw new ValidationException(sprintf(
59                'Invalid term. Must be one of: %s months',
60                implode(', ', self::VALID_TERMS)
61            ));
62        }
63
64        if (!is_numeric($amount) || (float)$amount <= 0) {
65            throw new ValidationException('Amount must be a positive number');
66        }
67
68        $eligibility = $this->repository->checkEligibility($investorId);
69        if (!$eligibility->eligible) {
70            throw new ValidationException($eligibility->reason);
71        }
72
73        if ((float)$amount > (float)$eligibility->maxLoanAmount) {
74            throw new ValidationException(sprintf(
75                'Requested amount exceeds maximum allowed ($%s)',
76                number_format((float)$eligibility->maxLoanAmount, 2)
77            ));
78        }
79
80        $accountId = $this->repository->getInvestorAccountId($investorId);
81        if ($accountId === null) {
82            throw new NotFoundException('No account found for this investor');
83        }
84
85        // Lazy-create the Stripe Customer the first time an investor takes a
86        // loan. We don't store the returned ID locally here — the service
87        // persists it on the investors row. Failing here fails the loan
88        // request, which is preferred over creating a loan we have no way
89        // to charge against later.
90        $this->investorStripe->ensureCustomerExists($investorId);
91
92        $config = $this->repository->getConfig();
93        $interestRate = $config['default_interest_rate']
94            ?? throw new RuntimeException('default_interest_rate is not configured in loan_config');
95        $servicingFeeRate = $config['servicing_fee_rate'] ?? '0.00';
96
97        $loanId = $this->repository->createLoanRequest(
98            investorId: $investorId,
99            accountId: $accountId,
100            amount: $amount,
101            termMonths: $termMonths,
102            interestRate: $interestRate,
103            servicingFeeRate: $servicingFeeRate,
104        );
105
106        return $this->getLoan($loanId);
107    }
108
109    public function getLoan(int $loanId): LoanData
110    {
111        $loan = $this->repository->findById($loanId);
112        if ($loan === null) {
113            throw new NotFoundException('Loan not found');
114        }
115        return $loan;
116    }
117
118    /** @return array<LoanData> */
119    public function getInvestorLoans(int $investorId): array
120    {
121        return $this->repository->findByInvestorId($investorId);
122    }
123
124    /** @return array<LoanData> */
125    public function getPendingLoans(): array
126    {
127        return $this->repository->findPending();
128    }
129
130    /** @return array<LoanData> */
131    public function getActiveLoans(): array
132    {
133        return $this->repository->findActive();
134    }
135
136    /** @return array<LoanData> */
137    public function getApprovedLoans(): array
138    {
139        return $this->repository->findApproved();
140    }
141
142    public function approveLoan(
143        int $loanId,
144        int $adminUserId,
145        ?string $amount = null,
146        ?int $termMonths = null,
147        ?string $interestRate = null,
148        ?string $notes = null,
149    ): LoanData {
150        $loan = $this->getLoan($loanId);
151
152        // FSC-130 follow-up: `under_review` is only ever reached by an investor
153        // declining the admin's terms (see declineLoan). Those terms are off the
154        // table — the admin can only deny (close) the request, not re-approve it.
155        if ($loan->status === 'under_review') {
156            throw new ConflictException(
157                'This loan was declined by the investor and can no longer be approved. Deny it to close the request.',
158            );
159        }
160
161        if ($loan->status !== 'requested') {
162            throw new ConflictException(sprintf('Cannot approve a loan with status "%s"', $loan->status));
163        }
164
165        if ($termMonths !== null && !in_array($termMonths, self::VALID_TERMS, true)) {
166            throw new ValidationException(sprintf(
167                'Invalid term. Must be one of: %s months',
168                implode(', ', self::VALID_TERMS)
169            ));
170        }
171
172        if ($amount !== null && (!is_numeric($amount) || (float)$amount <= 0)) {
173            throw new ValidationException('Approved amount must be a positive number');
174        }
175
176        // FSC-94: admin approval now moves the loan to `pending_acceptance`.
177        // The investor must accept the (possibly admin-modified) terms before
178        // the disbursement queue is created. acceptLoan() is what enqueues
179        // disbursement and moves status to `approved`.
180        $this->repository->approveLoan($loanId, $adminUserId, $amount, $termMonths, $interestRate, $notes);
181
182        return $this->getLoan($loanId);
183    }
184
185    /**
186     * Investor accepts a `pending_acceptance` loan. This is the gate that
187     * triggers disbursement queueing — only after the investor agrees to
188     * the (possibly admin-modified) terms does the loan become eligible
189     * for the FSC-96 disbursement queue.
190     *
191     * @param int $loanId
192     * @param int $investorId
193     * @throws ConflictException if the loan is not awaiting acceptance.
194     * @throws NotFoundException if the loan does not belong to the investor.
195     */
196    public function acceptLoan(int $loanId, int $investorId): LoanData
197    {
198        $loan = $this->getLoan($loanId);
199
200        if ((int)$loan->investorId !== $investorId) {
201            // Don't reveal existence of loans belonging to other investors.
202            throw new NotFoundException('Loan not found');
203        }
204
205        if ($loan->status !== 'pending_acceptance') {
206            throw new ConflictException(sprintf(
207                'Cannot accept a loan with status "%s"',
208                $loan->status,
209            ));
210        }
211
212        $this->repository->acceptLoan($loanId);
213
214        $accepted = $this->getLoan($loanId);
215        $disbursementAmount = $accepted->principleAmount ?? $accepted->requestedAmount ?? '0';
216        $this->disbursementService->enqueue($loanId, $disbursementAmount);
217
218        return $accepted;
219    }
220
221    /**
222     * Investor declines a `pending_acceptance` loan. FSC-130: rather than
223     * ending the loan at `denied`, the request returns to the admin queue as
224     * `under_review` with the investor-supplied reason attached, so the admin
225     * can revise the terms and re-approve (or deny outright). No disbursement
226     * is enqueued.
227     *
228     * @param int $loanId
229     * @param int $investorId
230     * @param string $reason
231     * @throws ConflictException if the loan is not awaiting acceptance.
232     * @throws NotFoundException if the loan does not belong to the investor.
233     * @throws ValidationException if the reason is empty.
234     */
235    public function declineLoan(int $loanId, int $investorId, string $reason): LoanData
236    {
237        $loan = $this->getLoan($loanId);
238
239        if ((int)$loan->investorId !== $investorId) {
240            throw new NotFoundException('Loan not found');
241        }
242
243        if ($loan->status !== 'pending_acceptance') {
244            throw new ConflictException(sprintf(
245                'Cannot decline a loan with status "%s"',
246                $loan->status,
247            ));
248        }
249
250        $trimmedReason = trim($reason);
251        if ($trimmedReason === '') {
252            throw new ValidationException('Decline reason is required');
253        }
254
255        $this->repository->declineLoan($loanId, $trimmedReason);
256
257        return $this->getLoan($loanId);
258    }
259
260    public function denyLoan(int $loanId, int $adminUserId, string $reason): LoanData
261    {
262        $loan = $this->getLoan($loanId);
263
264        if ($loan->status !== 'requested' && $loan->status !== 'under_review') {
265            throw new ConflictException(sprintf('Cannot deny a loan with status "%s"', $loan->status));
266        }
267
268        $this->repository->denyLoan($loanId, $adminUserId, $reason);
269
270        return $this->getLoan($loanId);
271    }
272
273    /** @return array<string, string> */
274    public function getConfig(): array
275    {
276        return $this->repository->getConfig();
277    }
278
279    /** @return list<array<mixed>> */
280    public function getPaymentSchedule(int $loanId): array
281    {
282        $this->getLoan($loanId); // Verify exists
283        return $this->repository->getPaymentSchedule($loanId);
284    }
285
286    /**
287     * @param int $loanId
288     * @param int $investorId
289     * @return array{loan: LoanData, paymentId: int, amountPaid: string}
290     */
291    public function makePayment(int $loanId, int $investorId): array
292    {
293        $loan = $this->getLoan($loanId);
294
295        if ((int)$loan->investorId !== $investorId) {
296            // Hide existence of loans belonging to other investors.
297            throw new NotFoundException('Loan not found');
298        }
299
300        if ($loan->status !== 'active' && $loan->status !== 'disbursed') {
301            throw new ConflictException(sprintf('Cannot make payment on a loan with status "%s"', $loan->status));
302        }
303
304        $scheduleEntry = $this->repository->getNextUnpaidScheduleEntry($loanId);
305        if ($scheduleEntry === null) {
306            throw new ConflictException('No payments remaining on this loan');
307        }
308
309        $scheduleId = Row::int($scheduleEntry, 'scheduleId');
310        $paymentNumber = Row::int($scheduleEntry, 'paymentNumber');
311        $amountPaid = Row::string($scheduleEntry, 'expectedAmount');
312        $principalPortion = Row::string($scheduleEntry, 'principalPortion');
313        $interestPortion = Row::string($scheduleEntry, 'interestPortion');
314        $servicingPortion = Row::string($scheduleEntry, 'servicingPortion');
315
316        // Charge the investor's default payment method via Stripe.
317        // Replaces the old "deduct from account balance" model — the loan
318        // payment now flows from card to FlowState's bank, not from the
319        // investor's pooled balance. No transactions row is written.
320        $charge = $this->investorStripe->chargeLoanPayment(
321            investorId: $investorId,
322            amountUsd: $amountPaid,
323            description: sprintf('Loan #%d payment %d', $loanId, $paymentNumber),
324            metadata: [
325                'loan_id' => (string)$loanId,
326                'schedule_id' => (string)$scheduleId,
327                'payment_number' => (string)$paymentNumber,
328            ],
329        );
330
331        if ($charge->status !== 'succeeded') {
332            throw new ConflictException(
333                $charge->declineMessage ?? 'Payment was declined. Please try a different card.',
334            );
335        }
336
337        // Record the payment and mark schedule entry as paid
338        $paymentId = $this->repository->recordPayment(
339            loanId: $loanId,
340            scheduleId: $scheduleId,
341            principalPaid: $principalPortion,
342            interestPaid: $interestPortion,
343            amountPaid: $amountPaid,
344            paymentMethod: 'card',
345            stripePaymentIntentId: $charge->id,
346            servicingPaid: $servicingPortion,
347        );
348
349        return [
350            'loan' => $this->getLoan($loanId),
351            'paymentId' => $paymentId,
352            'amountPaid' => $amountPaid,
353        ];
354    }
355
356    /**
357     * Make an additional (off-schedule) payment toward a loan — an extra
358     * amount on top of the scheduled installments, or a full payoff (FSC-102).
359     *
360     * The amount is charged via Stripe and recorded as an off-schedule payment.
361     * The update_loan_balance trigger draws it down from outstanding_balance and
362     * flips the loan to paid_off at zero; on payoff we settle any remaining
363     * schedule rows. No interest rebate — the borrower owes the precomputed
364     * total, so the amount is capped at the remaining balance.
365     *
366     * @param int $loanId
367     * @param int $investorId
368     * @param string $amount
369     * @return array{loan: LoanData, paymentId: int, amountPaid: string}
370     */
371    public function makeAdditionalPayment(int $loanId, int $investorId, string $amount): array
372    {
373        $loan = $this->getLoan($loanId);
374
375        if ((int)$loan->investorId !== $investorId) {
376            // Hide existence of loans belonging to other investors.
377            throw new NotFoundException('Loan not found');
378        }
379
380        if ($loan->status !== 'active' && $loan->status !== 'disbursed') {
381            throw new ConflictException(sprintf('Cannot make payment on a loan with status "%s"', $loan->status));
382        }
383
384        if (!is_numeric($amount) || (float)$amount <= 0) {
385            throw new ValidationException('Payment amount must be a positive number');
386        }
387
388        $remaining = $loan->outstandingBalance ?? '0';
389        if ((float)$remaining <= 0) {
390            throw new ConflictException('This loan is already paid off');
391        }
392
393        // Never charge more than is owed — the "pay off" action sends the full
394        // balance, and a stale figure shouldn't overcharge the borrower.
395        $chargeAmount = (float)$amount > (float)$remaining ? $remaining : $amount;
396
397        $charge = $this->investorStripe->chargeLoanPayment(
398            investorId: $investorId,
399            amountUsd: $chargeAmount,
400            description: sprintf('Loan #%d additional payment', $loanId),
401            metadata: [
402                'loan_id' => (string)$loanId,
403                'type' => 'additional',
404            ],
405        );
406
407        if ($charge->status !== 'succeeded') {
408            throw new ConflictException(
409                $charge->declineMessage ?? 'Payment was declined. Please try a different card.',
410            );
411        }
412
413        $paymentId = $this->repository->recordAdditionalPayment(
414            loanId: $loanId,
415            amount: $chargeAmount,
416            stripePaymentIntentId: $charge->id,
417        );
418
419        // The balance trigger drew the prepayment off the principal. If that
420        // cleared the loan, settle the remaining scheduled rows; otherwise recast
421        // the remaining schedule (lower payment over the same remaining term).
422        $updated = $this->getLoan($loanId);
423        if ($updated->status === 'paid_off') {
424            $this->repository->markRemainingScheduleEntriesPaid($loanId);
425        } else {
426            $this->repository->reamortizeLoan($loanId);
427        }
428        $updated = $this->getLoan($loanId);
429
430        return [
431            'loan' => $updated,
432            'paymentId' => $paymentId,
433            'amountPaid' => $chargeAmount,
434        ];
435    }
436
437    /** @return array<int> */
438    public static function getValidTerms(): array
439    {
440        return self::VALID_TERMS;
441    }
442}