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