From 0b46fff3c21c7176dccec45bbd1b5d941ae58cb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vi=CC=81ctor=20Falco=CC=81n?= Date: Sun, 19 Jul 2026 14:28:10 +0200 Subject: [PATCH 1/4] feat(transactions): split a transaction across multiple categories Add per-line splitting: a transaction can be divided into lines, each with its own category, amount and labels, summing to the transaction total. The transaction's own category is nulled and the lines become the source of truth. - transaction_splits + label_split tables, TransactionSplit model - split/unsplit via the existing PATCH update (validated: sum, sign, min 2) - shared effective-allocation layer (SQL + collection) so every category breakdown (dashboard top categories, cashflow sankey/trend/breakdown, dashboard analytics, MCP spending) attributes split lines correctly - budgets attribute per line (category + label match), aggregated per period - list shows the per-line breakdown, collapsing non-matching lines into a 'Rest' row under a category/label filter; bulk category assignment skips split transactions - gated behind the TransactionSplitting Pennant feature --- app/Features/TransactionSplitting.php | 25 ++ .../Api/CashflowAnalyticsController.php | 17 +- .../Api/DashboardAnalyticsController.php | 51 ++- .../Controllers/TransactionController.php | 81 +++-- app/Http/Middleware/HandleInertiaRequests.php | 4 + .../Requests/UpdateTransactionRequest.php | 68 ++++ app/Models/LabelSplit.php | 17 + app/Models/Transaction.php | 35 +- app/Models/TransactionSplit.php | 59 ++++ app/Services/BudgetTransactionService.php | 295 +++++++++------- app/Services/CategorySpendingService.php | 6 +- app/Services/TransactionAllocations.php | 83 +++++ app/Services/TransactionSplitter.php | 56 +++ .../factories/TransactionSplitFactory.php | 26 ++ ...114525_create_transaction_splits_table.php | 31 ++ ..._07_19_114526_create_label_split_table.php | 29 ++ lang/en.json | 13 +- lang/es.json | 13 +- .../components/transactions/category-cell.tsx | 187 +++++++++- .../edit-transaction-dialog.test.tsx | 7 + .../transactions/edit-transaction-dialog.tsx | 199 +++++++++-- .../transactions/split-editor.test.tsx | 40 +++ .../components/transactions/split-editor.tsx | 182 ++++++++++ .../transactions/transaction-columns.tsx | 7 + resources/js/pages/transactions/index.tsx | 88 +++-- resources/js/services/transaction-sync.ts | 7 +- resources/js/types/index.d.ts | 1 + resources/js/types/transaction.ts | 19 ++ tests/Feature/TransactionSplitTest.php | 323 ++++++++++++++++++ 29 files changed, 1718 insertions(+), 251 deletions(-) create mode 100644 app/Features/TransactionSplitting.php create mode 100644 app/Models/LabelSplit.php create mode 100644 app/Models/TransactionSplit.php create mode 100644 app/Services/TransactionAllocations.php create mode 100644 app/Services/TransactionSplitter.php create mode 100644 database/factories/TransactionSplitFactory.php create mode 100644 database/migrations/2026_07_19_114525_create_transaction_splits_table.php create mode 100644 database/migrations/2026_07_19_114526_create_label_split_table.php create mode 100644 resources/js/components/transactions/split-editor.test.tsx create mode 100644 resources/js/components/transactions/split-editor.tsx create mode 100644 tests/Feature/TransactionSplitTest.php diff --git a/app/Features/TransactionSplitting.php b/app/Features/TransactionSplitting.php new file mode 100644 index 00000000..590e7ac4 --- /dev/null +++ b/app/Features/TransactionSplitting.php @@ -0,0 +1,25 @@ +`. + * + * @api + */ +class TransactionSplitting +{ + /** + * Resolve the feature's initial value. + */ + public function resolve(?User $user): bool + { + return false; + } +} diff --git a/app/Http/Controllers/Api/CashflowAnalyticsController.php b/app/Http/Controllers/Api/CashflowAnalyticsController.php index d6543ab0..ddef3928 100644 --- a/app/Http/Controllers/Api/CashflowAnalyticsController.php +++ b/app/Http/Controllers/Api/CashflowAnalyticsController.php @@ -12,6 +12,7 @@ use App\Services\CashflowSummaryService; use App\Services\CategoryTree; use App\Services\ExchangeRateService; use App\Services\PeriodComparator; +use App\Services\TransactionAllocations; use Carbon\Carbon; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -186,9 +187,11 @@ class CashflowAnalyticsController extends Controller $transactions = Transaction::query() ->where('transactions.user_id', $userId) ->whereBetween('transactions.transaction_date', [$previousPeriod->from, $period->to]) - ->with(['account', 'category']) + ->with(['account', 'category', 'splits.category']) ->get(); + $transactions = TransactionAllocations::expand($transactions); + $this->preloadExchangeRates($transactions, $userCurrency); return [ @@ -245,9 +248,11 @@ class CashflowAnalyticsController extends Controller $transactions = Transaction::query() ->where('transactions.user_id', $userId) ->whereBetween('transactions.transaction_date', [$from, $to]) - ->with(['account', 'category']) + ->with(['account', 'category', 'splits.category']) ->get(); + $transactions = TransactionAllocations::expand($transactions); + $this->preloadExchangeRates($transactions, $userCurrency); $regularCategories = $transactions @@ -340,9 +345,11 @@ class CashflowAnalyticsController extends Controller $transactions = Transaction::query() ->where('transactions.user_id', $userId) ->whereBetween('transactions.transaction_date', [$from, $to]) - ->with(['account', 'category']) + ->with(['account', 'category', 'splits.category']) ->get(); + $transactions = TransactionAllocations::expand($transactions); + $this->preloadExchangeRates($transactions, $userCurrency); return $transactions @@ -398,9 +405,11 @@ class CashflowAnalyticsController extends Controller $transactions = Transaction::query() ->where('transactions.user_id', $userId) ->whereBetween('transactions.transaction_date', [$from, $to]) - ->with(['account', 'category']) + ->with(['account', 'category', 'splits.category']) ->get(); + $transactions = TransactionAllocations::expand($transactions); + $this->preloadExchangeRates($transactions, $userCurrency); $categorized = $transactions diff --git a/app/Http/Controllers/Api/DashboardAnalyticsController.php b/app/Http/Controllers/Api/DashboardAnalyticsController.php index b80a9772..24d65c25 100644 --- a/app/Http/Controllers/Api/DashboardAnalyticsController.php +++ b/app/Http/Controllers/Api/DashboardAnalyticsController.php @@ -6,16 +6,18 @@ use App\Enums\AccountType; use App\Enums\CategoryType; use App\Http\Controllers\Controller; use App\Models\Account; -use App\Models\Transaction; use App\Services\AccountMetricsService; use App\Services\BalanceLookup; use App\Services\CategorySpendingService; use App\Services\ExchangeRateService; use App\Services\LoanAmortizationService; use App\Services\PeriodComparator; +use App\Services\TransactionAllocations; use Carbon\Carbon; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Query\Builder; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; class DashboardAnalyticsController extends Controller { @@ -505,40 +507,15 @@ class DashboardAnalyticsController extends Controller private function calculateSpending(Carbon $from, Carbon $to): int { - $spending = Transaction::query() - ->where('transactions.user_id', request()->user()->id) - ->whereBetween('transactions.transaction_date', [$from, $to]) - ->join('categories', function ($join) { - $join->on('transactions.category_id', '=', 'categories.id') - ->where('categories.type', '=', CategoryType::Expense) - ->whereNull('categories.deleted_at'); - }) - ->sum('transactions.amount'); + $spending = $this->allocationsByType($from, $to, CategoryType::Expense)->sum('transactions.amount'); return max(0, -$spending); } private function calculateCashFlow(Carbon $from, Carbon $to): array { - $income = Transaction::query() - ->where('transactions.user_id', request()->user()->id) - ->whereBetween('transactions.transaction_date', [$from, $to]) - ->join('categories', function ($join) { - $join->on('transactions.category_id', '=', 'categories.id') - ->where('categories.type', '=', CategoryType::Income) - ->whereNull('categories.deleted_at'); - }) - ->sum('transactions.amount'); - - $expense = Transaction::query() - ->where('transactions.user_id', request()->user()->id) - ->whereBetween('transactions.transaction_date', [$from, $to]) - ->join('categories', function ($join) { - $join->on('transactions.category_id', '=', 'categories.id') - ->where('categories.type', '=', CategoryType::Expense) - ->whereNull('categories.deleted_at'); - }) - ->sum('transactions.amount'); + $income = $this->allocationsByType($from, $to, CategoryType::Income)->sum('transactions.amount'); + $expense = $this->allocationsByType($from, $to, CategoryType::Expense)->sum('transactions.amount'); return [ 'income' => max(0, $income), @@ -546,6 +523,22 @@ class DashboardAnalyticsController extends Controller ]; } + /** + * Effective category allocations (split-aware) for the period, joined to + * categories of the given type. A split transaction contributes one row per + * line so each line lands in its own category's type bucket. + */ + private function allocationsByType(Carbon $from, Carbon $to, CategoryType $type): Builder + { + return DB::query() + ->fromSub(TransactionAllocations::query(request()->user()->id, $from, $to), 'transactions') + ->join('categories', function ($join) use ($type) { + $join->on('transactions.category_id', '=', 'categories.id') + ->where('categories.type', '=', $type) + ->whereNull('categories.deleted_at'); + }); + } + /** * Get the linked loan account for a real estate account, if any. */ diff --git a/app/Http/Controllers/TransactionController.php b/app/Http/Controllers/TransactionController.php index da086fc8..fe97b311 100644 --- a/app/Http/Controllers/TransactionController.php +++ b/app/Http/Controllers/TransactionController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Enums\CategorySource; +use App\Features\TransactionSplitting; use App\Http\Requests\BulkUpdateTransactionsRequest; use App\Http\Requests\IndexTransactionRequest; use App\Http\Requests\StoreTransactionRequest; @@ -15,11 +16,13 @@ use App\Models\Label; use App\Models\Transaction; use App\Services\Ai\CategoryOverrideHandler; use App\Services\ManualBalanceAdjuster; +use App\Services\TransactionSplitter; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Inertia\Inertia; use Inertia\Response; +use Laravel\Pennant\Feature; class TransactionController extends Controller { @@ -55,7 +58,7 @@ class TransactionController extends Controller $query = Transaction::query() ->where('user_id', $user->id) - ->with(['account.bank', 'category', 'labels', 'categorizedByRule:id,origin']) + ->with(['account.bank', 'category', 'labels', 'categorizedByRule:id,origin', 'splits.category', 'splits.labels']) ->applyFilters($filters); $nullableSortColumns = ['creditor_name', 'debtor_name']; @@ -213,7 +216,7 @@ class TransactionController extends Controller ], 201); } - public function update(UpdateTransactionRequest $request, Transaction $transaction, ManualBalanceAdjuster $balanceAdjuster): JsonResponse + public function update(UpdateTransactionRequest $request, Transaction $transaction, ManualBalanceAdjuster $balanceAdjuster, TransactionSplitter $splitter): JsonResponse { $this->authorize('update', $transaction); @@ -222,11 +225,25 @@ class TransactionController extends Controller $hasLabelUpdate = $request->has('label_ids'); unset($data['label_ids']); + $splitLines = $request->has('splits') ? ($data['splits'] ?? []) : null; + unset($data['splits']); + $applyingSplit = is_array($splitLines) && $splitLines !== []; + // An explicit empty array collapses an existing split back to a single + // category; the normal category_id in this request restores it. + $unsplitting = $splitLines === [] && $transaction->isSplit(); + + // The flag only gates creating/changing splits; unsplitting and reads of + // existing splits always work so the feature can be rolled back safely. + if ($applyingSplit) { + abort_unless(Feature::for($request->user())->active(TransactionSplitting::class), 403); + } + $learnedRule = null; // A user-set category overrides any AI assignment: learn the correction as // a forward-looking rule, log/self-heal as needed, and reset provenance. - if ($request->has('category_id')) { + // Skipped while splitting — the lines own the categorisation, not the row. + if ($request->has('category_id') && ! $applyingSplit) { $newCategoryId = $data['category_id'] ?? null; if ($newCategoryId !== $transaction->category_id) { @@ -247,21 +264,32 @@ class TransactionController extends Controller $transaction->fill($data); } - // Sync labels if provided - if ($hasLabelUpdate) { - $transaction->labels()->sync($labelIds ?? []); - // Reload labels so the event listener has fresh data - $transaction->load('labels'); - } - - // Save to fire the updated event if there are any changes - // We need to save even if just labels changed (isDirty won't detect pivot changes) - if ($transaction->isDirty() || $hasLabelUpdate) { - // Touch the model to ensure it's marked as changed for the event - if (! $transaction->isDirty() && $hasLabelUpdate) { - $transaction->touch(); + if ($applyingSplit) { + // apply() persists the lines and saves the transaction (nulling its + // own category/labels) in a single write, so the queued budget + // listener sees the final split state. + $splitter->apply($transaction, $splitLines); + } else { + if ($unsplitting) { + $splitter->remove($transaction); + } + + // Sync labels if provided + if ($hasLabelUpdate) { + $transaction->labels()->sync($labelIds ?? []); + // Reload labels so the event listener has fresh data + $transaction->load('labels'); + } + + // Save to fire the updated event if there are any changes + // We need to save even if just labels changed (isDirty won't detect pivot changes) + if ($transaction->isDirty() || $hasLabelUpdate || $unsplitting) { + // Touch the model to ensure it's marked as changed for the event + if (! $transaction->isDirty() && ($hasLabelUpdate || $unsplitting)) { + $transaction->touch(); + } + $transaction->save(); } - $transaction->save(); } // Move the manual account balance to match an edited amount/date/account: @@ -277,7 +305,7 @@ class TransactionController extends Controller } return response()->json([ - 'data' => $transaction->fresh()->load('labels'), + 'data' => $transaction->fresh()->load(['labels', 'splits.category', 'splits.labels']), 'learned_rule' => $learnedRule === null ? null : [ 'id' => $learnedRule->id, 'title' => $learnedRule->title, @@ -325,13 +353,24 @@ class TransactionController extends Controller $transactions = $query->get(); } + // Split transactions carry no single category, so a bulk category + // assignment skips them (it would silently destroy their lines) and we + // report how many were left untouched. + $omittedSplitIds = collect(); $updateData = []; if ($request->has('category_id')) { $newCategoryId = $request->input('category_id'); + $transactions->loadMissing('splits:id,transaction_id'); + $omittedSplitIds = $transactions->filter->isSplit()->pluck('id'); + $overrideHandler = app(CategoryOverrideHandler::class); foreach ($transactions as $transaction) { + if ($transaction->isSplit()) { + continue; + } + $overrideHandler->record($transaction, $newCategoryId); } @@ -363,6 +402,9 @@ class TransactionController extends Controller } elseif ($filters !== null) { $updateQuery->applyFilters($filters); } + if ($omittedSplitIds->isNotEmpty()) { + $updateQuery->whereNotIn('id', $omittedSplitIds->all()); + } $updateQuery->update($updateData); } @@ -375,7 +417,8 @@ class TransactionController extends Controller return response()->json([ 'message' => 'Transactions updated successfully', - 'count' => $transactions->count(), + 'count' => $transactions->count() - $omittedSplitIds->count(), + 'omitted_splits' => $omittedSplitIds->count(), ]); } } diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index d39085cd..c62bfe49 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -6,6 +6,7 @@ use App\Enums\BankingConnectionStatus; use App\Enums\BankingProvider; use App\Features\CalculateBalancesOnImport; use App\Features\Mcp; +use App\Features\TransactionSplitting; use App\Jobs\PurgeResidualEncryptionArtifactsJob; use App\Models\BankingConnection; use App\Services\CurrencyOptions; @@ -181,18 +182,21 @@ class HandleInertiaRequests extends Middleware 'cashflow' => true, 'calculateBalancesOnImport' => false, 'mcp' => false, + 'transactionSplitting' => false, ]; } $features = Feature::for($user)->values([ CalculateBalancesOnImport::class, Mcp::class, + TransactionSplitting::class, ]); return [ 'cashflow' => true, 'calculateBalancesOnImport' => $features[CalculateBalancesOnImport::class] !== false, 'mcp' => $features[Mcp::class] !== false, + 'transactionSplitting' => $features[TransactionSplitting::class] !== false, ]; } diff --git a/app/Http/Requests/UpdateTransactionRequest.php b/app/Http/Requests/UpdateTransactionRequest.php index d77dde52..e339c9f0 100644 --- a/app/Http/Requests/UpdateTransactionRequest.php +++ b/app/Http/Requests/UpdateTransactionRequest.php @@ -5,6 +5,7 @@ namespace App\Http\Requests; use App\Enums\TransactionSource; use App\Http\Requests\Concerns\ValidatesUserOwnedResources; use App\Models\Transaction; +use Illuminate\Contracts\Validation\Validator; use Illuminate\Foundation\Http\FormRequest; class UpdateTransactionRequest extends FormRequest @@ -28,6 +29,14 @@ class UpdateTransactionRequest extends FormRequest 'debtor_name' => ['nullable', 'string', 'max:255'], 'label_ids' => ['nullable', 'array'], 'label_ids.*' => ['required', 'string', 'uuid', $this->userOwned('labels')], + // Split lines. An empty array collapses a split back to a single + // category; a non-empty one (min 2, checked in withValidator) makes + // the lines the source of truth. Absent leaves the split untouched. + 'splits' => ['sometimes', 'array'], + 'splits.*.category_id' => ['nullable', $this->userOwned('categories')], + 'splits.*.amount' => ['required', 'integer'], + 'splits.*.label_ids' => ['nullable', 'array'], + 'splits.*.label_ids.*' => ['required', 'string', 'uuid', $this->userOwned('labels')], ]; // Manually created transactions can edit every field after creation. @@ -45,6 +54,64 @@ class UpdateTransactionRequest extends FormRequest return $rules; } + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator): void { + if (! $this->has('splits')) { + return; + } + + $lines = $this->input('splits'); + + // An empty array is a valid "unsplit" instruction. + if (! is_array($lines) || $lines === []) { + return; + } + + if (count($lines) < 2) { + $validator->errors()->add('splits', __('A split needs at least two lines.')); + + return; + } + + $total = $this->effectiveAmount(); + $sum = 0; + + foreach ($lines as $index => $line) { + $amount = (int) ($line['amount'] ?? 0); + $sum += $amount; + + if ($amount === 0) { + $validator->errors()->add("splits.{$index}.amount", __('Each split line must be a non-zero amount.')); + } elseif ($total !== 0 && ($amount > 0) !== ($total > 0)) { + $validator->errors()->add("splits.{$index}.amount", __('Split lines must have the same sign as the transaction.')); + } + } + + if ($sum !== $total) { + $validator->errors()->add('splits', __('The split lines must add up to the transaction amount.')); + } + }); + } + + /** + * The amount the split lines must reconcile against: the newly submitted + * amount when a manual transaction edits it in the same request, otherwise + * the transaction's stored amount (imported amounts are locked). + */ + private function effectiveAmount(): int + { + $transaction = $this->route('transaction'); + + if ($transaction instanceof Transaction + && $transaction->source === TransactionSource::ManuallyCreated + && $this->filled('amount')) { + return (int) $this->input('amount'); + } + + return $transaction instanceof Transaction ? $transaction->amount : 0; + } + public function messages(): array { return [ @@ -52,6 +119,7 @@ class UpdateTransactionRequest extends FormRequest 'description_iv.size' => 'The description IV must be exactly 16 characters.', 'notes_iv.size' => 'The notes IV must be exactly 16 characters.', 'label_ids.*.exists' => 'One or more selected labels do not exist or do not belong to you.', + 'splits.*.label_ids.*.exists' => 'One or more selected labels do not exist or do not belong to you.', ]; } } diff --git a/app/Models/LabelSplit.php b/app/Models/LabelSplit.php new file mode 100644 index 00000000..b3cb3992 --- /dev/null +++ b/app/Models/LabelSplit.php @@ -0,0 +1,17 @@ +hasMany(BudgetTransaction::class); } + /** @return HasMany */ + public function splits(): HasMany + { + return $this->hasMany(TransactionSplit::class); + } + + /** + * Whether this transaction is split into per-category lines. Prefers the + * loaded relation (list/detail eager-load it) and only hits the DB when the + * relation was not loaded. + */ + public function isSplit(): bool + { + if ($this->relationLoaded('splits')) { + return $this->splits->isNotEmpty(); + } + + return $this->splits()->exists(); + } + /** * Transactions the AI backfill can act on: still uncategorized and stored * in plaintext (encrypted descriptions are never sent to the AI provider). @@ -294,18 +314,27 @@ class Transaction extends Model $query->where(function (Builder $outer) use ($hasCategoryFilter, $realIds, $hasUncategorized, $hasLabelFilter, $labelIds) { if ($hasCategoryFilter) { + // A split transaction carries no category itself; it matches + // through its lines. "Uncategorized" means an unsplit row with + // no category, or a split with an uncategorized line — never a + // split whose own (nulled) category is a bookkeeping artefact. $outer->where(function (Builder $q) use ($realIds, $hasUncategorized) { if (! empty($realIds)) { - $q->whereIn('category_id', $realIds); + $q->whereIn('category_id', $realIds) + ->orWhereHas('splits', fn (Builder $s) => $s->whereIn('category_id', $realIds)); } if ($hasUncategorized) { - $q->orWhereNull('category_id'); + $q->orWhere(fn (Builder $u) => $u->whereNull('category_id')->whereDoesntHave('splits')) + ->orWhereHas('splits', fn (Builder $s) => $s->whereNull('category_id')); } }); } if ($hasLabelFilter) { - $outer->orWhereHas('labels', fn (Builder $q) => $q->whereIn('labels.id', $labelIds)); + $outer->orWhere(function (Builder $q) use ($labelIds) { + $q->whereHas('labels', fn (Builder $l) => $l->whereIn('labels.id', $labelIds)) + ->orWhereHas('splits.labels', fn (Builder $l) => $l->whereIn('labels.id', $labelIds)); + }); } }); } diff --git a/app/Models/TransactionSplit.php b/app/Models/TransactionSplit.php new file mode 100644 index 00000000..dd1e9166 --- /dev/null +++ b/app/Models/TransactionSplit.php @@ -0,0 +1,59 @@ + */ + use HasFactory, HasUuids; + + protected $fillable = [ + 'transaction_id', + 'category_id', + 'amount', + ]; + + /** + * Hide the pivot so a Label loaded through this line's labels() looks + * identical to one loaded elsewhere. + * + * @var list + */ + protected $hidden = [ + 'pivot', + ]; + + protected function casts(): array + { + return [ + 'amount' => 'integer', + ]; + } + + /** @return BelongsTo */ + public function transaction(): BelongsTo + { + return $this->belongsTo(Transaction::class); + } + + /** @return BelongsTo */ + public function category(): BelongsTo + { + return $this->belongsTo(Category::class); + } + + /** @return BelongsToMany */ + public function labels(): BelongsToMany + { + return $this->belongsToMany(Label::class, 'label_split') + ->using(LabelSplit::class) + ->withTimestamps(); + } +} diff --git a/app/Services/BudgetTransactionService.php b/app/Services/BudgetTransactionService.php index adf39b80..34d18e0c 100644 --- a/app/Services/BudgetTransactionService.php +++ b/app/Services/BudgetTransactionService.php @@ -7,13 +7,20 @@ use App\Models\Budget; use App\Models\BudgetPeriod; use App\Models\BudgetTransaction; use App\Models\Transaction; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Log; class BudgetTransactionService { public function __construct(private readonly CategoryTree $tree = new CategoryTree) {} + /** + * (Re)assign a transaction to the budget periods it belongs to. A split + * transaction is attributed per line — each line matches budgets by its own + * category or labels — and the matching lines' amounts are aggregated into a + * single BudgetTransaction per period (the unique index allows only one row + * per transaction+period). + */ public function assignTransaction(Transaction $transaction): void { $userId = $transaction->user_id; @@ -22,62 +29,26 @@ class BudgetTransactionService return; } - // Ensure labels are available for matching (safe if already loaded). - $transaction->loadMissing('labels'); + $allocations = $this->allocationsFor($transaction); + $claimedCategoryIds = $this->claimedCategoryIds($userId); - $transactionLabelIds = $transaction->labels->pluck('id'); + // period_id => aggregated amount (already negated: expenses are positive + // spend in a budget). Periods that net to zero are dropped below. + $periodAmounts = []; - // A budget tracking a parent category also covers its children, so a - // transaction matches a budget when any of its category's ancestors - // (or itself) is attached to that budget. - $categoryMatchIds = $transaction->category_id - ? $this->tree->ancestorAndSelfIds($userId, $transaction->category_id) - : []; + foreach ($this->candidatePeriods($transaction, $userId, $allocations) as $period) { + $amount = $this->matchedAmountForBudget($allocations, $period->budget, $userId, $claimedCategoryIds); - // Find budget periods that potentially match this transaction. - $budgetPeriods = BudgetPeriod::query() - ->whereHas('budget', function ($query) use ($categoryMatchIds, $transactionLabelIds, $userId) { - $query->where('user_id', $userId) - ->where(function ($q) use ($categoryMatchIds, $transactionLabelIds) { - $q->whereHas('categories', function ($cq) use ($categoryMatchIds) { - $cq->whereIn('categories.id', $categoryMatchIds); - }) - ->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.categories:id', 'budget.labels:id') - ->get(); - - // Narrow down to periods whose budget actually matches the transaction. - $matchingPeriodIds = []; - - foreach ($budgetPeriods as $period) { - $budget = $period->budget; - - $matchesCategory = $categoryMatchIds !== [] - && $budget->categories->pluck('id')->intersect($categoryMatchIds)->isNotEmpty(); - $matchesLabel = $budget->labels - ->pluck('id') - ->intersect($transactionLabelIds) - ->isNotEmpty(); - - if ($matchesCategory || $matchesLabel) { - $matchingPeriodIds[] = $period->id; + if ($amount !== 0) { + $periodAmounts[$period->id] = $amount; } } - $matchingPeriodIds = array_merge( - $matchingPeriodIds, - $this->catchAllPeriodIds($transaction, $userId, $categoryMatchIds), - ); + $matchingPeriodIds = array_keys($periodAmounts); // Apply changes atomically so concurrent workers cannot leave the // transaction half-assigned and the unique index guards duplicates. - DB::transaction(function () use ($transaction, $matchingPeriodIds) { + DB::transaction(function () use ($transaction, $periodAmounts, $matchingPeriodIds) { Transaction::query() ->whereKey($transaction->id) ->lockForUpdate() @@ -91,14 +62,14 @@ class BudgetTransactionService ) ->delete(); - foreach ($matchingPeriodIds as $periodId) { + foreach ($periodAmounts as $periodId => $amount) { BudgetTransaction::updateOrCreate( [ 'transaction_id' => $transaction->id, 'budget_period_id' => $periodId, ], [ - 'amount' => -$transaction->amount, + 'amount' => $amount, ], ); } @@ -112,118 +83,186 @@ class BudgetTransactionService public function assignHistoricalTransactionsToPeriod(BudgetPeriod $period): int { - // Load the budget with its relationships $budget = $period->budget()->with(['categories:id', 'labels:id'])->first(); if (! $budget) { return 0; } + $claimedCategoryIds = $this->claimedCategoryIds($budget->user_id); $assignedCount = 0; - // Tracking a parent category also tracks its children's spending. - $categoryIds = collect($this->tree->expand($budget->user_id, $budget->categories->pluck('id')->all())); - $labelIds = $budget->labels->pluck('id'); - - Log::info('Building query for historical transactions', [ - 'user_id' => $budget->user_id, - 'category_ids' => $categoryIds->all(), - 'label_ids' => $labelIds->all(), - 'start_date' => $period->start_date->toDateString(), - 'end_date' => $period->end_date->toDateString(), - ]); - - // Build the query for matching transactions - $query = Transaction::query() + // Evaluate every transaction in range against this budget. Split-aware + // matching lives on the lines, so we can't pre-filter by the parent's + // category_id/labels in SQL. + // ponytail: full period scan (chunked); narrow to matched-or-split rows + // if a large backfill becomes slow. + Transaction::query() ->where('user_id', $budget->user_id) ->whereBetween('transaction_date', [$period->start_date, $period->end_date]) - ->withoutTrashed(); + ->withoutTrashed() + ->with(['category:id,type', 'labels:id', 'splits.category:id,type', 'splits.labels:id']) + ->chunk(500, function (Collection $transactions) use ($period, $budget, $claimedCategoryIds, &$assignedCount): void { + foreach ($transactions as $transaction) { + $amount = $this->matchedAmountForBudget( + $this->allocationsFor($transaction), + $budget, + $budget->user_id, + $claimedCategoryIds, + ); - if ($budget->is_catch_all) { - // A catch-all budget absorbs every expense whose category is not - // already tracked by one of the user's other budgets. - $claimedCategoryIds = $this->tree->expand( - $budget->user_id, - $this->claimedCategoryIds($budget->user_id), - ); + if ($amount === 0) { + continue; + } - $query->whereNotNull('category_id') - ->when( - $claimedCategoryIds !== [], - fn ($q) => $q->whereNotIn('category_id', $claimedCategoryIds), - ) - ->whereHas('category', fn ($q) => $q->where('type', CategoryType::Expense->value)); - } else { - // Filter by any tracked category OR label - $query->where(function ($q) use ($categoryIds, $labelIds) { - if ($categoryIds->isNotEmpty()) { - $q->whereIn('category_id', $categoryIds); - } + $budgetTransaction = BudgetTransaction::updateOrCreate( + [ + 'transaction_id' => $transaction->id, + 'budget_period_id' => $period->id, + ], + [ + 'amount' => $amount, + ], + ); - if ($labelIds->isNotEmpty()) { - $q->orWhereHas('labels', function ($labelQuery) use ($labelIds) { - $labelQuery->whereIn('labels.id', $labelIds); - }); + if ($budgetTransaction->wasRecentlyCreated) { + $assignedCount++; + } } }); - } - - $totalCount = $query->count(); - Log::info("Found {$totalCount} transactions to process in date range"); - - // Process in chunks to prevent memory issues - $query->chunk(500, function ($transactions) use ($period, &$assignedCount) { - foreach ($transactions as $transaction) { - $budgetTransaction = BudgetTransaction::updateOrCreate( - [ - 'transaction_id' => $transaction->id, - 'budget_period_id' => $period->id, - ], - [ - 'amount' => -$transaction->amount, - ], - ); - - if ($budgetTransaction->wasRecentlyCreated) { - $assignedCount++; - } - } - }); return $assignedCount; } /** - * Catch-all budget periods that should absorb this transaction: an expense - * whose category (or an ancestor) is not tracked by any non-catch-all budget. + * The transaction's category attributions: one entry for an unsplit + * transaction, one per line for a split one. * - * @param array $categoryMatchIds the transaction category and its ancestors - * @return array + * @return array, amount: int}> */ - private function catchAllPeriodIds(Transaction $transaction, string $userId, array $categoryMatchIds): array + private function allocationsFor(Transaction $transaction): array { - if ($transaction->category_id === null) { - return []; + if ($transaction->isSplit()) { + $transaction->loadMissing(['splits.category', 'splits.labels']); + + return $transaction->splits->map(fn ($line): array => [ + 'categoryId' => $line->category_id, + 'categoryType' => $line->category?->type, + 'labelIds' => $line->labels->pluck('id')->all(), + 'amount' => $line->amount, + ])->all(); } - $transaction->loadMissing('category'); + $transaction->loadMissing(['category', 'labels']); - if ($transaction->category?->type !== CategoryType::Expense) { - return []; + return [[ + 'categoryId' => $transaction->category_id, + 'categoryType' => $transaction->category?->type, + 'labelIds' => $transaction->labels->pluck('id')->all(), + 'amount' => $transaction->amount, + ]]; + } + + /** + * Budget periods in the transaction's date range that could match any of its + * allocations — by tracked category, tracked label, or being a catch-all. + * + * @param array, amount: int}> $allocations + * @return Collection + */ + private function candidatePeriods(Transaction $transaction, string $userId, array $allocations): Collection + { + $categoryMatchIds = []; + $labelIds = []; + + foreach ($allocations as $allocation) { + if ($allocation['categoryId'] !== null) { + $categoryMatchIds = array_merge($categoryMatchIds, $this->tree->ancestorAndSelfIds($userId, $allocation['categoryId'])); + } + + $labelIds = array_merge($labelIds, $allocation['labelIds']); } - if (array_intersect($categoryMatchIds, $this->claimedCategoryIds($userId)) !== []) { - return []; - } + $categoryMatchIds = array_values(array_unique($categoryMatchIds)); + $labelIds = array_values(array_unique($labelIds)); return BudgetPeriod::query() - ->whereHas('budget', function ($query) use ($userId) { - $query->where('user_id', $userId)->where('is_catch_all', true); + ->whereHas('budget', function ($query) use ($userId, $categoryMatchIds, $labelIds) { + $query->where('user_id', $userId) + ->where(function ($inner) use ($categoryMatchIds, $labelIds) { + $inner->where('is_catch_all', true) + ->orWhereHas('categories', fn ($cq) => $cq->whereIn('categories.id', $categoryMatchIds)) + ->orWhereHas('labels', fn ($lq) => $lq->whereIn('labels.id', $labelIds)); + }); }) ->where('start_date', '<=', $transaction->transaction_date) ->where('end_date', '>=', $transaction->transaction_date) + ->with('budget.categories:id', 'budget.labels:id') + ->get(); + } + + /** + * The portion of a transaction's amount that belongs to a budget: the sum of + * the allocations that match it (negated so expenses count as positive + * spend), or zero when none match. + * + * @param array, amount: int}> $allocations + * @param array $claimedCategoryIds + */ + private function matchedAmountForBudget(array $allocations, Budget $budget, string $userId, array $claimedCategoryIds): int + { + $total = 0; + + foreach ($allocations as $allocation) { + if ($this->allocationMatchesBudget($allocation, $budget, $userId, $claimedCategoryIds)) { + $total += -$allocation['amount']; + } + } + + return $total; + } + + /** + * @param array{categoryId: ?string, categoryType: ?CategoryType, labelIds: array, amount: int} $allocation + * @param array $claimedCategoryIds + */ + private function allocationMatchesBudget(array $allocation, Budget $budget, string $userId, array $claimedCategoryIds): bool + { + if ($budget->is_catch_all) { + return $this->allocationHitsCatchAll($allocation, $userId, $claimedCategoryIds); + } + + $categoryMatchIds = $allocation['categoryId'] !== null + ? $this->tree->ancestorAndSelfIds($userId, $allocation['categoryId']) + : []; + + $matchesCategory = $categoryMatchIds !== [] + && $budget->categories->pluck('id')->intersect($categoryMatchIds)->isNotEmpty(); + + $matchesLabel = $budget->labels ->pluck('id') - ->all(); + ->intersect($allocation['labelIds']) + ->isNotEmpty(); + + return $matchesCategory || $matchesLabel; + } + + /** + * A catch-all budget absorbs an expense allocation whose category (or an + * ancestor) is not tracked by any of the user's other budgets. + * + * @param array{categoryId: ?string, categoryType: ?CategoryType, labelIds: array, amount: int} $allocation + * @param array $claimedCategoryIds + */ + private function allocationHitsCatchAll(array $allocation, string $userId, array $claimedCategoryIds): bool + { + if ($allocation['categoryId'] === null || $allocation['categoryType'] !== CategoryType::Expense) { + return false; + } + + $categoryMatchIds = $this->tree->ancestorAndSelfIds($userId, $allocation['categoryId']); + + return array_intersect($categoryMatchIds, $claimedCategoryIds) === []; } /** diff --git a/app/Services/CategorySpendingService.php b/app/Services/CategorySpendingService.php index 3e41836d..7005d0f3 100644 --- a/app/Services/CategorySpendingService.php +++ b/app/Services/CategorySpendingService.php @@ -3,7 +3,6 @@ namespace App\Services; use App\Enums\CategoryType; -use App\Models\Transaction; use Carbon\Carbon; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; @@ -22,9 +21,8 @@ class CategorySpendingService */ public function forPeriod(string $userId, Carbon $from, Carbon $to, ?string $drillParentId = null): Collection { - $perCategory = Transaction::query() - ->where('transactions.user_id', $userId) - ->whereBetween('transactions.transaction_date', [$from, $to]) + $perCategory = DB::query() + ->fromSub(TransactionAllocations::query($userId, $from, $to), 'transactions') ->join('categories', function ($join) { $join->on('transactions.category_id', '=', 'categories.id') ->where('categories.type', '=', CategoryType::Expense) diff --git a/app/Services/TransactionAllocations.php b/app/Services/TransactionAllocations.php new file mode 100644 index 00000000..ed3d77b1 --- /dev/null +++ b/app/Services/TransactionAllocations.php @@ -0,0 +1,83 @@ +select('t.category_id', 't.amount', 't.transaction_date') + ->whereNull('t.deleted_at') + ->where('t.user_id', $userId) + ->whereBetween('t.transaction_date', [$from, $to]) + ->whereNotExists(function (QueryBuilder $query): void { + $query->select(DB::raw(1)) + ->from('transaction_splits as s') + ->whereColumn('s.transaction_id', 't.id'); + }); + + $lines = DB::table('transaction_splits as s') + ->join('transactions as t', 't.id', '=', 's.transaction_id') + ->select('s.category_id', 's.amount', 't.transaction_date') + ->whereNull('t.deleted_at') + ->where('t.user_id', $userId) + ->whereBetween('t.transaction_date', [$from, $to]); + + return $unsplit->unionAll($lines); + } + + /** + * Expand a loaded transaction collection into effective allocations: a split + * transaction becomes one synthetic Transaction per line (carrying the line's + * amount + category while keeping the parent's account/currency/date for FX + * and side classification); unsplit transactions pass through unchanged. + * + * Callers must eager-load `splits.category` (and `account` for FX). + * + * @param Collection $transactions + * @return Collection + */ + public static function expand(Collection $transactions): Collection + { + return $transactions->flatMap(function (Transaction $transaction): array { + $splits = $transaction->relationLoaded('splits') ? $transaction->splits : null; + + if ($splits === null || $splits->isEmpty()) { + return [$transaction]; + } + + return $splits->map(function (TransactionSplit $line) use ($transaction): Transaction { + $allocation = (new Transaction)->setRawAttributes($transaction->getAttributes()); + $allocation->forceFill([ + 'amount' => $line->amount, + 'category_id' => $line->category_id, + ]); + $allocation->setRelation('account', $transaction->relationLoaded('account') ? $transaction->account : null); + $allocation->setRelation('category', $line->relationLoaded('category') ? $line->category : null); + + return $allocation; + })->all(); + })->values(); + } +} diff --git a/app/Services/TransactionSplitter.php b/app/Services/TransactionSplitter.php new file mode 100644 index 00000000..27de5189 --- /dev/null +++ b/app/Services/TransactionSplitter.php @@ -0,0 +1,56 @@ +}> $lines + */ + public function apply(Transaction $transaction, array $lines): void + { + DB::transaction(function () use ($transaction, $lines): void { + $transaction->labels()->detach(); + $transaction->splits()->delete(); + + foreach ($lines as $line) { + $split = $transaction->splits()->create([ + 'category_id' => $line['category_id'] ?? null, + 'amount' => (int) $line['amount'], + ]); + + $labelIds = $line['label_ids'] ?? []; + + if ($labelIds !== []) { + $split->labels()->sync($labelIds); + } + } + + $transaction->forceFill([ + 'category_id' => null, + 'category_source' => null, + 'ai_confidence' => null, + 'categorized_by_rule_id' => null, + 'ai_suggested_category_id' => null, + 'ai_suggested_category_at' => null, + ])->save(); + }); + } + + /** + * Collapse a split back to a single-category transaction by dropping its + * lines. The caller restores the single category via the normal update path. + */ + public function remove(Transaction $transaction): void + { + $transaction->splits()->delete(); + } +} diff --git a/database/factories/TransactionSplitFactory.php b/database/factories/TransactionSplitFactory.php new file mode 100644 index 00000000..926bbfdb --- /dev/null +++ b/database/factories/TransactionSplitFactory.php @@ -0,0 +1,26 @@ + + */ +class TransactionSplitFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'transaction_id' => Transaction::factory(), + 'category_id' => Category::factory(), + 'amount' => fake()->numberBetween(-100000, 100000), + ]; + } +} diff --git a/database/migrations/2026_07_19_114525_create_transaction_splits_table.php b/database/migrations/2026_07_19_114525_create_transaction_splits_table.php new file mode 100644 index 00000000..2bc6e414 --- /dev/null +++ b/database/migrations/2026_07_19_114525_create_transaction_splits_table.php @@ -0,0 +1,31 @@ +uuid('id')->primary(); + $table->foreignUuid('transaction_id')->constrained()->cascadeOnDelete(); + $table->foreignUuid('category_id')->nullable()->constrained()->nullOnDelete(); + $table->bigInteger('amount'); + $table->timestamps(); + + $table->index('transaction_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('transaction_splits'); + } +}; diff --git a/database/migrations/2026_07_19_114526_create_label_split_table.php b/database/migrations/2026_07_19_114526_create_label_split_table.php new file mode 100644 index 00000000..6282c14d --- /dev/null +++ b/database/migrations/2026_07_19_114526_create_label_split_table.php @@ -0,0 +1,29 @@ +uuid('id')->primary(); + $table->foreignUuid('label_id')->constrained()->cascadeOnDelete(); + $table->foreignUuid('transaction_split_id')->constrained()->cascadeOnDelete(); + $table->timestamps(); + + $table->unique(['label_id', 'transaction_split_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('label_split'); + } +}; diff --git a/lang/en.json b/lang/en.json index b09818bc..14251de3 100644 --- a/lang/en.json +++ b/lang/en.json @@ -1,3 +1,14 @@ { - "Saved (cashflow)": "Saved" + "Saved (cashflow)": "Saved", + "Split": "Split", + "Rest": "Rest", + "Add line": "Add line", + "Remove line": "Remove line", + "Undo split": "Undo split", + "Remaining: :amount": "Remaining: :amount", + "Skipped :count split transactions": "Skipped :count split transactions", + "A split needs at least two lines.": "A split needs at least two lines.", + "The split lines must add up to the transaction amount.": "The split lines must add up to the transaction amount.", + "Each split line must be a non-zero amount.": "Each split line must be a non-zero amount.", + "Split lines must have the same sign as the transaction.": "Split lines must have the same sign as the transaction." } diff --git a/lang/es.json b/lang/es.json index 0ac4106a..5deef2fe 100644 --- a/lang/es.json +++ b/lang/es.json @@ -2291,5 +2291,16 @@ "Read and analyse your transactions, balances, categories and spending.": "Leer y analizar tus transacciones, saldos, categorías y gastos.", "Create, edit and delete transactions, categories, labels and automation rules.": "Crear, editar y eliminar transacciones, categorías, etiquetas y reglas de automatización.", "Bank-connected accounts and their transactions stay read-only. You can disconnect it at any time from the connected app.": "Las cuentas conectadas a un banco y sus transacciones siguen siendo de solo lectura. Puedes desconectarla cuando quieras desde la app conectada.", - "Connecting…": "Conectando…" + "Connecting…": "Conectando…", + "Split": "Dividir", + "Rest": "Resto", + "Add line": "Añadir línea", + "Remove line": "Eliminar línea", + "Undo split": "Deshacer división", + "Remaining: :amount": "Restante: :amount", + "Skipped :count split transactions": "Se omitieron :count transacciones divididas", + "A split needs at least two lines.": "Una división necesita al menos dos líneas.", + "The split lines must add up to the transaction amount.": "Las líneas de la división deben sumar el importe de la transacción.", + "Each split line must be a non-zero amount.": "Cada línea de la división debe tener un importe distinto de cero.", + "Split lines must have the same sign as the transaction.": "Las líneas de la división deben tener el mismo signo que la transacción." } diff --git a/resources/js/components/transactions/category-cell.tsx b/resources/js/components/transactions/category-cell.tsx index af22edf1..f29e04f1 100644 --- a/resources/js/components/transactions/category-cell.tsx +++ b/resources/js/components/transactions/category-cell.tsx @@ -9,17 +9,22 @@ import { TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip'; +import { useLocale } from '@/hooks/use-locale'; import { useIsMobile } from '@/hooks/use-mobile'; import { cn } from '@/lib/utils'; import { billing } from '@/routes/settings'; import { transactionSyncService } from '@/services/transaction-sync'; import { type SharedData } from '@/types'; import { type Account, type Bank } from '@/types/account'; -import { type Category } from '@/types/category'; -import { type DecryptedTransaction } from '@/types/transaction'; +import { getCategoryColorClasses, type Category } from '@/types/category'; +import { + type DecryptedTransaction, + type TransactionSplit, +} from '@/types/transaction'; import { __ } from '@/utils/i18n'; import { router, usePage } from '@inertiajs/react'; -import { useState } from 'react'; +import * as Icons from 'lucide-react'; +import { useMemo, useState } from 'react'; import { toast } from 'sonner'; interface CategoryCellProps { @@ -37,6 +42,9 @@ interface CategoryCellProps { withoutChevronIcon?: boolean; /** AI is currently categorizing this row in the background. */ isCategorizing?: boolean; + /** Active filters, used to collapse non-matching split lines into a "Rest" row. */ + appliedCategoryIds?: string[]; + appliedLabelIds?: string[]; } export function CategoryCell({ @@ -49,12 +57,28 @@ export function CategoryCell({ className, withoutChevronIcon, isCategorizing, + appliedCategoryIds = [], + appliedLabelIds = [], }: CategoryCellProps) { const [isUpdating, setIsUpdating] = useState(false); const isMobile = useIsMobile(); const { auth, subscriptionsEnabled, aiCategorizationUpsellRate } = usePage().props; + // A split transaction shows its per-line breakdown instead of a single, + // editable category. Editing happens in the modal (click the row). + if (transaction.splits && transaction.splits.length > 0) { + return ( + + ); + } + // Free-plan nudge: AI could categorize this row. Sampled to a configurable // share of rows so it stays subtle instead of marking every uncategorized one. const showAiUpsell = @@ -257,3 +281,160 @@ export function CategoryCell({ ); } + +function SplitCategoryChip({ category }: { category?: Category | null }) { + if (!category) { + return ( + + {__('Uncategorized')} + + ); + } + + const colorClasses = getCategoryColorClasses(category.color); + const IconComponent = Icons[ + category.icon as keyof typeof Icons + ] as Icons.LucideIcon; + + return ( + + {IconComponent && ( + + )} + {category.name} + + ); +} + +/** + * The per-line breakdown shown in the list for a split transaction. Without an + * active category/label filter every line is listed; with one, only the lines + * matching the filter are shown plus a single "Rest" row for the remainder, so + * the visible parts still add up to the transaction total. + */ +function SplitBreakdown({ + transaction, + categories, + appliedCategoryIds, + appliedLabelIds, + className, +}: { + transaction: DecryptedTransaction; + categories: Category[]; + appliedCategoryIds: string[]; + appliedLabelIds: string[]; + className?: string; +}) { + const locale = useLocale(); + const splits = transaction.splits ?? []; + const currencyCode = transaction.currency_code; + + const parentOf = useMemo( + () => + new Map( + categories.map((category) => [category.id, category.parent_id]), + ), + [categories], + ); + + const formatAmount = (amount: number) => + new Intl.NumberFormat(locale, { + style: 'currency', + currency: currencyCode, + }).format(amount / 100); + + const filterActive = + appliedCategoryIds.length > 0 || appliedLabelIds.length > 0; + + const lineMatchesFilter = (split: TransactionSplit): boolean => { + let matches = false; + + if (appliedCategoryIds.length > 0) { + if (split.category_id === null) { + matches ||= appliedCategoryIds.includes('uncategorized'); + } else { + const chain: string[] = []; + let current: string | null | undefined = split.category_id; + let guard = 0; + while (current && guard++ < 10) { + chain.push(current); + current = parentOf.get(current) ?? null; + } + matches ||= chain.some((id) => appliedCategoryIds.includes(id)); + } + } + + if (appliedLabelIds.length > 0) { + matches ||= (split.labels ?? []).some((label) => + appliedLabelIds.includes(label.id), + ); + } + + return matches; + }; + + const rows: { + key: string; + category?: Category | null; + amount: number; + isRest?: boolean; + }[] = []; + + if (filterActive) { + const matching = splits.filter(lineMatchesFilter); + const restAmount = splits + .filter((split) => !matching.includes(split)) + .reduce((sum, split) => sum + split.amount, 0); + + matching.forEach((split) => + rows.push({ + key: split.id, + category: split.category, + amount: split.amount, + }), + ); + + if (restAmount !== 0) { + rows.push({ key: 'rest', amount: restAmount, isRest: true }); + } + } else { + splits.forEach((split) => + rows.push({ + key: split.id, + category: split.category, + amount: split.amount, + }), + ); + } + + return ( +
+ {rows.map((row) => ( +
+ {row.isRest ? ( + + {__('Rest')} + + ) : ( + + )} + + {formatAmount(row.amount)} + +
+ ))} +
+ ); +} diff --git a/resources/js/components/transactions/edit-transaction-dialog.test.tsx b/resources/js/components/transactions/edit-transaction-dialog.test.tsx index b5dc4277..a16caa07 100644 --- a/resources/js/components/transactions/edit-transaction-dialog.test.tsx +++ b/resources/js/components/transactions/edit-transaction-dialog.test.tsx @@ -3,6 +3,13 @@ import type React from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { EditTransactionDialog } from './edit-transaction-dialog'; +vi.mock('@inertiajs/react', () => ({ + usePage: () => ({ + props: { features: { transactionSplitting: false } }, + }), + router: { reload: vi.fn(), delete: vi.fn(), visit: vi.fn() }, +})); + vi.mock('@/components/shared/label-combobox', () => ({ LabelCombobox: () =>
, })); diff --git a/resources/js/components/transactions/edit-transaction-dialog.tsx b/resources/js/components/transactions/edit-transaction-dialog.tsx index 8852db7b..1aa9b78c 100644 --- a/resources/js/components/transactions/edit-transaction-dialog.tsx +++ b/resources/js/components/transactions/edit-transaction-dialog.tsx @@ -1,6 +1,12 @@ import { destroy } from '@/actions/App/Http/Controllers/Settings/AutomationRuleController'; import { LabelCombobox } from '@/components/shared/label-combobox'; import { CategorySelect } from '@/components/transactions/category-select'; +import { + isSplitValid, + newSplitLine, + SplitEditor, + type SplitLineDraft, +} from '@/components/transactions/split-editor'; import { AmountInput } from '@/components/ui/amount-input'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; @@ -29,6 +35,7 @@ import { getStoredKey } from '@/lib/key-storage'; import { evaluateRulesForNewTransaction } from '@/lib/rule-engine'; import { appendNoteIfNotPresent } from '@/lib/utils'; import { transactionSyncService } from '@/services/transaction-sync'; +import { type SharedData } from '@/types'; import { filterTransactionalAccounts, type Account, @@ -40,9 +47,9 @@ import { type Label } from '@/types/label'; import { type DecryptedTransaction } from '@/types/transaction'; import { formatDate } from '@/utils/date'; import { __ } from '@/utils/i18n'; -import { router } from '@inertiajs/react'; +import { router, usePage } from '@inertiajs/react'; import { getYear, parseISO } from 'date-fns'; -import { Trash2 } from 'lucide-react'; +import { Split, Trash2 } from 'lucide-react'; import { useEffect, useState } from 'react'; import { toast } from 'sonner'; @@ -95,6 +102,7 @@ export function EditTransactionDialog({ const [categoryId, setCategoryId] = useState('null'); const [selectedLabelIds, setSelectedLabelIds] = useState([]); const [notes, setNotes] = useState(''); + const [splitLines, setSplitLines] = useState([]); const [isSubmitting, setIsSubmitting] = useState(false); const [decryptedAccountNames, setDecryptedAccountNames] = useState< Map @@ -113,6 +121,24 @@ export function EditTransactionDialog({ const canEditAllFields = mode === 'create' || transaction?.source === 'manually_created'; + const splittingEnabled = + usePage().props.features.transactionSplitting; + const isSplitting = splitLines.length > 0; + const wasSplit = (transaction?.splits?.length ?? 0) > 0; + + function startSplit() { + // Seed with the current category as the first line at the full amount, + // plus an empty second line the user fills as they split it up. + setSplitLines([ + newSplitLine({ + category_id: categoryId === 'null' ? null : categoryId, + amount, + label_ids: selectedLabelIds, + }), + newSplitLine(), + ]); + } + useEffect(() => { if (mode === 'edit' && transaction) { setTransactionDate(transaction.transaction_date); @@ -126,6 +152,15 @@ export function EditTransactionDialog({ [], ); setNotes(transaction.decryptedNotes || ''); + setSplitLines( + (transaction.splits ?? []).map((split) => + newSplitLine({ + category_id: split.category_id, + amount: split.amount, + label_ids: split.labels?.map((label) => label.id) ?? [], + }), + ), + ); } else if (mode === 'create' && open) { const today = new Date().toISOString().split('T')[0]; setTransactionDate(today); @@ -139,6 +174,7 @@ export function EditTransactionDialog({ setCategoryId('null'); setSelectedLabelIds([]); setNotes(''); + setSplitLines([]); } }, [mode, transaction, open, accounts, initialAccountId]); @@ -427,6 +463,11 @@ export function EditTransactionDialog({ transaction_date?: string; account_id?: string; currency_code?: string; + splits?: { + category_id: string | null; + amount: number; + label_ids: string[]; + }[]; } = { category_id: selectedCategoryId, notes: encryptedNotes, @@ -453,6 +494,51 @@ export function EditTransactionDialog({ updateData.currency_code = editedCurrencyCode; } + // A split change turns one row into several (or back). Persist it + // and let the server-rendered list re-fetch rather than patch the + // grouped rows by hand. + if (isSplitting || wasSplit) { + if (isSplitting) { + if (!isSplitValid(amount, splitLines)) { + toast.error( + __( + 'The split lines must add up to the transaction amount.', + ), + ); + setIsSubmitting(false); + return; + } + + // The lines own categorisation and labels now; drop the + // transaction-level values so the backend nulls them. + updateData.category_id = null; + delete updateData.label_ids; + updateData.splits = splitLines.map((line) => ({ + category_id: line.category_id, + amount: line.amount, + label_ids: line.label_ids, + })); + } else { + // Collapse an existing split back to a single category. + updateData.splits = []; + } + + await transactionSyncService.update( + transaction.id, + updateData, + { + updateBalance: canEditAllFields + ? updateAccountBalance + : false, + }, + ); + + toast.success(__('Transaction updated successfully')); + onOpenChange(false); + router.reload({ only: ['transactions'] }); + return; + } + const result = await transactionSyncService.update( transaction.id, updateData, @@ -827,34 +913,83 @@ export function EditTransactionDialog({ )}
-
- - {__('Category')} - - -
+ {isSplitting ? ( +
+
+ {__('Split')} + +
+ +
+ ) : ( + <> +
+
+ + {__('Category')} + + {mode === 'edit' && + splittingEnabled && ( + + )} +
+ +
-
- {__('Labels')} - -
+
+ {__('Labels')} + +
+ + )}
{__('Notes')} @@ -895,7 +1030,11 @@ export function EditTransactionDialog({ +
+ + updateLine(line.key, { label_ids: ids }) + } + labels={labels} + disabled={disabled} + placeholder={__('Add labels...')} + allowCreate={true} + onLabelCreated={onLabelCreated} + /> + + ))} + +
+ + + {__('Remaining: :amount', { amount: formattedRemaining })} + +
+ + ); +} diff --git a/resources/js/components/transactions/transaction-columns.tsx b/resources/js/components/transactions/transaction-columns.tsx index 00e8d26d..563df4dd 100644 --- a/resources/js/components/transactions/transaction-columns.tsx +++ b/resources/js/components/transactions/transaction-columns.tsx @@ -45,6 +45,9 @@ interface CreateColumnsOptions { isDateHidden?: boolean; /** Ids of transactions AI is categorizing in the background right now. */ categorizingIds?: Set; + /** Active category/label filters, used to collapse non-matching split lines into a "Rest" row. */ + appliedCategoryIds?: string[]; + appliedLabelIds?: string[]; } export function createTransactionColumns({ @@ -60,6 +63,8 @@ export function createTransactionColumns({ onReEvaluateRules, isDateHidden = false, categorizingIds, + appliedCategoryIds = [], + appliedLabelIds = [], }: CreateColumnsOptions): ColumnDef[] { return [ { @@ -158,6 +163,8 @@ export function createTransactionColumns({ className="relative -top-0.5 max-w-[150px] md:max-w-[180px]" withoutChevronIcon isCategorizing={categorizingIds?.has(row.original.id)} + appliedCategoryIds={appliedCategoryIds} + appliedLabelIds={appliedLabelIds} /> ); }, diff --git a/resources/js/pages/transactions/index.tsx b/resources/js/pages/transactions/index.tsx index 069266f7..94fd842c 100644 --- a/resources/js/pages/transactions/index.tsx +++ b/resources/js/pages/transactions/index.tsx @@ -1052,6 +1052,8 @@ export default function Transactions({ onReEvaluateRules: handleReEvaluateRules, isDateHidden: columnVisibility.transaction_date === false, categorizingIds, + appliedCategoryIds: filters.categoryIds.map(String), + appliedLabelIds: filters.labelIds.map(String), }), [ accounts, @@ -1064,6 +1066,8 @@ export default function Transactions({ handleReEvaluateRules, columnVisibility, categorizingIds, + filters.categoryIds, + filters.labelIds, ], ); @@ -1120,19 +1124,26 @@ export default function Transactions({ try { if (isSelectingAll) { const toastId = toast.loading(__('Updating transactions...')); - const response = await axios.patch<{ count: number }>( - '/transactions/bulk', - { - filters: clientFiltersToBackendFilters(filters), - category_id: categoryId, - }, - ); + const response = await axios.patch<{ + count: number; + omitted_splits: number; + }>('/transactions/bulk', { + filters: clientFiltersToBackendFilters(filters), + category_id: categoryId, + }); toast.dismiss(toastId); toast.success( __(`Updated :count transactions`, { count: response.data.count, }), ); + if (response.data.omitted_splits > 0) { + toast.info( + __('Skipped :count split transactions', { + count: response.data.omitted_splits, + }), + ); + } setRowSelection({}); setIsSelectingAll(false); refreshTransactions(); @@ -1144,28 +1155,51 @@ export default function Transactions({ ? categoriesMap.get(categoryId) || null : null; - await transactionSyncService.updateMany(selectedIds, { - category_id: categoryId, - }); - - setAllTransactions((previous) => - previous.map((transaction) => { - if (selectedIds.includes(transaction.id.toString())) { - return { - ...transaction, - category_id: categoryId, - category: selectedCategory, - }; - } - return transaction; - }), + // A bulk category assignment can't apply to split transactions + // (it would wipe their lines), so skip them and report how many. + const splitIds = new Set( + allTransactions + .filter( + (transaction) => + (transaction.splits?.length ?? 0) > 0, + ) + .map((transaction) => transaction.id.toString()), ); + const targetIds = selectedIds.filter((id) => !splitIds.has(id)); + const omitted = selectedIds.length - targetIds.length; - toast.success( - __(`Updated :count transactions`, { - count: selectedIds.length, - }), - ); + if (targetIds.length > 0) { + await transactionSyncService.updateMany(targetIds, { + category_id: categoryId, + }); + + setAllTransactions((previous) => + previous.map((transaction) => { + if (targetIds.includes(transaction.id.toString())) { + return { + ...transaction, + category_id: categoryId, + category: selectedCategory, + }; + } + return transaction; + }), + ); + + toast.success( + __(`Updated :count transactions`, { + count: targetIds.length, + }), + ); + } + + if (omitted > 0) { + toast.info( + __('Skipped :count split transactions', { + count: omitted, + }), + ); + } setRowSelection({}); } diff --git a/resources/js/services/transaction-sync.ts b/resources/js/services/transaction-sync.ts index 3d6b2653..0b98fe0a 100644 --- a/resources/js/services/transaction-sync.ts +++ b/resources/js/services/transaction-sync.ts @@ -1,7 +1,7 @@ import { db, withDb } from '@/lib/dexie-db'; import { TransactionSyncManager } from '@/lib/sync-manager'; import type { LearnedRuleNotice } from '@/types/automation-rule'; -import type { Transaction } from '@/types/transaction'; +import type { SplitLineInput, Transaction } from '@/types/transaction'; import type { UUID } from '@/types/uuid'; import axios from 'axios'; @@ -10,8 +10,11 @@ export type UpdatedTransaction = Transaction & { learned_rule?: LearnedRuleNotice | null; }; -interface TransactionUpdateData extends Partial { +interface TransactionUpdateData extends Omit, 'splits'> { label_ids?: string[]; + // Present = (re)split into these lines; empty array = collapse back to a + // single category; absent = leave the split state untouched. + splits?: SplitLineInput[]; } interface TransactionFilters { diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts index 607bb58e..1ff154d0 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -43,6 +43,7 @@ export interface Features { cashflow: boolean; calculateBalancesOnImport: boolean; mcp: boolean; + transactionSplitting: boolean; } export interface ExpiredBankingConnectionNotification { diff --git a/resources/js/types/transaction.ts b/resources/js/types/transaction.ts index 2ef49b6a..9d41a96d 100644 --- a/resources/js/types/transaction.ts +++ b/resources/js/types/transaction.ts @@ -12,6 +12,22 @@ export type TransactionSource = export type CategorySource = 'manual' | 'rule' | 'ai' | 'bank'; +/** One category+amount line of a split transaction, with its own labels. */ +export interface TransactionSplit { + id: UUID; + category_id: UUID | null; + amount: number; + category?: Category | null; + labels?: Label[]; +} + +/** A single split line as submitted to the backend when (re)splitting. */ +export interface SplitLineInput { + category_id: UUID | null; + amount: number; + label_ids?: UUID[]; +} + export interface Transaction { id: UUID; user_id: UUID; @@ -31,6 +47,7 @@ export interface Transaction { ai_confidence?: number | null; ai_categorized?: boolean; label_ids?: UUID[]; + splits?: TransactionSplit[]; created_at: string; updated_at: string; } @@ -39,6 +56,7 @@ export interface ServerTransaction extends Transaction { account?: Account; category?: Category | null; labels?: Label[]; + splits?: TransactionSplit[]; } export interface DecryptedTransaction extends Transaction { @@ -48,6 +66,7 @@ export interface DecryptedTransaction extends Transaction { category?: Category | null; bank?: Bank; labels?: Label[]; + splits?: TransactionSplit[]; } export interface TransactionFilters { diff --git a/tests/Feature/TransactionSplitTest.php b/tests/Feature/TransactionSplitTest.php new file mode 100644 index 00000000..2c73cf63 --- /dev/null +++ b/tests/Feature/TransactionSplitTest.php @@ -0,0 +1,323 @@ +for($user)->create([ + 'name' => $name, + 'type' => CategoryType::Expense, + 'cashflow_direction' => CategoryCashflowDirection::Outflow, + ]); +} + +function splittableTransaction(User $user, int $amount = -10000): Transaction +{ + return Transaction::factory()->for($user)->create([ + 'amount' => $amount, + 'category_id' => null, + 'currency_code' => $user->currency_code, + 'source' => TransactionSource::Imported, + ]); +} + +beforeEach(function () { + // Keep every amount in the user's own currency so analytics never reaches + // for an exchange rate (which would be a stray HTTP request under test). + $this->user = User::factory()->create(['currency_code' => 'EUR']); + Feature::for($this->user)->activate(TransactionSplitting::class); +}); + +it('splits a transaction into lines and nulls its own category', function () { + $restaurant = expenseCategory($this->user, 'Restaurant'); + $travel = expenseCategory($this->user, 'Travel'); + $transaction = splittableTransaction($this->user); + + actingAs($this->user) + ->patchJson(route('transactions.update', $transaction), [ + 'splits' => [ + ['category_id' => $restaurant->id, 'amount' => -2500, 'label_ids' => []], + ['category_id' => $travel->id, 'amount' => -7500, 'label_ids' => []], + ], + ]) + ->assertOk(); + + $transaction->refresh(); + + expect($transaction->category_id)->toBeNull(); + expect($transaction->splits)->toHaveCount(2); + expect($transaction->splits->sum('amount'))->toBe(-10000); + expect($transaction->splits->pluck('category_id')->all()) + ->toContain($restaurant->id, $travel->id); +}); + +it('moves transaction labels onto the split lines', function () { + $category = expenseCategory($this->user); + $label = Label::factory()->for($this->user)->create(); + $transaction = splittableTransaction($this->user); + $transaction->labels()->attach($label->id); + + actingAs($this->user) + ->patchJson(route('transactions.update', $transaction), [ + 'splits' => [ + ['category_id' => $category->id, 'amount' => -2500, 'label_ids' => [$label->id]], + ['category_id' => $category->id, 'amount' => -7500, 'label_ids' => []], + ], + ]) + ->assertOk(); + + $transaction->refresh(); + + expect($transaction->labels)->toHaveCount(0); + expect($transaction->splits->first()->labels->pluck('id')->all())->toContain($label->id); +}); + +it('rejects splits that do not add up to the transaction amount', function () { + $category = expenseCategory($this->user); + $transaction = splittableTransaction($this->user); + + actingAs($this->user) + ->patchJson(route('transactions.update', $transaction), [ + 'splits' => [ + ['category_id' => $category->id, 'amount' => -2500], + ['category_id' => $category->id, 'amount' => -2500], + ], + ]) + ->assertJsonValidationErrors(['splits']); + + expect($transaction->fresh()->splits)->toHaveCount(0); +}); + +it('rejects a single split line', function () { + $category = expenseCategory($this->user); + $transaction = splittableTransaction($this->user); + + actingAs($this->user) + ->patchJson(route('transactions.update', $transaction), [ + 'splits' => [ + ['category_id' => $category->id, 'amount' => -10000], + ], + ]) + ->assertJsonValidationErrors(['splits']); +}); + +it('rejects a split line whose sign differs from the transaction', function () { + $category = expenseCategory($this->user); + $transaction = splittableTransaction($this->user); + + actingAs($this->user) + ->patchJson(route('transactions.update', $transaction), [ + 'splits' => [ + ['category_id' => $category->id, 'amount' => -12500], + ['category_id' => $category->id, 'amount' => 2500], + ], + ]) + ->assertJsonValidationErrors(['splits.1.amount']); +}); + +it('collapses a split back to a single category', function () { + $category = expenseCategory($this->user); + $target = expenseCategory($this->user, 'Target'); + $transaction = splittableTransaction($this->user); + + app(TransactionSplitter::class)->apply($transaction, [ + ['category_id' => $category->id, 'amount' => -2500], + ['category_id' => $category->id, 'amount' => -7500], + ]); + + actingAs($this->user) + ->patchJson(route('transactions.update', $transaction), [ + 'category_id' => $target->id, + 'splits' => [], + ]) + ->assertOk(); + + $transaction->refresh(); + + expect($transaction->splits)->toHaveCount(0); + expect($transaction->category_id)->toBe($target->id); +}); + +it('forbids splitting when the feature is disabled', function () { + Feature::for($this->user)->deactivate(TransactionSplitting::class); + + $category = expenseCategory($this->user); + $transaction = splittableTransaction($this->user); + + actingAs($this->user) + ->patchJson(route('transactions.update', $transaction), [ + 'splits' => [ + ['category_id' => $category->id, 'amount' => -2500], + ['category_id' => $category->id, 'amount' => -7500], + ], + ]) + ->assertForbidden(); +}); + +it('cascade-deletes split lines when the transaction row is removed', function () { + $category = expenseCategory($this->user); + $transaction = splittableTransaction($this->user); + + app(TransactionSplitter::class)->apply($transaction, [ + ['category_id' => $category->id, 'amount' => -2500], + ['category_id' => $category->id, 'amount' => -7500], + ]); + + // Hit the DB-level foreign-key cascade directly (a model force-delete would + // additionally fire the queued TransactionDeleted listener). + DB::table('transactions')->where('id', $transaction->id)->delete(); + + expect(TransactionSplit::query()->where('transaction_id', $transaction->id)->count())->toBe(0); +}); + +it('attributes split lines to their categories in the spending breakdown', function () { + $restaurant = expenseCategory($this->user, 'Restaurant'); + $travel = expenseCategory($this->user, 'Travel'); + $transaction = splittableTransaction($this->user, -10000); + $transaction->update(['transaction_date' => now()]); + + app(TransactionSplitter::class)->apply($transaction, [ + ['category_id' => $restaurant->id, 'amount' => -2500], + ['category_id' => $travel->id, 'amount' => -7500], + ]); + + $spending = app(CategorySpendingService::class)->forPeriod( + $this->user->id, + now()->subDay(), + now()->addDay(), + ); + + $byCategory = $spending->keyBy('category_id'); + + expect($byCategory[$restaurant->id]['amount'])->toBe(2500); + expect($byCategory[$travel->id]['amount'])->toBe(7500); +}); + +it('reflects split lines in the cashflow expense breakdown endpoint', function () { + $restaurant = expenseCategory($this->user, 'Restaurant'); + $travel = expenseCategory($this->user, 'Travel'); + $transaction = splittableTransaction($this->user, -10000); + $transaction->update(['transaction_date' => now()]); + + app(TransactionSplitter::class)->apply($transaction, [ + ['category_id' => $restaurant->id, 'amount' => -2500], + ['category_id' => $travel->id, 'amount' => -7500], + ]); + + $response = actingAs($this->user)->getJson( + '/api/cashflow/breakdown?type=expense&from='.now()->subDay()->toDateString().'&to='.now()->addDay()->toDateString(), + )->assertOk(); + + $amounts = collect($response->json('data'))->keyBy('category_id'); + + expect($amounts[$restaurant->id]['amount'])->toBe(2500); + expect($amounts[$travel->id]['amount'])->toBe(7500); +}); + +it('attributes only the matching split line to a category budget', function () { + $tracked = expenseCategory($this->user, 'Tracked'); + $other = expenseCategory($this->user, 'Other'); + $transaction = splittableTransaction($this->user, -10000); + $transaction->update(['transaction_date' => now()]); + + app(TransactionSplitter::class)->apply($transaction, [ + ['category_id' => $tracked->id, 'amount' => -2500], + ['category_id' => $other->id, 'amount' => -7500], + ]); + + $budget = Budget::factory()->forCategories($tracked)->create(['user_id' => $this->user->id]); + $period = BudgetPeriod::factory()->create([ + 'budget_id' => $budget->id, + 'start_date' => now()->subDays(15), + 'end_date' => now()->addDays(15), + ]); + + app(BudgetTransactionService::class)->assignTransaction($transaction->fresh()); + + $budgetTransaction = BudgetTransaction::query() + ->where('transaction_id', $transaction->id) + ->where('budget_period_id', $period->id) + ->first(); + + expect($budgetTransaction)->not->toBeNull(); + expect($budgetTransaction->amount)->toBe(2500); +}); + +it('finds a split transaction when filtering the list by a line category', function () { + $restaurant = expenseCategory($this->user, 'Restaurant'); + $travel = expenseCategory($this->user, 'Travel'); + $transaction = splittableTransaction($this->user, -10000); + + app(TransactionSplitter::class)->apply($transaction, [ + ['category_id' => $restaurant->id, 'amount' => -2500], + ['category_id' => $travel->id, 'amount' => -7500], + ]); + + $matches = Transaction::query() + ->where('user_id', $this->user->id) + ->applyFilters(['category_ids' => [$restaurant->id], 'user_id' => $this->user->id]) + ->pluck('id'); + + expect($matches->all())->toContain($transaction->id); +}); + +it('does not treat a fully-categorized split as uncategorized', function () { + $restaurant = expenseCategory($this->user, 'Restaurant'); + $travel = expenseCategory($this->user, 'Travel'); + $split = splittableTransaction($this->user, -10000); + $uncategorized = splittableTransaction($this->user, -5000); + + app(TransactionSplitter::class)->apply($split, [ + ['category_id' => $restaurant->id, 'amount' => -2500], + ['category_id' => $travel->id, 'amount' => -7500], + ]); + + $matches = Transaction::query() + ->where('user_id', $this->user->id) + ->applyFilters(['category_ids' => ['uncategorized'], 'user_id' => $this->user->id]) + ->pluck('id'); + + expect($matches->all())->toContain($uncategorized->id); + expect($matches->all())->not->toContain($split->id); +}); + +it('skips split transactions on a bulk category assignment', function () { + $category = expenseCategory($this->user); + $newCategory = expenseCategory($this->user, 'New'); + $split = splittableTransaction($this->user, -10000); + $plain = splittableTransaction($this->user, -5000); + + app(TransactionSplitter::class)->apply($split, [ + ['category_id' => $category->id, 'amount' => -2500], + ['category_id' => $category->id, 'amount' => -7500], + ]); + + $response = actingAs($this->user) + ->patchJson(route('transactions.bulk-update'), [ + 'transaction_ids' => [$split->id, $plain->id], + 'category_id' => $newCategory->id, + ]) + ->assertOk(); + + expect($response->json('omitted_splits'))->toBe(1); + expect($split->fresh()->category_id)->toBeNull(); + expect($plain->fresh()->category_id)->toBe($newCategory->id); +}); From 49fb62d94db27d4d20e941afb8bd0d2446935880 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vi=CC=81ctor=20Falco=CC=81n?= Date: Sun, 19 Jul 2026 14:49:18 +0200 Subject: [PATCH 2/4] fix(transactions): address split feature review findings - keep editing an already-split transaction working when the flag is off: only resend splits when the split content actually changed or was undone - make the dashboard cashflow summary and the Transaction Analysis screen split-aware (per-line category/type + tag attribution) - skip split transactions on bulk label assignment (labels live on lines), matching the existing bulk-category skip - reject splitting a zero-amount transaction - split editor no longer shows a green 'Remaining: 0' with a dead Save button when a line is still zero - add isSplit() helper, UUID split-line keys, clarify allocation docblocks --- .../Api/TransactionAnalysisController.php | 12 ++- app/Http/Controllers/DashboardController.php | 10 ++- .../Controllers/TransactionController.php | 30 ++++--- .../Requests/UpdateTransactionRequest.php | 9 ++- app/Services/TransactionAllocations.php | 25 ++++-- lang/en.json | 4 +- lang/es.json | 4 +- .../components/transactions/category-cell.tsx | 3 +- .../transactions/edit-transaction-dialog.tsx | 32 +++++++- .../transactions/split-editor.test.tsx | 4 + .../components/transactions/split-editor.tsx | 28 ++++--- resources/js/pages/transactions/index.tsx | 10 +-- resources/js/types/transaction.ts | 5 ++ tests/Feature/TransactionSplitTest.php | 80 +++++++++++++++++++ 14 files changed, 205 insertions(+), 51 deletions(-) diff --git a/app/Http/Controllers/Api/TransactionAnalysisController.php b/app/Http/Controllers/Api/TransactionAnalysisController.php index ed325060..516bc7c6 100644 --- a/app/Http/Controllers/Api/TransactionAnalysisController.php +++ b/app/Http/Controllers/Api/TransactionAnalysisController.php @@ -9,6 +9,7 @@ use App\Models\Label; use App\Models\Transaction; use App\Services\CategoryTree; use App\Services\ExchangeRateService; +use App\Services\TransactionAllocations; use Carbon\Carbon; use Illuminate\Http\JsonResponse; use Illuminate\Support\Collection; @@ -56,14 +57,19 @@ class TransactionAnalysisController extends Controller $transactions = Transaction::query() ->where('user_id', $user->id) - ->with(['account.bank', 'category', 'labels']) + ->with(['account.bank', 'category', 'labels', 'splits.category', 'splits.labels']) ->applyFilters($filters) ->get(); $this->preloadExchangeRates($transactions, $currency); - $byCategory = $this->categoryBreakdown($transactions, $currency, $user->id); - $byTag = $this->tagBreakdown($transactions, $currency); + // Category and tag breakdowns are per-line for split transactions; payee, + // account and the totals stay at the transaction level (a split doesn't + // change who was paid or the amount that moved). + $allocations = TransactionAllocations::expand($transactions); + + $byCategory = $this->categoryBreakdown($allocations, $currency, $user->id); + $byTag = $this->tagBreakdown($allocations, $currency); $byPayee = $this->payeeBreakdown($transactions, $currency); $byAccount = $this->accountBreakdown($transactions, $currency); diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index f85ba628..3841a037 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -4,11 +4,11 @@ namespace App\Http\Controllers; use App\Enums\CategoryType; use App\Models\Account; -use App\Models\Transaction; use App\Services\AccountMetricsService; use App\Services\CashflowSummaryService; use App\Services\CategorySpendingService; use App\Services\PeriodComparator; +use App\Services\TransactionAllocations; use Carbon\Carbon; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -110,9 +110,11 @@ class DashboardController extends Controller private function getTransactionSum(string $userId, Carbon $from, Carbon $to, CategoryType $type): int { - return Transaction::query() - ->where('transactions.user_id', $userId) - ->whereBetween('transactions.transaction_date', [$from, $to]) + // Read effective allocations so a split's lines are classified by their + // own category type (a savings/transfer line no longer counts as a plain + // expense just because the parent row has no category). + return DB::query() + ->fromSub(TransactionAllocations::query($userId, $from, $to), 'transactions') ->where(function ($q) use ($type) { $q->whereExists(function ($sub) use ($type) { $sub->select(DB::raw(1)) diff --git a/app/Http/Controllers/TransactionController.php b/app/Http/Controllers/TransactionController.php index fe97b311..7c4b8ced 100644 --- a/app/Http/Controllers/TransactionController.php +++ b/app/Http/Controllers/TransactionController.php @@ -353,16 +353,23 @@ class TransactionController extends Controller $transactions = $query->get(); } - // Split transactions carry no single category, so a bulk category - // assignment skips them (it would silently destroy their lines) and we - // report how many were left untouched. - $omittedSplitIds = collect(); - $updateData = []; - if ($request->has('category_id')) { - $newCategoryId = $request->input('category_id'); + $labelIds = $request->input('label_ids'); + $hasLabelUpdate = $request->has('label_ids'); + $hasCategoryUpdate = $request->has('category_id'); + // A bulk category or label assignment skips split transactions: setting a + // single category would destroy their lines, and labels live on the lines, + // not the row. Notes still apply (they are transaction-level). We report + // how many splits were left untouched. + $omittedSplitIds = collect(); + if ($hasCategoryUpdate || $hasLabelUpdate) { $transactions->loadMissing('splits:id,transaction_id'); $omittedSplitIds = $transactions->filter->isSplit()->pluck('id'); + } + + $updateData = []; + if ($hasCategoryUpdate) { + $newCategoryId = $request->input('category_id'); $overrideHandler = app(CategoryOverrideHandler::class); @@ -386,9 +393,6 @@ class TransactionController extends Controller $updateData['notes_iv'] = $request->input('notes_iv'); } - $labelIds = $request->input('label_ids'); - $hasLabelUpdate = $request->has('label_ids'); - if (empty($updateData) && ! $hasLabelUpdate) { return response()->json([ 'message' => 'No update data provided.', @@ -402,7 +406,7 @@ class TransactionController extends Controller } elseif ($filters !== null) { $updateQuery->applyFilters($filters); } - if ($omittedSplitIds->isNotEmpty()) { + if ($hasCategoryUpdate && $omittedSplitIds->isNotEmpty()) { $updateQuery->whereNotIn('id', $omittedSplitIds->all()); } $updateQuery->update($updateData); @@ -410,6 +414,10 @@ class TransactionController extends Controller if ($hasLabelUpdate) { foreach ($transactions as $transaction) { + if ($transaction->isSplit()) { + continue; + } + $transaction->labels()->sync($labelIds ?? []); $transaction->save(); } diff --git a/app/Http/Requests/UpdateTransactionRequest.php b/app/Http/Requests/UpdateTransactionRequest.php index e339c9f0..489b82fa 100644 --- a/app/Http/Requests/UpdateTransactionRequest.php +++ b/app/Http/Requests/UpdateTransactionRequest.php @@ -75,6 +75,13 @@ class UpdateTransactionRequest extends FormRequest } $total = $this->effectiveAmount(); + + if ($total === 0) { + $validator->errors()->add('splits', __("You can't split a transaction with no amount.")); + + return; + } + $sum = 0; foreach ($lines as $index => $line) { @@ -83,7 +90,7 @@ class UpdateTransactionRequest extends FormRequest if ($amount === 0) { $validator->errors()->add("splits.{$index}.amount", __('Each split line must be a non-zero amount.')); - } elseif ($total !== 0 && ($amount > 0) !== ($total > 0)) { + } elseif (($amount > 0) !== ($total > 0)) { $validator->errors()->add("splits.{$index}.amount", __('Split lines must have the same sign as the transaction.')); } } diff --git a/app/Services/TransactionAllocations.php b/app/Services/TransactionAllocations.php index ed3d77b1..65b6cc3f 100644 --- a/app/Services/TransactionAllocations.php +++ b/app/Services/TransactionAllocations.php @@ -10,10 +10,13 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; /** - * The single source of truth for "effective category attribution": an unsplit - * transaction is attributed to its own category, a split transaction to its - * lines. Every category breakdown reads through here so a split never gets - * double-counted or dropped. + * Effective category attribution: an unsplit transaction is attributed to its + * own category, a split transaction to its lines. The category breakdowns read + * through here — either the SQL {@see self::query()} or the collection + * {@see self::expand()} — so a split is never double-counted or dropped. The two + * encarnations share the "iterate lines vs the row itself" rule but not code: + * SQL sums raw rows, expand() needs hydrated models (account/category/labels) + * for currency conversion and side classification. */ class TransactionAllocations { @@ -50,10 +53,14 @@ class TransactionAllocations /** * Expand a loaded transaction collection into effective allocations: a split * transaction becomes one synthetic Transaction per line (carrying the line's - * amount + category while keeping the parent's account/currency/date for FX - * and side classification); unsplit transactions pass through unchanged. + * amount + category + labels while keeping the parent's account/currency/date + * for FX and side classification); unsplit transactions pass through + * unchanged. * - * Callers must eager-load `splits.category` (and `account` for FX). + * Callers must eager-load `splits.category` (and `splits.labels`/`account` + * where they read those). Synthetic line instances deliberately keep the + * parent's `id`, so consumers must aggregate by `category_id`/labels, never + * `keyBy('id')`/`unique('id')` (that would collapse a split's lines). * * @param Collection $transactions * @return Collection @@ -76,6 +83,10 @@ class TransactionAllocations $allocation->setRelation('account', $transaction->relationLoaded('account') ? $transaction->account : null); $allocation->setRelation('category', $line->relationLoaded('category') ? $line->category : null); + if ($line->relationLoaded('labels')) { + $allocation->setRelation('labels', $line->labels); + } + return $allocation; })->all(); })->values(); diff --git a/lang/en.json b/lang/en.json index 14251de3..5f8647df 100644 --- a/lang/en.json +++ b/lang/en.json @@ -6,9 +6,11 @@ "Remove line": "Remove line", "Undo split": "Undo split", "Remaining: :amount": "Remaining: :amount", - "Skipped :count split transactions": "Skipped :count split transactions", + "Each line needs a non-zero amount": "Each line needs a non-zero amount", + "Split transactions skipped: :count": "Split transactions skipped: :count", "A split needs at least two lines.": "A split needs at least two lines.", "The split lines must add up to the transaction amount.": "The split lines must add up to the transaction amount.", "Each split line must be a non-zero amount.": "Each split line must be a non-zero amount.", + "You can't split a transaction with no amount.": "You can't split a transaction with no amount.", "Split lines must have the same sign as the transaction.": "Split lines must have the same sign as the transaction." } diff --git a/lang/es.json b/lang/es.json index 5deef2fe..a12af9a2 100644 --- a/lang/es.json +++ b/lang/es.json @@ -2298,9 +2298,11 @@ "Remove line": "Eliminar línea", "Undo split": "Deshacer división", "Remaining: :amount": "Restante: :amount", - "Skipped :count split transactions": "Se omitieron :count transacciones divididas", + "Each line needs a non-zero amount": "Cada línea necesita un importe distinto de cero", + "Split transactions skipped: :count": "Transacciones divididas omitidas: :count", "A split needs at least two lines.": "Una división necesita al menos dos líneas.", "The split lines must add up to the transaction amount.": "Las líneas de la división deben sumar el importe de la transacción.", "Each split line must be a non-zero amount.": "Cada línea de la división debe tener un importe distinto de cero.", + "You can't split a transaction with no amount.": "No puedes dividir una transacción sin importe.", "Split lines must have the same sign as the transaction.": "Las líneas de la división deben tener el mismo signo que la transacción." } diff --git a/resources/js/components/transactions/category-cell.tsx b/resources/js/components/transactions/category-cell.tsx index f29e04f1..ddf2c51b 100644 --- a/resources/js/components/transactions/category-cell.tsx +++ b/resources/js/components/transactions/category-cell.tsx @@ -18,6 +18,7 @@ import { type SharedData } from '@/types'; import { type Account, type Bank } from '@/types/account'; import { getCategoryColorClasses, type Category } from '@/types/category'; import { + isSplit, type DecryptedTransaction, type TransactionSplit, } from '@/types/transaction'; @@ -67,7 +68,7 @@ export function CategoryCell({ // A split transaction shows its per-line breakdown instead of a single, // editable category. Editing happens in the modal (click the row). - if (transaction.splits && transaction.splits.length > 0) { + if (isSplit(transaction)) { return ( ().props.features.transactionSplitting; const isSplitting = splitLines.length > 0; - const wasSplit = (transaction?.splits?.length ?? 0) > 0; + const wasSplit = transaction ? isSplit(transaction) : false; function startSplit() { // Seed with the current category as the first line at the full amount, @@ -496,8 +496,32 @@ export function EditTransactionDialog({ // A split change turns one row into several (or back). Persist it // and let the server-rendered list re-fetch rather than patch the - // grouped rows by hand. - if (isSplitting || wasSplit) { + // grouped rows by hand. Only send `splits` when the split content + // actually changed (or was undone) — editing another field of an + // already-split transaction must not re-trigger the gated split + // write, so it keeps working even if the feature is rolled back. + const originalSplitSignature = JSON.stringify( + (transaction.splits ?? []).map((split) => ({ + category_id: split.category_id, + amount: split.amount, + label_ids: [ + ...(split.labels?.map((label) => label.id) ?? []), + ].sort(), + })), + ); + const currentSplitSignature = JSON.stringify( + splitLines.map((line) => ({ + category_id: line.category_id, + amount: line.amount, + label_ids: [...line.label_ids].sort(), + })), + ); + const splitDirty = + (isSplitting && + currentSplitSignature !== originalSplitSignature) || + (!isSplitting && wasSplit); + + if (splitDirty) { if (isSplitting) { if (!isSplitValid(amount, splitLines)) { toast.error( diff --git a/resources/js/components/transactions/split-editor.test.tsx b/resources/js/components/transactions/split-editor.test.tsx index 4f8c6ddd..bf7d729a 100644 --- a/resources/js/components/transactions/split-editor.test.tsx +++ b/resources/js/components/transactions/split-editor.test.tsx @@ -37,4 +37,8 @@ describe('isSplitValid', () => { it('rejects a line whose sign differs from the total', () => { expect(isSplitValid(-10000, lines(-12500, 2500))).toBe(false); }); + + it('rejects splitting a zero-total transaction', () => { + expect(isSplitValid(0, lines(50, -50))).toBe(false); + }); }); diff --git a/resources/js/components/transactions/split-editor.tsx b/resources/js/components/transactions/split-editor.tsx index 85f1183a..5bd8e03c 100644 --- a/resources/js/components/transactions/split-editor.tsx +++ b/resources/js/components/transactions/split-editor.tsx @@ -26,15 +26,11 @@ interface SplitEditorProps { onLabelCreated?: (label: Label) => void; } -let keyCounter = 0; - export function newSplitLine( partial?: Partial, ): SplitLineDraft { - keyCounter += 1; - return { - key: `split-${keyCounter}`, + key: crypto.randomUUID(), category_id: null, amount: 0, label_ids: [], @@ -48,17 +44,16 @@ export function splitRemaining(total: number, lines: SplitLineDraft[]): number { /** * A split is valid once it has at least two lines that each carry a non-zero - * amount on the same side as the transaction and together reconcile to its - * total to the cent. + * amount on the same side as the (non-zero) transaction total and together + * reconcile to it to the cent. */ export function isSplitValid(total: number, lines: SplitLineDraft[]): boolean { - if (lines.length < 2 || splitRemaining(total, lines) !== 0) { + if (total === 0 || lines.length < 2 || splitRemaining(total, lines) !== 0) { return false; } return lines.every( - (line) => - line.amount !== 0 && (total === 0 || line.amount > 0 === total > 0), + (line) => line.amount !== 0 && line.amount > 0 === total > 0, ); } @@ -88,6 +83,11 @@ export function SplitEditor({ const addLine = () => onChange([...lines, newSplitLine({ amount: remaining })]); + const valid = isSplitValid(total, lines); + // Remaining can be 0 while the split is still invalid (e.g. a seeded + // zero-amount line), so key the styling off validity, not just the balance. + const hasZeroLine = lines.some((line) => line.amount === 0); + const formattedRemaining = new Intl.NumberFormat(locale, { style: 'currency', currency: currencyCode, @@ -168,13 +168,17 @@ export function SplitEditor({ - {__('Remaining: :amount', { amount: formattedRemaining })} + {remaining === 0 && hasZeroLine + ? __('Each line needs a non-zero amount') + : __('Remaining: :amount', { + amount: formattedRemaining, + })} diff --git a/resources/js/pages/transactions/index.tsx b/resources/js/pages/transactions/index.tsx index 94fd842c..7bdc1e04 100644 --- a/resources/js/pages/transactions/index.tsx +++ b/resources/js/pages/transactions/index.tsx @@ -104,6 +104,7 @@ import { import { type Category } from '@/types/category'; import { type Label } from '@/types/label'; import { + isSplit, type DecryptedTransaction, type TransactionFilters as Filters, type ServerTransaction, @@ -1139,7 +1140,7 @@ export default function Transactions({ ); if (response.data.omitted_splits > 0) { toast.info( - __('Skipped :count split transactions', { + __('Split transactions skipped: :count', { count: response.data.omitted_splits, }), ); @@ -1159,10 +1160,7 @@ export default function Transactions({ // (it would wipe their lines), so skip them and report how many. const splitIds = new Set( allTransactions - .filter( - (transaction) => - (transaction.splits?.length ?? 0) > 0, - ) + .filter((transaction) => isSplit(transaction)) .map((transaction) => transaction.id.toString()), ); const targetIds = selectedIds.filter((id) => !splitIds.has(id)); @@ -1195,7 +1193,7 @@ export default function Transactions({ if (omitted > 0) { toast.info( - __('Skipped :count split transactions', { + __('Split transactions skipped: :count', { count: omitted, }), ); diff --git a/resources/js/types/transaction.ts b/resources/js/types/transaction.ts index 9d41a96d..7a5d6ff0 100644 --- a/resources/js/types/transaction.ts +++ b/resources/js/types/transaction.ts @@ -52,6 +52,11 @@ export interface Transaction { updated_at: string; } +/** Whether a transaction is split into per-category lines. */ +export function isSplit(transaction: Pick): boolean { + return (transaction.splits?.length ?? 0) > 0; +} + export interface ServerTransaction extends Transaction { account?: Account; category?: Category | null; diff --git a/tests/Feature/TransactionSplitTest.php b/tests/Feature/TransactionSplitTest.php index 2c73cf63..fc49e474 100644 --- a/tests/Feature/TransactionSplitTest.php +++ b/tests/Feature/TransactionSplitTest.php @@ -299,6 +299,86 @@ it('does not treat a fully-categorized split as uncategorized', function () { expect($matches->all())->not->toContain($split->id); }); +it('rejects splitting a zero-amount transaction', function () { + $category = expenseCategory($this->user); + $transaction = splittableTransaction($this->user, 0); + + actingAs($this->user) + ->patchJson(route('transactions.update', $transaction), [ + 'splits' => [ + ['category_id' => $category->id, 'amount' => 50], + ['category_id' => $category->id, 'amount' => -50], + ], + ]) + ->assertJsonValidationErrors(['splits']); +}); + +it('classifies split lines by their own category type in the dashboard cash flow', function () { + $expense = expenseCategory($this->user, 'Groceries'); + $savings = Category::factory()->for($this->user)->create([ + 'name' => 'Savings', + 'type' => CategoryType::Savings, + 'cashflow_direction' => CategoryCashflowDirection::Outflow, + ]); + $transaction = splittableTransaction($this->user, -10000); + $transaction->update(['transaction_date' => now()]); + + app(TransactionSplitter::class)->apply($transaction, [ + ['category_id' => $expense->id, 'amount' => -3000], + ['category_id' => $savings->id, 'amount' => -7000], + ]); + + $response = actingAs($this->user)->getJson( + '/api/dashboard/cash-flow?from='.now()->startOfMonth()->toDateString().'&to='.now()->endOfMonth()->toDateString(), + )->assertOk(); + + // Only the expense line counts as expense; the savings line is excluded. + expect($response->json('current.expense'))->toBe(3000); +}); + +it('attributes split lines per category on the analysis screen', function () { + $restaurant = expenseCategory($this->user, 'Restaurant'); + $travel = expenseCategory($this->user, 'Travel'); + $transaction = splittableTransaction($this->user, -10000); + $transaction->update(['transaction_date' => now()]); + + app(TransactionSplitter::class)->apply($transaction, [ + ['category_id' => $restaurant->id, 'amount' => -2500], + ['category_id' => $travel->id, 'amount' => -7500], + ]); + + $response = actingAs($this->user)->getJson( + '/api/transactions/analysis?date_from='.now()->subDay()->toDateString().'&date_to='.now()->addDay()->toDateString(), + )->assertOk(); + + $byCategory = collect($response->json('by_category'))->keyBy('category_id'); + + expect($byCategory[$restaurant->id]['amount'])->toBe(2500); + expect($byCategory[$travel->id]['amount'])->toBe(7500); +}); + +it('skips split transactions on a bulk label assignment', function () { + $category = expenseCategory($this->user); + $label = Label::factory()->for($this->user)->create(); + $split = splittableTransaction($this->user, -10000); + $plain = splittableTransaction($this->user, -5000); + + app(TransactionSplitter::class)->apply($split, [ + ['category_id' => $category->id, 'amount' => -2500], + ['category_id' => $category->id, 'amount' => -7500], + ]); + + actingAs($this->user) + ->patchJson(route('transactions.bulk-update'), [ + 'transaction_ids' => [$split->id, $plain->id], + 'label_ids' => [$label->id], + ]) + ->assertOk(); + + expect($split->fresh()->labels)->toHaveCount(0); + expect($plain->fresh()->labels->pluck('id')->all())->toContain($label->id); +}); + it('skips split transactions on a bulk category assignment', function () { $category = expenseCategory($this->user); $newCategory = expenseCategory($this->user, 'New'); From 18d61b8e77778008135489334ac3cf2e39a92bf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vi=CC=81ctor=20Falco=CC=81n?= Date: Sun, 19 Jul 2026 14:59:55 +0200 Subject: [PATCH 3/4] test(transactions): rename split test helper to avoid a global collision expenseCategory() is already declared in AiRuleLearnerTest; Pest loads all test files into one scope, so the duplicate top-level function fatally aborted the full suite. Rename to splitTestExpenseCategory(). --- tests/Feature/TransactionSplitTest.php | 56 +++++++++++++------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/tests/Feature/TransactionSplitTest.php b/tests/Feature/TransactionSplitTest.php index fc49e474..098a9940 100644 --- a/tests/Feature/TransactionSplitTest.php +++ b/tests/Feature/TransactionSplitTest.php @@ -20,7 +20,7 @@ use Laravel\Pennant\Feature; use function Pest\Laravel\actingAs; -function expenseCategory(User $user, string $name = 'Cat'): Category +function splitTestExpenseCategory(User $user, string $name = 'Cat'): Category { return Category::factory()->for($user)->create([ 'name' => $name, @@ -47,8 +47,8 @@ beforeEach(function () { }); it('splits a transaction into lines and nulls its own category', function () { - $restaurant = expenseCategory($this->user, 'Restaurant'); - $travel = expenseCategory($this->user, 'Travel'); + $restaurant = splitTestExpenseCategory($this->user, 'Restaurant'); + $travel = splitTestExpenseCategory($this->user, 'Travel'); $transaction = splittableTransaction($this->user); actingAs($this->user) @@ -70,7 +70,7 @@ it('splits a transaction into lines and nulls its own category', function () { }); it('moves transaction labels onto the split lines', function () { - $category = expenseCategory($this->user); + $category = splitTestExpenseCategory($this->user); $label = Label::factory()->for($this->user)->create(); $transaction = splittableTransaction($this->user); $transaction->labels()->attach($label->id); @@ -91,7 +91,7 @@ it('moves transaction labels onto the split lines', function () { }); it('rejects splits that do not add up to the transaction amount', function () { - $category = expenseCategory($this->user); + $category = splitTestExpenseCategory($this->user); $transaction = splittableTransaction($this->user); actingAs($this->user) @@ -107,7 +107,7 @@ it('rejects splits that do not add up to the transaction amount', function () { }); it('rejects a single split line', function () { - $category = expenseCategory($this->user); + $category = splitTestExpenseCategory($this->user); $transaction = splittableTransaction($this->user); actingAs($this->user) @@ -120,7 +120,7 @@ it('rejects a single split line', function () { }); it('rejects a split line whose sign differs from the transaction', function () { - $category = expenseCategory($this->user); + $category = splitTestExpenseCategory($this->user); $transaction = splittableTransaction($this->user); actingAs($this->user) @@ -134,8 +134,8 @@ it('rejects a split line whose sign differs from the transaction', function () { }); it('collapses a split back to a single category', function () { - $category = expenseCategory($this->user); - $target = expenseCategory($this->user, 'Target'); + $category = splitTestExpenseCategory($this->user); + $target = splitTestExpenseCategory($this->user, 'Target'); $transaction = splittableTransaction($this->user); app(TransactionSplitter::class)->apply($transaction, [ @@ -159,7 +159,7 @@ it('collapses a split back to a single category', function () { it('forbids splitting when the feature is disabled', function () { Feature::for($this->user)->deactivate(TransactionSplitting::class); - $category = expenseCategory($this->user); + $category = splitTestExpenseCategory($this->user); $transaction = splittableTransaction($this->user); actingAs($this->user) @@ -173,7 +173,7 @@ it('forbids splitting when the feature is disabled', function () { }); it('cascade-deletes split lines when the transaction row is removed', function () { - $category = expenseCategory($this->user); + $category = splitTestExpenseCategory($this->user); $transaction = splittableTransaction($this->user); app(TransactionSplitter::class)->apply($transaction, [ @@ -189,8 +189,8 @@ it('cascade-deletes split lines when the transaction row is removed', function ( }); it('attributes split lines to their categories in the spending breakdown', function () { - $restaurant = expenseCategory($this->user, 'Restaurant'); - $travel = expenseCategory($this->user, 'Travel'); + $restaurant = splitTestExpenseCategory($this->user, 'Restaurant'); + $travel = splitTestExpenseCategory($this->user, 'Travel'); $transaction = splittableTransaction($this->user, -10000); $transaction->update(['transaction_date' => now()]); @@ -212,8 +212,8 @@ it('attributes split lines to their categories in the spending breakdown', funct }); it('reflects split lines in the cashflow expense breakdown endpoint', function () { - $restaurant = expenseCategory($this->user, 'Restaurant'); - $travel = expenseCategory($this->user, 'Travel'); + $restaurant = splitTestExpenseCategory($this->user, 'Restaurant'); + $travel = splitTestExpenseCategory($this->user, 'Travel'); $transaction = splittableTransaction($this->user, -10000); $transaction->update(['transaction_date' => now()]); @@ -233,8 +233,8 @@ it('reflects split lines in the cashflow expense breakdown endpoint', function ( }); it('attributes only the matching split line to a category budget', function () { - $tracked = expenseCategory($this->user, 'Tracked'); - $other = expenseCategory($this->user, 'Other'); + $tracked = splitTestExpenseCategory($this->user, 'Tracked'); + $other = splitTestExpenseCategory($this->user, 'Other'); $transaction = splittableTransaction($this->user, -10000); $transaction->update(['transaction_date' => now()]); @@ -262,8 +262,8 @@ it('attributes only the matching split line to a category budget', function () { }); it('finds a split transaction when filtering the list by a line category', function () { - $restaurant = expenseCategory($this->user, 'Restaurant'); - $travel = expenseCategory($this->user, 'Travel'); + $restaurant = splitTestExpenseCategory($this->user, 'Restaurant'); + $travel = splitTestExpenseCategory($this->user, 'Travel'); $transaction = splittableTransaction($this->user, -10000); app(TransactionSplitter::class)->apply($transaction, [ @@ -280,8 +280,8 @@ it('finds a split transaction when filtering the list by a line category', funct }); it('does not treat a fully-categorized split as uncategorized', function () { - $restaurant = expenseCategory($this->user, 'Restaurant'); - $travel = expenseCategory($this->user, 'Travel'); + $restaurant = splitTestExpenseCategory($this->user, 'Restaurant'); + $travel = splitTestExpenseCategory($this->user, 'Travel'); $split = splittableTransaction($this->user, -10000); $uncategorized = splittableTransaction($this->user, -5000); @@ -300,7 +300,7 @@ it('does not treat a fully-categorized split as uncategorized', function () { }); it('rejects splitting a zero-amount transaction', function () { - $category = expenseCategory($this->user); + $category = splitTestExpenseCategory($this->user); $transaction = splittableTransaction($this->user, 0); actingAs($this->user) @@ -314,7 +314,7 @@ it('rejects splitting a zero-amount transaction', function () { }); it('classifies split lines by their own category type in the dashboard cash flow', function () { - $expense = expenseCategory($this->user, 'Groceries'); + $expense = splitTestExpenseCategory($this->user, 'Groceries'); $savings = Category::factory()->for($this->user)->create([ 'name' => 'Savings', 'type' => CategoryType::Savings, @@ -337,8 +337,8 @@ it('classifies split lines by their own category type in the dashboard cash flow }); it('attributes split lines per category on the analysis screen', function () { - $restaurant = expenseCategory($this->user, 'Restaurant'); - $travel = expenseCategory($this->user, 'Travel'); + $restaurant = splitTestExpenseCategory($this->user, 'Restaurant'); + $travel = splitTestExpenseCategory($this->user, 'Travel'); $transaction = splittableTransaction($this->user, -10000); $transaction->update(['transaction_date' => now()]); @@ -358,7 +358,7 @@ it('attributes split lines per category on the analysis screen', function () { }); it('skips split transactions on a bulk label assignment', function () { - $category = expenseCategory($this->user); + $category = splitTestExpenseCategory($this->user); $label = Label::factory()->for($this->user)->create(); $split = splittableTransaction($this->user, -10000); $plain = splittableTransaction($this->user, -5000); @@ -380,8 +380,8 @@ it('skips split transactions on a bulk label assignment', function () { }); it('skips split transactions on a bulk category assignment', function () { - $category = expenseCategory($this->user); - $newCategory = expenseCategory($this->user, 'New'); + $category = splitTestExpenseCategory($this->user); + $newCategory = splitTestExpenseCategory($this->user, 'New'); $split = splittableTransaction($this->user, -10000); $plain = splittableTransaction($this->user, -5000); From 6e33f7942be0622e059acde8e1badda370335511 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vi=CC=81ctor=20Falco=CC=81n?= Date: Sun, 19 Jul 2026 15:06:35 +0200 Subject: [PATCH 4/4] test(transactions): update shared-flags and query-count baselines for splits - InertiaSharedDataTest: the shared feature flags now include transactionSplitting - performance thresholds: +1 query on the transactions list and cashflow breakdown for the splits eager-load (a single bulk query, does not scale) --- tests/Feature/InertiaSharedDataTest.php | 1 + tests/Performance/ApiQueryCountTest.php | 3 ++- tests/Performance/PageQueryCountTest.php | 8 +++++--- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/Feature/InertiaSharedDataTest.php b/tests/Feature/InertiaSharedDataTest.php index 02a7d293..a96fdcd1 100644 --- a/tests/Feature/InertiaSharedDataTest.php +++ b/tests/Feature/InertiaSharedDataTest.php @@ -100,6 +100,7 @@ test('shared feature flags do not include coinbase flag', function () { 'cashflow' => true, 'calculateBalancesOnImport' => false, 'mcp' => false, + 'transactionSplitting' => false, ]); }); diff --git a/tests/Performance/ApiQueryCountTest.php b/tests/Performance/ApiQueryCountTest.php index 5416d506..3aedc19d 100644 --- a/tests/Performance/ApiQueryCountTest.php +++ b/tests/Performance/ApiQueryCountTest.php @@ -78,7 +78,8 @@ test('cashflow trend API does not exceed query threshold', function () { }); test('cashflow breakdown API does not exceed query threshold', function () { - assertMaxQueries(15, function () { + // +1 vs. pre-split: the breakdown eager-loads each transaction's splits. + assertMaxQueries(16, function () { $this->getJson("/api/cashflow/breakdown?type=expense&{$this->dateParams}")->assertOk(); }, 'API Cashflow Breakdown'); }); diff --git a/tests/Performance/PageQueryCountTest.php b/tests/Performance/PageQueryCountTest.php index abc16d39..675c3721 100644 --- a/tests/Performance/PageQueryCountTest.php +++ b/tests/Performance/PageQueryCountTest.php @@ -57,7 +57,8 @@ test('account show page does not exceed query threshold', function () { }); test('transactions index page does not exceed query threshold', function () { - assertMaxQueries(21, function () { + // +1 vs. pre-split: the list eager-loads each transaction's splits. + assertMaxQueries(22, function () { $this->get(route('transactions.index'))->assertOk(); }, 'Transactions Index'); }); @@ -163,8 +164,9 @@ test('transactions page query count does not scale with number of transactions', 'category_id' => $category->id, ]); - // Same threshold — paginated queries should not scale - assertMaxQueries(21, function () { + // Same threshold as the base case — the splits eager-load is a single bulk + // query, so it stays constant regardless of how many transactions there are. + assertMaxQueries(22, function () { $this->get(route('transactions.index'))->assertOk(); }, 'Transactions with 120 records'); });