feat: add catch-all budgets (#527)

## Summary
Adds **catch-all budgets** — a budget flagged `is_catch_all` absorbs
every expense-category transaction not already claimed by another
(non-catch-all) budget, so out-of-budget spending is still tracked.

## Changes
- Migration: `is_catch_all` boolean (default false) on `budgets`.
- `Budget` model: fillable + boolean cast.
- `BudgetTransactionService`: a catch-all budget matches expense
transactions whose `category_id` is not claimed by any non-catch-all
budget; period assignment mirrors the same rule.
- `BudgetController`: supports the flag.

## Notes
- Pre-existing WIP committed as-is; CI is the validation gate.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Víctor Falcón <victoor89@gmail.com>
This commit is contained in:
Toni Grunwald 2026-06-15 11:07:19 -05:00 committed by GitHub
parent e065d4ab65
commit dbec1c4c13
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 691 additions and 108 deletions

View File

@ -125,6 +125,7 @@ class BudgetController extends Controller
'period_type' => $request->period_type,
'period_start_day' => $request->period_start_day,
'rollover_type' => $request->rollover_type,
'is_catch_all' => $request->boolean('is_catch_all'),
]);
$budget->categories()->sync($request->category_ids ?? []);

View File

@ -29,21 +29,30 @@ class StoreBudgetRequest extends FormRequest
'label_ids.*' => [$this->userOwned('labels')],
'rollover_type' => ['required', Rule::enum(RolloverType::class)],
'allocated_amount' => ['required', 'integer', 'min:0'],
'is_catch_all' => ['sometimes', 'boolean'],
];
}
public function withValidator($validator): void
{
$validator->after(function ($validator) {
$isCatchAll = $this->boolean('is_catch_all');
$hasCategories = ! empty($this->category_ids);
$hasLabels = ! empty($this->label_ids);
if (! $hasCategories && ! $hasLabels) {
if (! $isCatchAll && ! $hasCategories && ! $hasLabels) {
$validator->errors()->add(
'selection',
'You must select at least one category or label.'
);
}
if ($isCatchAll && $this->user()->budgets()->where('is_catch_all', true)->exists()) {
$validator->errors()->add(
'is_catch_all',
'You already have a catch-all budget.'
);
}
});
}
}

View File

@ -26,6 +26,7 @@ class Budget extends Model
'period_type',
'period_start_day',
'rollover_type',
'is_catch_all',
];
/** @var list<string> */
@ -39,6 +40,7 @@ class Budget extends Model
'period_type' => BudgetPeriodType::class,
'rollover_type' => RolloverType::class,
'period_start_day' => 'integer',
'is_catch_all' => 'boolean',
];
}

View File

