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