Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.67% covered (success)
93.67%
74 / 79
73.33% covered (warning)
73.33%
11 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
InvestorService
93.67% covered (success)
93.67%
74 / 79
73.33% covered (warning)
73.33%
11 / 15
49.61
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
 createInvestor
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
8.01
 getInvestor
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getInvestorByEmail
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getInvestorByUserId
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 updateInvestor
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
6.29
 updateKycStatus
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
7.05
 updateInvestorStatus
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
5.05
 activateInvestor
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 deactivateInvestor
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 suspendInvestor
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validateRequiredFields
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
5
 validateAge
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 validatePhone
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 validateZipCode
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace App\Domain\Investor\Service;
6
7use App\Domain\Exception\BadRequestException;
8use App\Domain\Exception\ConflictException;
9use App\Domain\Exception\NotFoundException;
10use App\Domain\Exception\ValidationException;
11use App\Domain\Investor\Data\InvestorData;
12use App\Domain\Investor\Repository\InvestorRepository;
13use App\Support\Row;
14use DateTime;
15use RuntimeException;
16
17use function in_array;
18use function strlen;
19
20/**
21 * Handles operations related to managing investors, including creation, retrieval,
22 * updates, and status changes. The service enforces validation rules and
23 * business logic for each operation.
24 */
25final class InvestorService
26{
27    public function __construct(
28        private readonly InvestorRepository $repository,
29    ) {}
30
31    /**
32     * @param array<mixed> $data
33     */
34    public function createInvestor(array $data): InvestorData
35    {
36        $this->validateRequiredFields($data, ['firstName', 'lastName', 'email', 'dateOfBirth']);
37
38        $email = Row::string($data, 'email');
39
40        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
41            throw new ValidationException('Invalid email format');
42        }
43
44        if ($this->repository->emailExists($email)) {
45            throw new ConflictException('Email address already exists');
46        }
47
48        $this->validateAge(Row::string($data, 'dateOfBirth'));
49
50        $phone = Row::nullableString($data, 'phone');
51        if ($phone !== null && $phone !== '') {
52            $this->validatePhone($phone);
53        }
54
55        $zipCode = Row::nullableString($data, 'zipCode');
56        if ($zipCode !== null && $zipCode !== '') {
57            $this->validateZipCode($zipCode);
58        }
59
60        $investorId = $this->repository->createInvestor($data);
61
62        $investor = $this->repository->findInvestorById($investorId);
63
64        if ($investor === null) {
65            throw new RuntimeException('Failed to create investor');
66        }
67
68        return $investor;
69    }
70
71    public function getInvestor(int $investorId): InvestorData
72    {
73        $investor = $this->repository->findInvestorById($investorId);
74
75        if ($investor === null) {
76            throw new NotFoundException('Investor not found');
77        }
78
79        return $investor;
80    }
81
82    public function getInvestorByEmail(string $email): InvestorData
83    {
84        $investor = $this->repository->findInvestorByEmail($email);
85
86        if ($investor === null) {
87            throw new NotFoundException('Investor not found');
88        }
89
90        return $investor;
91    }
92
93    public function getInvestorByUserId(int $userId): InvestorData
94    {
95        if ($userId <= 0) {
96            throw new ValidationException('Invalid user ID');
97        }
98
99        $investor = $this->repository->findInvestorByUserId($userId);
100
101        if ($investor === null) {
102            throw new NotFoundException('No investor found for user ID: ' . $userId);
103        }
104
105        return $investor;
106    }
107
108    /**
109     * @param array<mixed> $data
110     * @param int $investorId
111     */
112    public function updateInvestor(int $investorId, array $data): InvestorData
113    {
114        $this->getInvestor($investorId);
115
116        $phone = Row::nullableString($data, 'phone');
117        if ($phone !== null && $phone !== '') {
118            $this->validatePhone($phone);
119        }
120
121        $zipCode = Row::nullableString($data, 'zipCode');
122        if ($zipCode !== null && $zipCode !== '') {
123            $this->validateZipCode($zipCode);
124        }
125
126        if (!$this->repository->updateInvestor($investorId, $data)) {
127            throw new RuntimeException('Failed to update investor');
128        }
129
130        return $this->getInvestor($investorId);
131    }
132
133    public function updateKycStatus(int $investorId, string $status): InvestorData
134    {
135        $investor = $this->getInvestor($investorId);
136
137        if (!in_array($status, ['pending', 'verified', 'rejected'], true)) {
138            throw new ValidationException('Invalid KYC status. Must be: pending, verified, or rejected');
139        }
140
141        if ($status === 'verified' && $investor->status === 'suspended') {
142            throw new ConflictException('Cannot verify KYC for suspended investors');
143        }
144
145        if ($status === 'verified' && $investor->status === 'inactive') {
146            throw new ConflictException('Cannot verify KYC for inactive investors');
147        }
148
149        if (!$this->repository->updateKycStatus($investorId, $status)) {
150            throw new RuntimeException('Failed to update KYC status');
151        }
152
153        return $this->getInvestor($investorId);
154    }
155
156    public function updateInvestorStatus(int $investorId, string $status): InvestorData
157    {
158        $investor = $this->getInvestor($investorId);
159
160        if (!in_array($status, ['active', 'inactive', 'suspended'], true)) {
161            throw new ValidationException('Invalid investor status. Must be: active, inactive, or suspended');
162        }
163
164        if ($status === 'suspended' && $investor->kycStatus === 'verified') {
165            $this->repository->updateKycStatus($investorId, 'pending');
166        }
167
168        if (!$this->repository->updateInvestorStatus($investorId, $status)) {
169            throw new RuntimeException('Failed to update investor status');
170        }
171
172        return $this->getInvestor($investorId);
173    }
174
175    public function activateInvestor(int $investorId): InvestorData
176    {
177        return $this->updateInvestorStatus($investorId, 'active');
178    }
179
180    public function deactivateInvestor(int $investorId): InvestorData
181    {
182        return $this->updateInvestorStatus($investorId, 'inactive');
183    }
184
185    public function suspendInvestor(int $investorId): InvestorData
186    {
187        return $this->updateInvestorStatus($investorId, 'suspended');
188    }
189
190    /**
191     * @param array<mixed> $data
192     * @param array<int, string> $requiredFields
193     */
194    private function validateRequiredFields(array $data, array $requiredFields): void
195    {
196        foreach ($requiredFields as $field) {
197            $value = $data[$field] ?? null;
198            if ($value === null || (is_string($value) && trim($value) === '')) {
199                throw new BadRequestException("Field '{$field}' is required");
200            }
201        }
202    }
203
204    private function validateAge(string $dateOfBirth): void
205    {
206        $dob = DateTime::createFromFormat('Y-m-d', $dateOfBirth);
207
208        if ($dob === false) {
209            throw new ValidationException('Invalid date of birth format. Use YYYY-MM-DD');
210        }
211
212        $age = (new DateTime())->diff($dob)->y;
213
214        if ($age < 18) {
215            throw new ValidationException('Investor must be at least 18 years old');
216        }
217    }
218
219    private function validatePhone(string $phone): void
220    {
221        $digitsOnly = preg_replace('/\D/', '', $phone) ?? '';
222
223        if (strlen($digitsOnly) !== 10) {
224            throw new ValidationException('Invalid phone number format. Must be 10 digits');
225        }
226    }
227
228    private function validateZipCode(string $zipCode): void
229    {
230        if (!preg_match('/^\d{5}(-\d{4})?$/', $zipCode)) {
231            throw new ValidationException('Invalid zip code format. Use 12345 or 12345-6789');
232        }
233    }
234}