@ -2,6 +2,8 @@
namespace App\Services;
use App\Enums\CategoryType;
use App\Models\Budget;
use App\Models\BudgetPeriod;
use App\Models\BudgetTransaction;
use App\Models\Transaction;
@ -68,6 +70,11 @@ class BudgetTransactionService
}
}
$matchingPeriodIds = array_merge(
$matchingPeriodIds,
$this->catchAllPeriodIds($transaction, $userId, $categoryMatchIds),
);
// Apply changes atomically so concurrent workers cannot leave the
// transaction half-assigned and the unique index guards duplicates.
DB::transaction(function () use ($transaction, $matchingPeriodIds) {
@ -132,18 +139,34 @@ class BudgetTransactionService
->whereBetween('transaction_date', [$period->start_date, $period->end_date])
->withoutTrashed();
// Filter by any tracked category OR label
$query->where(function ($q) use ($categoryIds, $labelIds) {
if ($categoryIds->isNotEmpty()) {
$q->whereIn('category_id', $categoryIds);
}
if ($budget->is_catch_all) {
// A catch-all budget absorbs every expense whose category is not
// already tracked by one of the user's other budgets.
$claimedCategoryIds = $this->tree->expand(
$budget->user_id,
$this->claimedCategoryIds($budget->user_id),
);
if ($labelIds->isNotEmpty()) {
$q->orWhereHas('labels', function ($labelQuery) use ($labelIds) {
$labelQuery->whereIn('labels.id', $labelIds);
});
}
});
$query->whereNotNull('category_id')
->when(
$claimedCategoryIds !== [],
fn ($q) => $q->whereNotIn('category_id', $claimedCategoryIds),
)
->whereHas('category', fn ($q) => $q->where('type', CategoryType::Expense->value));
} else {
// Filter by any tracked category OR label
$query->where(function ($q) use ($categoryIds, $labelIds) {
if ($categoryIds->isNotEmpty()) {
$q->whereIn('category_id', $categoryIds);
}
if ($labelIds->isNotEmpty()) {
$q->orWhereHas('labels', function ($labelQuery) use ($labelIds) {
$labelQuery->whereIn('labels.id', $labelIds);
});
}
});
}
$totalCount = $query->count();
Log::info("Found {$totalCount} transactions to process in date range");
@ -169,4 +192,55 @@ class BudgetTransactionService
return $assignedCount;
}
/**
* Catch-all budget periods that should absorb this transaction: an expense
* whose category (or an ancestor) is not tracked by any non-catch-all budget.
*
* @param array<int, string> $categoryMatchIds the transaction category and its ancestors
* @return array<int, string>
*/
private function catchAllPeriodIds(Transaction $transaction, string $userId, array $categoryMatchIds): array
{
if ($transaction->category_id === null) {
return [];
}
$transaction->loadMissing('category');
if ($transaction->category?->type !== CategoryType::Expense) {
return [];
}
if (array_intersect($categoryMatchIds, $this->claimedCategoryIds($userId)) !== []) {
return [];
}
return BudgetPeriod::query()
->whereHas('budget', function ($query) use ($userId) {
$query->where('user_id', $userId)->where('is_catch_all', true);
})
->where('start_date', '<=', $transaction->transaction_date)
->where('end_date', '>=', $transaction->transaction_date)
->pluck('id')
->all();
}
/**
* Category ids directly tracked by the user's non-catch-all budgets.
*
* @return array<int, string>
*/
private function claimedCategoryIds(string $userId): array
{
return Budget::query()
->where('user_id', $userId)
->where('is_catch_all', false)
->with('categories:id')
->get()
->flatMap(fn (Budget $budget) => $budget->categories->pluck('id'))
->unique()
->values()
->all();
}
}

View File

