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