Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
65.45% |
72 / 110 |
|
37.50% |
3 / 8 |
CRAP | |
0.00% |
0 / 1 |
| FundingRequestService | |
65.45% |
72 / 110 |
|
37.50% |
3 / 8 |
89.43 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| submit | |
82.35% |
14 / 17 |
|
0.00% |
0 / 1 |
10.55 | |||
| listPending | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| countPending | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| updateStatus | |
85.71% |
24 / 28 |
|
0.00% |
0 / 1 |
10.29 | |||
| recordApprovedTransaction | |
72.22% |
13 / 18 |
|
0.00% |
0 / 1 |
4.34 | |||
| assertWithdrawable | |
81.25% |
13 / 16 |
|
0.00% |
0 / 1 |
5.16 | |||
| notifyAdmins | |
17.86% |
5 / 28 |
|
0.00% |
0 / 1 |
12.87 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace App\Domain\Funding\Service; |
| 6 | |
| 7 | use App\Domain\Exception\ConflictException; |
| 8 | use App\Domain\Exception\NotFoundException; |
| 9 | use App\Domain\Exception\ValidationException; |
| 10 | use App\Domain\Funding\Data\FundingRequestData; |
| 11 | use App\Domain\Funding\Repository\FundingRequestRepository; |
| 12 | use App\Domain\Loan\Repository\LoanRepository; |
| 13 | use App\Domain\Notification\Data\EmailMessage; |
| 14 | use App\Domain\Notification\Service\EmailServiceInterface; |
| 15 | use App\Domain\Transaction\Repository\TransactionRepository; |
| 16 | use App\Domain\Transaction\Service\TransactionService; |
| 17 | use PDO; |
| 18 | use Psr\Log\LoggerInterface; |
| 19 | use Throwable; |
| 20 | |
| 21 | use function htmlspecialchars; |
| 22 | use function in_array; |
| 23 | use function is_numeric; |
| 24 | use function max; |
| 25 | use function nl2br; |
| 26 | use function number_format; |
| 27 | use function sprintf; |
| 28 | use function strlen; |
| 29 | use function trim; |
| 30 | use 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 | */ |
| 40 | final 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 | } |