feat(budgets): track multiple categories and labels per budget (#466)

## Summary

Budgets previously tracked a single category **or** label (mutually
exclusive). This lets a budget span **multiple** categories and labels
at once, all pooling spend against one allocated amount per period.

Scope decided with the requester:
- **Shared pool** — one allocated amount; any tracked category/label
counts against it.
- **Multi categories + multi labels** on a single budget.
- **Create-only** — tracking is chosen at creation and locked afterward
(edit dialog shows it read-only).

## Changes

**Backend**
- New `budget_category` + `budget_label` pivot tables; data migration
copies existing `category_id`/`label_id` into them, then drops those
columns.
- `Budget` model: `categories()` / `labels()` belongsToMany.
- `BudgetTransactionService` matches transactions across **all** tracked
categories OR labels (live assignment + historical backfill).
- `StoreBudgetRequest` accepts `category_ids` / `label_ids` arrays,
requires ≥1 across both, validates ownership. `update` no longer touches
tracking.

**Frontend**
- Reusable `MultiSelect` (popover + command + badge).
- Create dialog uses multi-selects; cards and show page render tracked
categories/labels as badges; edit dialog shows them read-only.
- Dexie bumped to v10 (drops unused per-category allocations table, adds
`budget_labels`).

## Testing
- Updated all budget/transaction/listener/browser tests for the pivot
model; added cases for multi-category matching, mixed category+label
pooling, and store validation (empty selection + foreign-ownership).
- Added the new `__()` strings to `lang/es.json`.
- Local `pint`, `lint`, `format` pass. Relying on CI for the full suite.
This commit is contained in:
Víctor Falcón 2026-06-01 12:32:23 +02:00 committed by GitHub
parent 5ce439f463
commit 71dd6e2b7f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 886 additions and 272 deletions

View File

@ -9,6 +9,7 @@ use App\Models\Account;
use App\Models\Bank;
use App\Models\Budget;
use App\Models\Category;
use App\Models\Label;
use App\Services\BudgetPeriodService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\RedirectResponse;
@ -28,7 +29,7 @@ class BudgetController extends Controller
$user = $request->user();
$budgets = $user
->budgets()
->with(['category', 'label', 'periods' => function ($query) {
->with(['categories', 'labels', 'periods' => function ($query) {
$query->where('start_date', '<=', today())
->where('end_date', '>=', today())
->with(['budgetTransactions']);
@ -80,7 +81,7 @@ class BudgetController extends Controller
->orderBy('start_date', 'asc')
->first();
$budget->load(['category', 'label']);
$budget->load(['categories', 'labels']);
$categories = Category::query()
->where('user_id', $user->id)
@ -101,6 +102,11 @@ class BudgetController extends Controller
->orderBy('name')
->get(['id', 'name', 'logo']);
$labels = Label::query()
->where('user_id', $user->id)
->orderBy('name')
->get(['id', 'name', 'color']);
return Inertia::render('budgets/show', [
'budget' => $budget,
'currentPeriod' => $viewedPeriod,
@ -109,6 +115,7 @@ class BudgetController extends Controller
'categories' => $categories,
'accounts' => $accounts,
'banks' => $banks,
'labels' => $labels,
'currencyCode' => $user->currency_code ?? 'USD',
]);
}
@ -120,18 +127,21 @@ class BudgetController extends Controller
'name' => $request->name,
'period_type' => $request->period_type,
'period_start_day' => $request->period_start_day,
'category_id' => $request->category_id,
'label_id' => $request->label_id,
'rollover_type' => $request->rollover_type,
]);
$period = $this->budgetPeriodService->generatePeriod($budget, $request->allocated_amount, null, true);
$budget->categories()->sync($request->category_ids ?? []);
$budget->labels()->sync($request->label_ids ?? []);
return ['budget' => $budget, 'period' => $period];
$period = $this->budgetPeriodService->generatePeriod($budget, $request->allocated_amount, null, true);
$previousPeriod = $this->budgetPeriodService->generatePreviousPeriod($budget, $period, $request->allocated_amount, true);
return ['budget' => $budget, 'period' => $period, 'previousPeriod' => $previousPeriod];
});
// Dispatch job to assign historical transactions
// Dispatch jobs to assign historical transactions for the current and previous periods
AssignHistoricalTransactionsToBudget::dispatch($result['budget'], $result['period']);
AssignHistoricalTransactionsToBudget::dispatch($result['budget'], $result['previousPeriod']);
return redirect()->route('budgets.show', $result['budget']);
}
@ -145,8 +155,6 @@ class BudgetController extends Controller
'name',
'period_type',
'period_start_day',
'category_id',
'label_id',
'rollover_type',
]));

View File

