From dbec1c4c134a257b95c3c1402590a6727026a4d4 Mon Sep 17 00:00:00 2001 From: Toni Grunwald Date: Mon, 15 Jun 2026 11:07:19 -0500 Subject: [PATCH] feat: add catch-all budgets (#527) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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) Co-authored-by: Víctor Falcón --- app/Http/Controllers/BudgetController.php | 1 + app/Http/Requests/StoreBudgetRequest.php | 11 +- app/Models/Budget.php | 2 + app/Services/BudgetTransactionService.php | 96 +++++++- database/factories/BudgetFactory.php | 7 + ...0000_add_is_catch_all_to_budgets_table.php | 22 ++ lang/es.json | 4 + .../components/budgets/budget-list-card.tsx | 6 +- .../budgets/create-budget-dialog.test.tsx | 57 +++++ .../budgets/create-budget-dialog.tsx | 214 +++++++++++------- .../budgets/edit-budget-dialog.test.tsx | 1 + .../components/budgets/edit-budget-dialog.tsx | 53 +++-- resources/js/pages/budgets/show.tsx | 7 +- resources/js/types/budget.ts | 1 + tests/Browser/CatchAllBudgetCrudTest.php | 124 ++++++++++ tests/Feature/BudgetTest.php | 43 ++++ tests/Feature/CatchAllBudgetTest.php | 150 ++++++++++++ 17 files changed, 691 insertions(+), 108 deletions(-) create mode 100644 database/migrations/2026_06_13_000000_add_is_catch_all_to_budgets_table.php create mode 100644 resources/js/components/budgets/create-budget-dialog.test.tsx create mode 100644 tests/Browser/CatchAllBudgetCrudTest.php create mode 100644 tests/Feature/CatchAllBudgetTest.php diff --git a/app/Http/Controllers/BudgetController.php b/app/Http/Controllers/BudgetController.php index b394e5c6..c42457ba 100644 --- a/app/Http/Controllers/BudgetController.php +++ b/app/Http/Controllers/BudgetController.php @@ -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 ?? []); diff --git a/app/Http/Requests/StoreBudgetRequest.php b/app/Http/Requests/StoreBudgetRequest.php index f5e760eb..679591e3 100644 --- a/app/Http/Requests/StoreBudgetRequest.php +++ b/app/Http/Requests/StoreBudgetRequest.php @@ -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.' + ); + } }); } } diff --git a/app/Models/Budget.php b/app/Models/Budget.php index 5847f3c7..13f07b7e 100644 --- a/app/Models/Budget.php +++ b/app/Models/Budget.php @@ -26,6 +26,7 @@ class Budget extends Model 'period_type', 'period_start_day', 'rollover_type', + 'is_catch_all', ]; /** @var list */ @@ -39,6 +40,7 @@ class Budget extends Model 'period_type' => BudgetPeriodType::class, 'rollover_type' => RolloverType::class, 'period_start_day' => 'integer', + 'is_catch_all' => 'boolean', ]; } diff --git a/app/Services/BudgetTransactionService.php b/app/Services/BudgetTransactionService.php index d9fb103d..adf39b80 100644 --- a/app/Services/BudgetTransactionService.php +++ b/app/Services/BudgetTransactionService.php @@ -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 $categoryMatchIds the transaction category and its ancestors + * @return array + */ + 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 + */ + 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(); + } } diff --git a/database/factories/BudgetFactory.php b/database/factories/BudgetFactory.php index 4285146f..b57d7503 100644 --- a/database/factories/BudgetFactory.php +++ b/database/factories/BudgetFactory.php @@ -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. * diff --git a/database/migrations/2026_06_13_000000_add_is_catch_all_to_budgets_table.php b/database/migrations/2026_06_13_000000_add_is_catch_all_to_budgets_table.php new file mode 100644 index 00000000..2e7f3b32 --- /dev/null +++ b/database/migrations/2026_06_13_000000_add_is_catch_all_to_budgets_table.php @@ -0,0 +1,22 @@ +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'); + }); + } +}; diff --git a/lang/es.json b/lang/es.json index 5bf4a30f..405d4e69 100644 --- a/lang/es.json +++ b/lang/es.json @@ -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.", diff --git a/resources/js/components/budgets/budget-list-card.tsx b/resources/js/components/budgets/budget-list-card.tsx index 5d553a9c..b167087b 100644 --- a/resources/js/components/budgets/budget-list-card.tsx +++ b/resources/js/components/budgets/budget-list-card.tsx @@ -144,7 +144,11 @@ export function BudgetListCard({ budget, currencyCode }: Props) { {__('Tracking:')} - {trackingNames.length > 0 ? ( + {budget.is_catch_all ? ( + + {__('All untracked expenses')} + + ) : trackingNames.length > 0 ? ( <> {trackingNames.slice(0, 2).map((name) => ( diff --git a/resources/js/components/budgets/create-budget-dialog.test.tsx b/resources/js/components/budgets/create-budget-dialog.test.tsx new file mode 100644 index 00000000..6dd0ae24 --- /dev/null +++ b/resources/js/components/budgets/create-budget-dialog.test.tsx @@ -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 }) => ( +
{placeholder}
+ ), +})); + +vi.mock('@/components/ui/amount-input', () => ({ + AmountInput: () => , +})); + +function openDialog() { + render(); + 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(); + }); +}); diff --git a/resources/js/components/budgets/create-budget-dialog.tsx b/resources/js/components/budgets/create-budget-dialog.tsx index fdaf8696..9a9f882a 100644 --- a/resources/js/components/budgets/create-budget-dialog.tsx +++ b/resources/js/components/budgets/create-budget-dialog.tsx @@ -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(0); const [rolloverType, setRolloverType] = useState('carry_over'); + const [isCatchAll, setIsCatchAll] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [errors, setErrors] = useState>({}); @@ -75,7 +77,11 @@ export function CreateBudgetDialog({ const newErrors: Record = {}; - 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({ )}
- {errors.selection && ( +
+ + setIsCatchAll(checked === true) + } + className="mt-0.5" + /> +
+ + {__('Catch-all budget')} + +

+ {__( + 'Automatically track every expense that no other budget covers. You can only have one.', + )} +

+
+
+ + {errors.is_catch_all && (
- {errors.selection} + {errors.is_catch_all}
)} -
- - {__('Categories')} - - { - 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 ? ( - - ) : undefined, - badgeClassName: cn( - colorClasses.bg, - colorClasses.text, - ), - }; - })} - selected={selectedCategoryIds} - onChange={setSelectedCategoryIds} - placeholder={__('Select categories')} - searchPlaceholder={__('Search categories…')} - emptyText={__('No categories found.')} - /> -
- -
- - {__('Labels')} - - { - const colorClasses = - getLabelColorClasses(label.color); - - return { - value: label.id, - label: label.name, - icon: ( - - ), - badgeClassName: cn( - colorClasses.bg, - colorClasses.text, - ), - }; - })} - selected={selectedLabelIds} - onChange={setSelectedLabelIds} - placeholder={__('Select labels')} - searchPlaceholder={__('Search labels…')} - emptyText={__('No labels found.')} - /> -

