Add yearly budget period (#384)

## Summary
- Add yearly budget period option on backend and frontend
- Generate yearly budget periods for Jan 1 through Dec 31
- Add feature coverage for yearly budget creation and period generation

## Tests
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/BudgetPeriodServiceTest.php
tests/Feature/BudgetTest.php

## Notes
- npm run types fails on existing unrelated TypeScript errors: missing
generated Wayfinder modules and pre-existing type mismatches
This commit is contained in:
Víctor Falcón 2026-05-12 12:35:25 +01:00 committed by GitHub
parent c3dcbb48fd
commit f8f3b06073
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 161 additions and 66 deletions

View File

@ -7,6 +7,7 @@ enum BudgetPeriodType: string
case Monthly = 'monthly';
case Weekly = 'weekly';
case Biweekly = 'biweekly';
case Yearly = 'yearly';
public function label(): string
{
@ -14,6 +15,7 @@ enum BudgetPeriodType: string
self::Monthly => 'Monthly',
self::Weekly => 'Weekly',
self::Biweekly => 'Bi-weekly',
self::Yearly => 'Yearly',
};
}
}

View File

@ -80,6 +80,10 @@ class BudgetPeriodService
$endDate = $startDate->copy()->addWeeks(2)->subDay();
break;
case BudgetPeriodType::Yearly:
$startDate->startOfYear();
$endDate = $startDate->copy()->addYear()->subDay();
break;
}
return [$startDate, $endDate];

View File

@ -42,4 +42,12 @@ class BudgetFactory extends Factory
'period_start_day' => fake()->numberBetween(0, 6),
]);
}
public function yearly(): static
{
return $this->state(fn (array $attributes) => [
'period_type' => BudgetPeriodType::Yearly,
'period_start_day' => 1,
]);
}
}

View File

