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