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
This commit is contained in:
Víctor Falcón 2026-07-19 14:28:10 +02:00
parent 89174af4e6
commit 0b46fff3c2
29 changed files with 1718 additions and 251 deletions

View File

@ -0,0 +1,25 @@
<?php
namespace App\Features;
use App\Models\User;
/**
* Gates the "Split" entry point in the transaction editor while the feature is
* rolled out. Reads always honour splits that already exist in the data; this
* flag only hides the UI/API path to create or change them.
* Toggle per user / everyone with
* `php artisan feature:enable App\\Features\\TransactionSplitting <target>`.
*
* @api
*/
class TransactionSplitting
{
/**
* Resolve the feature's initial value.
*/
public function resolve(?User $user): bool
{
return false;
}
}

View File

@ -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

View File

@ -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.
*/

View File

@ -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(),
]);
}
}

View File

@ -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,
];
}

View File

@ -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.',
];
}
}

17
app/Models/LabelSplit.php Normal file
View File

@ -0,0 +1,17 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Relations\Pivot;
class LabelSplit extends Pivot
{
use HasUuids;
protected $table = 'label_split';
public $incrementing = false;
protected $keyType = 'string';
}

View File

@ -234,6 +234,26 @@ class Transaction extends Model
return $this->hasMany(BudgetTransaction::class);
}
/** @return HasMany<TransactionSplit, $this> */
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));
});
}
});
}

View File

@ -0,0 +1,59 @@
<?php
namespace App\Models;
use Database\Factories\TransactionSplitFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class TransactionSplit extends Model
{
/** @use HasFactory<TransactionSplitFactory> */
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<string>
*/
protected $hidden = [
'pivot',
];
protected function casts(): array
{
return [
'amount' => 'integer',
];
}
/** @return BelongsTo<Transaction, $this> */
public function transaction(): BelongsTo
{
return $this->belongsTo(Transaction::class);
}
/** @return BelongsTo<Category, $this> */
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
/** @return BelongsToMany<Label, $this, LabelSplit, 'pivot'> */
public function labels(): BelongsToMany
{
return $this->belongsToMany(Label::class, 'label_split')
->using(LabelSplit::class)
->withTimestamps();
}
}

View File

@ -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<int, string> $categoryMatchIds the transaction category and its ancestors
* @return array<int, string>
* @return array<int, array{categoryId: ?string, categoryType: ?CategoryType, labelIds: array<int, string>, 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<int, array{categoryId: ?string, categoryType: ?CategoryType, labelIds: array<int, string>, amount: int}> $allocations
* @return Collection<int, BudgetPeriod>
*/
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<int, array{categoryId: ?string, categoryType: ?CategoryType, labelIds: array<int, string>, amount: int}> $allocations
* @param array<int, string> $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<int, string>, amount: int} $allocation
* @param array<int, string> $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<int, string>, amount: int} $allocation
* @param array<int, string> $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) === [];
}
/**

View File

@ -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)

View File

@ -0,0 +1,83 @@
<?php
namespace App\Services;
use App\Models\Transaction;
use App\Models\TransactionSplit;
use Carbon\Carbon;
use Illuminate\Database\Query\Builder as QueryBuilder;
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.
*/
class TransactionAllocations
{
/**
* SQL allocation rows for a user's transactions in a period one row per
* unsplit transaction (its own category + amount) and one per split line
* (the line's category + amount). Soft-deleted transactions are excluded.
* Returned as a query builder so callers can alias it as `transactions` and
* keep their existing joins/aggregations unchanged.
*/
public static function query(string $userId, Carbon $from, Carbon $to): QueryBuilder
{
$unsplit = DB::table('transactions as t')
->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<int, Transaction> $transactions
* @return Collection<int, Transaction>
*/
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();
}
}

View File

@ -0,0 +1,56 @@
<?php
namespace App\Services;
use App\Models\Transaction;
use Illuminate\Support\Facades\DB;
class TransactionSplitter
{
/**
* Replace a transaction's split lines. The lines become the source of truth
* for its category attribution: the transaction itself is left with no
* category and no labels (both now live on the lines). Amounts are assumed
* validated (same sign as the transaction, summing to its total).
*
* @param array<int, array{category_id?: ?string, amount: int|string, label_ids?: array<int, string>}> $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();
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace Database\Factories;
use App\Models\Category;
use App\Models\Transaction;
use App\Models\TransactionSplit;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<TransactionSplit>
*/
class TransactionSplitFactory extends Factory
{
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'transaction_id' => Transaction::factory(),
'category_id' => Category::factory(),
'amount' => fake()->numberBetween(-100000, 100000),
];
}
}

