Skip to content
Merged
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
52 changes: 38 additions & 14 deletions app/Console/Commands/RollForwardRecurringTransactions.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,38 +11,62 @@
class RollForwardRecurringTransactions extends Command
{
protected $signature = 'app:roll-forward-recurring-transactions';
protected $description = 'Backfill recurring transactions for all users';

protected $description = 'Prepare next month recurring transactions for all users';

public function handle(): void
{
$service = app(RecurringTransactionService::class);

$users = User::all();

$this->info("Processing {$users->count()} users");
$referenceDate = now()->startOfMonth();

$targetPeriod = $referenceDate
->copy()
->addMonthNoOverflow()
->format('F Y');

$this->info(
"Processing {$users->count()} users for {$targetPeriod}"
);

foreach ($users as $user) {
try {
$created = $service->run(
userId: $user->id,
nextMonthOnly: true,
referenceDate: $referenceDate,
);

$created = $service->run($user->id, false);
$this->line(
"User {$user->id}: {$created} created"
);

$this->line("User {$user->id}: {$created} created");

Log::info('User processed', [
Log::info('Recurring transactions prepared', [
'user_id' => $user->id,
'source_period' => $referenceDate->format('Y-m'),
'target_period' => $referenceDate
->copy()
->addMonthNoOverflow()
->format('Y-m'),
'created' => $created,
]);

} catch (Throwable $e) {

Log::error('Recurring transaction failed for user', [
'user_id' => $user->id,
'error' => $e->getMessage(),
]);
Log::error(
'Recurring transaction preparation failed',
[
'user_id' => $user->id,
'source_period' => $referenceDate->format('Y-m'),
'target_period' => $referenceDate
->copy()
->addMonthNoOverflow()
->format('Y-m'),
'error' => $e->getMessage(),
]
);

$this->error("User {$user->id} failed");

continue;
}
}

Expand Down
29 changes: 13 additions & 16 deletions app/Filament/Pages/MonthlyBudget.php
Original file line number Diff line number Diff line change
Expand Up @@ -202,12 +202,14 @@ public function table(Table $table): Table

protected function getHeaderActions(): array
{
$selected = Carbon::create(
$selectedPeriod = Carbon::create(
$this->getSelectedYear(),
$this->getSelectedMonth()
);
)->startOfDay();

$nextPeriod = $selected->copy()->addMonth();
$nextPeriod = $selectedPeriod
->copy()
->addMonthNoOverflow();

$nextPeriodLabel = $nextPeriod->format('F Y');

Expand All @@ -218,22 +220,17 @@ protected function getHeaderActions(): array
->color('success')
->requiresConfirmation()
->modalHeading("Prepare {$nextPeriodLabel}")
->modalDescription("Recurring transactions will be carried forward into {$nextPeriodLabel} based on their frequency.")
->modalDescription(
"Recurring transactions will be carried forward into {$nextPeriodLabel} based on their frequency."
)
->modalSubmitActionLabel('Prepare')
->action(function () use ($nextPeriodLabel) {
$referenceDate = Carbon::create(
$this->getSelectedYear(),
$this->getSelectedMonth(),
1
->action(function () use ($selectedPeriod, $nextPeriodLabel) {
$count = app(RecurringTransactionService::class)->run(
userId: auth()->id(),
nextMonthOnly: true,
referenceDate: $selectedPeriod,
);

$count = app(RecurringTransactionService::class)
->run(
auth()->id(),
true,
$referenceDate
);

Notification::make()
->title("{$nextPeriodLabel} prepared")
->body("{$count} transactions created.")
Expand Down
66 changes: 33 additions & 33 deletions app/Services/RecurringTransactionService.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,62 +7,66 @@

class RecurringTransactionService
{
public function run(
int $userId,
bool $nextMonthOnly = false,
?Carbon $referenceDate = null
): int
public function run(int $userId, bool $nextMonthOnly = false, ?Carbon $referenceDate = null): int
{
$referenceDate ??= now();

$now = $referenceDate;
$sourceStart = $referenceDate->copy()->startOfMonth();
$sourceEnd = $referenceDate->copy()->endOfMonth();

$nextMonth = $referenceDate->copy()->addMonth();
$targetStart = $nextMonthOnly
? $referenceDate->copy()->addMonthNoOverflow()->startOfMonth()
: $sourceStart->copy();

$targetEnd = $nextMonthOnly
? $nextMonth->copy()->endOfMonth()
: $referenceDate->copy()->endOfMonth();
$targetEnd = $targetStart->copy()->endOfMonth();

/*
* Only use transactions from the selected month as the source.
*
* For example, preparing August 2026 from July 2026 will only
* carry forward recurring transactions that exist in July 2026.
*/
$transactions = Transaction::query()
->where('user_id', $userId)
->whereNotNull('recurring_rule')
->where('recurring_rule', '!=', 'once')
->whereBetween('due_at', [$sourceStart, $sourceEnd])
->orderBy('due_at')
->get();

$created = 0;

foreach ($transactions as $transaction) {

if (! $nextMonthOnly && $transaction->due_at->gt($now)) {
continue;
}

$currentDate = $transaction->due_at->copy();
$day = $transaction->due_at->day;

$iterations = 0;

while (true) {

if ($iterations++ > 50) {
break;
}

while ($iterations++ <= 50) {
$nextDate = match ($transaction->recurring_rule) {
'weekly' => $currentDate->copy()->addWeek(),

'biweekly' => $currentDate->copy()->addWeeks(2),

'monthly' => tap(
$currentDate->copy()->addMonthNoOverflow(),
fn($d) => $d->day(min($day, $d->copy()->endOfMonth()->day))
fn(Carbon $date) => $date->day(
min($day, $date->copy()->endOfMonth()->day)
)
),

'quarterly' => tap(
$currentDate->copy()->addMonthsNoOverflow(3),
fn($d) => $d->day(min($day, $d->copy()->endOfMonth()->day))
fn(Carbon $date) => $date->day(
min($day, $date->copy()->endOfMonth()->day)
)
),

'yearly' => tap(
$currentDate->copy()->addYearNoOverflow(),
fn($d) => $d->day(min($day, $d->copy()->endOfMonth()->day))),
fn(Carbon $date) => $date->day(
min($day, $date->copy()->endOfMonth()->day)
)
),

default => null,
};
Expand All @@ -71,8 +75,9 @@ public function run(
break;
}

if ($nextMonthOnly && $nextDate->format('Y-m') !== $nextMonth->format('Y-m')) {
if ($nextDate->lt($targetStart)) {
$currentDate = $nextDate;

continue;
}

Expand All @@ -82,8 +87,8 @@ public function run(
->where('user_id', $transaction->user_id)
->where('merchant', $transaction->merchant)
->where('type', $transaction->type)
->whereMonth('due_at', $nextDate->month)
->whereYear('due_at', $nextDate->year);
->whereYear('due_at', $nextDate->year)
->whereMonth('due_at', $nextDate->month);

if (config('database.default') === 'sqlite') {
$query->whereRaw(
Expand All @@ -100,11 +105,6 @@ public function run(
$existing = $query->first();

if ($existing) {
if (! $nextMonthOnly) {
$currentDate = $nextDate; // move forward
continue;
}

$existing->update([
'category_id' => $transaction->category_id,
'amount' => $transaction->amount,
Expand Down
Loading
Loading