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:
parent
e065d4ab65
commit
dbec1c4c13
|
|
@ -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 ?? []);
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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')}{' '}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue