Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.56% covered (danger)
0.56%
1 / 179
6.25% covered (danger)
6.25%
1 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
RegistrationService
0.56% covered (danger)
0.56%
1 / 179
6.25% covered (danger)
6.25%
1 / 16
3139.73
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
 registerComplete
0.00% covered (danger)
0.00%
0 / 44
0.00% covered (danger)
0.00%
0 / 1
72
 validateAllInput
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
2
 validateUsername
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 validateEmail
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 validatePassword
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
42
 validateRequiredString
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 validateOptionalString
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 validateDateOfBirth
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 validatePhone
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 validateState
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 validateZipCode
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 investorEmailExists
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 createInvestor
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
6
 createAccount
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 generateAccountNumber
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3declare(strict_types=1);
4
5namespace App\Domain\Auth\Service;
6
7use App\Domain\Auth\Data\TokenData;
8use App\Domain\Auth\Data\UserAuthData;
9use App\Domain\Auth\Repository\AuthRepository;
10use DateTimeImmutable;
11use DomainException;
12use InvalidArgumentException;
13use PDO;
14use RuntimeException;
15use Throwable;
16
17use function in_array;
18use function preg_match;
19use function sprintf;
20use function strlen;
21use function trim;
22
23/**
24 * Handles complete user registration flow with investor profile and account creation.
25 *
26 * This service coordinates the registration process atomically:
27 * 1. Validates all input data upfront
28 * 2. Creates investor profile
29 * 3. Creates user account linked to investor
30 * 4. Creates investment account (pending status)
31 * 5. Generates authentication tokens
32 *
33 * All operations are wrapped in a database transaction for atomicity.
34 */
35final class RegistrationService
36{
37    // Password requirements
38    private const int MIN_PASSWORD_LENGTH = 8;
39    private const int MAX_PASSWORD_LENGTH = 72;
40
41    // Valid US states
42    private const array VALID_STATES = [
43        'AL', 'AK', 'AZ', 'AR', 'CA', 'CO', 'CT', 'DE', 'FL', 'GA',
44        'HI', 'ID', 'IL', 'IN', 'IA', 'KS', 'KY', 'LA', 'ME', 'MD',
45        'MA', 'MI', 'MN', 'MS', 'MO', 'MT', 'NE', 'NV', 'NH', 'NJ',
46        'NM', 'NY', 'NC', 'ND', 'OH', 'OK', 'OR', 'PA', 'RI', 'SC',
47        'SD', 'TN', 'TX', 'UT', 'VT', 'VA', 'WA', 'WV', 'WI', 'WY',
48        'DC', 'PR', 'VI', 'GU', 'AS', 'MP',
49    ];
50
51    public function __construct(
52        private readonly PDO $pdo,
53        private readonly AuthRepository $authRepository,
54        private readonly PasswordService $passwordService,
55        private readonly JwtService $jwtService,
56    ) {}
57
58    /**
59     * Complete registration: create investor, user, and account atomically.
60     *
61     * @param array<string, mixed> $data Registration data
62     * @param string|null $ipAddress Client IP address
63     * @param string|null $userAgent Client user agent
64     *
65     * @throws InvalidArgumentException If validation fails
66     * @throws RuntimeException If registration fails
67     *
68     * @return array{
69     *     user: UserAuthData,
70     *     tokens: TokenData,
71     *     investorId: int,
72     *     accountId: int,
73     *     accountNumber: string
74     * }
75     */
76    public function registerComplete(
77        array $data,
78        ?string $ipAddress = null,
79        ?string $userAgent = null,
80    ): array {
81        // Validate ALL data upfront before any database operations
82        $this->validateAllInput($data);
83
84        // Check for existing user/email before starting transaction
85        if ($this->authRepository->usernameExists($data['username'])) {
86            throw new DomainException('Username already exists');
87        }
88
89        if ($this->authRepository->emailExists($data['email'])) {
90            throw new DomainException('Email already exists');
91        }
92
93        // Check if investor email already exists
94        if ($this->investorEmailExists($data['email'])) {
95            throw new DomainException('An investor profile with this email already exists');
96        }
97
98        // Start transaction
99        $this->pdo->beginTransaction();
100
101        try {
102            // 1. Create investor profile
103            $investorId = $this->createInvestor($data);
104
105            // 2. Create user linked to investor
106            $passwordHash = $this->passwordService->hashPassword($data['password']);
107            $userId = $this->authRepository->createUser(
108                investorId: $investorId,
109                username: $data['username'],
110                email: $data['email'],
111                passwordHash: $passwordHash,
112                role: 'investor',
113            );
114
115            // 3. Create investment account (pending status, $0 balance)
116            $accountResult = $this->createAccount($investorId);
117
118            // 4. Generate tokens
119            $tokens = $this->jwtService->generateTokenPair($userId, 'investor');
120
121            // 5. Store refresh token
122            $expiresAt = new DateTimeImmutable('+30 days');
123            $this->authRepository->storeRefreshToken(
124                userId: $userId,
125                refreshToken: $tokens->refreshToken,
126                expiresAt: $expiresAt,
127                ipAddress: $ipAddress,
128                userAgent: $userAgent,
129            );
130
131            // 6. Update last login
132            $this->authRepository->updateLastLogin($userId);
133
134            // Commit transaction
135            $this->pdo->commit();
136
137            // Get full user data
138            $user = $this->authRepository->findUserById($userId);
139
140            if ($user === null) {
141                throw new RuntimeException('Failed to retrieve created user');
142            }
143
144            return [
145                'user' => $user,
146                'tokens' => $tokens,
147                'investorId' => $investorId,
148                'accountId' => $accountResult['accountId'],
149                'accountNumber' => $accountResult['accountNumber'],
150            ];
151        } catch (Throwable $e) {
152            $this->pdo->rollBack();
153
154            // Re-throw with context if it's not already our exception type
155            if ($e instanceof InvalidArgumentException || $e instanceof RuntimeException) {
156                throw $e;
157            }
158
159            throw new RuntimeException('Registration failed: ' . $e->getMessage(), 0, $e);
160        }
161    }
162
163    /**
164     * Validate all input data before any database operations.
165     *
166     * @param array<string, mixed> $data
167     *
168     * @throws InvalidArgumentException
169     */
170    private function validateAllInput(array $data): void
171    {
172        // User fields
173        $this->validateUsername($data['username'] ?? '');
174        $this->validateEmail($data['email'] ?? '');
175        $this->validatePassword($data['password'] ?? '');
176
177        // Investor fields
178        $this->validateRequiredString($data, 'firstName', 'First name', 1, 100);
179        $this->validateRequiredString($data, 'lastName', 'Last name', 1, 100);
180        $this->validateDateOfBirth($data['dateOfBirth'] ?? '');
181        $this->validatePhone($data['phone'] ?? '');
182        $this->validateRequiredString($data, 'addressLine1', 'Address', 1, 255);
183        $this->validateOptionalString($data, 'addressLine2', 'Address line 2', 255);
184        $this->validateRequiredString($data, 'city', 'City', 1, 100);
185        $this->validateState($data['state'] ?? '');
186        $this->validateZipCode($data['zipCode'] ?? '');
187        $this->validateRequiredString($data, 'country', 'Country', 1, 100);
188    }
189
190    /**
191     * Validate username.
192     *
193     * @param string $username
194     * @throws InvalidArgumentException
195     */
196    private function validateUsername(string $username): void
197    {
198        $username = trim($username);
199
200        if ($username === '') {
201            throw new InvalidArgumentException('Username is required');
202        }
203
204        if (strlen($username) < 3) {
205            throw new InvalidArgumentException('Username must be at least 3 characters');
206        }
207
208        if (strlen($username) > 50) {
209            throw new InvalidArgumentException('Username cannot exceed 50 characters');
210        }
211
212        if (!preg_match('/^[a-zA-Z0-9_]+$/', $username)) {
213            throw new InvalidArgumentException('Username can only contain letters, numbers, and underscores');
214        }
215    }
216
217    /**
218     * Validate email.
219     *
220     * @param string $email
221     * @throws InvalidArgumentException
222     */
223    private function validateEmail(string $email): void
224    {
225        $email = trim($email);
226
227        if ($email === '') {
228            throw new InvalidArgumentException('Email is required');
229        }
230
231        if (strlen($email) > 255) {
232            throw new InvalidArgumentException('Email cannot exceed 255 characters');
233        }
234
235        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
236            throw new InvalidArgumentException('Invalid email format');
237        }
238    }
239
240    /**
241     * Validate password.
242     *
243     * @param string $password
244     * @throws InvalidArgumentException
245     */
246    private function validatePassword(string $password): void
247    {
248        if (strlen($password) < self::MIN_PASSWORD_LENGTH) {
249            throw new InvalidArgumentException(
250                sprintf('Password must be at least %d characters', self::MIN_PASSWORD_LENGTH),
251            );
252        }
253
254        if (strlen($password) > self::MAX_PASSWORD_LENGTH) {
255            throw new InvalidArgumentException(
256                sprintf('Password cannot exceed %d characters', self::MAX_PASSWORD_LENGTH),
257            );
258        }
259
260        if (!preg_match('/[A-Z]/', $password)) {
261            throw new InvalidArgumentException('Password must contain at least one uppercase letter');
262        }
263
264        if (!preg_match('/[a-z]/', $password)) {
265            throw new InvalidArgumentException('Password must contain at least one lowercase letter');
266        }
267
268        if (!preg_match('/[0-9]/', $password)) {
269            throw new InvalidArgumentException('Password must contain at least one number');
270        }
271    }
272
273    /**
274     * Validate required string field.
275     *
276     * @param array<string, mixed> $data
277     * @param string $field
278     * @param string $label
279     * @param int $minLength
280     * @param int $maxLength
281     *
282     * @throws InvalidArgumentException
283     */
284    private function validateRequiredString(
285        array $data,
286        string $field,
287        string $label,
288        int $minLength = 1,
289        int $maxLength = 255,
290    ): void {
291        $value = trim((string)($data[$field] ?? ''));
292
293        if ($value === '' || strlen($value) < $minLength) {
294            throw new InvalidArgumentException(sprintf('%s is required', $label));
295        }
296
297        if (strlen($value) > $maxLength) {
298            throw new InvalidArgumentException(
299                sprintf('%s cannot exceed %d characters', $label, $maxLength),
300            );
301        }
302    }
303
304    /**
305     * Validate optional string field.
306     *
307     * @param array<string, mixed> $data
308     * @param string $field
309     * @param string $label
310     * @param int $maxLength
311     *
312     * @throws InvalidArgumentException
313     */
314    private function validateOptionalString(
315        array $data,
316        string $field,
317        string $label,
318        int $maxLength = 255,
319    ): void {
320        $value = $data[$field] ?? '';
321
322        if ($value !== '' && strlen((string)$value) > $maxLength) {
323            throw new InvalidArgumentException(
324                sprintf('%s cannot exceed %d characters', $label, $maxLength),
325            );
326        }
327    }
328
329    /**
330     * Validate date of birth (must be 18+ years old).
331     *
332     * @param string $dateOfBirth
333     * @throws InvalidArgumentException
334     */
335    private function validateDateOfBirth(string $dateOfBirth): void
336    {
337        if (trim($dateOfBirth) === '') {
338            throw new InvalidArgumentException('Date of birth is required');
339        }
340
341        $dob = DateTimeImmutable::createFromFormat('Y-m-d', $dateOfBirth);
342
343        if ($dob === false) {
344            throw new InvalidArgumentException('Invalid date of birth format (expected YYYY-MM-DD)');
345        }
346
347        $today = new DateTimeImmutable();
348        $age = $today->diff($dob)->y;
349
350        if ($age < 18) {
351            throw new InvalidArgumentException('You must be at least 18 years old to register');
352        }
353
354        if ($age > 120) {
355            throw new InvalidArgumentException('Invalid date of birth');
356        }
357    }
358
359    /**
360     * Validate phone number (10 digits for US).
361     *
362     * @param string $phone
363     * @throws InvalidArgumentException
364     */
365    private function validatePhone(string $phone): void
366    {
367        // Remove non-digits
368        $digits = preg_replace('/\D/', '', $phone);
369
370        if ($digits === null || strlen($digits) !== 10) {
371            throw new InvalidArgumentException('Phone number must be 10 digits');
372        }
373    }
374
375    /**
376     * Validate US state code.
377     *
378     * @param string $state
379     * @throws InvalidArgumentException
380     */
381    private function validateState(string $state): void
382    {
383        $state = strtoupper(trim($state));
384
385        if ($state === '') {
386            throw new InvalidArgumentException('State is required');
387        }
388
389        if (!in_array($state, self::VALID_STATES, true)) {
390            throw new InvalidArgumentException('Invalid state code');
391        }
392    }
393
394    /**
395     * Validate ZIP code (5 digits or 5+4 format).
396     *
397     * @param string $zipCode
398     * @throws InvalidArgumentException
399     */
400    private function validateZipCode(string $zipCode): void
401    {
402        $zipCode = trim($zipCode);
403
404        if ($zipCode === '') {
405            throw new InvalidArgumentException('ZIP code is required');
406        }
407
408        if (!preg_match('/^\d{5}(-\d{4})?$/', $zipCode)) {
409            throw new InvalidArgumentException('ZIP code must be 5 digits or 5+4 format (e.g., 12345 or 12345-6789)');
410        }
411    }
412
413    /**
414     * Check if investor email already exists.
415     * @param string $email
416     * @return bool
417     */
418    private function investorEmailExists(string $email): bool
419    {
420        $stmt = $this->pdo->prepare('SELECT COUNT(*) FROM investors WHERE email = :email');
421        if ($stmt === false) {
422            throw new RuntimeException('Failed to prepare statement');
423        }
424        $stmt->execute(['email' => $email]);
425
426        return (int)$stmt->fetchColumn() > 0;
427    }
428
429    /**
430     * Create investor profile.
431     *
432     * @param array<string, mixed> $data
433     *
434     * @return int Investor ID
435     */
436    private function createInvestor(array $data): int
437    {
438        // Normalize phone to digits only
439        $phone = preg_replace('/\D/', '', (string)$data['phone']);
440
441        $stmt = $this->pdo->prepare(
442            'INSERT INTO investors (
443                first_name, last_name, email, date_of_birth, phone,
444                address_line1, address_line2, city, state, zip_code, country,
445                status, kyc_status, created_at, updated_at
446            ) VALUES (
447                :firstName, :lastName, :email, :dateOfBirth, :phone,
448                :addressLine1, :addressLine2, :city, :state, :zipCode, :country,
449                :status, :kycStatus, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
450            ) RETURNING investor_id',
451        );
452
453        if ($stmt === false) {
454            throw new RuntimeException('Failed to prepare statement');
455        }
456        $stmt->execute([
457            'firstName' => trim((string)$data['firstName']),
458            'lastName' => trim((string)$data['lastName']),
459            'email' => trim((string)$data['email']),
460            'dateOfBirth' => $data['dateOfBirth'],
461            'phone' => $phone,
462            'addressLine1' => trim((string)$data['addressLine1']),
463            'addressLine2' => trim((string)($data['addressLine2'] ?? '')),
464            'city' => trim((string)$data['city']),
465            'state' => strtoupper(trim((string)$data['state'])),
466            'zipCode' => trim((string)$data['zipCode']),
467            'country' => trim((string)$data['country']),
468            'status' => 'active',
469            'kycStatus' => 'pending',
470        ]);
471
472        return (int)$stmt->fetchColumn();
473    }
474
475    /**
476     * Create investment account.
477     *
478     * @param int $investorId
479     * @return array{accountId: int, accountNumber: string}
480     */
481    private function createAccount(int $investorId): array
482    {
483        // Generate unique account number
484        $accountNumber = $this->generateAccountNumber();
485
486        $stmt = $this->pdo->prepare(
487            'INSERT INTO accounts (
488                investor_id, account_number, balance, available_balance,
489                interest_rate, loan_to_value_ratio, status, created_at, updated_at
490            ) VALUES (
491                :investorId, :accountNumber, 0, 0,
492                0.08, 0.80, :status, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
493            ) RETURNING account_id',
494        );
495
496        if ($stmt === false) {
497            throw new RuntimeException('Failed to prepare statement');
498        }
499
500        $stmt->execute([
501            'investorId' => $investorId,
502            'accountNumber' => $accountNumber,
503            'status' => 'pending',
504        ]);
505
506        return [
507            'accountId' => (int)$stmt->fetchColumn(),
508            'accountNumber' => $accountNumber,
509        ];
510    }
511
512    /**
513     * Generate unique account number in format INV-XXXXX.
514     */
515    private function generateAccountNumber(): string
516    {
517        for ($i = 0; $i < 10; $i++) {
518            $accountNumber = sprintf('INV-%05d', random_int(10000, 99999));
519
520            $stmt = $this->pdo->prepare(
521                'SELECT COUNT(*) FROM accounts WHERE account_number = :accountNumber',
522            );
523            if ($stmt === false) {
524                throw new RuntimeException('Failed to prepare statement');
525            }
526            $stmt->execute(['accountNumber' => $accountNumber]);
527
528            if ((int)$stmt->fetchColumn() === 0) {
529                return $accountNumber;
530            }
531        }
532
533        throw new RuntimeException('Failed to generate unique account number');
534    }
535}