@ -54,6 +54,13 @@ class BudgetFactory extends Factory
]);
}
public function catchAll(): static
{
return $this->state(fn (array $attributes) => [
'is_catch_all' => true,
]);
}
/**
* Attach one or more categories to the budget after creation.
*

View File

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('budgets', function (Blueprint $table) {
$table->boolean('is_catch_all')->default(false)->after('period_start_day');
});
}
public function down(): void
{
Schema::table('budgets', function (Blueprint $table) {
$table->dropColumn('is_catch_all');
});
}
};

View File

@ -231,6 +231,7 @@
"All matching filters": "Todas por filtro",
"All transactions appear to be duplicates. No new transactions will be imported.": "Todas las transacciones parecen ser duplicadas. No se importarán transacciones nuevas.",
"All transactions failed to import": "Todas las transacciones fallaron al importar",
"All untracked expenses": "Todos los gastos no asignados",
"All your accounts at a glance": "Todas tus cuentas de un vistazo",
"All your bank accounts at a glance": "Todas tus cuentas bancarias de un vistazo",
"All your money in one place. No spreadsheets. Private.": "Todo tu dinero en un solo sitio. Sin Excels. Privado.",
@ -298,6 +299,7 @@
"Automatic sync": "Sincronización automática",
"Automatically categorize transactions with customizable rules and patterns.": "Categoriza automáticamente las transacciones con reglas y patrones personalizables.",
"Automatically sync transactions directly from your bank. No manual imports needed.": "Sincroniza automáticamente las transacciones desde tu banco. Sin importaciones manuales.",
"Automatically track every expense that no other budget covers. You can only have one.": "Rastrea automáticamente cada gasto que ningún otro presupuesto cubre. Solo puedes tener uno.",
"Automation Rules": "Reglas de Automatización",
"Automation rules": "Reglas de automatización",
"Automation rules settings": "Configuración de reglas de automatización",
@ -375,6 +377,7 @@
"Cashflow and charts": "Flujo de caja y gráficos",
"Cashflow at a Glance": "Flujo de Caja de un Vistazo",
"Cashflow income vs expenses bar chart": "Gráfico de barras de ingresos frente a gastos",
"Catch-all budget": "Presupuesto general",
"Cashflow periods and analysis will start on this day.": "Los períodos y análisis de flujo de caja empezarán este día.",
"Cashflow visualization": "Visualización del flujo de caja",
"Categories below 5% of total": "Categorías por debajo del 5% del total",
@ -1494,6 +1497,7 @@
"This account type is for balance tracking only and doesn't support transactions.": "Este tipo de cuenta es solo para rastreo de balance y no soporta transacciones.",
"This account type is for balance tracking only and\\n doesn't support transactions.": "Este tipo de cuenta es solo para seguimiento de saldo y no admite transacciones.",
"This action cannot be undone.": "Esta acción no se puede deshacer.",
"This catch-all budget tracks every expense that no other budget covers.": "Este presupuesto general rastrea cada gasto que ningún otro presupuesto cubre.",
"This action cannot be undone. To confirm, type": "Esta acción no se puede deshacer. Para confirmar, escribe",
"This action is irreversible. All transactions in this account will also be permanently deleted.": "Esta acción es irreversible. Todas las transacciones en esta cuenta también serán eliminadas permanentemente.",
"This code won't last forever, but more importantly, your support means the world to me. As a solo founder, every subscriber helps me continue building something I'm passionate about.": "Este código no durará para siempre, pero lo más importante es que tu apoyo significa mucho para mí. Como fundador en solitario, cada suscriptor me ayuda a seguir construyendo algo que me apasiona.",

View File

@ -144,7 +144,11 @@ export function BudgetListCard({ budget, currencyCode }: Props) {
<span className="text-sm text-muted-foreground">
{__('Tracking:')}
</span>
{trackingNames.length > 0 ? (
{budget.is_catch_all ? (
<Badge variant="secondary">
{__('All untracked expenses')}
</Badge>
) : trackingNames.length > 0 ? (
<>
{trackingNames.slice(0, 2).map((name) => (
<Badge key={name} variant="secondary">

View File

@ -0,0 +1,57 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeAll, describe, expect, it, vi } from 'vitest';
import { CreateBudgetDialog } from './create-budget-dialog';
beforeAll(() => {
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
};
});
const post = vi.fn();
vi.mock('@inertiajs/react', () => ({
router: {
post: (...args: unknown[]) => post(...args),
},
usePage: () => ({ props: { categories: [], labels: [] } }),
}));
vi.mock('@/actions/App/Http/Controllers/BudgetController', () => ({
store: () => ({ url: '/budgets' }),
}));
vi.mock('@/components/ui/multi-select', () => ({
MultiSelect: ({ placeholder }: { placeholder: string }) => (
<div>{placeholder}</div>
),
}));
vi.mock('@/components/ui/amount-input', () => ({
AmountInput: () => <input aria-label="Allocated Amount" />,
}));
function openDialog() {
render(<CreateBudgetDialog />);
fireEvent.click(screen.getByText('Create Budget'));
}
describe('CreateBudgetDialog', () => {
it('shows category and label selectors by default', () => {
openDialog();
expect(screen.getByText('Select categories')).toBeInTheDocument();
expect(screen.getByText('Select labels')).toBeInTheDocument();
});
it('hides category and label selectors when catch-all is enabled', () => {
openDialog();
fireEvent.click(screen.getByLabelText('Catch-all budget'));
expect(screen.queryByText('Select categories')).not.toBeInTheDocument();
expect(screen.queryByText('Select labels')).not.toBeInTheDocument();
});
});

View File

@ -1,6 +1,7 @@
import { store } from '@/actions/App/Http/Controllers/BudgetController';
import { AmountInput } from '@/components/ui/amount-input';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
@ -63,6 +64,7 @@ export function CreateBudgetDialog({
const [allocatedAmount, setAllocatedAmount] = useState<number>(0);
const [rolloverType, setRolloverType] =
useState<RolloverType>('carry_over');
const [isCatchAll, setIsCatchAll] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
@ -75,7 +77,11 @@ export function CreateBudgetDialog({
const newErrors: Record<string, string> = {};
if (selectedCategoryIds.length === 0 && selectedLabelIds.length === 0) {
if (
!isCatchAll &&
selectedCategoryIds.length === 0 &&
selectedLabelIds.length === 0
) {
newErrors.selection = __(
'You must select at least one category or label.',
);
@ -94,10 +100,11 @@ export function CreateBudgetDialog({
name,
period_type: periodType,
period_start_day: periodType === 'yearly' ? 1 : periodStartDay,
category_ids: selectedCategoryIds,
label_ids: selectedLabelIds,
category_ids: isCatchAll ? [] : selectedCategoryIds,
label_ids: isCatchAll ? [] : selectedLabelIds,
rollover_type: rolloverType,
allocated_amount: allocatedAmount,
is_catch_all: isCatchAll,
},
{
onSuccess: () => {
@ -109,6 +116,7 @@ export function CreateBudgetDialog({
setSelectedLabelIds([]);
setAllocatedAmount(0);
setRolloverType('carry_over');
setIsCatchAll(false);
setErrors({});
},
onError: (errors) => {
@ -220,85 +228,134 @@ export function CreateBudgetDialog({
)}
<div className="space-y-4">
{errors.selection && (
<div className="flex items-start gap-3 rounded-md border p-2">
<Checkbox
id="is-catch-all"
checked={isCatchAll}
onCheckedChange={(checked) =>
setIsCatchAll(checked === true)
}
className="mt-0.5"
/>
<div className="-mt-1 space-y-1">
<UILabel
htmlFor="is-catch-all"
className="cursor-pointer font-normal"
>
{__('Catch-all budget')}
</UILabel>
<p className="text-sm text-muted-foreground">
{__(
'Automatically track every expense that no other budget covers. You can only have one.',
)}
</p>
</div>
</div>
{errors.is_catch_all && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{errors.selection}
{errors.is_catch_all}
</div>
)}
<div className="space-y-2">
<UILabel htmlFor="categories">
{__('Categories')}
</UILabel>
<MultiSelect
id="categories"
options={flattenCategoryTree(
buildCategoryTree(allCategories),
).map((category) => {
const colorClasses =
getCategoryColorClasses(
category.color,
);
const IconComponent = Icons[
category.icon as keyof typeof Icons
] as Icons.LucideIcon | undefined;
return {
value: category.id,
label: category.name,
depth: category.depth,
parentValue: category.parent_id,
icon: IconComponent ? (
<IconComponent className="h-3 w-3 opacity-80" />
) : undefined,
badgeClassName: cn(
colorClasses.bg,
colorClasses.text,
),
};
})}
selected={selectedCategoryIds}
onChange={setSelectedCategoryIds}
placeholder={__('Select categories')}
searchPlaceholder={__('Search categories…')}
emptyText={__('No categories found.')}
/>
</div>
<div className="space-y-2">
<UILabel htmlFor="labels">
{__('Labels')}
</UILabel>
<MultiSelect
id="labels"
options={allLabels.map((label) => {
const colorClasses =
getLabelColorClasses(label.color);
return {
value: label.id,
label: label.name,
icon: (
<Tag className="h-3 w-3 opacity-80" />
),
badgeClassName: cn(
colorClasses.bg,
colorClasses.text,
),
};
})}
selected={selectedLabelIds}
onChange={setSelectedLabelIds}
placeholder={__('Select labels')}
searchPlaceholder={__('Search labels…')}
emptyText={__('No labels found.')}
/>
<p className="text-sm text-muted-foreground">
{__(
'Select at least one category or label to track.',
{!isCatchAll && (
<>
{errors.selection && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{errors.selection}
</div>
)}
</p>
</div>
<div className="space-y-2">
<UILabel htmlFor="categories">
{__('Categories')}
</UILabel>
<MultiSelect
id="categories"
options={flattenCategoryTree(
buildCategoryTree(
allCategories,
),
).map((category) => {
const colorClasses =
getCategoryColorClasses(
category.color,
);
const IconComponent = Icons[
category.icon as keyof typeof Icons
] as
| Icons.LucideIcon
| undefined;
return {
value: category.id,
label: category.name,
depth: category.depth,
parentValue:
category.parent_id,
icon: IconComponent ? (
<IconComponent className="h-3 w-3 opacity-80" />
) : undefined,
badgeClassName: cn(
colorClasses.bg,
colorClasses.text,
),
};
})}
selected={selectedCategoryIds}
onChange={setSelectedCategoryIds}
placeholder={__(
'Select categories',
)}
searchPlaceholder={__(
'Search categories…',
)}
emptyText={__(
'No categories found.',
)}
/>
</div>
<div className="space-y-2">
<UILabel htmlFor="labels">
{__('Labels')}
</UILabel>
<MultiSelect
id="labels"
options={allLabels.map((label) => {
const colorClasses =
getLabelColorClasses(
label.color,
);
return {
value: label.id,
label: label.name,
icon: (
<Tag className="h-3 w-3 opacity-80" />
),
badgeClassName: cn(
colorClasses.bg,
colorClasses.text,
),
};
})}
selected={selectedLabelIds}
onChange={setSelectedLabelIds}
placeholder={__('Select labels')}
searchPlaceholder={__(
'Search labels…',
)}
emptyText={__('No labels found.')}
/>
<p className="text-sm text-muted-foreground">
{__(
'Select at least one category or label to track.',
)}
</p>
</div>
</>
)}
<div className="space-y-2">
<UILabel htmlFor="allocated-amount">
@ -367,7 +424,8 @@ export function CreateBudgetDialog({
disabled={
isSubmitting ||
!name ||
(selectedCategoryIds.length === 0 &&
(!isCatchAll &&
selectedCategoryIds.length === 0 &&
selectedLabelIds.length === 0) ||
allocatedAmount <= 0
}

View File

@ -27,6 +27,7 @@ function makeBudget(): Budget {
categories: [],
labels: [],
rollover_type: 'carry_over',
is_catch_all: false,
created_at: '2026-05-26T00:00:00.000000Z',
updated_at: '2026-05-26T00:00:00.000000Z',
deleted_at: null,

View File

@ -3,6 +3,7 @@ import { CategoryBadge } from '@/components/shared/category-combobox';
import { LabelBadge } from '@/components/shared/label-combobox';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { AmountInput } from '@/components/ui/amount-input';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
@ -134,22 +135,42 @@ export function EditBudgetDialog({
<div className="space-y-2">
<Label>{__('Tracking')}</Label>
<div className="flex flex-wrap items-center gap-1">
{budget.categories?.map((category) => (
<CategoryBadge
key={category.id}
category={category}
/>
))}
{budget.labels?.map((label) => (
<LabelBadge key={label.id} label={label} />
))}
</div>
<p className="text-sm text-muted-foreground">
{__(
'Tracked categories and labels cannot be changed after creation.',
)}
</p>
{budget.is_catch_all ? (
<>
<div className="flex flex-wrap items-center gap-1">
<Badge variant="secondary">
{__('All untracked expenses')}
</Badge>
</div>
<p className="text-sm text-muted-foreground">
{__(
'This catch-all budget tracks every expense that no other budget covers.',
)}
</p>
</>
) : (
<>
<div className="flex flex-wrap items-center gap-1">
{budget.categories?.map((category) => (
<CategoryBadge
key={category.id}
category={category}
/>
))}
{budget.labels?.map((label) => (
<LabelBadge
key={label.id}
label={label}
/>
))}
</div>
<p className="text-sm text-muted-foreground">
{__(
'Tracked categories and labels cannot be changed after creation.',
)}
</p>
</>
)}
</div>
<div className="space-y-2">

View File

@ -8,6 +8,7 @@ import { MobileBackButton } from '@/components/mobile-back-button';
import { CategoryBadge } from '@/components/shared/category-combobox';
import { LabelBadge } from '@/components/shared/label-combobox';
import { TransactionList } from '@/components/transactions/transaction-list';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
@ -104,7 +105,11 @@ export default function BudgetShow({
title={budget.name}
description={
<div className="flex flex-row flex-wrap items-center gap-1 text-sm">
{trackingCount > 0 ? (
{budget.is_catch_all ? (
<Badge variant="secondary">
{__('All untracked expenses')}
</Badge>
) : trackingCount > 0 ? (
<>
<span className="opacity-50">
{__('Tracking')}{' '}

View File

@ -24,6 +24,7 @@ export interface Budget {
period_type: BudgetPeriodType;
period_start_day: number | null;
rollover_type: RolloverType;
is_catch_all: boolean;
created_at: string;
updated_at: string;
deleted_at: string | null;

View File

@ -0,0 +1,124 @@
<?php
use App\Models\Budget;
use App\Models\BudgetPeriod;
use App\Models\Category;
use App\Models\User;
test('catch-all toggle hides category and label selection', function () {
$user = User::factory()->create(['onboarded_at' => now()]);
Category::factory()->create([
'user_id' => $user->id,
'name' => 'Groceries',
]);
$page = $this->actingAs($user)->visit('/budgets');
$page->wait(2);
$page->assertSee('Budgets')
->waitForText('Create Budget', 10)
->wait(1)
->click('Create Budget')
->wait(3)
->assertSee('Create Budget')
->wait(1)
->assertSee('Catch-all budget')
->assertSee('Categories')
->screenshot(filename: 'catch-all-create-default')
->fill('name', 'Everything Else')
->fill('#allocated-amount', '500')
->click('label:has-text("Budget Name")')
->wait(1)
->click('label:has-text("Catch-all budget")')
->wait(1)
->assertDontSee('Select categories')
->assertDontSee('Select labels')
->screenshot(filename: 'catch-all-create-enabled')
->assertButtonEnabled('[role="dialog"] button[type="submit"]')
->assertNoJavascriptErrors();
});
test('user can create a catch-all budget', function () {
$user = User::factory()->create(['onboarded_at' => now()]);
$page = $this->actingAs($user)->visit('/budgets');
$page->wait(2);
$page->waitForText('Create Budget', 10)
->wait(1)
->click('Create Budget')
->wait(3)
->fill('name', 'Everything Else')
->wait(1)
->click('label:has-text("Catch-all budget")')
->wait(1)
->fill('#allocated-amount', '500')
->click('label:has-text("Budget Name")')
->wait(2)
->click('[role="dialog"] button[type="submit"]')
->wait(4)
->assertPathBeginsWith('/budgets/')
->waitForText('Everything Else', 15)
->assertNoJavascriptErrors();
$budget = Budget::where('user_id', $user->id)->where('name', 'Everything Else')->first();
expect($budget)->not->toBeNull()
->and($budget->is_catch_all)->toBeTrue();
});
test('catch-all budget is labelled in the budget list', function () {
$user = User::factory()->create(['onboarded_at' => now()]);
$budget = Budget::factory()->catchAll()->monthly()->create([
'user_id' => $user->id,
'name' => 'Everything Else',
]);
BudgetPeriod::factory()->create([
'budget_id' => $budget->id,
'start_date' => now()->startOfMonth(),
'end_date' => now()->endOfMonth(),
'allocated_amount' => 50000,
]);
$page = $this->actingAs($user)->visit('/budgets');
$page->wait(2);
$page->assertSee('Everything Else')
->waitForText('All untracked expenses', 10)
->screenshot(filename: 'catch-all-budget-list-card')
->assertNoJavascriptErrors();
});
test('edit dialog shows the catch-all budget as read-only tracking', function () {
$user = User::factory()->create(['onboarded_at' => now()]);
$budget = Budget::factory()->catchAll()->monthly()->create([
'user_id' => $user->id,
'name' => 'Everything Else',
]);
BudgetPeriod::factory()->create([
'budget_id' => $budget->id,
'start_date' => now()->startOfMonth(),
'end_date' => now()->endOfMonth(),
'allocated_amount' => 50000,
]);
$page = $this->actingAs($user)->visit("/budgets/{$budget->id}");
$page->wait(2);
$page->assertSee('Everything Else')
->wait(4)
->click('[aria-label="More options"]')
->wait(1)
->click('Edit budget')
->wait(3)
->assertSee('Edit Budget')
->wait(1)
->assertSee('All untracked expenses')
->assertSee('This catch-all budget tracks every expense that no other budget covers.')
->screenshot(filename: 'catch-all-edit-dialog')
->assertNoJavascriptErrors();
});

View File

@ -389,6 +389,49 @@ test('creating a budget requires at least one category or label', function () {
expect(Budget::where('user_id', $user->id)->count())->toBe(0);
});
test('user can create a catch-all budget without categories or labels', function () {
$user = User::factory()->create(['onboarded_at' => now()]);
$response = $this->actingAs($user)->post('/budgets', [
'name' => 'Everything Else',
'period_type' => 'monthly',
'period_start_day' => 1,
'category_ids' => [],
'label_ids' => [],
'rollover_type' => 'reset',
'allocated_amount' => 50000,
'is_catch_all' => true,
]);
$response->assertRedirect();
$response->assertSessionHasNoErrors();
$budget = Budget::where('user_id', $user->id)->first();
expect($budget)->not->toBeNull()
->and($budget->is_catch_all)->toBeTrue()
->and($budget->categories)->toBeEmpty()
->and($budget->labels)->toBeEmpty();
});
test('user cannot create a second catch-all budget', function () {
$user = User::factory()->create(['onboarded_at' => now()]);
Budget::factory()->catchAll()->create(['user_id' => $user->id]);
$response = $this->actingAs($user)->post('/budgets', [
'name' => 'Another Catch-all',
'period_type' => 'monthly',
'period_start_day' => 1,
'category_ids' => [],
'label_ids' => [],
'rollover_type' => 'reset',
'allocated_amount' => 50000,
'is_catch_all' => true,
]);
$response->assertSessionHasErrors('is_catch_all');
expect(Budget::where('user_id', $user->id)->count())->toBe(1);
});
test('creating a budget rejects categories owned by another user', function () {
$user = User::factory()->create(['onboarded_at' => now()]);
$otherUser = User::factory()->create();

View File

@ -0,0 +1,150 @@
<?php
use App\Enums\CategoryType;
use App\Models\Budget;
use App\Models\BudgetPeriod;
use App\Models\BudgetTransaction;
use App\Models\Category;
use App\Models\Transaction;
use App\Models\User;
use App\Services\BudgetTransactionService;
beforeEach(function () {
$this->service = app(BudgetTransactionService::class);
$this->user = User::factory()->create();
});
function catchAllPeriod(User $user): BudgetPeriod
{
$budget = Budget::factory()->catchAll()->create(['user_id' => $user->id]);
return BudgetPeriod::factory()->create([
'budget_id' => $budget->id,
'start_date' => now()->subDays(30),
'end_date' => now()->addDays(30),
]);
}
test('catch-all budget absorbs an expense not tracked by any other budget', function () {
$period = catchAllPeriod($this->user);
$category = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Expense,
]);
$transaction = Transaction::factory()->create([
'user_id' => $this->user->id,
'category_id' => $category->id,
'transaction_date' => now(),
'amount' => -1000,
]);
$this->service->assignTransaction($transaction);
$budgetTransaction = BudgetTransaction::where('transaction_id', $transaction->id)
->where('budget_period_id', $period->id)
->first();
expect($budgetTransaction)->not->toBeNull();
expect($budgetTransaction->amount)->toBe(1000);
});
test('catch-all budget ignores an expense already tracked by another budget', function () {
$catchAll = catchAllPeriod($this->user);
$category = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Expense,
]);
$tracked = Budget::factory()->forCategories($category)->create(['user_id' => $this->user->id]);
$trackedPeriod = BudgetPeriod::factory()->create([
'budget_id' => $tracked->id,
'start_date' => now()->subDays(30),
'end_date' => now()->addDays(30),
]);
$transaction = Transaction::factory()->create([
'user_id' => $this->user->id,
'category_id' => $category->id,
'transaction_date' => now(),
'amount' => -1000,
]);
$this->service->assignTransaction($transaction);
expect(BudgetTransaction::where('transaction_id', $transaction->id)->where('budget_period_id', $trackedPeriod->id)->exists())->toBeTrue();
expect(BudgetTransaction::where('transaction_id', $transaction->id)->where('budget_period_id', $catchAll->id)->exists())->toBeFalse();
});
test('catch-all budget ignores income transactions', function () {
$period = catchAllPeriod($this->user);
$category = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Income,
]);
$transaction = Transaction::factory()->create([
'user_id' => $this->user->id,
'category_id' => $category->id,
'transaction_date' => now(),
'amount' => 5000,
]);
$this->service->assignTransaction($transaction);
expect(BudgetTransaction::where('transaction_id', $transaction->id)->where('budget_period_id', $period->id)->exists())->toBeFalse();
});
test('catch-all budget excludes a child whose parent category is tracked', function () {
$catchAll = catchAllPeriod($this->user);
$parent = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Expense,
]);
$child = Category::factory()->childOf($parent)->create();
$tracked = Budget::factory()->forCategories($parent)->create(['user_id' => $this->user->id]);
BudgetPeriod::factory()->create([
'budget_id' => $tracked->id,
'start_date' => now()->subDays(30),
'end_date' => now()->addDays(30),
]);
$transaction = Transaction::factory()->create([
'user_id' => $this->user->id,
'category_id' => $child->id,
'transaction_date' => now(),
'amount' => -1000,
]);
$this->service->assignTransaction($transaction);
expect(BudgetTransaction::where('transaction_id', $transaction->id)->where('budget_period_id', $catchAll->id)->exists())->toBeFalse();
});
test('historical assignment backfills only unclaimed expenses into a catch-all budget', function () {
$loose = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense]);
$claimed = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense]);
$income = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Income]);
// Transactions are created before any budget exists so they are not
// auto-assigned on creation — the historical backfill does the work.
// 2 unclaimed expenses (absorbed), 1 claimed expense + 1 income (ignored).
Transaction::factory()->create(['user_id' => $this->user->id, 'category_id' => $loose->id, 'transaction_date' => now()->subDay(), 'amount' => -1000]);
Transaction::factory()->create(['user_id' => $this->user->id, 'category_id' => $loose->id, 'transaction_date' => now()->subDays(2), 'amount' => -2000]);
Transaction::factory()->create(['user_id' => $this->user->id, 'category_id' => $claimed->id, 'transaction_date' => now()->subDay(), 'amount' => -3000]);
Transaction::factory()->create(['user_id' => $this->user->id, 'category_id' => $income->id, 'transaction_date' => now()->subDay(), 'amount' => 4000]);
$tracked = Budget::factory()->forCategories($claimed)->create(['user_id' => $this->user->id]);
BudgetPeriod::factory()->create([
'budget_id' => $tracked->id,
'start_date' => now()->subDays(30),
'end_date' => now()->addDays(30),
]);
$period = catchAllPeriod($this->user);
$count = $this->service->assignHistoricalTransactionsToPeriod($period);
expect($count)->toBe(2);
expect(BudgetTransaction::where('budget_period_id', $period->id)->count())->toBe(2);
});