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] 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); +});