Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
65.45% covered (warning)
65.45%
72 / 110
37.50% covered (danger)
37.50%
3 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
FundingRequestService
65.45% covered (warning)
65.45%
72 / 110
37.50% covered (danger)
37.50%
3 / 8
89.43
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
 submit
82.35% covered (warning)
82.35%
14 / 17
0.00% covered (danger)
0.00%
0 / 1
10.55
 listPending
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 countPending
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 updateStatus
85.71% covered (warning)
85.71%
24 / 28
0.00% covered (danger)
0.00%
0 / 1
10.29
 recordApprovedTransaction
72.22% covered (warning)
72.22%
13 / 18
0.00% covered (danger)
0.00%
0 / 1
4.34
 assertWithdrawable
81.25% covered (warning)
81.25%
13 / 16
0.00% covered (danger)
0.00%
0 / 1
5.16
 notifyAdmins
17.86% covered (danger)
17.86%
5 / 28
0.00% covered (danger)
0.00%
0 / 1
12.87
1<?php
2
3declare(strict_types=1);
4
5namespace App\Domain\Funding\Service;
6
7use App\Domain\Exception\ConflictException;
8use App\Domain\Exception\NotFoundException;
9use App\Domain\Exception\ValidationException;
10use App\Domain\Funding\Data\FundingRequestData;
11use App\Domain\Funding\Repository\FundingRequestRepository;
12use App\Domain\Loan\Repository\LoanRepository;
13use App\Domain\Notification\Data\EmailMessage;
14use App\Domain\Notification\Service\EmailServiceInterface;
15use App\Domain\Transaction\Repository\TransactionRepository;
16use App\Domain\Transaction\Service\TransactionService;
17use PDO;
18use Psr\Log\LoggerInterface;
19use Throwable;
20
21use function htmlspecialchars;
22use function in_array;
23use function is_numeric;
24use function max;
25use function nl2br;
26use function number_format;
27use function sprintf;
28use function strlen;
29use function trim;
30use function ucfirst;
31
32/**
33 * Investor-initiated deposit/withdrawal requests (FSC-21).
34 *
35 * No ACH yet, so these are requests an admin fulfils by hand. Withdrawal
36 * eligibility reuses the same config-driven gates the transaction layer
37 * enforces — FSC-49 (active-loan block) and FSC-48 (90-day lock-up) — so the
38 * investor can't submit something that would just be rejected at execution.
39 */
40final readonly class FundingRequestService
41{
42    private const array VALID_TYPES = ['deposit', 'withdrawal'];
43    private const int MAX_NOTE_LENGTH = 1000;
44
45    /**
46     * Terminal statuses an admin may set from the queue. The table also allows
47     * 'approved'/'cancelled', but the admin queue is a simple two-outcome
48     * workflow: the request was either handled ('completed') or it wasn't
49     * ('rejected'). Marking it 'completed' posts the matching account
50     * transaction, so the admin "puts the money in" by approving — no separate
51     * step in the admin transaction tool.
52     */
53    private const array ADMIN_TARGET_STATUSES = ['completed', 'rejected'];
54
55    /**
56     * @param list<string> $adminRecipients
57     * @param FundingRequestRepository $repository
58     * @param TransactionRepository $transactionRepository
59     * @param TransactionService $transactionService
60     * @param LoanRepository $loanRepository
61     * @param EmailServiceInterface $emailService
62     * @param LoggerInterface $logger
63     * @param PDO $pdo
64     */
65    public function __construct(
66        private FundingRequestRepository $repository,
67        private TransactionRepository $transactionRepository,
68        private TransactionService $transactionService,
69        private LoanRepository $loanRepository,
70        private EmailServiceInterface $emailService,
71        private LoggerInterface $logger,
72        private PDO $pdo,
73        private array $adminRecipients,
74    ) {}
75
76    public function submit(int $investorId, string $type, string $amount, ?string $note): FundingRequestData
77    {
78        if (!in_array($type, self::VALID_TYPES, true)) {
79            throw new ValidationException('Request type must be "deposit" or "withdrawal"');
80        }
81        if (!is_numeric($amount) || (float)$amount <= 0) {
82            throw new ValidationException('Amount must be greater than zero');
83        }
84
85        $note = $note !== null ? trim($note) : null;
86        if ($note === '') {
87            $note = null;
88        }
89        if ($note !== null && strlen($note) > self::MAX_NOTE_LENGTH) {
90            throw new ValidationException(sprintf('Note must be %d characters or fewer', self::MAX_NOTE_LENGTH));
91        }
92
93        $accountId = $this->loanRepository->getInvestorAccountId($investorId);
94        if ($accountId === null) {
95            throw new NotFoundException('No account found for this investor');
96        }
97
98        if ($type === 'withdrawal') {
99            $this->assertWithdrawable($accountId, (float)$amount);
100        }
101
102        $stored = $this->repository->create($investorId, $accountId, $type, $amount, $note);
103        $this->notifyAdmins($stored, $investorId);
104
105        return $stored;
106    }
107
108    /**
109     * Pending requests awaiting admin action (the queue).
110     *
111     * @return list<FundingRequestData>
112     */
113    public function listPending(): array
114    {
115        return $this->repository->listPending();
116    }
117
118    /**
119     * Count of pending requests — drives the admin nav badge.
120     */
121    public function countPending(): int
122    {
123        return $this->repository->countByStatus('pending');
124    }
125
126    /**
127     * Action a pending request: mark it 'completed' or 'rejected', stamping the
128     * reviewing admin. Only a still-pending request can be transitioned, so two
129     * admins can't double-process the same row.
130     *
131     * Approving ('completed') now moves the money in the same DB transaction:
132     * a deposit posts an 'investment' credit, a withdrawal posts a 'withdrawal'
133     * debit, so the approved amount actually lands on the investor's balance
134     * (via the transaction triggers). Rejecting moves nothing. If the money
135     * movement fails (e.g. insufficient balance, active-loan guard) the status
136     * update rolls back with it, so the request stays pending rather than
137     * silently completing without a transaction.
138     * @param int $fundingRequestId
139     * @param string $status
140     * @param int $adminUserId
141     */
142    public function updateStatus(int $fundingRequestId, string $status, int $adminUserId): FundingRequestData
143    {
144        if (!in_array($status, self::ADMIN_TARGET_STATUSES, true)) {
145            throw new ValidationException('Status must be "completed" or "rejected"');
146        }
147
148        $startedTransaction = !$this->pdo->inTransaction();
149        if ($startedTransaction) {
150            $this->pdo->beginTransaction();
151        }
152
153        try {
154            $existing = $this->repository->findById($fundingRequestId);
155            if ($existing === null) {
156                throw new NotFoundException(sprintf('Funding request %d not found', $fundingRequestId));
157            }
158            if ($existing->status !== 'pending') {
159                throw new ConflictException(sprintf(
160                    'Funding request %d is already %s — it cannot be actioned again',
161                    $fundingRequestId,
162                    (string)$existing->status,
163                ));
164            }
165
166            // Only approval moves money; a rejection just stamps the status.
167            if ($status === 'completed') {
168                $this->recordApprovedTransaction($existing);
169            }
170
171            $this->repository->updateStatus($fundingRequestId, $status, $adminUserId);
172
173            if ($startedTransaction) {
174                $this->pdo->commit();
175                $startedTransaction = false;
176            }
177        } catch (Throwable $e) {
178            if ($startedTransaction) {
179                $this->pdo->rollBack();
180            }
181
182            throw $e;
183        }
184
185        $updated = $this->repository->findById($fundingRequestId);
186        if ($updated === null) {
187            throw new NotFoundException(sprintf('Funding request %d disappeared after update', $fundingRequestId));
188        }
189
190        return $updated;
191    }
192
193    /**
194     * Translate an approved funding request into the account transaction that
195     * actually moves the money: a deposit becomes an 'investment' credit, a
196     * withdrawal becomes a 'withdrawal' debit. Routing through TransactionService
197     * (rather than inserting directly) re-runs the authoritative guards — the
198     * 90-day lock-up, the active-loan blocks, and the available-balance check —
199     * so an approval can never push an account past what it could withdraw or
200     * violate the secured-loan invariants.
201     * @param FundingRequestData $request
202     */
203    private function recordApprovedTransaction(FundingRequestData $request): void
204    {
205        $transactionType = match ($request->requestType) {
206            'deposit' => 'investment',
207            'withdrawal' => 'withdrawal',
208            default => throw new ValidationException(sprintf(
209                'Funding request %d has an unsupported type "%s" and cannot be approved',
210                (int)$request->fundingRequestId,
211                (string)$request->requestType,
212            )),
213        };
214
215        $this->transactionService->createTransaction(
216            accountId: (int)$request->accountId,
217            transactionType: $transactionType,
218            amount: (string)$request->amount,
219            description: sprintf(
220                'Funding request #%d (%s) approved',
221                (int)$request->fundingRequestId,
222                (string)$request->requestType,
223            ),
224        );
225    }
226
227    /**
228     * Mirrors the withdrawal guards in TransactionService so a request can't be
229     * submitted for funds that aren't withdrawable. The authoritative check
230     * still runs when an admin records the actual withdrawal transaction.
231     * @param int $accountId
232     * @param float $amount
233     */
234    private function assertWithdrawable(int $accountId, float $amount): void
235    {
236        // FSC-49 — no withdrawals while a loan is in progress (super_admin may
237        // override via allow_withdrawal_with_active_loans).
238        if (
239            !$this->loanRepository->getConfigBool('allow_withdrawal_with_active_loans')
240            && $this->loanRepository->investorHasBlockingLoan($accountId)
241        ) {
242            throw new ConflictException(
243                'You cannot request a withdrawal while you have a pending, approved, or active loan (MPA §5.3).',
244            );
245        }
246
247        // FSC-48 — funds within the 90-day lock-up can't be withdrawn
248        // (super_admin may override via allow_withdrawal_during_lockup).
249        $lockupDays = (int)($this->loanRepository->getConfig()['lockup_period_days'] ?? '90');
250        $available = (float)$this->transactionRepository->getAccountAvailableBalance($accountId);
251
252        if (!$this->loanRepository->getConfigBool('allow_withdrawal_during_lockup')) {
253            $locked = (float)$this->transactionRepository->getLockedInvestmentBalance($accountId, $lockupDays);
254            $available = max(0.0, $available - $locked);
255        }
256
257        if ($amount > $available) {
258            throw new ConflictException(sprintf(
259                'Withdrawal request exceeds your available balance — $%s is available to withdraw (funds within the %d-day lock-up are excluded).',
260                number_format($available, 2),
261                $lockupDays,
262            ));
263        }
264    }
265
266    private function notifyAdmins(FundingRequestData $request, int $investorId): void
267    {
268        if ($this->adminRecipients === []) {
269            $this->logger->warning('Funding request submitted but no admin_recipients configured', [
270                'fundingRequestId' => $request->fundingRequestId,
271            ]);
272
273            return;
274        }
275
276        $subject = sprintf('[Funding] %s request', ucfirst((string)$request->requestType));
277        $bodyText = sprintf(
278            "A %s request was submitted and needs processing.\n\nInvestor ID: %d\nAccount ID: %d\nAmount: \$%s\nNote: %s",
279            (string)$request->requestType,
280            $investorId,
281            (int)$request->accountId,
282            (string)$request->amount,
283            $request->note ?? '(none)',
284        );
285        $bodyHtml = nl2br(htmlspecialchars($bodyText, ENT_QUOTES, 'UTF-8'));
286
287        foreach ($this->adminRecipients as $recipient) {
288            try {
289                $this->emailService->send(new EmailMessage(
290                    to: $recipient,
291                    subject: $subject,
292                    bodyHtml: $bodyHtml,
293                    bodyText: $bodyText,
294                    sensitive: false,
295                ));
296            } catch (Throwable $e) {
297                $this->logger->error('Failed to dispatch funding request email', [
298                    'recipient' => $recipient,
299                    'error' => $e->getMessage(),
300                ]);
301            }
302        }
303    }
304}