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:
parent
5ce439f463
commit
71dd6e2b7f
|
|
@ -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',
|
||||
]));
|
||||
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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> */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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]);
|
||||
});
|
||||
}
|
||||
};
|
||||
15
lang/es.json
15
lang/es.json
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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', [
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue