diff --git a/app/Services/BudgetPeriodService.php b/app/Services/BudgetPeriodService.php index 38c283fb..7e105c43 100644 --- a/app/Services/BudgetPeriodService.php +++ b/app/Services/BudgetPeriodService.php @@ -17,20 +17,30 @@ class BudgetPeriodService [$periodStart, $periodEnd] = $this->calculatePeriodDates($budget, $startDate); + $periodStart = $periodStart->startOfDay(); + $periodEnd = $periodEnd->startOfDay(); + // If no allocated amount provided, use the last period's amount or 0 if ($allocatedAmount === null) { $lastPeriod = $budget->periods()->orderBy('end_date', 'desc')->first(); $allocatedAmount = $lastPeriod !== null ? $lastPeriod->allocated_amount : 0; } - return BudgetPeriod::create([ - 'budget_id' => $budget->id, - 'start_date' => $periodStart, - 'end_date' => $periodEnd, - 'allocated_amount' => $allocatedAmount, - 'carried_over_amount' => 0, - 'processing_historical' => $processHistorical, - ]); + // Idempotent on the (budget_id, start_date) unique key: the scheduled + // command can recompute the same next start date across overlapping or + // repeated runs, so return the existing period instead of colliding. + return BudgetPeriod::firstOrCreate( + [ + 'budget_id' => $budget->id, + 'start_date' => $periodStart, + ], + [ + 'end_date' => $periodEnd, + 'allocated_amount' => $allocatedAmount, + 'carried_over_amount' => 0, + 'processing_historical' => $processHistorical, + ], + ); } public function generatePreviousPeriod(Budget $budget, BudgetPeriod $period, ?int $allocatedAmount = null, bool $processHistorical = false): BudgetPeriod diff --git a/tests/Feature/BudgetPeriodServiceTest.php b/tests/Feature/BudgetPeriodServiceTest.php index 7435507f..39bec78b 100644 --- a/tests/Feature/BudgetPeriodServiceTest.php +++ b/tests/Feature/BudgetPeriodServiceTest.php @@ -96,6 +96,30 @@ test('generatePeriod uses period_start_day snap when no prior periods exist', fu expect($period->end_date->toDateString())->toBe('2026-05-31'); }); +test('generatePeriod is idempotent when a period already exists for the start date', function () { + Carbon::setTestNow(Carbon::parse('2026-06-15 09:00:00')); + + $user = User::factory()->create(['onboarded_at' => now()]); + $budget = Budget::factory()->create([ + 'user_id' => $user->id, + 'period_type' => BudgetPeriodType::Monthly, + 'period_start_day' => 1, + ]); + + $existing = BudgetPeriod::factory()->create([ + 'budget_id' => $budget->id, + 'start_date' => '2026-06-01', + 'end_date' => '2026-06-30', + 'allocated_amount' => 44500, + ]); + + $period = app(BudgetPeriodService::class)->generatePeriod($budget, 100, Carbon::parse('2026-06-15')); + + expect($period->id)->toBe($existing->id); + expect($period->allocated_amount)->toBe(44500); + expect(BudgetPeriod::where('budget_id', $budget->id)->count())->toBe(1); +}); + test('generatePeriod creates current calendar year when yearly budget has no prior periods', function () { Carbon::setTestNow(Carbon::parse('2026-05-15 09:00:00'));