Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions backend/migrations/Version20260208193500.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20260208193500 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add recurring expense schedules and link generated expenses to schedule.';
}

public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE recurring_expense (id INT AUTO_INCREMENT NOT NULL, user_id INT NOT NULL, calendar_id INT NOT NULL, category_id INT NOT NULL, amount DOUBLE PRECISION NOT NULL, label VARCHAR(255) NOT NULL, description LONGTEXT DEFAULT NULL, confirmed TINYINT(1) NOT NULL, start_at DATETIME NOT NULL, next_run_at DATETIME NOT NULL, end_at DATETIME DEFAULT NULL, frequency VARCHAR(255) NOT NULL, `interval` INT NOT NULL, active TINYINT(1) NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, INDEX IDX_8C88E6BDA76ED395 (user_id), INDEX IDX_8C88E6BD3E3C8C06 (calendar_id), INDEX IDX_8C88E6BD12469DE2 (category_id), INDEX IDX_8C88E6BDFE5B8E3E (next_run_at), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE recurring_expense ADD CONSTRAINT FK_8C88E6BDA76ED395 FOREIGN KEY (user_id) REFERENCES user (id)');
$this->addSql('ALTER TABLE recurring_expense ADD CONSTRAINT FK_8C88E6BD3E3C8C06 FOREIGN KEY (calendar_id) REFERENCES calendar (id)');
$this->addSql('ALTER TABLE recurring_expense ADD CONSTRAINT FK_8C88E6BD12469DE2 FOREIGN KEY (category_id) REFERENCES category (id)');

$this->addSql('ALTER TABLE expense ADD recurring_expense_id INT DEFAULT NULL, ADD recurring_occurrence_at DATETIME DEFAULT NULL');
$this->addSql('ALTER TABLE expense ADD CONSTRAINT FK_2D3A8DA9A50F1DC FOREIGN KEY (recurring_expense_id) REFERENCES recurring_expense (id) ON DELETE SET NULL');
$this->addSql('CREATE INDEX IDX_2D3A8DA9A50F1DC ON expense (recurring_expense_id)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_EXPENSE_RECURRING_OCCURRENCE ON expense (recurring_expense_id, recurring_occurrence_at)');
}

public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE expense DROP FOREIGN KEY FK_2D3A8DA9A50F1DC');
$this->addSql('DROP INDEX UNIQ_EXPENSE_RECURRING_OCCURRENCE ON expense');
$this->addSql('DROP INDEX IDX_2D3A8DA9A50F1DC ON expense');
$this->addSql('ALTER TABLE expense DROP recurring_expense_id, DROP recurring_occurrence_at');

$this->addSql('DROP TABLE recurring_expense');
}
}
44 changes: 44 additions & 0 deletions backend/src/Command/GenerateRecurringExpensesCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);

namespace App\Command;

use App\Service\RecurringExpense\RecurringExpenseGeneratorService;
use DateTime;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

#[AsCommand(
name: 'app:recurring-expenses:generate',
description: 'Generate due recurring expense occurrences',
)]
class GenerateRecurringExpensesCommand extends Command
{
public function __construct(
private readonly RecurringExpenseGeneratorService $generator,
) {
parent::__construct();
}

protected function configure(): void
{
$this
->addOption('now', null, InputOption::VALUE_OPTIONAL, 'Override current time (Y-m-d H:i:s)')
;
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
$now = $input->getOption('now');
$nowDt = $now ? new DateTime((string) $now) : new DateTime();

$count = $this->generator->generate($nowDt);
$output->writeln(sprintf('Generated %d recurring expense(s).', $count));

return Command::SUCCESS;
}
}
33 changes: 33 additions & 0 deletions backend/src/Controller/Finance/ExpenseController.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,21 @@
use App\Const\ContextGroup\ExpenseContextGroupConst;
use App\Controller\AbstractApiController;
use App\Entity\Expense;
use App\Entity\RecurringExpense;
use App\Entity\User;
use App\Enum\CalendarPermission;
use App\Enum\ExpensePermission;
use App\Message\ImportExpenseMessage;
use App\Repository\ExpenseRepository;
use App\Repository\RecurringExpenseRepository;
use App\Request\Expense\CreateExpenseRequest;
use App\Request\Expense\ImportExpenseRequest;
use App\Request\Expense\SuggestRequest;
use App\Request\Expense\UpdateExpenseRequest;
use App\Response\EmptyResponse;
use App\Security\Voters\CalendarVoter;
use App\Security\Voters\ExpenseVoter;
use App\Service\RecurringExpense\RecurringExpenseCalculator;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Messenger\Exception\ExceptionInterface;
Expand All @@ -31,6 +34,8 @@ class ExpenseController extends AbstractApiController
{
public function __construct(
private readonly ExpenseRepository $expenseRepository,
private readonly RecurringExpenseRepository $recurringExpenseRepository,
private readonly RecurringExpenseCalculator $recurringExpenseCalculator,
) {
}

Expand Down Expand Up @@ -58,6 +63,34 @@ public function create(#[CurrentUser] User $user, CreateExpenseRequest $request)
->setDescription($request->getDescription())
;

// If user requested recurring behavior, create a schedule based on this expense.
if ($request->getRecurringRule() !== null) {
$rule = $request->getRecurringRule();

$schedule = (new RecurringExpense())
->setUser($user)
->setCalendar($request->getCalendar())
->setCategory($request->getCategory())
->setLabel($request->getLabel())
->setDescription($request->getDescription())
->setAmount($request->getAmount())
->setConfirmed($request->isConfirmed())
->setFrequency($rule->getFrequency())
->setInterval($rule->getInterval())
->setStartAt($rule->getStartAt())
->setEndAt($rule->getEndAt())
;

$schedule->setNextRunAt($this->recurringExpenseCalculator->calculateNextRunAt($schedule, $rule->getStartAt()));

$this->recurringExpenseRepository->save($schedule);

$expense
->setRecurringExpense($schedule)
->setRecurringOccurrenceAt($request->getCreatedAt())
;
}

$this->expenseRepository->save($expense);

return $this->respond($expense, groups: ExpenseContextGroupConst::DETAILS);
Expand Down
29 changes: 29 additions & 0 deletions backend/src/Entity/Expense.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ class Expense
#[Groups(ExpenseContextGroupConst::ALWAYS)]
private DateTime $createdAt;

#[ORM\ManyToOne(targetEntity: RecurringExpense::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?RecurringExpense $recurringExpense = null;

#[ORM\Column(nullable: true)]
private ?DateTime $recurringOccurrenceAt = null;

public function __construct()
{
$this->createdAt = new DateTime();
Expand Down Expand Up @@ -166,6 +173,28 @@ public function setCreatedAt(DateTime $createdAt): self
return $this;
}

public function getRecurringExpense(): ?RecurringExpense
{
return $this->recurringExpense;
}

public function setRecurringExpense(?RecurringExpense $recurringExpense): self
{
$this->recurringExpense = $recurringExpense;
return $this;
}

public function getRecurringOccurrenceAt(): ?DateTime
{
return $this->recurringOccurrenceAt;
}

public function setRecurringOccurrenceAt(?DateTime $recurringOccurrenceAt): self
{
$this->recurringOccurrenceAt = $recurringOccurrenceAt;
return $this;
}

public function isIncome(): bool
{
return $this->getAmount() > 0;
Expand Down
Loading
Loading