Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
77.86% covered (warning)
77.86%
313 / 402
48.00% covered (danger)
48.00%
12 / 25
CRAP
0.00% covered (danger)
0.00%
0 / 1
SuperAdminService
77.86% covered (warning)
77.86%
313 / 402
48.00% covered (danger)
48.00%
12 / 25
218.12
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
 getCronJobStatuses
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
2
 classifyStatus
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
7.01
 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
 deleteUser
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 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
 generateRandomInvestment
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 generateMonthTransactions
92.31% covered (success)
92.31%
60 / 65
0.00% covered (danger)
0.00%
0 / 1
21.20
 pickWithdrawalAmount
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
3.14
 pickFeeAmount
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 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
 accrueInterest
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
30
 postInterest
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 projectInterest
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 1
72
 guardNonProduction
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3declare(strict_types=1);
4
5namespace App\Domain\SuperAdmin\Service;
6
7use App\Domain\Exception\BadRequestException;
8use App\Domain\Exception\ConflictException;
9use App\Domain\Exception\NotFoundException;
10use App\Domain\Exception\ValidationException;
11use App\Domain\SuperAdmin\Data\CronJobStatusData;
12use App\Domain\SuperAdmin\Repository\SuperAdminRepository;
13use App\Support\Row;
14use DateInterval;
15use DateMalformedStringException;
16use DateTimeImmutable;
17
18use DomainException;
19
20use Random\RandomException;
21
22use function in_array;
23use function sprintf;
24use function strlen;
25
26final class SuperAdminService
27{
28    private const string TEST_EMAIL_DOMAIN = 'testdata.local';
29    private const string DEFAULT_PASSWORD = 'TestPassword123!';
30    private const float MIN_INVESTMENT = 25000.00;
31    private const float MAX_INVESTMENT = 2000000.00;
32    private const int MIN_ACCOUNT_AGE_MONTHS = 1;
33    private const int MAX_ACCOUNT_AGE_MONTHS = 120;
34
35    /** Daily cron is healthy up to 25h since last run. */
36    private const int DAILY_HEALTHY_HOURS = 25;
37    /** Daily cron is "warning" between 25h and 30h; "stale" past 30h. */
38    private const int DAILY_STALE_HOURS = 30;
39    /** Monthly post is healthy up to 31 days; warning to 33; stale past 33. */
40    private const int MONTHLY_HEALTHY_DAYS = 31;
41    private const int MONTHLY_STALE_DAYS = 33;
42
43    public function __construct(
44        private readonly SuperAdminRepository $repository,
45    ) {}
46
47    /**
48     * Status of the three scheduled console commands for the cron-jobs
49     * dashboard tile (FSC-114). Pulls last-run summaries from the
50     * repository and applies staleness thresholds.
51     *
52     * @param ?DateTimeImmutable $now
53     * @return list<CronJobStatusData>
54     */
55    public function getCronJobStatuses(?DateTimeImmutable $now = null): array
56    {
57        $now ??= new DateTimeImmutable();
58        $summaries = $this->repository->getCronJobLastRunSummaries();
59
60        $jobs = [
61            'interest:accrue' => ['Daily interest accrual', 'daily'],
62            'balance:snapshot' => ['Daily balance snapshot', 'daily'],
63            'interest:post' => ['Monthly interest post', 'monthly'],
64        ];
65
66        $result = [];
67        foreach ($jobs as $jobName => [$displayName, $frequency]) {
68            $summary = $summaries[$jobName] ?? [
69                'lastRunAt' => null,
70                'lastRunFor' => null,
71                'accountsAffected' => 0,
72                'totalAmount' => null,
73            ];
74
75            $result[] = new CronJobStatusData([
76                'jobName' => $jobName,
77                'displayName' => $displayName,
78                'expectedFrequency' => $frequency,
79                'lastRunAt' => $summary['lastRunAt'],
80                'lastRunFor' => $summary['lastRunFor'],
81                'accountsAffected' => $summary['accountsAffected'],
82                'totalAmount' => $summary['totalAmount'],
83                'status' => $this->classifyStatus($summary['lastRunAt'], $frequency, $now),
84            ]);
85        }
86
87        return $result;
88    }
89
90    private function classifyStatus(?string $lastRunAt, string $frequency, DateTimeImmutable $now): string
91    {
92        if ($lastRunAt === null) {
93            return 'never_run';
94        }
95
96        $lastRun = new DateTimeImmutable($lastRunAt);
97        $hoursSince = ($now->getTimestamp() - $lastRun->getTimestamp()) / 3600;
98
99        if ($frequency === 'daily') {
100            if ($hoursSince <= self::DAILY_HEALTHY_HOURS) {
101                return 'healthy';
102            }
103            if ($hoursSince <= self::DAILY_STALE_HOURS) {
104                return 'warning';
105            }
106            return 'stale';
107        }
108
109        // monthly
110        $daysSince = $hoursSince / 24;
111        if ($daysSince <= self::MONTHLY_HEALTHY_DAYS) {
112            return 'healthy';
113        }
114        if ($daysSince <= self::MONTHLY_STALE_DAYS) {
115            return 'warning';
116        }
117        return 'stale';
118    }
119
120    /**
121     *
122     * @param string $firstName
123     * @param string $lastName
124     * @param ?string $email
125     * @param ?string $password
126     * @param string $kycStatus
127     * @param bool $createAccount
128     * @param ?float $initialInvestment
129     * @throws BadRequestException|ValidationException|ConflictException|RandomException
130     * @return array{userId: int, investorId: int, accountId: int|null, accountNumber: string|null, email: string}
131     */
132    public function createTestUser(
133        string $firstName,
134        string $lastName,
135        ?string $email = null,
136        ?string $password = null,
137        string $kycStatus = 'verified',
138        bool $createAccount = true,
139        ?float $initialInvestment = null,
140    ): array {
141        if (empty(trim($firstName))) {
142            throw new BadRequestException('First name is required');
143        }
144        if (empty(trim($lastName))) {
145            throw new BadRequestException('Last name is required');
146        }
147
148        if (!in_array($kycStatus, ['pending', 'verified', 'rejected'], true)) {
149            throw new ValidationException('Invalid KYC status. Must be: pending, verified, or rejected');
150        }
151
152        $email ??= $this->generateEmail($firstName, $lastName);
153
154        if (!str_ends_with($email, '@' . self::TEST_EMAIL_DOMAIN)) {
155            throw new ValidationException('Test user email must end with @' . self::TEST_EMAIL_DOMAIN);
156        }
157
158        if ($this->repository->emailExistsInUsers($email)) {
159            throw new ConflictException('Email already exists in users table');
160        }
161        if ($this->repository->emailExistsInInvestors($email)) {
162            throw new ConflictException('Email already exists in investors table');
163        }
164
165        $username = $this->generateUsername($email);
166        $passwordHash = password_hash($password ?? self::DEFAULT_PASSWORD, PASSWORD_ARGON2ID);
167        $dateOfBirth = $this->generateRandomDateOfBirth();
168
169        $userId = $this->repository->createUser($email, $username, $passwordHash, 'investor');
170
171        $investorId = $this->repository->createInvestor([
172            'firstName' => $firstName,
173            'lastName' => $lastName,
174            'email' => $email,
175            'dateOfBirth' => $dateOfBirth,
176            'kycStatus' => $kycStatus,
177            'status' => 'active',
178        ]);
179
180        $accountId = null;
181        $accountNumber = null;
182
183        if ($createAccount) {
184            $investment = $initialInvestment ?? $this->generateRandomInvestment();
185
186            if ($investment < self::MIN_INVESTMENT) {
187                throw new ValidationException(
188                    sprintf('Initial investment must be at least $%.2f', self::MIN_INVESTMENT),
189                );
190            }
191
192            $accountResult = $this->repository->createAccount($investorId, 'pending');
193            $accountId = $accountResult['accountId'];
194            $accountNumber = $accountResult['accountNumber'];
195
196            $this->repository->createTransaction(
197                $accountId,
198                'investment',
199                $investment,
200                $investment,
201                'Initial investment',
202                $this->generateReferenceNumber('DEP'),
203                date('Y-m-d H:i:s'),
204            );
205
206            $this->repository->updateAccountStatus($accountId, 'active');
207        }
208
209        return [
210            'userId' => $userId,
211            'investorId' => $investorId,
212            'accountId' => $accountId,
213            'accountNumber' => $accountNumber,
214            'email' => $email,
215        ];
216    }
217
218    /**
219     *
220     * @param int $accountId
221     * @param int $monthsBack
222     * @throws ValidationException|RandomException
223     * @return array{transactionsCreated: int, totalInvestments: string, totalWithdrawals: string, totalInterest: string, totalFees: string, finalBalance: string}
224     */
225    public function generateTransactions(int $accountId, int $monthsBack = 6): array
226    {
227        $account = $this->repository->getAccountById($accountId);
228        if ($account === null) {
229            throw new NotFoundException('Account not found');
230        }
231
232        if ($monthsBack < 1 || $monthsBack > 120) {
233            throw new ValidationException('Months back must be between 1 and 120');
234        }
235
236        $currentBalance = Row::float($account, 'balance');
237        $transactionsCreated = 0;
238        $totalInvestments = 0.0;
239        $totalWithdrawals = 0.0;
240        $totalInterest = 0.0;
241        $totalFees = 0.0;
242
243        $now = new DateTimeImmutable();
244
245        for ($month = $monthsBack; $month >= 1; $month--) {
246            $monthStart = $now->sub(new DateInterval("P{$month}M"));
247            $monthEnd = $month > 1
248                ? $now->sub(new DateInterval('P' . ($month - 1) . 'M'))
249                : $now;
250
251            $monthTransactions = $this->generateMonthTransactions(
252                $accountId,
253                $currentBalance,
254                $monthStart,
255                $monthEnd,
256            );
257
258            foreach ($monthTransactions as $tx) {
259                $this->repository->createTransaction(
260                    $accountId,
261                    $tx['type'],
262                    $tx['amount'],
263                    $tx['balanceAfter'],
264                    $tx['description'],
265                    $tx['referenceNumber'],
266                    $tx['createdAt'],
267                );
268
269                $currentBalance = $tx['balanceAfter'];
270                $transactionsCreated++;
271
272                match ($tx['type']) {
273                    'investment' => $totalInvestments += $tx['amount'],
274                    'withdrawal' => $totalWithdrawals += $tx['amount'],
275                    'interest' => $totalInterest += $tx['amount'],
276                    'fee' => $totalFees += $tx['amount'],
277                    default => null,
278                };
279            }
280        }
281
282        $this->repository->updateAccountBalance($accountId, $currentBalance);
283
284        return [
285            'transactionsCreated' => $transactionsCreated,
286            'totalInvestments' => number_format($totalInvestments, 2, '.', ''),
287            'totalWithdrawals' => number_format($totalWithdrawals, 2, '.', ''),
288            'totalInterest' => number_format($totalInterest, 2, '.', ''),
289            'totalFees' => number_format($totalFees, 2, '.', ''),
290            'finalBalance' => number_format($currentBalance, 2, '.', ''),
291        ];
292    }
293
294    /**
295     *
296     * @param int $userCount
297     * @throws RandomException
298     * @return array{usersCreated: int, accountsCreated: int, transactionsCreated: int}
299     */
300    public function generateBulkData(int $userCount = 5): array
301    {
302        if ($userCount < 1 || $userCount > 50) {
303            throw new ValidationException('User count must be between 1 and 50');
304        }
305
306        $usersCreated = 0;
307        $accountsCreated = 0;
308        $transactionsCreated = 0;
309
310        $firstNames = ['James', 'Mary', 'John', 'Patricia', 'Robert', 'Jennifer', 'Michael', 'Linda', 'William', 'Elizabeth'];
311        $lastNames = ['Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller', 'Davis', 'Rodriguez', 'Martinez'];
312
313        for ($i = 0; $i < $userCount; $i++) {
314            $firstName = $firstNames[array_rand($firstNames)];
315            $lastName = $lastNames[array_rand($lastNames)];
316
317            $accountAgeMonths = random_int(self::MIN_ACCOUNT_AGE_MONTHS, self::MAX_ACCOUNT_AGE_MONTHS);
318
319            $accountCreatedAt = (new DateTimeImmutable())
320                ->sub(new DateInterval("P{$accountAgeMonths}M"))
321                ->format('Y-m-d H:i:s');
322
323            $initialInvestment = $this->generateRandomInvestment();
324
325            $email = $this->generateEmail($firstName, $lastName);
326
327            while ($this->repository->emailExistsInUsers($email) || $this->repository->emailExistsInInvestors($email)) {
328                $email = $this->generateEmail($firstName, $lastName);
329            }
330
331            $username = $this->generateUsername($email);
332            $passwordHash = password_hash(self::DEFAULT_PASSWORD, PASSWORD_ARGON2ID);
333            $dateOfBirth = $this->generateRandomDateOfBirth();
334
335            $kycStatus = random_int(1, 10) <= 8 ? 'verified' : 'pending';
336
337            $this->repository->createUser($email, $username, $passwordHash, 'investor');
338            $usersCreated++;
339
340            $investorId = $this->repository->createInvestor([
341                'firstName' => $firstName,
342                'lastName' => $lastName,
343                'email' => $email,
344                'dateOfBirth' => $dateOfBirth,
345                'kycStatus' => $kycStatus,
346                'status' => 'active',
347                'createdAt' => $accountCreatedAt,
348            ]);
349
350            $accountResult = $this->repository->createAccount(
351                $investorId,
352                'pending',
353                $accountCreatedAt,
354            );
355            $accountsCreated++;
356
357            $this->repository->createTransaction(
358                $accountResult['accountId'],
359                'investment',
360                $initialInvestment,
361                $initialInvestment,
362                'Initial investment',
363                $this->generateReferenceNumber('DEP'),
364                $accountCreatedAt,
365            );
366            $transactionsCreated++;
367
368            $this->repository->updateAccountStatus($accountResult['accountId'], 'active');
369
370            // MIN_ACCOUNT_AGE_MONTHS is 1, so accountAgeMonths is always >= 1, guard not needed
371            $txResult = $this->generateTransactions($accountResult['accountId'], $accountAgeMonths);
372            $transactionsCreated += $txResult['transactionsCreated'];
373        }
374
375        return [
376            'usersCreated' => $usersCreated,
377            'accountsCreated' => $accountsCreated,
378            'transactionsCreated' => $transactionsCreated,
379        ];
380    }
381
382    /**
383     * @return list<array<mixed>>
384     */
385    public function getAccountsForDropdown(): array
386    {
387        return $this->repository->getAllAccountsWithInvestors();
388    }
389
390    /**
391     * @return array{users: int, investors: int, accounts: int, transactions: int}
392     */
393    public function deleteTestData(): array
394    {
395        return $this->repository->deleteTestData('%@' . self::TEST_EMAIL_DOMAIN);
396    }
397
398    /**
399     * @param int $userId
400     * @return array{user: int, investor: int, account: int, loans: int, transactions: int, sessions: int}
401     */
402    public function deleteUser(int $userId): array
403    {
404        $user = $this->repository->getUserById($userId);
405        if ($user === null) {
406            throw new NotFoundException('User not found');
407        }
408
409        return $this->repository->deleteUser($userId);
410    }
411
412    /**
413     * @return array{users: int, investors: int, accounts: int, transactions: int}
414     */
415    public function getTestDataCounts(): array
416    {
417        return $this->repository->countTestData('%@' . self::TEST_EMAIL_DOMAIN);
418    }
419
420    /**
421     * @param int $actingUserId The super admin performing the action
422     * @param int $targetUserId The user being updated
423     * @param string $newRole The new role to assign
424     * @return array{userId: int, username: string, email: string, role: string}
425     */
426    public function updateUserRole(int $actingUserId, int $targetUserId, string $newRole): array
427    {
428        if (!in_array($newRole, ['investor', 'admin', 'super_admin'], true)) {
429            throw new ValidationException('Invalid role. Must be: investor, admin, or super_admin');
430        }
431
432        if ($actingUserId === $targetUserId) {
433            throw new ConflictException('You cannot change your own role');
434        }
435
436        $user = $this->repository->getUserById($targetUserId);
437        if ($user === null) {
438            throw new NotFoundException('User not found');
439        }
440
441        if (Row::nullableString($user, 'role') === $newRole) {
442            throw new ConflictException(sprintf('User already has the %s role', $newRole));
443        }
444
445        $this->repository->updateUserRole($targetUserId, $newRole);
446
447        return [
448            'userId' => Row::int($user, 'userId'),
449            'username' => Row::string($user, 'username'),
450            'email' => Row::string($user, 'email'),
451            'role' => $newRole,
452        ];
453    }
454
455    private function generateEmail(string $firstName, string $lastName): string
456    {
457        $cleanFirst = preg_replace('/[^a-zA-Z]/', '', $firstName) ?? '';
458        $cleanLast = preg_replace('/[^a-zA-Z]/', '', $lastName) ?? '';
459        $randomSuffix = random_int(1000, 9999);
460
461        return strtolower("{$cleanFirst}{$cleanLast}{$randomSuffix}.created@" . self::TEST_EMAIL_DOMAIN);
462    }
463
464    private function generateUsername(string $email): string
465    {
466        return explode('@', $email)[0];
467    }
468
469    private function generateRandomDateOfBirth(): string
470    {
471        $age = random_int(25, 65);
472
473        return (new DateTimeImmutable())
474            ->sub(new DateInterval("P{$age}Y"))
475            ->format('Y-m-d');
476    }
477
478    private function generateRandomInvestment(): float
479    {
480        $minThousands = (int)(self::MIN_INVESTMENT / 1000);
481        $maxThousands = (int)(self::MAX_INVESTMENT / 1000);
482
483        return (float)(random_int($minThousands, $maxThousands) * 1000);
484    }
485
486    /**
487     * Generates a month's worth of synthetic transactions for an account.
488     *
489     * Builds a chronological schedule of (timestamp, type) first, then walks
490     * it in order picking each amount against the actual running balance.
491     * This ensures the active-account minimum balance ($25k) invariant holds
492     * for every step, regardless of how the random timestamps shake out.
493     *
494     * @param int $accountId
495     * @param float $startingBalance
496     * @param DateTimeImmutable $monthStart
497     * @param DateTimeImmutable $monthEnd
498     * @return array<int, array{type: string, amount: float, balanceAfter: float, description: string, referenceNumber: string|null, createdAt: string}>
499     */
500    private function generateMonthTransactions(
501        int $accountId,
502        float $startingBalance,
503        DateTimeImmutable $monthStart,
504        DateTimeImmutable $monthEnd,
505    ): array {
506        $schedule = [];
507
508        // 70% chance of 1-2 investments at random times in the month
509        if (random_int(1, 100) <= 70) {
510            $count = random_int(1, 2);
511            for ($i = 0; $i < $count; $i++) {
512                $schedule[] = [
513                    'type' => 'investment',
514                    'createdAt' => $this->generateRandomDateInRange($monthStart, $monthEnd),
515                ];
516            }
517        }
518
519        // 60% chance of 1-3 withdrawals at random times
520        if (random_int(1, 100) <= 60) {
521            $count = random_int(1, 3);
522            for ($i = 0; $i < $count; $i++) {
523                $schedule[] = [
524                    'type' => 'withdrawal',
525                    'createdAt' => $this->generateRandomDateInRange($monthStart, $monthEnd),
526                ];
527            }
528        }
529
530        // 20% chance of a single fee at a random time
531        if (random_int(1, 100) <= 20) {
532            $schedule[] = [
533                'type' => 'fee',
534                'createdAt' => $this->generateRandomDateInRange($monthStart, $monthEnd),
535            ];
536        }
537
538        // Always one interest event at month-end
539        $schedule[] = [
540            'type' => 'interest',
541            'createdAt' => $monthEnd->format('Y-m-d 23:59:59'),
542        ];
543
544        // Sort chronologically; interest must land last among same-timestamp events
545        usort($schedule, function ($a, $b) {
546            $cmp = strcmp($a['createdAt'], $b['createdAt']);
547            if ($cmp === 0) {
548                if ($a['type'] === 'interest') {
549                    return 1;
550                }
551                if ($b['type'] === 'interest') {
552                    return -1;
553                }
554            }
555            return $cmp;
556        });
557
558        $rate = (float)$this->repository->getAccountYieldRate();
559        $balance = $startingBalance;
560        $transactions = [];
561
562        foreach ($schedule as $event) {
563            $type = $event['type'];
564            $amount = match ($type) {
565                'investment' => (float)(random_int(5, 50) * 1000),
566                'withdrawal' => $this->pickWithdrawalAmount($balance),
567                'fee' => $this->pickFeeAmount($balance),
568                'interest' => round($balance * ($rate / 100.0 / 12.0), 2),
569            };
570
571            if ($amount <= 0) {
572                continue;
573            }
574
575            if (in_array($type, ['investment', 'interest'], true)) {
576                $balance += $amount;
577            } else {
578                $balance -= $amount;
579            }
580
581            $transactions[] = [
582                'type' => $type,
583                'amount' => $amount,
584                'balanceAfter' => round($balance, 2),
585                'description' => $type === 'interest'
586                    ? 'Monthly interest payment'
587                    : $this->generateTransactionDescription($type),
588                'referenceNumber' => match ($type) {
589                    'investment' => $this->generateReferenceNumber('DEP'),
590                    'withdrawal' => $this->generateReferenceNumber('WTH'),
591                    'fee' => $this->generateReferenceNumber('FEE'),
592                    default => null,
593                },
594                'createdAt' => $event['createdAt'],
595            ];
596        }
597
598        return $transactions;
599    }
600
601    /**
602     * Pick a withdrawal amount (rounded thousands) that keeps the active-account
603     * minimum balance intact. Returns 0.0 if no safe amount fits.
604     * @param float $balance
605     */
606    private function pickWithdrawalAmount(float $balance): float
607    {
608        $headroom = $balance - self::MIN_INVESTMENT;
609        if ($headroom < 1000.0) {
610            return 0.0;
611        }
612        $maxWithdrawal = $headroom * 0.3;
613        $maxThousands = (int)min(20, $maxWithdrawal / 1000);
614        if ($maxThousands < 1) {
615            return 0.0;
616        }
617
618        return (float)(random_int(1, $maxThousands) * 1000);
619    }
620
621    /**
622     * Pick a fee amount ($25-$100) that keeps the active-account minimum balance
623     * intact. Returns 0.0 if no safe amount fits.
624     * @param float $balance
625     */
626    private function pickFeeAmount(float $balance): float
627    {
628        $headroom = $balance - self::MIN_INVESTMENT;
629        if ($headroom < 25.0) {
630            return 0.0;
631        }
632        $max = (int)min(100.0, $headroom);
633
634        return (float)random_int(25, $max);
635    }
636
637    private function generateTransactionDescription(string $type): string
638    {
639        $descriptions = match ($type) {
640            'investment' => ['Wire transfer', 'ACH transfer', 'Check investment', 'Investment contribution', 'Funds transfer'],
641            'withdrawal' => ['Wire transfer withdrawal', 'ACH withdrawal', 'Funds distribution', 'Account withdrawal', 'Transfer out'],
642            'fee' => ['Account maintenance fee', 'Wire transfer fee', 'Service fee', 'Administrative fee'],
643            default => ['Transaction'],
644        };
645
646        return $descriptions[array_rand($descriptions)];
647    }
648
649    private function generateReferenceNumber(string $prefix): string
650    {
651        $chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
652        $suffix = '';
653        for ($i = 0; $i < 8; $i++) {
654            $suffix .= $chars[random_int(0, strlen($chars) - 1)];
655        }
656
657        return "{$prefix}-{$suffix}";
658    }
659
660    /**
661     * @param DateTimeImmutable $start
662     * @param DateTimeImmutable $end
663     * @throws DateMalformedStringException
664     * @throws RandomException
665     */
666    private function generateRandomDateInRange(DateTimeImmutable $start, DateTimeImmutable $end): string
667    {
668        return (new DateTimeImmutable('@' . random_int($start->getTimestamp(), $end->getTimestamp()))
669        )->format('Y-m-d H:i:s');
670    }
671
672    /**
673     * Batch-accrue daily interest for a date range.
674     *
675     * @param ?string $startDate
676     * @param ?string $endDate
677     * @return array{daysProcessed: int, totalAccrualRows: int, startDate: string, endDate: string}
678     */
679    public function accrueInterest(?string $startDate, ?string $endDate): array
680    {
681        $this->guardNonProduction();
682
683        if ($startDate === null) {
684            $startDate = date('Y-m-d');
685        }
686        if ($endDate === null) {
687            $endDate = $startDate;
688        }
689
690        if ($startDate > $endDate) {
691            throw new BadRequestException('startDate must be before or equal to endDate');
692        }
693
694        $totalAccrued = 0;
695        $daysProcessed = 0;
696        $current = $startDate;
697
698        while ($current <= $endDate) {
699            $totalAccrued += $this->repository->accrueDailyInterest($current);
700            $daysProcessed++;
701            $current = date('Y-m-d', (int)strtotime($current . ' +1 day'));
702        }
703
704        return [
705            'daysProcessed' => $daysProcessed,
706            'totalAccrualRows' => $totalAccrued,
707            'startDate' => $startDate,
708            'endDate' => $endDate,
709        ];
710    }
711
712    /**
713     * Post accumulated daily interest as transactions for a given month.
714     *
715     * @param ?string $month
716     * @return array{accountsPosted: int, totalInterest: string, month: string}
717     */
718    public function postInterest(?string $month): array
719    {
720        $this->guardNonProduction();
721
722        if ($month === null) {
723            $month = date('Y-m-01', strtotime('first day of last month'));
724        } else {
725            $month = $month . '-01';
726        }
727
728        $result = $this->repository->postMonthlyInterest($month);
729
730        return [
731            'accountsPosted' => $result['accountsPosted'],
732            'totalInterest' => $result['totalInterest'],
733            'month' => $month,
734        ];
735    }
736
737    /**
738     * Project daily interest accrual from today to a future date.
739     *
740     * Simulates daily accrual (balance * rate / 365) with monthly posting on the 1st.
741     * Per-account effective rate is the override on accounts.interest_rate when set,
742     * otherwise the global account_yield_rate. The repository already coalesces both.
743     *
744     * @param string $endDate
745     * @return list<array{
746     *     accountId: int,
747     *     accountNumber: string,
748     *     investorName: string,
749     *     date: string,
750     *     dailyBalance: string,
751     *     annualRate: string,
752     *     dailyInterest: string,
753     *     monthlyAccumulated: string,
754     *     totalAccumulated: string,
755     *     posted: bool,
756     * }>
757     */
758    public function projectInterest(string $endDate): array
759    {
760        $startDate = date('Y-m-d');
761
762        if ($endDate <= $startDate) {
763            throw new BadRequestException('End date must be in the future');
764        }
765
766        $accounts = $this->repository->getActiveAccountsWithBalances();
767
768        if ($accounts === []) {
769            return [];
770        }
771
772        $rows = [];
773
774        foreach ($accounts as $account) {
775            $balance = (float)$account['balance'];
776            $rate = (float)$account['interestRate'];
777            if ($rate <= 0) {
778                continue;
779            }
780            $monthlyAccumulated = 0.0;
781            $totalAccumulated = 0.0;
782            $current = $startDate;
783
784            while ($current <= $endDate) {
785                $dayOfMonth = (int)date('j', (int)strtotime($current));
786                $posted = false;
787
788                // On the 1st, post previous month's accumulated interest
789                if ($dayOfMonth === 1 && $monthlyAccumulated > 0.005) {
790                    $postedAmount = round($monthlyAccumulated, 2);
791                    $balance += $postedAmount;
792                    $monthlyAccumulated = 0.0;
793                    $posted = true;
794                }
795
796                $dailyInterest = $balance * ($rate / 100.0 / 365.0);
797                $monthlyAccumulated += $dailyInterest;
798                $totalAccumulated += $dailyInterest;
799
800                $rows[] = [
801                    'accountId' => $account['accountId'],
802                    'accountNumber' => $account['accountNumber'],
803                    'investorName' => $account['investorName'],
804                    'date' => $current,
805                    'dailyBalance' => number_format($balance, 2, '.', ''),
806                    'annualRate' => number_format($rate, 2, '.', ''),
807                    'dailyInterest' => number_format($dailyInterest, 6, '.', ''),
808                    'monthlyAccumulated' => number_format($monthlyAccumulated, 6, '.', ''),
809                    'totalAccumulated' => number_format($totalAccumulated, 2, '.', ''),
810                    'posted' => $posted,
811                ];
812
813                $current = date('Y-m-d', (int)strtotime($current . ' +1 day'));
814            }
815        }
816
817        return $rows;
818    }
819
820    private function guardNonProduction(): void
821    {
822        if (($_ENV['APP_ENV'] ?? 'dev') === 'prod') {
823            throw new DomainException('This tool is disabled in production');
824        }
825    }
826}