- {__( - 'Select at least one category or label to track.', + {!isCatchAll && ( + <> + {errors.selection && ( +

+ {errors.selection} +
)} -

-
+ +
+ + {__('Categories')} + + { + 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 ? ( + + ) : undefined, + badgeClassName: cn( + colorClasses.bg, + colorClasses.text, + ), + }; + })} + selected={selectedCategoryIds} + onChange={setSelectedCategoryIds} + placeholder={__( + 'Select categories', + )} + searchPlaceholder={__( + 'Search categories…', + )} + emptyText={__( + 'No categories found.', + )} + /> +
+ +
+ + {__('Labels')} + + { + const colorClasses = + getLabelColorClasses( + label.color, + ); + + return { + value: label.id, + label: label.name, + icon: ( + + ), + badgeClassName: cn( + colorClasses.bg, + colorClasses.text, + ), + }; + })} + selected={selectedLabelIds} + onChange={setSelectedLabelIds} + placeholder={__('Select labels')} + searchPlaceholder={__( + 'Search labels…', + )} + emptyText={__('No labels found.')} + /> +

+ {__( + 'Select at least one category or label to track.', + )} +

+
+ + )}
@@ -367,7 +424,8 @@ export function CreateBudgetDialog({ disabled={ isSubmitting || !name || - (selectedCategoryIds.length === 0 && + (!isCatchAll && + selectedCategoryIds.length === 0 && selectedLabelIds.length === 0) || allocatedAmount <= 0 } diff --git a/resources/js/components/budgets/edit-budget-dialog.test.tsx b/resources/js/components/budgets/edit-budget-dialog.test.tsx index 4823d81c..10ecc233 100644 --- a/resources/js/components/budgets/edit-budget-dialog.test.tsx +++ b/resources/js/components/budgets/edit-budget-dialog.test.tsx @@ -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, diff --git a/resources/js/components/budgets/edit-budget-dialog.tsx b/resources/js/components/budgets/edit-budget-dialog.tsx index 83c4c955..b625f4a7 100644 --- a/resources/js/components/budgets/edit-budget-dialog.tsx +++ b/resources/js/components/budgets/edit-budget-dialog.tsx @@ -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({
-
- {budget.categories?.map((category) => ( - - ))} - {budget.labels?.map((label) => ( - - ))} -
-

- {__( - 'Tracked categories and labels cannot be changed after creation.', - )} -

+ {budget.is_catch_all ? ( + <> +
+ + {__('All untracked expenses')} + +
+

+ {__( + 'This catch-all budget tracks every expense that no other budget covers.', + )} +

+ + ) : ( + <> +
+ {budget.categories?.map((category) => ( + + ))} + {budget.labels?.map((label) => ( + + ))} +
+

+ {__( + 'Tracked categories and labels cannot be changed after creation.', + )} +

+ + )}
diff --git a/resources/js/pages/budgets/show.tsx b/resources/js/pages/budgets/show.tsx index 4f515b39..16024c02 100644 --- a/resources/js/pages/budgets/show.tsx +++ b/resources/js/pages/budgets/show.tsx @@ -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={
- {trackingCount > 0 ? ( + {budget.is_catch_all ? ( + + {__('All untracked expenses')} + + ) : trackingCount > 0 ? ( <> {__('Tracking')}{' '} diff --git a/resources/js/types/budget.ts b/resources/js/types/budget.ts index d6a8a14b..c8d77fa2 100644 --- a/resources/js/types/budget.ts +++ b/resources/js/types/budget.ts @@ -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; diff --git a/tests/Browser/CatchAllBudgetCrudTest.php b/tests/Browser/CatchAllBudgetCrudTest.php new file mode 100644 index 00000000..0f4106e1 --- /dev/null +++ b/tests/Browser/CatchAllBudgetCrudTest.php @@ -0,0 +1,124 @@ +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(); +}); diff --git a/tests/Feature/BudgetTest.php b/tests/Feature/BudgetTest.php index 0097e761..cd668b7b 100644 --- a/tests/Feature/BudgetTest.php +++ b/tests/Feature/BudgetTest.php @@ -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(); diff --git a/tests/Feature/CatchAllBudgetTest.php b/tests/Feature/CatchAllBudgetTest.php new file mode 100644 index 00000000..330ac0dc --- /dev/null +++ b/tests/Feature/CatchAllBudgetTest.php @@ -0,0 +1,150 @@ +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); +});