Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
0.56% |
1 / 179 |
|
6.25% |
1 / 16 |
CRAP | |
0.00% |
0 / 1 |
| RegistrationService | |
0.56% |
1 / 179 |
|
6.25% |
1 / 16 |
3139.73 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| registerComplete | |
0.00% |
0 / 44 |
|
0.00% |
0 / 1 |
72 | |||
| validateAllInput | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
2 | |||
| validateUsername | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
30 | |||
| validateEmail | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
20 | |||
| validatePassword | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
42 | |||
| validateRequiredString | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
20 | |||
| validateOptionalString | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
| validateDateOfBirth | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
30 | |||
| validatePhone | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
12 | |||
| validateState | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
| validateZipCode | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
| investorEmailExists | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
| createInvestor | |
0.00% |
0 / 23 |
|
0.00% |
0 / 1 |
6 | |||
| createAccount | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
6 | |||
| generateAccountNumber | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
20 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace App\Domain\Auth\Service; |
| 6 | |
| 7 | use App\Domain\Auth\Data\TokenData; |
| 8 | use App\Domain\Auth\Data\UserAuthData; |
| 9 | use App\Domain\Auth\Repository\AuthRepository; |
| 10 | use DateTimeImmutable; |
| 11 | use DomainException; |
| 12 | use InvalidArgumentException; |
| 13 | use PDO; |
| 14 | use RuntimeException; |
| 15 | use Throwable; |
| 16 | |
| 17 | use function in_array; |
| 18 | use function preg_match; |
| 19 | use function sprintf; |
| 20 | use function strlen; |
| 21 | use 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 | */ |
| 35 | final 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 | } |