Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.82% covered (success)
96.82%
274 / 283
70.59% covered (warning)
70.59%
12 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
SuperAdminService
96.82% covered (success)
96.82%
274 / 283
70.59% covered (warning)
70.59%
12 / 17
68
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
 createTestUser
98.11% covered (success)
98.11%
52 / 53
0.00% covered (danger)
0.00%
0 / 1
9
 generateTransactions
98.04% covered (success)
98.04%
50 / 51
0.00% covered (danger)
0.00%
0 / 1
12
 generateBulkData
98.25% covered (success)
98.25%
56 / 57
0.00% covered (danger)
0.00%
0 / 1
7
 getAccountsForDropdown
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 deleteTestData
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTestDataCounts
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 updateUserRole
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
5
 generateEmail
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 generateUsername
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 generateRandomDateOfBirth
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 generateRandomDeposit
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 generateMonthTransactions
92.96% covered (success)
92.96%
66 / 71
0.00% covered (danger)
0.00%
0 / 1
15.08
 generateTransactionAmount
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 generateTransactionDescription
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
 generateReferenceNumber
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 generateRandomDateInRange
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace App\Domain\SuperAdmin\Service;
6
7use App\Domain\Exception\NotFoundException;
8use App\Domain\SuperAdmin\Repository\SuperAdminRepository;
9use DateInterval;
10use DateMalformedStringException;
11use DateTimeImmutable;
12use DomainException;
13use InvalidArgumentException;
14
15use Random\RandomException;
16
17use function in_array;
18use function sprintf;
19use function strlen;
20
21final class SuperAdminService
22{
23    private const string TEST_EMAIL_DOMAIN = 'testdata.local';
24    private const string DEFAULT_PASSWORD = 'TestPassword123!';
25    private const float MIN_DEPOSIT = 25000.00;
26    private const float MAX_DEPOSIT = 2000000.00;
27    private const int MIN_ACCOUNT_AGE_MONTHS = 1;
28    private const int MAX_ACCOUNT_AGE_MONTHS = 120;
29    public function __construct(
30        private readonly SuperAdminRepository $repository,
31    ) {}
32
33    /**
34     *
35     * @param string $firstName
36     * @param string $lastName
37     * @param ?string $email
38     * @param ?string $password
39     * @param string $kycStatus
40     * @param bool $createAccount
41     * @param ?float $initialDeposit
42     * @throws InvalidArgumentException|DomainException|RandomException
43     * @return array{userId: int, investorId: int, accountId: int|null, accountNumber: string|null, email: string}
44     */
45    public function createTestUser(
46        string $firstName,
47        string $lastName,
48        ?string $email = null,
49        ?string $password = null,
50        string $kycStatus = 'verified',
51        bool $createAccount = true,
52        ?float $initialDeposit = null,
53    ): array {
54        if (empty(trim($firstName))) {
55            throw new InvalidArgumentException('First name is required');
56        }
57        if (empty(trim($lastName))) {
58            throw new InvalidArgumentException('Last name is required');
59        }
60
61        if (!in_array($kycStatus, ['pending', 'verified', 'rejected'], true)) {
62            throw new InvalidArgumentException('Invalid KYC status. Must be: pending, verified, or rejected');
63        }
64
65        $email ??= $this->generateEmail($firstName, $lastName);
66
67        if (!str_ends_with($email, '@' . self::TEST_EMAIL_DOMAIN)) {
68            throw new InvalidArgumentException('Test user email must end with @' . self::TEST_EMAIL_DOMAIN);
69        }
70
71        if ($this->repository->emailExistsInUsers($email)) {
72            throw new DomainException('Email already exists in users table');
73        }
74        if ($this->repository->emailExistsInInvestors($email)) {
75            throw new DomainException('Email already exists in investors table');
76        }
77
78        $username = $this->generateUsername($email);
79        $passwordHash = password_hash($password ?? self::DEFAULT_PASSWORD, PASSWORD_ARGON2ID);
80        $dateOfBirth = $this->generateRandomDateOfBirth();
81
82        $userId = $this->repository->createUser($email, $username, $passwordHash, 'investor');
83
84        $investorId = $this->repository->createInvestor([
85            'firstName' => $firstName,
86            'lastName' => $lastName,
87            'email' => $email,
88            'dateOfBirth' => $dateOfBirth,
89            'kycStatus' => $kycStatus,
90            'status' => 'active',
91        ]);
92
93        $accountId = null;
94        $accountNumber = null;
95
96        if ($createAccount) {
97            $deposit = $initialDeposit ?? $this->generateRandomDeposit();
98
99            if ($deposit < self::MIN_DEPOSIT) {
100                throw new InvalidArgumentException(
101                    sprintf('Initial deposit must be at least $%.2f', self::MIN_DEPOSIT),
102                );
103            }
104
105            $accountResult = $this->repository->createAccount($investorId, 'pending');
106            $accountId = $accountResult['accountId'];
107            $accountNumber = $accountResult['accountNumber'];
108
109            $this->repository->createTransaction(
110                $accountId,
111                'deposit',
112                $deposit,
113                $deposit,
114                'Initial deposit',
115                $this->generateReferenceNumber('DEP'),
116                date('Y-m-d H:i:s'),
117            );
118
119            $this->repository->updateAccountStatus($accountId, 'active');
120        }
121
122        return [
123            'userId' => $userId,
124            'investorId' => $investorId,
125            'accountId' => $accountId,
126            'accountNumber' => $accountNumber,
127            'email' => $email,
128        ];
129    }
130
131    /**
132     *
133     * @param int $accountId
134     * @param int $monthsBack
135     * @throws DomainException|RandomException
136     * @return array{transactionsCreated: int, totalDeposits: string, totalWithdrawals: string, totalInterest: string, totalFees: string, finalBalance: string}
137     */
138    public function generateTransactions(int $accountId, int $monthsBack = 6): array
139    {
140        $account = $this->repository->getAccountById($accountId);
141        if ($account === null) {
142            throw new NotFoundException('Account not found');
143        }
144
145        if ($monthsBack < 1 || $monthsBack > 120) {
146            throw new InvalidArgumentException('Months back must be between 1 and 120');
147        }
148
149        $currentBalance = (float)$account['balance'];
150        $transactionsCreated = 0;
151        $totalDeposits = 0.0;
152        $totalWithdrawals = 0.0;
153        $totalInterest = 0.0;
154        $totalFees = 0.0;
155
156        $now = new DateTimeImmutable();
157
158        for ($month = $monthsBack; $month >= 1; $month--) {
159            $monthStart = $now->sub(new DateInterval("P{$month}M"));
160            $monthEnd = $month > 1
161                ? $now->sub(new DateInterval('P' . ($month - 1) . 'M'))
162                : $now;
163
164            $monthTransactions = $this->generateMonthTransactions(
165                $accountId,
166                $currentBalance,
167                $monthStart,
168                $monthEnd,
169            );
170
171            foreach ($monthTransactions as $tx) {
172                $this->repository->createTransaction(
173                    $accountId,
174                    $tx['type'],
175                    $tx['amount'],
176                    $tx['balanceAfter'],
177                    $tx['description'],
178                    $tx['referenceNumber'],
179                    $tx['createdAt'],
180                );
181
182                $currentBalance = $tx['balanceAfter'];
183                $transactionsCreated++;
184
185                match ($tx['type']) {
186                    'deposit' => $totalDeposits += $tx['amount'],
187                    'withdrawal' => $totalWithdrawals += $tx['amount'],
188                    'interest' => $totalInterest += $tx['amount'],
189                    'fee' => $totalFees += $tx['amount'],
190                    default => null,
191                };
192            }
193        }
194
195        $this->repository->updateAccountBalance($accountId, $currentBalance);
196
197        return [
198            'transactionsCreated' => $transactionsCreated,
199            'totalDeposits' => number_format($totalDeposits, 2, '.', ''),
200            'totalWithdrawals' => number_format($totalWithdrawals, 2, '.', ''),
201            'totalInterest' => number_format($totalInterest, 2, '.', ''),
202            'totalFees' => number_format($totalFees, 2, '.', ''),
203            'finalBalance' => number_format($currentBalance, 2, '.', ''),
204        ];
205    }
206
207    /**
208     *
209     * @param int $userCount
210     * @throws RandomException
211     * @return array{usersCreated: int, accountsCreated: int, transactionsCreated: int}
212     */
213    public function generateBulkData(int $userCount = 5): array
214    {
215        if ($userCount < 1 || $userCount > 50) {
216            throw new InvalidArgumentException('User count must be between 1 and 50');
217        }
218
219        $usersCreated = 0;
220        $accountsCreated = 0;
221        $transactionsCreated = 0;
222
223        $firstNames = ['James', 'Mary', 'John', 'Patricia', 'Robert', 'Jennifer', 'Michael', 'Linda', 'William', 'Elizabeth'];
224        $lastNames = ['Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller', 'Davis', 'Rodriguez', 'Martinez'];
225
226        for ($i = 0; $i < $userCount; $i++) {
227            $firstName = $firstNames[array_rand($firstNames)];
228            $lastName = $lastNames[array_rand($lastNames)];
229
230            $accountAgeMonths = random_int(self::MIN_ACCOUNT_AGE_MONTHS, self::MAX_ACCOUNT_AGE_MONTHS);
231
232            $accountCreatedAt = (new DateTimeImmutable())
233                ->sub(new DateInterval("P{$accountAgeMonths}M"))
234                ->format('Y-m-d H:i:s');
235
236            $initialDeposit = $this->generateRandomDeposit();
237
238            $email = $this->generateEmail($firstName, $lastName);
239
240            while ($this->repository->emailExistsInUsers($email) || $this->repository->emailExistsInInvestors($email)) {
241                $email = $this->generateEmail($firstName, $lastName);
242            }
243
244            $username = $this->generateUsername($email);
245            $passwordHash = password_hash(self::DEFAULT_PASSWORD, PASSWORD_ARGON2ID);
246            $dateOfBirth = $this->generateRandomDateOfBirth();
247
248            $kycStatus = random_int(1, 10) <= 8 ? 'verified' : 'pending';
249
250            $this->repository->createUser($email, $username, $passwordHash, 'investor');
251            $usersCreated++;
252
253            $investorId = $this->repository->createInvestor([
254                'firstName' => $firstName,
255                'lastName' => $lastName,
256                'email' => $email,
257                'dateOfBirth' => $dateOfBirth,
258                'kycStatus' => $kycStatus,
259                'status' => 'active',
260                'createdAt' => $accountCreatedAt,
261            ]);
262
263            $accountResult = $this->repository->createAccount(
264                $investorId,
265                'pending',
266                $accountCreatedAt,
267            );
268            $accountsCreated++;
269
270            $this->repository->createTransaction(
271                $accountResult['accountId'],
272                'deposit',
273                $initialDeposit,
274                $initialDeposit,
275                'Initial deposit',
276                $this->generateReferenceNumber('DEP'),
277                $accountCreatedAt,
278            );
279            $transactionsCreated++;
280
281            $this->repository->updateAccountStatus($accountResult['accountId'], 'active');
282
283            // MIN_ACCOUNT_AGE_MONTHS is 1, so accountAgeMonths is always >= 1, guard not needed
284            $txResult = $this->generateTransactions($accountResult['accountId'], $accountAgeMonths);
285            $transactionsCreated += $txResult['transactionsCreated'];
286        }
287
288        return [
289            'usersCreated' => $usersCreated,
290            'accountsCreated' => $accountsCreated,
291            'transactionsCreated' => $transactionsCreated,
292        ];
293    }
294
295    /**
296     * @return array<int, array<string, mixed>>
297     */
298    public function getAccountsForDropdown(): array
299    {
300        return $this->repository->getAllAccountsWithInvestors();
301    }
302
303    /**
304     * @return array{users: int, investors: int, accounts: int, transactions: int}
305     */
306    public function deleteTestData(): array
307    {
308        return $this->repository->deleteTestData('%@' . self::TEST_EMAIL_DOMAIN);
309    }
310
311    /**
312     * @return array{users: int, investors: int, accounts: int, transactions: int}
313     */
314    public function getTestDataCounts(): array
315    {
316        return $this->repository->countTestData('%@' . self::TEST_EMAIL_DOMAIN);
317    }
318
319    /**
320     * @param int $actingUserId The super admin performing the action
321     * @param int $targetUserId The user being updated
322     * @param string $newRole The new role to assign
323     * @return array{userId: int, username: string, email: string, role: string}
324     */
325    public function updateUserRole(int $actingUserId, int $targetUserId, string $newRole): array
326    {
327        if (!in_array($newRole, ['investor', 'admin', 'super_admin'], true)) {
328            throw new InvalidArgumentException('Invalid role. Must be: investor, admin, or super_admin');
329        }
330
331        if ($actingUserId === $targetUserId) {
332            throw new DomainException('You cannot change your own role');
333        }
334
335        $user = $this->repository->getUserById($targetUserId);
336        if ($user === null) {
337            throw new NotFoundException('User not found');
338        }
339
340        if ($user['role'] === $newRole) {
341            throw new DomainException(sprintf('User already has the %s role', $newRole));
342        }
343
344        $this->repository->updateUserRole($targetUserId, $newRole);
345
346        return [
347            'userId' => $user['userId'],
348            'username' => $user['username'],
349            'email' => $user['email'],
350            'role' => $newRole,
351        ];
352    }
353
354    private function generateEmail(string $firstName, string $lastName): string
355    {
356        $cleanFirst = preg_replace('/[^a-zA-Z]/', '', $firstName) ?? '';
357        $cleanLast = preg_replace('/[^a-zA-Z]/', '', $lastName) ?? '';
358        $randomSuffix = random_int(1000, 9999);
359
360        return strtolower("{$cleanFirst}{$cleanLast}{$randomSuffix}.created@" . self::TEST_EMAIL_DOMAIN);
361    }
362
363    private function generateUsername(string $email): string
364    {
365        return explode('@', $email)[0];
366    }
367
368    private function generateRandomDateOfBirth(): string
369    {
370        $age = random_int(25, 65);
371
372        return (new DateTimeImmutable())
373            ->sub(new DateInterval("P{$age}Y"))
374            ->format('Y-m-d');
375    }
376
377    private function generateRandomDeposit(): float
378    {
379        $minThousands = (int)(self::MIN_DEPOSIT / 1000);
380        $maxThousands = (int)(self::MAX_DEPOSIT / 1000);
381
382        return (float)(random_int($minThousands, $maxThousands) * 1000);
383    }
384
385    /**
386     * @param int $accountId
387     * @param float $startingBalance
388     * @param DateTimeImmutable $monthStart
389     * @param DateTimeImmutable $monthEnd
390     * @return array<int, array{type: string, amount: float, balanceAfter: float, description: string, referenceNumber: string|null, createdAt: string}>
391     */
392    private function generateMonthTransactions(
393        int $accountId,
394        float $startingBalance,
395        DateTimeImmutable $monthStart,
396        DateTimeImmutable $monthEnd,
397    ): array {
398        $transactions = [];
399        $balance = $startingBalance;
400
401        // 70% chance of 1-2 deposits
402        if (random_int(1, 100) <= 70) {
403            $depositCount = random_int(1, 2);
404            for ($i = 0; $i < $depositCount; $i++) {
405                $amount = $this->generateTransactionAmount('deposit', $balance);
406                $balance += $amount;
407
408                $transactions[] = [
409                    'type' => 'deposit',
410                    'amount' => $amount,
411                    'balanceAfter' => $balance,
412                    'description' => $this->generateTransactionDescription('deposit'),
413                    'referenceNumber' => $this->generateReferenceNumber('DEP'),
414                    'createdAt' => $this->generateRandomDateInRange($monthStart, $monthEnd),
415                ];
416            }
417        }
418
419        // 60% chance of 1-3 withdrawals (only if balance allows)
420        if (random_int(1, 100) <= 60 && $balance > self::MIN_DEPOSIT) {
421            $withdrawalCount = random_int(1, 3);
422            for ($i = 0; $i < $withdrawalCount; $i++) {
423                $maxWithdrawal = ($balance - self::MIN_DEPOSIT) * 0.3;
424                if ($maxWithdrawal < 1000) {
425                    break;
426                }
427
428                $amount = $this->generateTransactionAmount('withdrawal', $balance, $maxWithdrawal);
429                $balance -= $amount;
430
431                $transactions[] = [
432                    'type' => 'withdrawal',
433                    'amount' => $amount,
434                    'balanceAfter' => $balance,
435                    'description' => $this->generateTransactionDescription('withdrawal'),
436                    'referenceNumber' => $this->generateReferenceNumber('WTH'),
437                    'createdAt' => $this->generateRandomDateInRange($monthStart, $monthEnd),
438                ];
439            }
440        }
441
442        // 100% chance of monthly interest
443        $interestAmount = round($balance * (0.08 / 12), 2);
444        if ($interestAmount > 0) {
445            $balance += $interestAmount;
446
447            $transactions[] = [
448                'type' => 'interest',
449                'amount' => $interestAmount,
450                'balanceAfter' => $balance,
451                'description' => 'Monthly interest payment',
452                'referenceNumber' => null,
453                'createdAt' => $monthEnd->format('Y-m-d 23:59:59'),
454            ];
455        }
456
457        // 20% chance of a fee
458        if (random_int(1, 100) <= 20) {
459            $feeAmount = (float)random_int(25, 100);
460            $balance -= $feeAmount;
461
462            $transactions[] = [
463                'type' => 'fee',
464                'amount' => $feeAmount,
465                'balanceAfter' => $balance,
466                'description' => $this->generateTransactionDescription('fee'),
467                'referenceNumber' => $this->generateReferenceNumber('FEE'),
468                'createdAt' => $this->generateRandomDateInRange($monthStart, $monthEnd),
469            ];
470        }
471
472        // Sort by date, but keep interest last within the same day
473        usort($transactions, function ($a, $b) {
474            $cmp = strcmp($a['createdAt'], $b['createdAt']);
475            if ($cmp === 0) {
476                // Interest should always be calculated last
477                if ($a['type'] === 'interest') {
478                    return 1;
479                }
480                if ($b['type'] === 'interest') {
481                    return -1;
482                }
483            }
484            return $cmp;
485        });
486
487        // Recalculate balances and interest amount after sorting
488        $balance = $startingBalance;
489        foreach ($transactions as &$tx) {
490            if ($tx['type'] === 'interest') {
491                // Recalculate interest on the actual balance at this point
492                $tx['amount'] = round($balance * (0.08 / 12), 2);
493            }
494            if (in_array($tx['type'], ['deposit', 'interest'], true)) {
495                $balance += $tx['amount'];
496            } else {
497                $balance -= $tx['amount'];
498            }
499            $tx['balanceAfter'] = round($balance, 2);
500        }
501
502        return $transactions;
503    }
504
505    private function generateTransactionAmount(string $type, float $currentBalance, ?float $maxAmount = null): float
506    {
507        return match ($type) {
508            'deposit' => (float)(random_int(5, 50) * 1000),
509            'withdrawal' => (float)(random_int(1, (int)min(20, ($maxAmount ?? 20000) / 1000)) * 1000),
510            default => 0.0,
511        };
512    }
513
514    private function generateTransactionDescription(string $type): string
515    {
516        $descriptions = match ($type) {
517            'deposit' => ['Wire transfer deposit', 'ACH deposit', 'Check deposit', 'Investment contribution', 'Funds transfer'],
518            'withdrawal' => ['Wire transfer withdrawal', 'ACH withdrawal', 'Funds distribution', 'Account withdrawal', 'Transfer out'],
519            'fee' => ['Account maintenance fee', 'Wire transfer fee', 'Service fee', 'Administrative fee'],
520            default => ['Transaction'],
521        };
522
523        return $descriptions[array_rand($descriptions)];
524    }
525
526    private function generateReferenceNumber(string $prefix): string
527    {
528        $chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
529        $suffix = '';
530        for ($i = 0; $i < 8; $i++) {
531            $suffix .= $chars[random_int(0, strlen($chars) - 1)];
532        }
533
534        return "{$prefix}-{$suffix}";
535    }
536
537    /**
538     * @param DateTimeImmutable $start
539     * @param DateTimeImmutable $end
540     * @throws DateMalformedStringException
541     * @throws RandomException
542     */
543    private function generateRandomDateInRange(DateTimeImmutable $start, DateTimeImmutable $end): string
544    {
545        return (new DateTimeImmutable('@' . random_int($start->getTimestamp(), $end->getTimestamp()))
546        )->format('Y-m-d H:i:s');
547    }
548}