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