View File

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* A split line attributes part of a transaction's amount to a category. A
* transaction is "split" when it has any of these rows; its own category_id
* is nulled and the lines become the source of truth for category breakdowns.
*/
public function up(): void
{
Schema::create('transaction_splits', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Per-line labels for split transactions. Mirrors label_transaction: when a
* transaction is split its labels live on the lines, not the transaction.
*/
public function up(): void
{
Schema::create('label_split', function (Blueprint $table) {
$table->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');
}
};

View File

@ -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."
}

View File

@ -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."
}

View File

@ -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<SharedData>().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 (
<SplitBreakdown
transaction={transaction}
categories={categories}
appliedCategoryIds={appliedCategoryIds}
appliedLabelIds={appliedLabelIds}
className={className}
/>
);
}
// 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({
</div>
);
}
function SplitCategoryChip({ category }: { category?: Category | null }) {
if (!category) {
return (
<span className="truncate text-xs text-muted-foreground">
{__('Uncategorized')}
</span>
);
}
const colorClasses = getCategoryColorClasses(category.color);
const IconComponent = Icons[
category.icon as keyof typeof Icons
] as Icons.LucideIcon;
return (
<span
className={cn(
'inline-flex min-w-0 items-center gap-1 rounded px-1.5 py-0.5 text-xs',
colorClasses.bg,
colorClasses.text,
)}
>
{IconComponent && (
<IconComponent className="h-3 w-3 shrink-0 opacity-80" />
)}
<span className="truncate">{category.name}</span>
</span>
);
}
/**
* 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 (
<div
className={cn('flex w-full flex-col gap-1', className)}
data-testid="split-breakdown"
>
{rows.map((row) => (
<div
key={row.key}
className="flex items-center justify-between gap-2"
>
{row.isRest ? (
<span className="truncate text-xs text-muted-foreground">
{__('Rest')}
</span>
) : (
<SplitCategoryChip category={row.category} />
)}
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
{formatAmount(row.amount)}
</span>
</div>
))}
</div>
);
}

View File

@ -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: () => <div />,
}));

View File

@ -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<string>('null');
const [selectedLabelIds, setSelectedLabelIds] = useState<string[]>([]);
const [notes, setNotes] = useState('');
const [splitLines, setSplitLines] = useState<SplitLineDraft[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [decryptedAccountNames, setDecryptedAccountNames] = useState<
Map<string, string>
@ -113,6 +121,24 @@ export function EditTransactionDialog({
const canEditAllFields =
mode === 'create' || transaction?.source === 'manually_created';
const splittingEnabled =
usePage<SharedData>().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({
)}
</div>
<div className="space-y-2">
<FormLabel htmlFor="category">
{__('Category')}
</FormLabel>
<CategorySelect
value={categoryId}
onValueChange={setCategoryId}
categories={categories}
disabled={isSubmitting}
placeholder={__('Uncategorized')}
triggerClassName="w-full"
showUncategorized={true}
data-testid="category-select"
/>
</div>
{isSplitting ? (
<div className="space-y-2">
<div className="flex items-center justify-between">
<FormLabel>{__('Split')}</FormLabel>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setSplitLines([])}
disabled={isSubmitting}
>
{__('Undo split')}
</Button>
</div>
<SplitEditor
total={amount}
currencyCode={
selectedAccount?.currency_code ??
transaction?.currency_code ??
'EUR'
}
categories={categories}
labels={labels}
lines={splitLines}
onChange={setSplitLines}
disabled={isSubmitting}
onLabelCreated={onLabelCreated}
/>
</div>
) : (
<>
<div className="space-y-2">
<div className="flex items-center justify-between">
<FormLabel htmlFor="category">
{__('Category')}
</FormLabel>
{mode === 'edit' &&
splittingEnabled && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={startSplit}
disabled={isSubmitting}
data-testid="split-button"
>
<Split className="mr-1 h-4 w-4" />
{__('Split')}
</Button>
)}
</div>
<CategorySelect
value={categoryId}
onValueChange={setCategoryId}
categories={categories}
disabled={isSubmitting}
placeholder={__('Uncategorized')}
triggerClassName="w-full"
showUncategorized={true}
data-testid="category-select"
/>
</div>
<div className="space-y-2">
<FormLabel>{__('Labels')}</FormLabel>
<LabelCombobox
value={selectedLabelIds}
onValueChange={setSelectedLabelIds}
labels={labels}
disabled={isSubmitting}
placeholder={__('Add labels...')}
allowCreate={true}
onLabelCreated={onLabelCreated}
/>
</div>
<div className="space-y-2">
<FormLabel>{__('Labels')}</FormLabel>
<LabelCombobox
value={selectedLabelIds}
onValueChange={setSelectedLabelIds}
labels={labels}
disabled={isSubmitting}
placeholder={__('Add labels...')}
allowCreate={true}
onLabelCreated={onLabelCreated}
/>
</div>
</>
)}
<div className="space-y-2">
<FormLabel htmlFor="notes">{__('Notes')}</FormLabel>
@ -895,7 +1030,11 @@ export function EditTransactionDialog({
</Button>
<Button
type="submit"
disabled={isSubmitting}
disabled={
isSubmitting ||
(isSplitting &&
!isSplitValid(amount, splitLines))
}
data-testid="submit-transaction"
>
{isSubmitting

View File

@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import {
isSplitValid,
newSplitLine,
splitRemaining,
type SplitLineDraft,
} from './split-editor';
function lines(...amounts: number[]): SplitLineDraft[] {
return amounts.map((amount) => newSplitLine({ amount }));
}
describe('splitRemaining', () => {
it('is the total minus the sum of the lines', () => {
expect(splitRemaining(-10000, lines(-2500, -7500))).toBe(0);
expect(splitRemaining(-10000, lines(-2500))).toBe(-7500);
});
});
describe('isSplitValid', () => {
it('accepts at least two same-sign lines that reconcile to the total', () => {
expect(isSplitValid(-10000, lines(-2500, -7500))).toBe(true);
});
it('rejects fewer than two lines', () => {
expect(isSplitValid(-10000, lines(-10000))).toBe(false);
});
it('rejects lines that do not add up to the total', () => {
expect(isSplitValid(-10000, lines(-2500, -2500))).toBe(false);
});
it('rejects a zero-amount line', () => {
expect(isSplitValid(-10000, lines(-10000, 0))).toBe(false);
});
it('rejects a line whose sign differs from the total', () => {
expect(isSplitValid(-10000, lines(-12500, 2500))).toBe(false);
});
});

View File

@ -0,0 +1,182 @@
import { LabelCombobox } from '@/components/shared/label-combobox';
import { CategorySelect } from '@/components/transactions/category-select';
import { AmountInput } from '@/components/ui/amount-input';
import { Button } from '@/components/ui/button';
import { useLocale } from '@/hooks/use-locale';
import { type Category } from '@/types/category';
import { type Label } from '@/types/label';
import { __ } from '@/utils/i18n';
import { Plus, X } from 'lucide-react';
export interface SplitLineDraft {
key: string;
category_id: string | null;
amount: number;
label_ids: string[];
}
interface SplitEditorProps {
total: number;
currencyCode: string;
categories: Category[];
labels: Label[];
lines: SplitLineDraft[];
onChange: (lines: SplitLineDraft[]) => void;
disabled?: boolean;
onLabelCreated?: (label: Label) => void;
}
let keyCounter = 0;
export function newSplitLine(
partial?: Partial<SplitLineDraft>,
): SplitLineDraft {
keyCounter += 1;
return {
key: `split-${keyCounter}`,
category_id: null,
amount: 0,
label_ids: [],
...partial,
};
}
export function splitRemaining(total: number, lines: SplitLineDraft[]): number {
return total - lines.reduce((sum, line) => sum + line.amount, 0);
}
/**
* 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.
*/
export function isSplitValid(total: number, lines: SplitLineDraft[]): boolean {
if (lines.length < 2 || splitRemaining(total, lines) !== 0) {
return false;
}
return lines.every(
(line) =>
line.amount !== 0 && (total === 0 || line.amount > 0 === total > 0),
);
}
export function SplitEditor({
total,
currencyCode,
categories,
labels,
lines,
onChange,
disabled,
onLabelCreated,
}: SplitEditorProps) {
const locale = useLocale();
const remaining = splitRemaining(total, lines);
const updateLine = (key: string, patch: Partial<SplitLineDraft>) =>
onChange(
lines.map((line) =>
line.key === key ? { ...line, ...patch } : line,
),
);
const removeLine = (key: string) =>
onChange(lines.filter((line) => line.key !== key));
const addLine = () =>
onChange([...lines, newSplitLine({ amount: remaining })]);
const formattedRemaining = new Intl.NumberFormat(locale, {
style: 'currency',
currency: currencyCode,
}).format(remaining / 100);
return (
<div className="space-y-3" data-testid="split-editor">
{lines.map((line) => (
<div
key={line.key}
className="space-y-2 rounded-md border border-border p-3"
>
<div className="flex items-start gap-2">
<div className="flex-1">
<CategorySelect
value={line.category_id ?? 'null'}
onValueChange={(value) =>
updateLine(line.key, {
category_id:
value === 'null' ||
value === 'uncategorized'
? null
: value,
})
}
categories={categories}
disabled={disabled}
showUncategorized={true}
triggerClassName="w-full"
/>
</div>
<div className="w-32">
<AmountInput
value={line.amount}
onChange={(value) =>
updateLine(line.key, { amount: value })
}
currencyCode={currencyCode}
disabled={disabled}
allowNegative={true}
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeLine(line.key)}
disabled={disabled || lines.length <= 2}
aria-label={__('Remove line')}
>
<X className="h-4 w-4" />
</Button>
</div>
<LabelCombobox
value={line.label_ids}
onValueChange={(ids) =>
updateLine(line.key, { label_ids: ids })
}
labels={labels}
disabled={disabled}
placeholder={__('Add labels...')}
allowCreate={true}
onLabelCreated={onLabelCreated}
/>
</div>
))}
<div className="flex items-center justify-between">
<Button
type="button"
variant="outline"
size="sm"
onClick={addLine}
disabled={disabled}
>
<Plus className="mr-1 h-4 w-4" />
{__('Add line')}
</Button>
<span
className={
remaining === 0
? 'text-sm text-muted-foreground'
: 'text-sm font-medium text-destructive'
}
data-testid="split-remaining"
>
{__('Remaining: :amount', { amount: formattedRemaining })}
</span>
</div>
</div>
);
}

View File

@ -45,6 +45,9 @@ interface CreateColumnsOptions {
isDateHidden?: boolean;
/** Ids of transactions AI is categorizing in the background right now. */
categorizingIds?: Set<string>;
/** 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<DecryptedTransaction>[] {
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}
/>
);
},

View File

@ -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({});
}

View File

@ -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<Transaction> {
interface TransactionUpdateData extends Omit<Partial<Transaction>, '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 {

View File

@ -43,6 +43,7 @@ export interface Features {
cashflow: boolean;
calculateBalancesOnImport: boolean;
mcp: boolean;
transactionSplitting: boolean;
}
export interface ExpiredBankingConnectionNotification {

View File

@ -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 {

View File

@ -0,0 +1,323 @@
<?php
use App\Enums\CategoryCashflowDirection;
use App\Enums\CategoryType;
use App\Enums\TransactionSource;
use App\Features\TransactionSplitting;
use App\Models\Budget;
use App\Models\BudgetPeriod;
use App\Models\BudgetTransaction;
use App\Models\Category;
use App\Models\Label;
use App\Models\Transaction;
use App\Models\TransactionSplit;
use App\Models\User;
use App\Services\BudgetTransactionService;
use App\Services\CategorySpendingService;
use App\Services\TransactionSplitter;
use Illuminate\Support\Facades\DB;
use Laravel\Pennant\Feature;
use function Pest\Laravel\actingAs;
function expenseCategory(User $user, string $name = 'Cat'): Category
{
return Category::factory()->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);
});