Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
PostMonthlyInterestCommand
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 3
20
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 / 13
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
6
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 DateTimeImmutable;
11use Psr\Log\LoggerInterface;
12use Symfony\Component\Console\Command\Command;
13use Symfony\Component\Console\Input\InputInterface;
14use Symfony\Component\Console\Input\InputOption;
15use Symfony\Component\Console\Output\OutputInterface;
16use Throwable;
17
18/**
19 * Monthly interest posting command.
20 *
21 * Posts accumulated daily interest as a single transaction per account
22 * for the given month. Defaults to the previous month so it can run
23 * early on the 1st and capture the month that just closed.
24 *
25 * Usage:
26 *   php bin/console.php interest:post
27 *   php bin/console.php interest:post --month=2026-03-01
28 */
29final class PostMonthlyInterestCommand extends Command
30{
31    private const string JOB_NAME = 'Monthly interest post';
32    private const string JOB_KEY = 'interest:post';
33
34    public function __construct(
35        private readonly SuperAdminRepository $repository,
36        private readonly LoggerInterface $logger,
37        private readonly CronEmailNotifier $notifier,
38        private readonly HealthcheckPinger $healthcheck,
39    ) {
40        parent::__construct();
41    }
42
43    protected function configure(): void
44    {
45        parent::configure();
46
47        $this->setName('interest:post');
48        $this->setDescription('Post accrued daily interest as monthly transactions');
49
50        // Explicit "first day of this month, then minus one month" — avoids
51        // strtotime('first day of last month') which mis-fires near month
52        // boundaries when the active timezone is east of the system clock.
53        $previousMonth = (new DateTimeImmutable('first day of this month'))
54            ->modify('-1 month')
55            ->format('Y-m-d');
56
57        $this->addOption(
58            'month',
59            'm',
60            InputOption::VALUE_REQUIRED,
61            'Month-start date (YYYY-MM-01) to post for (default: previous month)',
62            $previousMonth,
63        );
64    }
65
66    protected function execute(InputInterface $input, OutputInterface $output): int
67    {
68        /** @var string $month */
69        $month = $input->getOption('month');
70
71        $output->writeln(sprintf('<info>Posting monthly interest for %s...</info>', $month));
72
73        try {
74            $result = $this->repository->postMonthlyInterest($month);
75        } catch (Throwable $e) {
76            $this->notifier->notifyJobFailure(self::JOB_NAME, $e);
77            $this->healthcheck->pingFailure(self::JOB_KEY);
78
79            throw $e;
80        }
81
82        $message = sprintf(
83            'Posted interest for %d account(s), total %s, month %s',
84            $result['accountsPosted'],
85            $result['totalInterest'],
86            $month,
87        );
88        $output->writeln(sprintf('<info>%s</info>', $message));
89        $this->logger->info($message);
90
91        // Monthly post always emails — every run produces a record of what was
92        // (or wasn't) posted. Zero accounts is itself useful information here
93        // because it means a previous run already posted the month, which
94        // shouldn't happen unattended.
95        $this->notifier->notifyJobSuccess(self::JOB_NAME, $message);
96        $this->healthcheck->pingSuccess(self::JOB_KEY);
97
98        return Command::SUCCESS;
99    }
100}