@ -22,8 +22,10 @@ class StoreBudgetRequest extends FormRequest
'name' => ['required', 'string', 'max:255'],
'period_type' => ['required', Rule::enum(BudgetPeriodType::class)],
'period_start_day' => ['nullable', 'integer', 'min:0', 'max:31'],
'category_id' => ['nullable', Rule::exists('categories', 'id')->where('user_id', $userId)],
'label_id' => ['nullable', Rule::exists('labels', 'id')->where('user_id', $userId)],
'category_ids' => ['nullable', 'array'],
'category_ids.*' => [Rule::exists('categories', 'id')->where('user_id', $userId)],
'label_ids' => ['nullable', 'array'],
'label_ids.*' => [Rule::exists('labels', 'id')->where('user_id', $userId)],
'rollover_type' => ['required', Rule::enum(RolloverType::class)],
'allocated_amount' => ['required', 'integer', 'min:0'],
];
@ -32,13 +34,13 @@ class StoreBudgetRequest extends FormRequest
public function withValidator($validator): void
{
$validator->after(function ($validator) {
$hasCategoryId = ! empty($this->category_id);
$hasLabelId = ! empty($this->label_id);
$hasCategories = ! empty($this->category_ids);
$hasLabels = ! empty($this->label_ids);
if (! $hasCategoryId && ! $hasLabelId) {
if (! $hasCategories && ! $hasLabels) {
$validator->errors()->add(
'selection',
'You must select either a category or a label.'
'You must select at least one category or label.'
);
}
});

View File

@ -16,14 +16,10 @@ class UpdateBudgetRequest extends FormRequest
public function rules(): array
{
$userId = $this->user()->id;
return [
'name' => ['sometimes', 'string', 'max:255'],
'period_type' => ['sometimes', Rule::enum(BudgetPeriodType::class)],
'period_start_day' => ['nullable', 'integer', 'min:0', 'max:31'],
'category_id' => ['nullable', Rule::exists('categories', 'id')->where('user_id', $userId)],
'label_id' => ['nullable', Rule::exists('labels', 'id')->where('user_id', $userId)],
'rollover_type' => ['sometimes', Rule::enum(RolloverType::class)],
'allocated_amount' => ['sometimes', 'integer', 'min:0'],
];

View File

@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
@ -24,8 +25,6 @@ class Budget extends Model
'name',
'period_type',
'period_start_day',
'category_id',
'label_id',
'rollover_type',
];
@ -44,16 +43,16 @@ class Budget extends Model
return $this->belongsTo(User::class);
}
/** @return BelongsTo<Category, $this> */
public function category(): BelongsTo
/** @return BelongsToMany<Category, $this> */
public function categories(): BelongsToMany
{
return $this->belongsTo(Category::class);
return $this->belongsToMany(Category::class);
}
/** @return BelongsTo<Label, $this> */
public function label(): BelongsTo
/** @return BelongsToMany<Label, $this> */
public function labels(): BelongsToMany
{
return $this->belongsTo(Label::class);
return $this->belongsToMany(Label::class);
}
/** @return HasMany<BudgetPeriod, $this> */

View File

@ -33,6 +33,13 @@ class BudgetPeriodService
]);
}
public function generatePreviousPeriod(Budget $budget, BudgetPeriod $period, ?int $allocatedAmount = null, bool $processHistorical = false): BudgetPeriod
{
$referenceDate = $period->start_date->copy()->subDay();
return $this->generatePeriod($budget, $allocatedAmount ?? $period->allocated_amount, $referenceDate, $processHistorical);
}
public function closePeriod(BudgetPeriod $period): void
{
$budget = $period->budget;

View File

@ -21,22 +21,24 @@ class BudgetTransactionService
// Ensure labels are available for matching (safe if already loaded).
$transaction->loadMissing('labels');
$transactionLabelIds = $transaction->labels->pluck('id');
// Find budget periods that potentially match this transaction.
$budgetPeriods = BudgetPeriod::query()
->whereHas('budget', function ($query) use ($transaction, $userId) {
->whereHas('budget', function ($query) use ($transaction, $transactionLabelIds, $userId) {
$query->where('user_id', $userId)
->where(function ($q) use ($transaction) {
$q->where('category_id', $transaction->category_id)
->orWhere(function ($labelQuery) use ($transaction) {
$labelQuery->whereHas('label', function ($lq) use ($transaction) {
$lq->whereIn('id', $transaction->labels->pluck('id'));
});
->where(function ($q) use ($transaction, $transactionLabelIds) {
$q->whereHas('categories', function ($cq) use ($transaction) {
$cq->whereKey($transaction->category_id);
})
->orWhereHas('labels', function ($lq) use ($transactionLabelIds) {
$lq->whereIn('labels.id', $transactionLabelIds);
});
});
})
->where('start_date', '<=', $transaction->transaction_date)
->where('end_date', '>=', $transaction->transaction_date)
->with('budget')
->with('budget.categories:id', 'budget.labels:id')
->get();
// Narrow down to periods whose budget actually matches the transaction.
@ -45,8 +47,12 @@ class BudgetTransactionService
foreach ($budgetPeriods as $period) {
$budget = $period->budget;
$matchesCategory = $budget->category_id && $budget->category_id === $transaction->category_id;
$matchesLabel = $budget->label_id && $transaction->labels->contains('id', $budget->label_id);
$matchesCategory = $transaction->category_id
&& $budget->categories->contains('id', $transaction->category_id);
$matchesLabel = $budget->labels
->pluck('id')
->intersect($transactionLabelIds)
->isNotEmpty();
if ($matchesCategory || $matchesLabel) {
$matchingPeriodIds[] = $period->id;
@ -91,7 +97,7 @@ class BudgetTransactionService
public function assignHistoricalTransactionsToPeriod(BudgetPeriod $period): int
{
// Load the budget with its relationships
$budget = $period->budget()->with(['category', 'label'])->first();
$budget = $period->budget()->with(['categories:id', 'labels:id'])->first();
if (! $budget) {
return 0;
@ -99,10 +105,13 @@ class BudgetTransactionService
$assignedCount = 0;
$categoryIds = $budget->categories->pluck('id');
$labelIds = $budget->labels->pluck('id');
Log::info('Building query for historical transactions', [
'user_id' => $budget->user_id,
'category_id' => $budget->category_id,
'label_id' => $budget->label_id,
'category_ids' => $categoryIds->all(),
'label_ids' => $labelIds->all(),
'start_date' => $period->start_date->toDateString(),
'end_date' => $period->end_date->toDateString(),
]);
@ -113,15 +122,15 @@ class BudgetTransactionService
->whereBetween('transaction_date', [$period->start_date, $period->end_date])
->withoutTrashed();
// Filter by category OR label
$query->where(function ($q) use ($budget) {
if ($budget->category_id) {
$q->where('category_id', $budget->category_id);
// Filter by any tracked category OR label
$query->where(function ($q) use ($categoryIds, $labelIds) {
if ($categoryIds->isNotEmpty()) {
$q->whereIn('category_id', $categoryIds);
}
if ($budget->label_id) {
$q->orWhereHas('labels', function ($labelQuery) use ($budget) {
$labelQuery->where('labels.id', $budget->label_id);
if ($labelIds->isNotEmpty()) {
$q->orWhereHas('labels', function ($labelQuery) use ($labelIds) {
$labelQuery->whereIn('labels.id', $labelIds);
});
}
});

View File

@ -4,8 +4,11 @@ namespace Database\Factories;
use App\Enums\BudgetPeriodType;
use App\Models\Budget;
use App\Models\Category;
use App\Models\Label;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Arr;
/**
* @extends Factory<Budget>
@ -50,4 +53,36 @@ class BudgetFactory extends Factory
'period_start_day' => 1,
]);
}
/**
* Attach one or more categories to the budget after creation.
*
* @param Category|array<int, Category|string> $categories
*/
public function forCategories(Category|array $categories): static
{
$ids = collect(Arr::wrap($categories))
->map(fn ($category) => $category instanceof Category ? $category->id : $category)
->all();
return $this->afterCreating(function (Budget $budget) use ($ids) {
$budget->categories()->syncWithoutDetaching($ids);
});
}
/**
* Attach one or more labels to the budget after creation.
*
* @param Label|array<int, Label|string> $labels
*/
public function forLabels(Label|array $labels): static
{
$ids = collect(Arr::wrap($labels))
->map(fn ($label) => $label instanceof Label ? $label->id : $label)
->all();
return $this->afterCreating(function (Budget $budget) use ($ids) {
$budget->labels()->syncWithoutDetaching($ids);
});
}
}

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('budget_category', function (Blueprint $table) {
$table->foreignUuid('budget_id')->constrained()->cascadeOnDelete();
$table->foreignUuid('category_id')->constrained()->cascadeOnDelete();
$table->unique(['budget_id', 'category_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('budget_category');
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('budget_label', function (Blueprint $table) {
$table->foreignUuid('budget_id')->constrained()->cascadeOnDelete();
$table->foreignUuid('label_id')->constrained()->cascadeOnDelete();
$table->unique(['budget_id', 'label_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('budget_label');
}
};

View File

@ -0,0 +1,65 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
DB::table('budgets')
->whereNotNull('category_id')
->orderBy('id')
->each(function ($budget) {
DB::table('budget_category')->insertOrIgnore([
'budget_id' => $budget->id,
'category_id' => $budget->category_id,
]);
});
DB::table('budgets')
->whereNotNull('label_id')
->orderBy('id')
->each(function ($budget) {
DB::table('budget_label')->insertOrIgnore([
'budget_id' => $budget->id,
'label_id' => $budget->label_id,
]);
});
Schema::table('budgets', function (Blueprint $table) {
$table->dropConstrainedForeignId('category_id');
$table->dropConstrainedForeignId('label_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('budgets', function (Blueprint $table) {
$table->foreignUuid('category_id')->nullable()->constrained()->nullOnDelete();
$table->foreignUuid('label_id')->nullable()->constrained()->nullOnDelete();
});
DB::table('budget_category')->orderBy('budget_id')->each(function ($row) {
DB::table('budgets')
->where('id', $row->budget_id)
->whereNull('category_id')
->update(['category_id' => $row->category_id]);
});
DB::table('budget_label')->orderBy('budget_id')->each(function ($row) {
DB::table('budgets')
->where('id', $row->budget_id)
->whereNull('label_id')
->update(['label_id' => $row->label_id]);
});
}
};

View File

@ -1,4 +1,19 @@
{
"+:count": "+:count",
":count selected": ":count seleccionadas",
"Categories": "Categorías",
"Labels": "Etiquetas",
"No results.": "Sin resultados.",
"Remove :label": "Eliminar :label",
"Search categories…": "Buscar categorías…",
"Search labels…": "Buscar etiquetas…",
"Search…": "Buscar…",
"Select at least one category or label to track.": "Selecciona al menos una categoría o etiqueta para seguir.",
"Select categories": "Seleccionar categorías",
"Select…": "Seleccionar…",
"Set up a spending limit across one or more categories or labels.": "Configura un límite de gasto en una o más categorías o etiquetas.",
"Tracked categories and labels cannot be changed after creation.": "Las categorías y etiquetas seguidas no se pueden cambiar después de la creación.",
"You must select at least one category or label.": "Debes seleccionar al menos una categoría o etiqueta.",
" All communications between your device and our servers are protected using TLS (Transport Layer Security)": " Todas las comunicaciones entre tu dispositivo y nuestros servidores están protegidas con TLS (Transport Layer Security)",
" All data is stored on secure servers with encryption at rest, protecting your information from unauthorized access": " Todos los datos se almacenan en servidores seguros con cifrado en reposo, protegiendo tu información del acceso no autorizado",
" Strict access controls and authentication mechanisms protect against unauthorized access": " Controles de acceso estrictos y mecanismos de autenticación protegen contra el acceso no autorizado",

View File

@ -73,10 +73,11 @@ export function BudgetListCard({ budget, currencyCode }: Props) {
return 'text-green-600 dark:text-green-400';
}, [stats.percentageUsed]);
const trackingLabel = useMemo(() => {
if (budget.category) return budget.category.name;
if (budget.label) return budget.label.name;
return __('No tracking');
const trackingNames = useMemo(() => {
return [
...(budget.categories?.map((category) => category.name) ?? []),
...(budget.labels?.map((label) => label.name) ?? []),
];
}, [budget]);
return (
@ -138,10 +139,32 @@ export function BudgetListCard({ budget, currencyCode }: Props) {
</div>
</div>
<div className="flex items-center justify-between border-t pt-4">
<span className="text-sm text-muted-foreground">
{__('Tracking:')} {trackingLabel}
</span>
<div className="flex items-center justify-between gap-2 border-t pt-4">
<div className="flex flex-wrap items-center gap-1">
<span className="text-sm text-muted-foreground">
{__('Tracking:')}
</span>
{trackingNames.length > 0 ? (
<>
{trackingNames.slice(0, 2).map((name) => (
<Badge key={name} variant="secondary">
{name}
</Badge>
))}
{trackingNames.length > 2 && (
<Badge variant="secondary">
{__('+:count', {
count: trackingNames.length - 2,
})}
</Badge>
)}
</>
) : (
<span className="text-sm text-muted-foreground">
{__('No tracking')}
</span>
)}
</div>
<Link href={show({ budget: budget.id }).url}>
<Button
className="cursor-pointer"

View File

@ -12,6 +12,7 @@ import {
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label as UILabel } from '@/components/ui/label';
import { MultiSelect } from '@/components/ui/multi-select';
import {
Select,
SelectContent,
@ -29,11 +30,12 @@ import {
ROLLOVER_TYPES,
RolloverType,
} from '@/types/budget';
import { Category } from '@/types/category';
import { Label } from '@/types/label';
import { Category, getCategoryColorClasses } from '@/types/category';
import { getLabelColorClasses, Label } from '@/types/label';
import { __ } from '@/utils/i18n';
import { router, usePage } from '@inertiajs/react';
import { Plus, X } from 'lucide-react';
import * as Icons from 'lucide-react';
import { Plus, Tag } from 'lucide-react';
import React, { useState } from 'react';
import { Card, CardContent } from '../ui/card';
@ -53,8 +55,10 @@ export function CreateBudgetDialog({
const [name, setName] = useState('');
const [periodType, setPeriodType] = useState<BudgetPeriodType>('monthly');
const [periodStartDay, setPeriodStartDay] = useState<number>(1);
const [selectedCategoryId, setSelectedCategoryId] = useState<string>('');
const [selectedLabelId, setSelectedLabelId] = useState<string>('');
const [selectedCategoryIds, setSelectedCategoryIds] = useState<string[]>(
[],
);
const [selectedLabelIds, setSelectedLabelIds] = useState<string[]>([]);
const [allocatedAmount, setAllocatedAmount] = useState<number>(0);
const [rolloverType, setRolloverType] =
useState<RolloverType>('carry_over');
@ -70,9 +74,9 @@ export function CreateBudgetDialog({
const newErrors: Record<string, string> = {};
if (!selectedCategoryId && !selectedLabelId) {
if (selectedCategoryIds.length === 0 && selectedLabelIds.length === 0) {
newErrors.selection = __(
'You must select either a category or a label.',
'You must select at least one category or label.',
);
}
@ -89,8 +93,8 @@ export function CreateBudgetDialog({
name,
period_type: periodType,
period_start_day: periodType === 'yearly' ? 1 : periodStartDay,
category_id: selectedCategoryId || null,
label_id: selectedLabelId || null,
category_ids: selectedCategoryIds,
label_ids: selectedLabelIds,
rollover_type: rolloverType,
allocated_amount: allocatedAmount,
},
@ -100,8 +104,8 @@ export function CreateBudgetDialog({
setName('');
setPeriodType('monthly');
setPeriodStartDay(1);
setSelectedCategoryId('');
setSelectedLabelId('');
setSelectedCategoryIds([]);
setSelectedLabelIds([]);
setAllocatedAmount(0);
setRolloverType('carry_over');
setErrors({});
@ -139,7 +143,7 @@ export function CreateBudgetDialog({
<DialogTitle>{__('Create Budget')}</DialogTitle>
<DialogDescription>
{__(
'Set up a spending limit for a category or label.',
'Set up a spending limit across one or more categories or labels.',
)}
</DialogDescription>
</DialogHeader>
@ -222,98 +226,71 @@ export function CreateBudgetDialog({
)}
<div className="space-y-2">
<UILabel htmlFor="category">
{__('Category (Optional)')}
<UILabel htmlFor="categories">
{__('Categories')}
</UILabel>
<div className="flex gap-2">
<Select
value={selectedCategoryId || undefined}
onValueChange={setSelectedCategoryId}
>
<SelectTrigger
id="category"
className="flex-1"
>
<SelectValue
placeholder={__(
'Select a category',
)}
/>
</SelectTrigger>
<SelectContent>
{allCategories.map((category) => (
<SelectItem
key={category.id}
value={category.id}
>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedCategoryId && (
<Button
type="button"
variant="outline"
size="icon"
onClick={() =>
setSelectedCategoryId('')
}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
<MultiSelect
id="categories"
options={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,
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="label">
{__('Label (Optional)')}
<UILabel htmlFor="labels">
{__('Labels')}
</UILabel>
<div className="flex gap-2">
<Select
value={selectedLabelId || undefined}
onValueChange={(value) =>
setSelectedLabelId(value)
}
>
<SelectTrigger
id="label"
className="flex-1"
>
<SelectValue
placeholder={__(
'Select a label',
)}
/>
</SelectTrigger>
<SelectContent>
{allLabels.map((label) => (
<SelectItem
key={label.id}
value={label.id}
>
{label.name}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedLabelId && (
<Button
type="button"
variant="outline"
size="icon"
onClick={() =>
setSelectedLabelId('')
}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
<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 a category or a label to track.',
'Select at least one category or label to track.',
)}
</p>
</div>
@ -385,7 +362,8 @@ export function CreateBudgetDialog({
disabled={
isSubmitting ||
!name ||
(!selectedCategoryId && !selectedLabelId) ||
(selectedCategoryIds.length === 0 &&
selectedLabelIds.length === 0) ||
allocatedAmount <= 0
}
>

View File

@ -24,8 +24,8 @@ function makeBudget(): Budget {
name: 'Monthly budget',
period_type: 'monthly',
period_start_day: 1,
category_id: null,
label_id: null,
categories: [],
labels: [],
rollover_type: 'carry_over',
created_at: '2026-05-26T00:00:00.000000Z',
updated_at: '2026-05-26T00:00:00.000000Z',

View File

@ -1,4 +1,6 @@
import { update } from '@/actions/App/Http/Controllers/BudgetController';
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 { Button } from '@/components/ui/button';
@ -130,6 +132,26 @@ export function EditBudgetDialog({
/>
</div>
<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>
</div>
<div className="space-y-2">
<Label htmlFor="period-type">
{__('Period Type')}

View File

@ -1,3 +1,4 @@
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Command,
@ -220,6 +221,23 @@ export const CategoryIcon = memo(function CategoryIcon({
);
});
export function CategoryBadge({ category }: { category: Category }) {
const colorClasses = getCategoryColorClasses(category.color);
return (
<Badge
className={cn(
'gap-1 px-2 py-0.5',
colorClasses.bg,
colorClasses.text,
)}
>
<DynamicIcon name={category.icon} className="h-3 w-3 opacity-80" />
{category.name}
</Badge>
);
}
const DynamicIcon = memo(function DynamicIcon({
name,
className,

View File

@ -407,6 +407,12 @@ export function TransactionList({
...transaction,
decryptedDescription,
decryptedNotes,
label_ids:
transaction.label_ids ??
transaction.labels?.map(
(label) => label.id,
) ??
[],
} as DecryptedTransaction;
} catch (error) {
console.error(

View File

@ -0,0 +1,157 @@
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import { __ } from '@/utils/i18n';
import { Check, ChevronsUpDown, X } from 'lucide-react';
import { useState } from 'react';
export interface MultiSelectOption {
value: string;
label: string;
icon?: React.ReactNode;
badgeClassName?: string;
}
interface Props {
options: MultiSelectOption[];
selected: string[];
onChange: (selected: string[]) => void;
placeholder?: string;
searchPlaceholder?: string;
emptyText?: string;
id?: string;
className?: string;
}
export function MultiSelect({
options,
selected,
onChange,
placeholder = __('Select…'),
searchPlaceholder = __('Search…'),
emptyText = __('No results.'),
id,
className,
}: Props) {
const [open, setOpen] = useState(false);
const toggle = (value: string) => {
if (selected.includes(value)) {
onChange(selected.filter((item) => item !== value));
} else {
onChange([...selected, value]);
}
};
const remove = (value: string) => {
onChange(selected.filter((item) => item !== value));
};
const selectedOptions = options.filter((option) =>
selected.includes(option.value),
);
return (
<div className={cn('space-y-2', className)}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
id={id}
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between font-normal"
>
<span className="truncate text-muted-foreground">
{selected.length > 0
? __(':count selected', {
count: selected.length,
})
: placeholder}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[--radix-popover-trigger-width] p-0"
align="start"
>
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList>
<CommandEmpty>{emptyText}</CommandEmpty>
<CommandGroup>
{options.map((option) => {
const isSelected = selected.includes(
option.value,
);
return (
<CommandItem
key={option.value}
value={option.label}
onSelect={() => toggle(option.value)}
>
<Check
className={cn(
'mr-2 h-4 w-4',
isSelected
? 'opacity-100'
: 'opacity-0',
)}
/>
{option.icon && (
<span className="mr-2 flex items-center">
{option.icon}
</span>
)}
{option.label}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{selectedOptions.length > 0 && (
<div className="flex flex-wrap gap-1">
{selectedOptions.map((option) => (
<Badge
key={option.value}
variant={option.badgeClassName ? undefined : 'secondary'}
className={cn('gap-1', option.badgeClassName)}
>
{option.icon}
{option.label}
<button
type="button"
onClick={() => remove(option.value)}
className="rounded-full outline-none ring-offset-background hover:text-foreground focus:ring-2 focus:ring-ring"
aria-label={__('Remove :label', {
label: option.label,
})}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
</div>
);
}

View File

@ -1,8 +1,8 @@
import type {
Budget,
BudgetCategory,
BudgetLabel,
BudgetPeriod,
BudgetPeriodAllocation,
} from '@/types/budget';
import type { Transaction } from '@/types/transaction';
import Dexie, { type EntityTable } from 'dexie';
@ -16,8 +16,8 @@ type WhisperMoneyDB = Dexie & {
transactions: EntityTable<Transaction, 'id'>;
budgets: EntityTable<Budget, 'id'>;
budget_categories: EntityTable<BudgetCategory, 'id'>;
budget_labels: EntityTable<BudgetLabel, 'id'>;
budget_periods: EntityTable<BudgetPeriod, 'id'>;
budget_period_allocations: EntityTable<BudgetPeriodAllocation, 'id'>;
sync_metadata: EntityTable<SyncMetadata, 'key'>;
};
@ -80,6 +80,18 @@ function initializeDatabase(): WhisperMoneyDB {
sync_metadata: 'key',
});
// Version 10: Multi-category/label budgets share a single pool, so the
// per-category allocations table is dropped and a budget_labels pivot added.
database.version(10).stores({
transactions: 'id, user_id, account_id, updated_at',
budgets: 'id, user_id, updated_at',
budget_categories: 'id, budget_id, updated_at',
budget_labels: 'id, budget_id, updated_at',
budget_periods: 'id, budget_id, start_date, updated_at',
budget_period_allocations: null,
sync_metadata: 'key',
});
return database;
}

View File

@ -5,6 +5,8 @@ import { DeleteBudgetDialog } from '@/components/budgets/delete-budget-dialog';
import { EditBudgetDialog } from '@/components/budgets/edit-budget-dialog';
import HeadingSmall from '@/components/heading-small';
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 { Button } from '@/components/ui/button';
import {
@ -19,6 +21,7 @@ import { BreadcrumbItem } from '@/types';
import { Account, Bank } from '@/types/account';
import { Budget, BudgetPeriod } from '@/types/budget';
import { Category } from '@/types/category';
import { Label } from '@/types/label';
import { __ } from '@/utils/i18n';
import { Head, router } from '@inertiajs/react';
import { ChevronDown, Loader2 } from 'lucide-react';
@ -32,6 +35,7 @@ interface Props {
categories: Category[];
accounts: Account[];
banks: Bank[];
labels: Label[];
currencyCode: string;
}
@ -43,6 +47,7 @@ export default function BudgetShow({
categories,
accounts,
banks,
labels,
currencyCode,
}: Props) {
const [editOpen, setEditOpen] = useState(false);
@ -74,11 +79,8 @@ export default function BudgetShow({
},
];
const trackingLabel = useMemo((): string | null => {
if (budget.category) return budget.category.name;
if (budget.label) return budget.label.name;
return null;
}, [budget]);
const trackingCount =
(budget.categories?.length ?? 0) + (budget.labels?.length ?? 0);
const periodTransactions = useMemo(() => {
return (
@ -101,21 +103,32 @@ export default function BudgetShow({
<HeadingSmall
title={budget.name}
description={
<div className="flex flex-row items-center gap-1 text-sm">
<div className="inline">
{trackingLabel !== null ? (
<>
<span className="opacity-50">
{__('Tracking')}{' '}
</span>
<span>{trackingLabel}</span>
</>
) : (
<div className="flex flex-row flex-wrap items-center gap-1 text-sm">
{trackingCount > 0 ? (
<>
<span className="opacity-50">
{__('No tracking')}
{__('Tracking')}{' '}
</span>
)}
</div>
{budget.categories?.map(
(category) => (
<CategoryBadge
key={category.id}
category={category}
/>
),
)}
{budget.labels?.map((label) => (
<LabelBadge
key={label.id}
label={label}
/>
))}
</>
) : (
<span className="opacity-50">
{__('No tracking')}
</span>
)}
</div>
}
/>
@ -190,6 +203,7 @@ export default function BudgetShow({
categories={categories}
accounts={accounts}
banks={banks}
labels={labels}
transactions={periodTransactions}
pageSize={10}
showActionsMenu={false}

View File

@ -23,17 +23,29 @@ export interface Budget {
name: string;
period_type: BudgetPeriodType;
period_start_day: number | null;
category_id: UUID | null;
label_id: UUID | null;
rollover_type: RolloverType;
created_at: string;
updated_at: string;
deleted_at: string | null;
category?: Category;
label?: Label;
categories?: Category[];
labels?: Label[];
periods?: BudgetPeriod[];
}
export interface BudgetCategory {
id: UUID;
budget_id: UUID;
category_id: UUID;
updated_at: string;
}
export interface BudgetLabel {
id: UUID;
budget_id: UUID;
label_id: UUID;
updated_at: string;
}
export interface BudgetPeriod {
id: UUID;
budget_id: UUID;

View File

@ -28,9 +28,11 @@ test('user can create a budget with category', function () {
->wait(0.5)
->click('[role="option"]:has-text("Monthly")')
->wait(1)
->click('button:has-text("Select a category")')
->click('button:has-text("Select categories")')
->wait(0.5)
->click('[role="option"]:has-text("'.$category->name.'")')
->wait(0.5)
->click('#categories') // Close the categories combobox popover
->wait(1)
->click('button:has-text("Carry Over")')
->wait(0.5)
@ -47,20 +49,17 @@ test('user can create a budget with category', function () {
->assertSee('Budget Spending')
->assertNoJavascriptErrors();
$this->assertDatabaseHas('budgets', [
'user_id' => $user->id,
'name' => 'Monthly Groceries',
'category_id' => $category->id,
]);
$budget = Budget::where('user_id', $user->id)->where('name', 'Monthly Groceries')->first();
expect($budget)->not->toBeNull()
->and($budget->categories->pluck('id')->contains($category->id))->toBeTrue();
});
test('user can update budget name', function () {
$user = User::factory()->create(['onboarded_at' => now()]);
$category = Category::factory()->create(['user_id' => $user->id]);
$budget = Budget::factory()->create([
$budget = Budget::factory()->forCategories($category)->create([
'user_id' => $user->id,
'category_id' => $category->id,
'name' => 'Old Name',
]);
@ -99,9 +98,8 @@ test('user can delete a budget', function () {
$user = User::factory()->create(['onboarded_at' => now()]);
$category = Category::factory()->create(['user_id' => $user->id]);
$budget = Budget::factory()->create([
$budget = Budget::factory()->forCategories($category)->create([
'user_id' => $user->id,
'category_id' => $category->id,
'name' => 'Budget to Delete',
]);
@ -150,9 +148,8 @@ test('budget shows current period information', function () {
$user = User::factory()->create(['onboarded_at' => now()]);
$category = Category::factory()->create(['user_id' => $user->id]);
$budget = Budget::factory()->create([
$budget = Budget::factory()->forCategories($category)->create([
'user_id' => $user->id,
'category_id' => $category->id,
]);
$page = $this->actingAs($user)->visit("/budgets/{$budget->id}");
@ -166,9 +163,8 @@ test('user can navigate back to budgets list from budget detail', function () {
$user = User::factory()->create(['onboarded_at' => now()]);
$category = Category::factory()->create(['user_id' => $user->id]);
$budget = Budget::factory()->create([
$budget = Budget::factory()->forCategories($category)->create([
'user_id' => $user->id,
'category_id' => $category->id,
]);
$page = $this->actingAs($user)->visit("/budgets/{$budget->id}");

View File

@ -36,9 +36,8 @@ test('user can view budgets list with existing budgets', function () {
$user = User::factory()->create(['onboarded_at' => now()]);
$category = Category::factory()->create(['user_id' => $user->id]);
$budget = Budget::factory()->create([
$budget = Budget::factory()->forCategories($category)->create([
'user_id' => $user->id,
'category_id' => $category->id,
'name' => 'Test Budget',
]);
@ -67,9 +66,8 @@ test('user can view a specific budget', function () {
$user = User::factory()->create(['onboarded_at' => now()]);
$category = Category::factory()->create(['user_id' => $user->id]);
$budget = Budget::factory()->create([
$budget = Budget::factory()->forCategories($category)->create([
'user_id' => $user->id,
'category_id' => $category->id,
'name' => 'My Monthly Budget',
]);
@ -87,9 +85,8 @@ test('user can open edit budget dialog', function () {
$user = User::factory()->create(['onboarded_at' => now()]);
$category = Category::factory()->create(['user_id' => $user->id]);
$budget = Budget::factory()->create([
$budget = Budget::factory()->forCategories($category)->create([
'user_id' => $user->id,
'category_id' => $category->id,
'name' => 'Original Name',
]);
@ -108,9 +105,8 @@ test('user can open delete budget dialog', function () {
$user = User::factory()->create(['onboarded_at' => now()]);
$category = Category::factory()->create(['user_id' => $user->id]);
$budget = Budget::factory()->create([
$budget = Budget::factory()->forCategories($category)->create([
'user_id' => $user->id,
'category_id' => $category->id,
'name' => 'Budget to Delete',
]);

View File

@ -23,7 +23,7 @@ test('budget creation dispatches the historical assignment job', function () {
'name' => 'Test Budget',
'period_type' => 'monthly',
'period_start_day' => 1,
'category_id' => $category->id,
'category_ids' => [$category->id],
'rollover_type' => 'reset',
'allocated_amount' => 100000,
]);
@ -31,6 +31,61 @@ test('budget creation dispatches the historical assignment job', function () {
Queue::assertPushed(AssignHistoricalTransactionsToBudget::class);
});
test('budget creation dispatches the historical assignment job for the current and previous periods', function () {
Queue::fake();
$category = Category::factory()->create(['user_id' => $this->user->id]);
$this->actingAs($this->user)->post('/budgets', [
'name' => 'Test Budget',
'period_type' => 'monthly',
'period_start_day' => 1,
'category_ids' => [$category->id],
'rollover_type' => 'reset',
'allocated_amount' => 100000,
]);
Queue::assertPushed(AssignHistoricalTransactionsToBudget::class, 2);
$budget = $this->user->budgets()->first();
expect($budget->periods()->count())->toBe(2);
});
test('historical transactions in the previous period are assigned for comparison', function () {
$category = Category::factory()->create(['user_id' => $this->user->id]);
$previousPeriodTransaction = Transaction::factory()->create([
'user_id' => $this->user->id,
'category_id' => $category->id,
'transaction_date' => now()->subMonthNoOverflow()->startOfMonth(),
'amount' => -4000,
]);
$response = $this->actingAs($this->user)->post('/budgets', [
'name' => 'Comparison Budget',
'period_type' => 'monthly',
'period_start_day' => 1,
'category_ids' => [$category->id],
'rollover_type' => 'reset',
'allocated_amount' => 100000,
]);
$response->assertRedirect();
$this->artisan('queue:work --stop-when-empty');
$budget = $this->user->budgets()->first();
$previousPeriod = $budget->periods()->orderBy('start_date')->first();
expect($previousPeriod->start_date->toDateString())->toBe(now()->subMonthNoOverflow()->startOfMonth()->toDateString());
assertDatabaseHas('budget_transactions', [
'transaction_id' => $previousPeriodTransaction->id,
'budget_period_id' => $previousPeriod->id,
]);
});
test('historical transactions matching by category are assigned', function () {
$category = Category::factory()->create(['user_id' => $this->user->id]);
@ -54,7 +109,7 @@ test('historical transactions matching by category are assigned', function () {
'name' => 'Category Budget',
'period_type' => 'monthly',
'period_start_day' => 1,
'category_id' => $category->id,
'category_ids' => [$category->id],
'rollover_type' => 'reset',
'allocated_amount' => 100000,
]);
@ -91,7 +146,7 @@ test('historical transactions matching by label are assigned', function () {
'name' => 'Label Budget',
'period_type' => 'monthly',
'period_start_day' => 1,
'label_id' => $label->id,
'label_ids' => [$label->id],
'rollover_type' => 'reset',
'allocated_amount' => 100000,
]);
@ -131,7 +186,7 @@ test('transactions outside the period date range are not assigned', function ()
'name' => 'Date Range Budget',
'period_type' => 'monthly',
'period_start_day' => 1,
'category_id' => $category->id,
'category_ids' => [$category->id],
'rollover_type' => 'reset',
'allocated_amount' => 100000,
]);
@ -179,7 +234,7 @@ test('transactions on boundary dates are assigned', function () {
'name' => 'Boundary Budget',
'period_type' => 'monthly',
'period_start_day' => 1,
'category_id' => $category->id,
'category_ids' => [$category->id],
'rollover_type' => 'reset',
'allocated_amount' => 100000,
]);
@ -217,7 +272,7 @@ test('soft deleted transactions are not assigned', function () {
'name' => 'Soft Delete Budget',
'period_type' => 'monthly',
'period_start_day' => 1,
'category_id' => $category->id,
'category_ids' => [$category->id],
'rollover_type' => 'reset',
'allocated_amount' => 100000,
]);
@ -248,7 +303,7 @@ test('duplicate assignments are prevented', function () {
'name' => 'Duplicate Budget',
'period_type' => 'monthly',
'period_start_day' => 1,
'category_id' => $category->id,
'category_ids' => [$category->id],
'rollover_type' => 'reset',
'allocated_amount' => 100000,
]);
@ -258,7 +313,7 @@ test('duplicate assignments are prevented', function () {
// Get the budget and period
$budget = $this->user->budgets()->first();
$period = $budget->periods()->first();
$period = $budget->getCurrentPeriod();
// Count initial assignments
$initialCount = BudgetTransaction::where('transaction_id', $transaction->id)
@ -303,7 +358,7 @@ test('multiple budgets assign independently', function () {
'name' => 'Budget 1',
'period_type' => 'monthly',
'period_start_day' => 1,
'category_id' => $category1->id,
'category_ids' => [$category1->id],
'rollover_type' => 'reset',
'allocated_amount' => 100000,
]);
@ -313,7 +368,7 @@ test('multiple budgets assign independently', function () {
'name' => 'Budget 2',
'period_type' => 'monthly',
'period_start_day' => 1,
'category_id' => $category2->id,
'category_ids' => [$category2->id],
'rollover_type' => 'reset',
'allocated_amount' => 100000,
]);
@ -325,8 +380,8 @@ test('multiple budgets assign independently', function () {
$budget1 = $this->user->budgets()->where('name', 'Budget 1')->first();
$budget2 = $this->user->budgets()->where('name', 'Budget 2')->first();
$period1 = $budget1->periods()->first();
$period2 = $budget2->periods()->first();
$period1 = $budget1->getCurrentPeriod();
$period2 = $budget2->getCurrentPeriod();
// Transaction 1 should only be in budget 1
assertDatabaseHas('budget_transactions', [

View File

@ -2,6 +2,7 @@
use App\Models\Budget;
use App\Models\Category;
use App\Models\Label;
use App\Models\User;
test('user can create a budget', function () {
@ -13,7 +14,7 @@ test('user can create a budget', function () {
'name' => 'Monthly Budget',
'period_type' => 'monthly',
'period_start_day' => 1,
'category_id' => $category->id,
'category_ids' => [$category->id],
'rollover_type' => 'reset',
'allocated_amount' => 100000,
]);
@ -24,12 +25,12 @@ test('user can create a budget', function () {
'user_id' => $user->id,
'name' => 'Monthly Budget',
'period_type' => 'monthly',
'category_id' => $category->id,
]);
$budget = Budget::where('user_id', $user->id)->first();
$this->assertNotNull($budget);
$this->assertCount(1, $budget->periods);
$this->assertTrue($budget->categories->pluck('id')->contains($category->id));
$this->assertCount(2, $budget->periods);
});
test('user can create a yearly budget', function () {
@ -41,7 +42,7 @@ test('user can create a yearly budget', function () {
'name' => 'Yearly Budget',
'period_type' => 'yearly',
'period_start_day' => 1,
'category_id' => $category->id,
'category_ids' => [$category->id],
'rollover_type' => 'reset',
'allocated_amount' => 1200000,
]);
@ -50,10 +51,12 @@ test('user can create a yearly budget', function () {
$budget = Budget::where('user_id', $user->id)->where('period_type', 'yearly')->first();
$currentPeriod = $budget->getCurrentPeriod();
expect($budget)->not->toBeNull()
->and($budget->periods()->count())->toBe(1)
->and($budget->periods()->first()->start_date->toDateString())->toBe(now()->startOfYear()->toDateString())
->and($budget->periods()->first()->end_date->toDateString())->toBe(now()->endOfYear()->toDateString());
->and($budget->periods()->count())->toBe(2)
->and($currentPeriod->start_date->toDateString())->toBe(now()->startOfYear()->toDateString())
->and($currentPeriod->end_date->toDateString())->toBe(now()->endOfYear()->toDateString());
});
test('user can view their budgets', function () {
@ -74,9 +77,8 @@ test('user can view a specific budget', function () {
$user = User::factory()->create(['onboarded_at' => now()]);
$category = Category::factory()->create(['user_id' => $user->id]);
$budget = Budget::factory()->create([
$budget = Budget::factory()->forCategories($category)->create([
'user_id' => $user->id,
'category_id' => $category->id,
]);
$response = $this->actingAs($user)->get("/budgets/{$budget->id}");
@ -330,16 +332,77 @@ test('budget period is automatically generated', function () {
'name' => 'Test Budget',
'period_type' => 'monthly',
'period_start_day' => 1,
'category_id' => $category->id,
'category_ids' => [$category->id],
'rollover_type' => 'reset',
'allocated_amount' => 50000,
]);
$budget = Budget::where('user_id', $user->id)->first();
$this->assertNotNull($budget);
$this->assertCount(1, $budget->periods);
$this->assertCount(2, $budget->periods);
$period = $budget->periods->first();
$period = $budget->getCurrentPeriod();
$this->assertNotNull($period->start_date);
$this->assertNotNull($period->end_date);
});
test('user can create a budget tracking multiple categories and labels', function () {
$user = User::factory()->create(['onboarded_at' => now()]);
$food = Category::factory()->create(['user_id' => $user->id]);
$restaurants = Category::factory()->create(['user_id' => $user->id]);
$trip = Label::factory()->create(['user_id' => $user->id]);
$response = $this->actingAs($user)->post('/budgets', [
'name' => 'Food Budget',
'period_type' => 'monthly',
'period_start_day' => 1,
'category_ids' => [$food->id, $restaurants->id],
'label_ids' => [$trip->id],
'rollover_type' => 'reset',
'allocated_amount' => 80000,
]);
$response->assertRedirect();
$budget = Budget::where('user_id', $user->id)->first();
expect($budget->categories->pluck('id')->all())
->toEqualCanonicalizing([$food->id, $restaurants->id])
->and($budget->labels->pluck('id')->all())->toEqualCanonicalizing([$trip->id]);
});
test('creating a budget requires at least one category or label', function () {
$user = User::factory()->create(['onboarded_at' => now()]);
$response = $this->actingAs($user)->post('/budgets', [
'name' => 'Empty Budget',
'period_type' => 'monthly',
'period_start_day' => 1,
'category_ids' => [],
'label_ids' => [],
'rollover_type' => 'reset',
'allocated_amount' => 50000,
]);
$response->assertSessionHasErrors('selection');
expect(Budget::where('user_id', $user->id)->count())->toBe(0);
});
test('creating a budget rejects categories owned by another user', function () {
$user = User::factory()->create(['onboarded_at' => now()]);
$otherUser = User::factory()->create();
$foreignCategory = Category::factory()->create(['user_id' => $otherUser->id]);
$response = $this->actingAs($user)->post('/budgets', [
'name' => 'Sneaky Budget',
'period_type' => 'monthly',
'period_start_day' => 1,
'category_ids' => [$foreignCategory->id],
'rollover_type' => 'reset',
'allocated_amount' => 50000,
]);
$response->assertSessionHasErrors('category_ids.0');
expect(Budget::where('user_id', $user->id)->count())->toBe(0);
});

View File

@ -28,9 +28,8 @@ test('assignHistoricalTransactionsToPeriod returns correct count', function () {
]);
}
$budget = Budget::factory()->create([
$budget = Budget::factory()->forCategories($category)->create([
'user_id' => $this->user->id,
'category_id' => $category->id,
]);
$period = BudgetPeriod::factory()->create([
@ -47,9 +46,8 @@ test('assignHistoricalTransactionsToPeriod returns correct count', function () {
test('assignHistoricalTransactionsToPeriod handles empty results', function () {
$category = Category::factory()->create(['user_id' => $this->user->id]);
$budget = Budget::factory()->create([
$budget = Budget::factory()->forCategories($category)->create([
'user_id' => $this->user->id,
'category_id' => $category->id,
]);
$period = BudgetPeriod::factory()->create([
@ -83,9 +81,8 @@ test('assignHistoricalTransactionsToPeriod processes large batches', function ()
// Insert in batches
Transaction::insert($transactions->toArray());
$budget = Budget::factory()->create([
$budget = Budget::factory()->forCategories($category)->create([
'user_id' => $this->user->id,
'category_id' => $category->id,
]);
$period = BudgetPeriod::factory()->create([
@ -125,9 +122,8 @@ test('assignHistoricalTransactionsToPeriod excludes transactions outside date ra
'amount' => -1000,
]);
$budget = Budget::factory()->create([
$budget = Budget::factory()->forCategories($category)->create([
'user_id' => $this->user->id,
'category_id' => $category->id,
]);
$period = BudgetPeriod::factory()->create([
@ -161,9 +157,8 @@ test('assignHistoricalTransactionsToPeriod works with category-based budgets', f
'amount' => -1000,
]);
$budget = Budget::factory()->create([
$budget = Budget::factory()->forCategories($category)->create([
'user_id' => $this->user->id,
'category_id' => $category->id,
]);
$period = BudgetPeriod::factory()->create([
@ -197,9 +192,8 @@ test('assignHistoricalTransactionsToPeriod works with label-based budgets', func
]);
$transaction2->labels()->attach($otherLabel->id);
$budget = Budget::factory()->create([
$budget = Budget::factory()->forLabels($label)->create([
'user_id' => $this->user->id,
'label_id' => $label->id,
]);
$period = BudgetPeriod::factory()->create([
@ -226,9 +220,8 @@ test('assignHistoricalTransactionsToPeriod works with transactions having multip
]);
$transaction->labels()->attach([$targetLabel->id, $otherLabel1->id, $otherLabel2->id]);
$budget = Budget::factory()->create([
$budget = Budget::factory()->forLabels($targetLabel)->create([
'user_id' => $this->user->id,
'label_id' => $targetLabel->id,
]);
$period = BudgetPeriod::factory()->create([
@ -252,9 +245,8 @@ test('assignHistoricalTransactionsToPeriod stores negated transaction amount for
'amount' => -5000, // Expense (negative)
]);
$budget = Budget::factory()->create([
$budget = Budget::factory()->forCategories($category)->create([
'user_id' => $this->user->id,
'category_id' => $category->id,
]);
$period = BudgetPeriod::factory()->create([
@ -280,9 +272,8 @@ test('assignHistoricalTransactionsToPeriod stores refund as negative amount', fu
'amount' => 1000, // Refund (positive)
]);
$budget = Budget::factory()->create([
$budget = Budget::factory()->forCategories($category)->create([
'user_id' => $this->user->id,
'category_id' => $category->id,
]);
$period = BudgetPeriod::factory()->create([
@ -317,9 +308,8 @@ test('budget spending correctly reflects mix of expenses and refunds', function
'amount' => 1000,
]);
$budget = Budget::factory()->create([
$budget = Budget::factory()->forCategories($category)->create([
'user_id' => $this->user->id,
'category_id' => $category->id,
]);
$period = BudgetPeriod::factory()->create([
@ -339,9 +329,8 @@ test('budget spending correctly reflects mix of expenses and refunds', function
test('assignTransaction stores refund as negative budget transaction amount', function () {
$category = Category::factory()->create(['user_id' => $this->user->id]);
$budget = Budget::factory()->create([
$budget = Budget::factory()->forCategories($category)->create([
'user_id' => $this->user->id,
'category_id' => $category->id,
]);
$period = BudgetPeriod::factory()->create([
@ -386,9 +375,8 @@ test('assignHistoricalTransactionsToPeriod only assigns to correct user', functi
'amount' => -1000,
]);
$budget = Budget::factory()->create([
$budget = Budget::factory()->forCategories($category)->create([
'user_id' => $user1->id,
'category_id' => $category->id,
]);
$period = BudgetPeriod::factory()->create([
@ -406,9 +394,8 @@ test('assignHistoricalTransactionsToPeriod only assigns to correct user', functi
test('assignTransaction is idempotent when called twice (regression for PHP-LARAVEL-A)', function () {
$category = Category::factory()->create(['user_id' => $this->user->id]);
$budget = Budget::factory()->create([
$budget = Budget::factory()->forCategories($category)->create([
'user_id' => $this->user->id,
'category_id' => $category->id,
]);
$period = BudgetPeriod::factory()->create([
@ -434,9 +421,8 @@ test('assignTransaction is idempotent when called twice (regression for PHP-LARA
test('assignTransaction retries deadlocks during reconciliation (regression for PHP-LARAVEL-D)', function () {
$category = Category::factory()->create(['user_id' => $this->user->id]);
$budget = Budget::factory()->create([
$budget = Budget::factory()->forCategories($category)->create([
'user_id' => $this->user->id,
'category_id' => $category->id,
]);
$period = BudgetPeriod::factory()->create([
@ -469,9 +455,8 @@ test('assignTransaction removes stale rows when category changes', function () {
$oldCategory = Category::factory()->create(['user_id' => $this->user->id]);
$newCategory = Category::factory()->create(['user_id' => $this->user->id]);
$oldBudget = Budget::factory()->create([
$oldBudget = Budget::factory()->forCategories($oldCategory)->create([
'user_id' => $this->user->id,
'category_id' => $oldCategory->id,
]);
$oldPeriod = BudgetPeriod::factory()->create([
'budget_id' => $oldBudget->id,
@ -479,9 +464,8 @@ test('assignTransaction removes stale rows when category changes', function () {
'end_date' => now()->addDays(30),
]);
$newBudget = Budget::factory()->create([
$newBudget = Budget::factory()->forCategories($newCategory)->create([
'user_id' => $this->user->id,
'category_id' => $newCategory->id,
]);
$newPeriod = BudgetPeriod::factory()->create([
'budget_id' => $newBudget->id,
@ -510,9 +494,8 @@ test('assignTransaction removes stale rows when category changes', function () {
test('assignTransaction updates amount on existing row when transaction amount changes', function () {
$category = Category::factory()->create(['user_id' => $this->user->id]);
$budget = Budget::factory()->create([
$budget = Budget::factory()->forCategories($category)->create([
'user_id' => $this->user->id,
'category_id' => $category->id,
]);
$period = BudgetPeriod::factory()->create([
'budget_id' => $budget->id,
@ -557,9 +540,8 @@ test('assignTransaction leaves table untouched when no budgets match', function
test('assignTransaction survives pre-existing duplicate row (regression for PHP-LARAVEL-A/B race)', function () {
$category = Category::factory()->create(['user_id' => $this->user->id]);
$budget = Budget::factory()->create([
$budget = Budget::factory()->forCategories($category)->create([
'user_id' => $this->user->id,
'category_id' => $category->id,
]);
$period = BudgetPeriod::factory()->create([
@ -603,9 +585,8 @@ test('assignHistoricalTransactionsToPeriod reruns converge existing rows without
'amount' => -2500,
]);
$budget = Budget::factory()->create([
$budget = Budget::factory()->forCategories($category)->create([
'user_id' => $this->user->id,
'category_id' => $category->id,
]);
$period = BudgetPeriod::factory()->create([
@ -628,3 +609,97 @@ test('assignHistoricalTransactionsToPeriod reruns converge existing rows without
expect(BudgetTransaction::where('transaction_id', $transaction->id)->count())->toBe(1)
->and((int) BudgetTransaction::where('transaction_id', $transaction->id)->value('amount'))->toBe(2500);
});
test('assignHistoricalTransactionsToPeriod matches transactions across multiple categories', function () {
$food = Category::factory()->create(['user_id' => $this->user->id]);
$restaurants = Category::factory()->create(['user_id' => $this->user->id]);
$unrelated = Category::factory()->create(['user_id' => $this->user->id]);
foreach ([$food, $restaurants, $unrelated] as $category) {
Transaction::factory()->create([
'user_id' => $this->user->id,
'category_id' => $category->id,
'transaction_date' => now()->subDays(5),
'amount' => -1000,
]);
}
$budget = Budget::factory()->forCategories([$food, $restaurants])->create([
'user_id' => $this->user->id,
]);
$period = BudgetPeriod::factory()->create([
'budget_id' => $budget->id,
'start_date' => now()->subDays(30),
'end_date' => now()->addDays(30),
]);
$count = $this->service->assignHistoricalTransactionsToPeriod($period);
expect($count)->toBe(2);
});
test('assignHistoricalTransactionsToPeriod pools matches from categories and labels', function () {
$category = Category::factory()->create(['user_id' => $this->user->id]);
$label = Label::factory()->create(['user_id' => $this->user->id]);
// Matches via category.
Transaction::factory()->create([
'user_id' => $this->user->id,
'category_id' => $category->id,
'transaction_date' => now()->subDays(5),
'amount' => -1000,
]);
// Matches via label (different category).
$labelled = Transaction::factory()->create([
'user_id' => $this->user->id,
'category_id' => Category::factory()->create(['user_id' => $this->user->id])->id,
'transaction_date' => now()->subDays(5),
'amount' => -1000,
]);
$labelled->labels()->attach($label->id);
$budget = Budget::factory()
->forCategories($category)
->forLabels($label)
->create(['user_id' => $this->user->id]);
$period = BudgetPeriod::factory()->create([
'budget_id' => $budget->id,
'start_date' => now()->subDays(30),
'end_date' => now()->addDays(30),
]);
$count = $this->service->assignHistoricalTransactionsToPeriod($period);
expect($count)->toBe(2);
});
test('assignTransaction matches a budget tracking multiple categories', function () {
$food = Category::factory()->create(['user_id' => $this->user->id]);
$restaurants = Category::factory()->create(['user_id' => $this->user->id]);
$budget = Budget::factory()->forCategories([$food, $restaurants])->create([
'user_id' => $this->user->id,
]);
$period = BudgetPeriod::factory()->create([
'budget_id' => $budget->id,
'start_date' => now()->subDays(30),
'end_date' => now()->addDays(30),
]);
$transaction = Transaction::factory()->create([
'user_id' => $this->user->id,
'category_id' => $restaurants->id,
'transaction_date' => now()->subDays(5),
'amount' => -1500,
]);
$this->service->assignTransaction($transaction);
expect(BudgetTransaction::where('transaction_id', $transaction->id)
->where('budget_period_id', $period->id)
->exists())->toBeTrue();
});

View File

@ -34,9 +34,8 @@ function runQueuedAssignTransactionListener(): CallQueuedListener
test('queued listener re-runs assignment when TransactionCreated fires', function () {
$category = Category::factory()->create(['user_id' => $this->user->id]);
$budget = Budget::factory()->create([
$budget = Budget::factory()->forCategories($category)->create([
'user_id' => $this->user->id,
'category_id' => $category->id,
]);
BudgetPeriod::factory()->create([
'budget_id' => $budget->id,
@ -65,9 +64,8 @@ test('queued listener re-runs assignment when TransactionCreated fires', functio
test('queued listener runs when TransactionUpdated changes category', function () {
$oldCategory = Category::factory()->create(['user_id' => $this->user->id]);
$newCategory = Category::factory()->create(['user_id' => $this->user->id]);
$budget = Budget::factory()->create([
$budget = Budget::factory()->forCategories($newCategory)->create([
'user_id' => $this->user->id,
'category_id' => $newCategory->id,
]);
BudgetPeriod::factory()->create([
'budget_id' => $budget->id,
@ -97,9 +95,8 @@ test('queued listener runs when TransactionUpdated changes category', function (
test('queued listener runs when TransactionUpdated changes labels', function () {
$label = Label::factory()->create(['user_id' => $this->user->id]);
$budget = Budget::factory()->create([
$budget = Budget::factory()->forLabels($label)->create([
'user_id' => $this->user->id,
'label_id' => $label->id,
]);
$period = BudgetPeriod::factory()->create([
'budget_id' => $budget->id,

View File

@ -624,11 +624,9 @@ test('when budget with label exists, updating transaction with that label assign
$label = Label::factory()->create(['user_id' => $user->id, 'name' => 'Work']);
// Create a budget filtered by this label
$budget = Budget::factory()->create([
$budget = Budget::factory()->forLabels($label)->create([
'user_id' => $user->id,
'name' => 'Work Expenses',
'label_id' => $label->id,
'category_id' => null,
]);
// Create the current budget period