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:
parent
c3dcbb48fd
commit
f8f3b06073
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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()]);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue