Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 82
0.00% covered (danger)
0.00%
0 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
FundCashflowRequestAdminService
0.00% covered (danger)
0.00%
0 / 82
0.00% covered (danger)
0.00%
0 / 5
1122
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 listRequests
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
380
 approveRequest
0.00% covered (danger)
0.00%
0 / 39
0.00% covered (danger)
0.00%
0 / 1
72
 rejectRequest
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 isValidDate
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3declare(strict_types=1);
4
5namespace App\Domain\Funds\Service;
6
7use App\Domain\Account\Repository\AccountRepository;
8use App\Domain\Email\EmailNotificationService;
9use App\Domain\Exception\NotFoundException;
10use App\Domain\Funds\Data\FundCashflowRequestData;
11use App\Domain\Funds\Repository\FundCashflowRequestRepository;
12use App\Domain\Funds\Repository\FundsRepository;
13use App\Domain\Transaction\Service\TransactionService;
14use DomainException;
15use InvalidArgumentException;
16
17final readonly class FundCashflowRequestAdminService
18{
19    private const VALID_STATUSES = ['pending', 'approved', 'rejected', 'all'];
20    private const VALID_REQUEST_TYPES = ['add_funds', 'withdrawal'];
21
22    public function __construct(
23        private FundCashflowRequestRepository $requestRepository,
24        private TransactionService $transactionService,
25        private AccountRepository $accountRepository,
26        private FundsRepository $fundsRepository,
27        private EmailNotificationService $emailService,
28    ) {}
29
30    public function listRequests(
31        int $page = 1,
32        int $limit = 25,
33        ?string $status = null,
34        ?string $requestType = null,
35        ?string $search = null,
36        ?string $startDate = null,
37        ?string $endDate = null,
38    ): array {
39        if ($page < 1) {
40            throw new InvalidArgumentException('Page must be at least 1');
41        }
42
43        if ($limit < 1 || $limit > 100) {
44            $limit = 25;
45        }
46
47        if ($status !== null && $status !== '' && !in_array($status, self::VALID_STATUSES, true)) {
48            throw new InvalidArgumentException('Invalid status filter');
49        }
50
51        if ($requestType !== null && $requestType !== '' && !in_array($requestType, self::VALID_REQUEST_TYPES, true)) {
52            throw new InvalidArgumentException('Invalid requestType filter');
53        }
54
55        if ($startDate !== null && $startDate !== '' && !$this->isValidDate($startDate)) {
56            throw new InvalidArgumentException('Invalid startDate format. Use YYYY-MM-DD');
57        }
58
59        if ($endDate !== null && $endDate !== '' && !$this->isValidDate($endDate)) {
60            throw new InvalidArgumentException('Invalid endDate format. Use YYYY-MM-DD');
61        }
62
63        if ($startDate !== null && $endDate !== null && $startDate > $endDate) {
64            throw new InvalidArgumentException('startDate cannot be after endDate');
65        }
66
67        return $this->requestRepository->findByFilters(
68            page: $page,
69            limit: $limit,
70            status: $status,
71            requestType: $requestType,
72            search: $search,
73            startDate: $startDate,
74            endDate: $endDate,
75        );
76    }
77
78    public function approveRequest(string $requestId): FundCashflowRequestData
79    {
80        $request = $this->requestRepository->findByIdWithDetails($requestId);
81        if ($request === null) {
82            throw new NotFoundException('Fund cashflow request not found');
83        }
84
85        if ($request['status'] !== 'pending') {
86            throw new DomainException('Only pending fund requests can be approved');
87        }
88
89        $account = $this->accountRepository->findAccountByInvestorId((int)$request['userId']);
90        if ($account === null) {
91            throw new NotFoundException('Investor account not found');
92        }
93
94        $transactionType = $request['requestType'] === 'add_funds' ? 'deposit' : 'withdrawal';
95        $description = $request['requestType'] === 'add_funds'
96            ? 'Approved fund add request'
97            : 'Approved fund withdrawal request';
98
99        $this->transactionService->createTransaction(
100            $account->accountId,
101            $transactionType,
102            $request['amount'],
103            $description,
104        );
105
106        // Create or update allocation for add_funds requests
107        if ($request['requestType'] === 'add_funds') {
108            $this->fundsRepository->createOrUpdateAllocation(
109                (int)$request['userId'],
110                $request['fundId'],
111                $request['amount']
112            );
113        } elseif ($request['requestType'] === 'withdrawal') {
114            $this->fundsRepository->decreaseAllocation(
115                (int)$request['userId'],
116                $request['fundId'],
117                $request['amount']
118            );
119        }
120
121        $updated = $this->requestRepository->updateStatus($requestId, 'approved');
122
123        $this->emailService->sendRequestApprovedEmail(
124            $request['investorEmail'],
125            $request['userFirstName'] . ' ' . $request['userLastName'],
126            $request['fundName'],
127            $request['amount'],
128            $request['requestType'],
129        );
130
131        return $updated;
132    }
133
134    public function rejectRequest(string $requestId, ?string $reason = null): FundCashflowRequestData
135    {
136        $request = $this->requestRepository->findByIdWithDetails($requestId);
137        if ($request === null) {
138            throw new NotFoundException('Fund cashflow request not found');
139        }
140
141        if ($request['status'] !== 'pending') {
142            throw new DomainException('Only pending fund requests can be rejected');
143        }
144
145        $updated = $this->requestRepository->updateStatus($requestId, 'rejected', $reason);
146
147        $this->emailService->sendRequestRejectedEmail(
148            $request['investorEmail'],
149            $request['userFirstName'] . ' ' . $request['userLastName'],
150            $request['fundName'],
151            $request['amount'],
152            $reason,
153            'support@apiaryfund.com'
154        );
155
156        return $updated;
157    }
158
159    private function isValidDate(string $date): bool
160    {
161        if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
162            return false;
163        }
164
165        $parts = explode('-', $date);
166        return checkdate((int)$parts[1], (int)$parts[2], (int)$parts[0]);
167    }
168}