Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
AccrueInterestCommand
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 3
30
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 configure
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3declare(strict_types=1);
4
5namespace App\Console;
6
7use App\Domain\Notification\Service\CronEmailNotifier;
8use App\Domain\Notification\Service\HealthcheckPinger;
9use App\Domain\SuperAdmin\Repository\SuperAdminRepository;
10use Psr\Log\LoggerInterface;
11use Symfony\Component\Console\Command\Command;
12use Symfony\Component\Console\Input\InputInterface;
13use Symfony\Component\Console\Input\InputOption;
14use Symfony\Component\Console\Output\OutputInterface;
15use Throwable;
16
17/**
18 * Daily interest accrual command.
19 *
20 * Usage:
21 *   php bin/console.php interest:accrue
22 *   php bin/console.php interest:accrue --date=2026-04-10
23 */
24final class AccrueInterestCommand extends Command
25{
26    private const string JOB_NAME = 'Daily interest accrual';
27    private const string JOB_KEY = 'interest:accrue';
28
29    public function __construct(
30        private readonly SuperAdminRepository $repository,
31        private readonly LoggerInterface $logger,
32        private readonly CronEmailNotifier $notifier,
33        private readonly HealthcheckPinger $healthcheck,
34    ) {
35        parent::__construct();
36    }
37
38    protected function configure(): void
39    {
40        parent::configure();
41
42        $this->setName('interest:accrue');
43        $this->setDescription('Accrue daily interest for all active accounts');
44        $this->addOption(
45            'date',
46            'd',
47            InputOption::VALUE_REQUIRED,
48            'The date to accrue interest for (default: today)',
49            date('Y-m-d'),
50        );
51    }
52
53    protected function execute(InputInterface $input, OutputInterface $output): int
54    {
55        /** @var string $date */
56        $date = $input->getOption('date');
57
58        $output->writeln(sprintf('<info>Accruing interest for %s...</info>', $date));
59
60        try {
61            $count = $this->repository->accrueDailyInterest($date);
62        } catch (Throwable $e) {
63            $this->notifier->notifyJobFailure(self::JOB_NAME, $e);
64            $this->healthcheck->pingFailure(self::JOB_KEY);
65
66            throw $e;
67        }
68
69        $message = sprintf('Accrued interest for %d account(s) on %s', $count, $date);
70        $output->writeln(sprintf('<info>%s</info>', $message));
71        $this->logger->info($message);
72
73        // Daily run is silent on a normal success — only email when zero
74        // accounts accrued, which usually means something upstream is wrong
75        // (no active accounts with positive balance, missing rate config).
76        if ($count === 0) {
77            $this->notifier->notifyJobAnomaly(self::JOB_NAME, $message);
78        }
79
80        $this->healthcheck->pingSuccess(self::JOB_KEY);
81
82        return Command::SUCCESS;
83    }
84}