Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.05% covered (success)
98.05%
252 / 257
68.75% covered (warning)
68.75%
11 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
SuperAdminService
98.05% covered (success)
98.05%
252 / 257
68.75% covered (warning)
68.75%
11 / 16
59
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
 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
98.36% covered (success)
98.36%
60 / 61
0.00% covered (danger)
0.00%
0 / 1
11
 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    private function generateEmail(string $firstName, string $lastName): string
320    {
321        $cleanFirst = preg_replace('/[^a-zA-Z]/', '', $firstName) ?? '';
322        $cleanLast = preg_replace('/[^a-zA-Z]/', '', $lastName) ?? '';
323        $randomSuffix = random_int(1000, 9999);
324
325        return strtolower("{$cleanFirst}{$cleanLast}{$randomSuffix}.created@" . self::TEST_EMAIL_DOMAIN);
326    }
327
328    private function generateUsername(string $email): string
329    {
330        return explode('@', $email)[0];
331    }
332
333    private function generateRandomDateOfBirth(): string
334    {
335        $age = random_int(25, 65);
336
337        return (new DateTimeImmutable())
338            ->sub(new DateInterval("P{$age}Y"))
339            ->format('Y-m-d');
340    }
341
342    private function generateRandomDeposit(): float
343    {
344        $minThousands = (int)(self::MIN_DEPOSIT / 1000);
345        $maxThousands = (int)(self::MAX_DEPOSIT / 1000);
346
347        return (float)(random_int($minThousands, $maxThousands) * 1000);
348    }
349
350    /**
351     * @param int $accountId
352     * @param float $startingBalance
353     * @param DateTimeImmutable $monthStart
354     * @param DateTimeImmutable $monthEnd
355     * @return array<int, array{type: string, amount: float, balanceAfter: float, description: string, referenceNumber: string|null, createdAt: string}>
356     */
357    private function generateMonthTransactions(
358        int $accountId,
359        float $startingBalance,
360        DateTimeImmutable $monthStart,
361        DateTimeImmutable $monthEnd,
362    ): array {
363        $transactions = [];
364        $balance = $startingBalance;
365
366        // 70% chance of 1-2 deposits
367        if (random_int(1, 100) <= 70) {
368            $depositCount = random_int(1, 2);
369            for ($i = 0; $i < $depositCount; $i++) {
370                $amount = $this->generateTransactionAmount('deposit', $balance);
371                $balance += $amount;
372
373                $transactions[] = [
374                    'type' => 'deposit',
375                    'amount' => $amount,
376                    'balanceAfter' => $balance,
377                    'description' => $this->generateTransactionDescription('deposit'),
378                    'referenceNumber' => $this->generateReferenceNumber('DEP'),
379                    'createdAt' => $this->generateRandomDateInRange($monthStart, $monthEnd),
380                ];
381            }
382        }
383
384        // 60% chance of 1-3 withdrawals (only if balance allows)
385        if (random_int(1, 100) <= 60 && $balance > self::MIN_DEPOSIT) {
386            $withdrawalCount = random_int(1, 3);
387            for ($i = 0; $i < $withdrawalCount; $i++) {
388                $maxWithdrawal = ($balance - self::MIN_DEPOSIT) * 0.3;
389                if ($maxWithdrawal < 1000) {
390                    break;
391                }
392
393                $amount = $this->generateTransactionAmount('withdrawal', $balance, $maxWithdrawal);
394                $balance -= $amount;
395
396                $transactions[] = [
397                    'type' => 'withdrawal',
398                    'amount' => $amount,
399                    'balanceAfter' => $balance,
400                    'description' => $this->generateTransactionDescription('withdrawal'),
401                    'referenceNumber' => $this->generateReferenceNumber('WTH'),
402                    'createdAt' => $this->generateRandomDateInRange($monthStart, $monthEnd),
403                ];
404            }
405        }
406
407        // 100% chance of monthly interest
408        $interestAmount = round($balance * (0.08 / 12), 2);
409        if ($interestAmount > 0) {
410            $balance += $interestAmount;
411
412            $transactions[] = [
413                'type' => 'interest',
414                'amount' => $interestAmount,
415                'balanceAfter' => $balance,
416                'description' => 'Monthly interest payment',
417                'referenceNumber' => null,
418                'createdAt' => $monthEnd->format('Y-m-d 23:59:59'),
419            ];
420        }
421
422        // 20% chance of a fee
423        if (random_int(1, 100) <= 20) {
424            $feeAmount = (float)random_int(25, 100);
425            $balance -= $feeAmount;
426
427            $transactions[] = [
428                'type' => 'fee',
429                'amount' => $feeAmount,
430                'balanceAfter' => $balance,
431                'description' => $this->generateTransactionDescription('fee'),
432                'referenceNumber' => $this->generateReferenceNumber('FEE'),
433                'createdAt' => $this->generateRandomDateInRange($monthStart, $monthEnd),
434            ];
435        }
436
437        // Sort by date
438        usort($transactions, fn($a, $b) => strcmp($a['createdAt'], $b['createdAt']));
439
440        // Recalculate balances after sorting
441        $balance = $startingBalance;
442        foreach ($transactions as &$tx) {
443            if (in_array($tx['type'], ['deposit', 'interest'], true)) {
444                $balance += $tx['amount'];
445            } else {
446                $balance -= $tx['amount'];
447            }
448            $tx['balanceAfter'] = round($balance, 2);
449        }
450
451        return $transactions;
452    }
453
454    private function generateTransactionAmount(string $type, float $currentBalance, ?float $maxAmount = null): float
455    {
456        return match ($type) {
457            'deposit' => (float)(random_int(5, 50) * 1000),
458            'withdrawal' => (float)(random_int(1, (int)min(20, ($maxAmount ?? 20000) / 1000)) * 1000),
459            default => 0.0,
460        };
461    }
462
463    private function generateTransactionDescription(string $type): string
464    {
465        $descriptions = match ($type) {
466            'deposit' => ['Wire transfer deposit', 'ACH deposit', 'Check deposit', 'Investment contribution', 'Funds transfer'],
467            'withdrawal' => ['Wire transfer withdrawal', 'ACH withdrawal', 'Funds distribution', 'Account withdrawal', 'Transfer out'],
468            'fee' => ['Account maintenance fee', 'Wire transfer fee', 'Service fee', 'Administrative fee'],
469            default => ['Transaction'],
470        };
471
472        return $descriptions[array_rand($descriptions)];
473    }
474
475    private function generateReferenceNumber(string $prefix): string
476    {
477        $chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
478        $suffix = '';
479        for ($i = 0; $i < 8; $i++) {
480            $suffix .= $chars[random_int(0, strlen($chars) - 1)];
481        }
482
483        return "{$prefix}-{$suffix}";
484    }
485
486    /**
487     * @param DateTimeImmutable $start
488     * @param DateTimeImmutable $end
489     * @throws DateMalformedStringException
490     * @throws RandomException
491     */
492    private function generateRandomDateInRange(DateTimeImmutable $start, DateTimeImmutable $end): string
493    {
494        return (new DateTimeImmutable('@' . random_int($start->getTimestamp(), $end->getTimestamp()))
495        )->format('Y-m-d H:i:s');
496    }
497}