Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.54% covered (warning)
86.54%
45 / 52
66.67% covered (warning)
66.67%
4 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
DisbursementService
86.54% covered (warning)
86.54%
45 / 52
66.67% covered (warning)
66.67%
4 / 6
24.29
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
 enqueue
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 listPending
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 listHistory
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
5.02
 countByStatus
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 markDisbursed
87.80% covered (warning)
87.80%
36 / 41
0.00% covered (danger)
0.00%
0 / 1
13.31
1<?php
2
3declare(strict_types=1);
4
5namespace App\Domain\Disbursement\Service;
6
7use App\Domain\Disbursement\Data\DisbursementData;
8use App\Domain\Disbursement\Repository\DisbursementRepository;
9use App\Domain\Exception\ConflictException;
10use App\Domain\Exception\NotFoundException;
11use App\Domain\Exception\ValidationException;
12use App\Domain\Loan\Repository\LoanRepository;
13use PDO;
14use Throwable;
15
16/**
17 * Coordinates the disbursement queue admin uses to fulfil approved loans.
18 *
19 * PR 2 scope: queue + admin endpoints. PR 3 extends `markDisbursed` to
20 * also activate the underlying loan and generate its payment schedule
21 * within the same PDO transaction.
22 */
23final readonly class DisbursementService
24{
25    private const int MAX_BANK_REFERENCE_LENGTH = 100;
26
27    public function __construct(
28        private DisbursementRepository $repository,
29        private LoanRepository $loanRepository,
30        private PDO $pdo,
31    ) {}
32
33    /**
34     * Enqueue a new disbursement row in `pending_disbursement` state.
35     *
36     * Called by LoanService::approveLoan (added in PR 3). Exposed on the
37     * service rather than only the repository so the validation lives in
38     * one place.
39     * @param int $loanId
40     * @param string $requestedAmount
41     */
42    public function enqueue(int $loanId, string $requestedAmount): int
43    {
44        if (!is_numeric($requestedAmount) || (float)$requestedAmount <= 0) {
45            throw new ValidationException('Requested amount must be a positive number');
46        }
47
48        return $this->repository->enqueue($loanId, $requestedAmount);
49    }
50
51    /**
52     * @return list<DisbursementData>
53     */
54    public function listPending(): array
55    {
56        return $this->repository->listPending();
57    }
58
59    /**
60     * @param string $status
61     * @param int $page
62     * @param int $perPage
63     * @return list<DisbursementData>
64     */
65    public function listHistory(string $status, int $page, int $perPage): array
66    {
67        if ($page < 1) {
68            throw new ValidationException('page must be >= 1');
69        }
70        if ($perPage < 1 || $perPage > 100) {
71            throw new ValidationException('perPage must be between 1 and 100');
72        }
73
74        return $this->repository->listHistory($status, $perPage, ($page - 1) * $perPage);
75    }
76
77    public function countByStatus(string $status): int
78    {
79        return $this->repository->countByStatus($status);
80    }
81
82    /**
83     * Confirm a wire and transition the row to `disbursed`.
84     *
85     * PR 3 will extend this method to also mark the underlying loan
86     * `active` and generate its payment schedule, all in the same PDO
87     * transaction. The transaction wrapper is intentionally added now so
88     * PR 3 only has to fill in the inner steps.
89     * @param int $disbursementId
90     * @param string $bankReference
91     * @param string $disbursedDate
92     * @param string $confirmedAmount
93     * @param int $adminUserId
94     */
95    public function markDisbursed(
96        int $disbursementId,
97        string $bankReference,
98        string $disbursedDate,
99        string $confirmedAmount,
100        int $adminUserId,
101    ): DisbursementData {
102        $bankReference = trim($bankReference);
103        if ($bankReference === '') {
104            throw new ValidationException('bankReference is required');
105        }
106        if (strlen($bankReference) > self::MAX_BANK_REFERENCE_LENGTH) {
107            throw new ValidationException('bankReference is too long');
108        }
109
110        if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $disbursedDate)) {
111            throw new ValidationException('disbursedDate must be in YYYY-MM-DD format');
112        }
113
114        if (!is_numeric($confirmedAmount) || (float)$confirmedAmount <= 0) {
115            throw new ValidationException('confirmedAmount must be a positive number');
116        }
117
118        $startedTransaction = !$this->pdo->inTransaction();
119        if ($startedTransaction) {
120            $this->pdo->beginTransaction();
121        }
122        try {
123            $existing = $this->repository->findById($disbursementId);
124            if ($existing === null) {
125                throw new NotFoundException(sprintf('Disbursement %d not found', $disbursementId));
126            }
127            if ($existing->status !== 'pending_disbursement') {
128                throw new ConflictException(sprintf(
129                    'Disbursement %d is already %s â€” cannot mark disbursed',
130                    $disbursementId,
131                    (string)$existing->status,
132                ));
133            }
134
135            $this->repository->markDisbursed(
136                disbursementId: $disbursementId,
137                bankReference: $bankReference,
138                disbursedDate: $disbursedDate,
139                confirmedAmount: $confirmedAmount,
140                disbursedBy: $adminUserId,
141            );
142
143            // Activate the underlying loan in the same transaction. The
144            // on_loan_activated trigger fills activated_at, schedule fields,
145            // and outstanding_balance from the principle.
146            $this->loanRepository->markActive($existing->loanId ?? 0);
147            $this->loanRepository->generatePaymentSchedule($existing->loanId ?? 0);
148
149            if ($startedTransaction) {
150                $this->pdo->commit();
151                $startedTransaction = false;
152            }
153        } catch (Throwable $e) {
154            if ($startedTransaction) {
155                $this->pdo->rollBack();
156            }
157
158            throw $e;
159        }
160
161        $updated = $this->repository->findById($disbursementId);
162        if ($updated === null) {
163            throw new NotFoundException(sprintf('Disbursement %d disappeared after update', $disbursementId));
164        }
165
166        return $updated;
167    }
168}