@ -88,7 +88,7 @@ export function CreateBudgetDialog({
{
name,
period_type: periodType,
period_start_day: periodStartDay,
period_start_day: periodType === 'yearly' ? 1 : periodStartDay,
category_id: selectedCategoryId || null,
label_id: selectedLabelId || null,
rollover_type: rolloverType,
@ -164,9 +164,10 @@ export function CreateBudgetDialog({
</UILabel>
<Select
value={periodType}
onValueChange={(value) =>
setPeriodType(value as BudgetPeriodType)
}
onValueChange={(value) => {
setPeriodType(value as BudgetPeriodType);
setPeriodStartDay(1);
}}
>
<SelectTrigger id="period-type">
<SelectValue />
@ -181,34 +182,37 @@ export function CreateBudgetDialog({
</Select>
</div>
<div className="space-y-2">
<UILabel htmlFor="period-start-day">
{periodType === 'monthly'
? __('Start Day of Month')
: __('Start Day')}
</UILabel>
<Input
id="period-start-day"
type="number"
min="0"
max={periodType === 'monthly' ? '31' : '6'}
value={periodStartDay}
onChange={(e) =>
setPeriodStartDay(parseInt(e.target.value))
}
/>
{periodType !== 'yearly' && (
<div className="space-y-2">
<UILabel htmlFor="period-start-day">
{periodType === 'monthly'
? __('Start Day of Month')
: __('Start Day')}
</UILabel>
<Input
id="period-start-day"
type="number"
min="0"
max={periodType === 'monthly' ? '31' : '6'}
value={periodStartDay}
onChange={(e) =>
setPeriodStartDay(
parseInt(e.target.value),
)
}
/>
<p className="text-sm text-muted-foreground">
{periodType === 'monthly'
? __(
'Day of the month when the period starts (1-31)',
)
: periodType === 'weekly' ||
periodType === 'biweekly'
? __('Day of week (0=Sunday, 6=Saturday)')
: __('Starting day')}
</p>
</div>
<p className="text-sm text-muted-foreground">
{periodType === 'monthly'
? __(
'Day of the month when the period starts (1-31)',
)
: __(
'Day of week (0=Sunday, 6=Saturday)',
)}
</p>
</div>
)}
<div className="space-y-4">
{errors.selection && (
@ -308,7 +312,9 @@ export function CreateBudgetDialog({
)}
</div>
<p className="text-sm text-muted-foreground">
{__('Select at least a category or a label to track.')}
{__(
'Select at least a category or a label to track.',
)}
</p>
</div>

View File

@ -80,7 +80,7 @@ export function EditBudgetDialog({
{
name,
period_type: periodType,
period_start_day: periodStartDay,
period_start_day: periodType === 'yearly' ? 1 : periodStartDay,
allocated_amount: allocatedAmount,
rollover_type: rolloverType,
},
@ -127,9 +127,12 @@ export function EditBudgetDialog({
<Select
value={periodType}
disabled
onValueChange={(value) =>
setPeriodType(value as BudgetPeriodType)
}
onValueChange={(value) => {
setPeriodType(
value as BudgetPeriodType,
);
setPeriodStartDay(1);
}}
>
<SelectTrigger id="period-type">
<SelectValue />
@ -145,36 +148,39 @@ export function EditBudgetDialog({
</div>
</div>
<div className="space-y-2">
<Label htmlFor="period-start-day">
{periodType === 'monthly'
? __('Start Day of Month')
: __('Start Day')}
</Label>
<Input
id="period-start-day"
disabled
type="number"
className="mt-1"
min="0"
max={periodType === 'monthly' ? '31' : '6'}
value={periodStartDay}
onChange={(e) =>
setPeriodStartDay(parseInt(e.target.value))
}
/>
{periodType !== 'yearly' && (
<div className="space-y-2">
<Label htmlFor="period-start-day">
{periodType === 'monthly'
? __('Start Day of Month')
: __('Start Day')}
</Label>
<Input
id="period-start-day"
disabled
type="number"
className="mt-1"
min="0"
max={periodType === 'monthly' ? '31' : '6'}
value={periodStartDay}
onChange={(e) =>
setPeriodStartDay(
parseInt(e.target.value),
)
}
/>
<p className="text-sm text-muted-foreground">
{periodType === 'monthly'
? __(
'Day of the month when the period starts (1-31)',
)
: periodType === 'weekly' ||
periodType === 'biweekly'
? __('Day of week (0=Sunday, 6=Saturday)')
: __('Starting day')}
</p>
</div>
<p className="text-sm text-muted-foreground">
{periodType === 'monthly'
? __(
'Day of the month when the period starts (1-31)',
)
: __(
'Day of week (0=Sunday, 6=Saturday)',
)}
</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="allocated-amount">

View File

@ -4,7 +4,12 @@ import { Label } from './label';
import { Transaction } from './transaction';
import { UUID } from './uuid';
export const BUDGET_PERIOD_TYPES = ['monthly', 'weekly', 'biweekly'] as const;
export const BUDGET_PERIOD_TYPES = [
'monthly',
'weekly',
'biweekly',
'yearly',
] as const;
export type BudgetPeriodType = (typeof BUDGET_PERIOD_TYPES)[number];
@ -64,6 +69,7 @@ export function getBudgetPeriodTypeLabel(type: BudgetPeriodType): string {
monthly: __('Monthly'),
weekly: __('Weekly'),
biweekly: __('Bi-weekly'),
yearly: __('Yearly'),
};
return labels[type];
}

View File

@ -57,6 +57,29 @@ test('generatePeriod advances weekly periods to next week', function () {
expect($next->end_date->toDateString())->toBe('2026-05-17');
});
test('generatePeriod advances yearly periods to next year', function () {
Carbon::setTestNow(Carbon::parse('2026-06-02 09:00:00'));
$user = User::factory()->create(['onboarded_at' => now()]);
$budget = Budget::factory()->create([
'user_id' => $user->id,
'period_type' => BudgetPeriodType::Yearly,
'period_start_day' => 1,
]);
BudgetPeriod::factory()->create([
'budget_id' => $budget->id,
'start_date' => '2025-01-01',
'end_date' => '2025-12-31',
'allocated_amount' => 10000,
]);
$next = app(BudgetPeriodService::class)->generatePeriod($budget);
expect($next->start_date->toDateString())->toBe('2026-01-01');
expect($next->end_date->toDateString())->toBe('2026-12-31');
});
test('generatePeriod uses period_start_day snap when no prior periods exist', function () {
Carbon::setTestNow(Carbon::parse('2026-05-15 09:00:00'));
@ -72,3 +95,19 @@ test('generatePeriod uses period_start_day snap when no prior periods exist', fu
expect($period->start_date->toDateString())->toBe('2026-05-01');
expect($period->end_date->toDateString())->toBe('2026-05-31');
});
test('generatePeriod creates current calendar year when yearly budget has no prior periods', function () {
Carbon::setTestNow(Carbon::parse('2026-05-15 09:00:00'));
$user = User::factory()->create(['onboarded_at' => now()]);
$budget = Budget::factory()->create([
'user_id' => $user->id,
'period_type' => BudgetPeriodType::Yearly,
'period_start_day' => 1,
]);
$period = app(BudgetPeriodService::class)->generatePeriod($budget);
expect($period->start_date->toDateString())->toBe('2026-01-01');
expect($period->end_date->toDateString())->toBe('2026-12-31');
});

View File

@ -32,6 +32,30 @@ test('user can create a budget', function () {
$this->assertCount(1, $budget->periods);
});
test('user can create a yearly budget', function () {
$user = User::factory()->create(['onboarded_at' => now()]);
$category = Category::factory()->create(['user_id' => $user->id]);
$response = $this->actingAs($user)->post('/budgets', [
'name' => 'Yearly Budget',
'period_type' => 'yearly',
'period_start_day' => 1,
'category_id' => $category->id,
'rollover_type' => 'reset',
'allocated_amount' => 1200000,
]);
$response->assertRedirect();
$budget = Budget::where('user_id', $user->id)->where('period_type', 'yearly')->first();
expect($budget)->not->toBeNull()
->and($budget->periods()->count())->toBe(1)
->and($budget->periods()->first()->start_date->toDateString())->toBe(now()->startOfYear()->toDateString())
->and($budget->periods()->first()->end_date->toDateString())->toBe(now()->endOfYear()->toDateString());
});
test('user can view their budgets', function () {
$user = User::factory()->create(['onboarded_at' => now()]);