This commit is contained in:
Víctor Falcón 2026-07-19 13:06:39 +00:00 committed by GitHub
commit bfcadda63e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 1892 additions and 267 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

@ -9,6 +9,7 @@ use App\Models\Label;
use App\Models\Transaction;
use App\Services\CategoryTree;
use App\Services\ExchangeRateService;
use App\Services\TransactionAllocations;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Collection;
@ -56,14 +57,19 @@ class TransactionAnalysisController extends Controller
$transactions = Transaction::query()
->where('user_id', $user->id)
->with(['account.bank', 'category', 'labels'])
->with(['account.bank', 'category', 'labels', 'splits.category', 'splits.labels'])
->applyFilters($filters)
->get();
$this->preloadExchangeRates($transactions, $currency);
$byCategory = $this->categoryBreakdown($transactions, $currency, $user->id);
$byTag = $this->tagBreakdown($transactions, $currency);
// Category and tag breakdowns are per-line for split transactions; payee,
// account and the totals stay at the transaction level (a split doesn't
// change who was paid or the amount that moved).
$allocations = TransactionAllocations::expand($transactions);
$byCategory = $this->categoryBreakdown($allocations, $currency, $user->id);
$byTag = $this->tagBreakdown($allocations, $currency);
$byPayee = $this->payeeBreakdown($transactions, $currency);
$byAccount = $this->accountBreakdown($transactions, $currency);

View File

@ -4,11 +4,11 @@ namespace App\Http\Controllers;
use App\Enums\CategoryType;
use App\Models\Account;
use App\Models\Transaction;
use App\Services\AccountMetricsService;
use App\Services\CashflowSummaryService;
use App\Services\CategorySpendingService;
use App\Services\PeriodComparator;
use App\Services\TransactionAllocations;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@ -110,9 +110,11 @@ class DashboardController extends Controller
private function getTransactionSum(string $userId, Carbon $from, Carbon $to, CategoryType $type): int
{
return Transaction::query()
->where('transactions.user_id', $userId)
->whereBetween('transactions.transaction_date', [$from, $to])
// Read effective allocations so a split's lines are classified by their
// own category type (a savings/transfer line no longer counts as a plain
// expense just because the parent row has no category).
return DB::query()
->fromSub(TransactionAllocations::query($userId, $from, $to), 'transactions')
->where(function ($q) use ($type) {
$q->whereExists(function ($sub) use ($type) {
$sub->select(DB::raw(1))

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,31 @@ class TransactionController extends Controller
$transactions = $query->get();
}
$labelIds = $request->input('label_ids');
$hasLabelUpdate = $request->has('label_ids');
$hasCategoryUpdate = $request->has('category_id');
// A bulk category or label assignment skips split transactions: setting a
// single category would destroy their lines, and labels live on the lines,
// not the row. Notes still apply (they are transaction-level). We report
// how many splits were left untouched.
$omittedSplitIds = collect();
if ($hasCategoryUpdate || $hasLabelUpdate) {
$transactions->loadMissing('splits:id,transaction_id');
$omittedSplitIds = $transactions->filter->isSplit()->pluck('id');
}
$updateData = [];
if ($request->has('category_id')) {
if ($hasCategoryUpdate) {
$newCategoryId = $request->input('category_id');
$overrideHandler = app(CategoryOverrideHandler::class);
foreach ($transactions as $transaction) {
if ($transaction->isSplit()) {
continue;
}
$overrideHandler->record($transaction, $newCategoryId);
}
@ -347,9 +393,6 @@ class TransactionController extends Controller
$updateData['notes_iv'] = $request->input('notes_iv');
}
$labelIds = $request->input('label_ids');
$hasLabelUpdate = $request->has('label_ids');
if (empty($updateData) && ! $hasLabelUpdate) {
return response()->json([
'message' => 'No update data provided.',
@ -363,11 +406,18 @@ class TransactionController extends Controller
} elseif ($filters !== null) {
$updateQuery->applyFilters($filters);
}
if ($hasCategoryUpdate && $omittedSplitIds->isNotEmpty()) {
$updateQuery->whereNotIn('id', $omittedSplitIds->all());
}
$updateQuery->update($updateData);
}
if ($hasLabelUpdate) {
foreach ($transactions as $transaction) {
if ($transaction->isSplit()) {
continue;
}
$transaction->labels()->sync($labelIds ?? []);
$transaction->save();
}
@ -375,7 +425,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,71 @@ 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();
if ($total === 0) {
$validator->errors()->add('splits', __("You can't split a transaction with no amount."));
return;
}
$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 (($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 +126,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,94 @@
<?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;
/**
* Effective category attribution: an unsplit transaction is attributed to its
* own category, a split transaction to its lines. The category breakdowns read
* through here either the SQL {@see self::query()} or the collection
* {@see self::expand()} so a split is never double-counted or dropped. The two
* encarnations share the "iterate lines vs the row itself" rule but not code:
* SQL sums raw rows, expand() needs hydrated models (account/category/labels)
* for currency conversion and side classification.
*/
class TransactionAllocations
{
/**
* 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 + labels while keeping the parent's account/currency/date
* for FX and side classification); unsplit transactions pass through
* unchanged.
*
* Callers must eager-load `splits.category` (and `splits.labels`/`account`
* where they read those). Synthetic line instances deliberately keep the
* parent's `id`, so consumers must aggregate by `category_id`/labels, never
* `keyBy('id')`/`unique('id')` (that would collapse a split's lines).
*
* @param Collection<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);
if ($line->relationLoaded('labels')) {
$allocation->setRelation('labels', $line->labels);
}
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,16 @@
{
"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",
"Each line needs a non-zero amount": "Each line needs a non-zero amount",
"Split transactions skipped: :count": "Split transactions skipped: :count",
"A split needs at least two lines.": "A split needs at least two lines.",
"The split lines must add up to the transaction amount.": "The split lines must add up to the transaction amount.",
"Each split line must be a non-zero amount.": "Each split line must be a non-zero amount.",
"You can't split a transaction with no amount.": "You can't split a transaction with no amount.",
"Split lines must have the same sign as the transaction.": "Split lines must have the same sign as the transaction."
}

View File

@ -2294,5 +2294,18 @@
"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",
"Each line needs a non-zero amount": "Cada línea necesita un importe distinto de cero",
"Split transactions skipped: :count": "Transacciones divididas omitidas: :count",
"A split needs at least two lines.": "Una división necesita al menos dos líneas.",
"The split lines must add up to the transaction amount.": "Las líneas de la división deben sumar el importe de la transacción.",
"Each split line must be a non-zero amount.": "Cada línea de la división debe tener un importe distinto de cero.",
"You can't split a transaction with no amount.": "No puedes dividir una transacción sin importe.",
"Split lines must have the same sign as the transaction.": "Las líneas de la división deben tener el mismo signo que la transacción."
}

View File

@ -9,17 +9,23 @@ 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 {
isSplit,
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 +43,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 +58,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 (isSplit(transaction)) {
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 +282,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,
@ -37,12 +44,12 @@ import {
import { type AutomationRule } from '@/types/automation-rule';
import { type Category } from '@/types/category';
import { type Label } from '@/types/label';
import { type DecryptedTransaction } from '@/types/transaction';
import { isSplit, 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 ? isSplit(transaction) : false;
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,75 @@ 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. Only send `splits` when the split content
// actually changed (or was undone) — editing another field of an
// already-split transaction must not re-trigger the gated split
// write, so it keeps working even if the feature is rolled back.
const originalSplitSignature = JSON.stringify(
(transaction.splits ?? []).map((split) => ({
category_id: split.category_id,
amount: split.amount,
label_ids: [
...(split.labels?.map((label) => label.id) ?? []),
].sort(),
})),
);
const currentSplitSignature = JSON.stringify(
splitLines.map((line) => ({
category_id: line.category_id,
amount: line.amount,
label_ids: [...line.label_ids].sort(),
})),
);
const splitDirty =
(isSplitting &&
currentSplitSignature !== originalSplitSignature) ||
(!isSplitting && wasSplit);
if (splitDirty) {
if (isSplitting) {
if (!isSplitValid(amount, splitLines)) {
toast.error(
__(
'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 +937,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 +1054,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,44 @@
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);
});
it('rejects splitting a zero-total transaction', () => {
expect(isSplitValid(0, lines(50, -50))).toBe(false);
});
});

View File

@ -0,0 +1,186 @@
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;
}
export function newSplitLine(
partial?: Partial<SplitLineDraft>,
): SplitLineDraft {
return {
key: crypto.randomUUID(),
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 (non-zero) transaction total and together
* reconcile to it to the cent.
*/
export function isSplitValid(total: number, lines: SplitLineDraft[]): boolean {
if (total === 0 || lines.length < 2 || splitRemaining(total, lines) !== 0) {
return false;
}
return lines.every(
(line) => line.amount !== 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 valid = isSplitValid(total, lines);
// Remaining can be 0 while the split is still invalid (e.g. a seeded
// zero-amount line), so key the styling off validity, not just the balance.
const hasZeroLine = lines.some((line) => line.amount === 0);
const formattedRemaining = new Intl.NumberFormat(locale, {
style: 'currency',
currency: currencyCode,
}).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={
valid
? 'text-sm text-muted-foreground'
: 'text-sm font-medium text-destructive'
}
data-testid="split-remaining"
>
{remaining === 0 && hasZeroLine
? __('Each line needs a non-zero amount')
: __('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

@ -104,6 +104,7 @@ import {
import { type Category } from '@/types/category';
import { type Label } from '@/types/label';
import {
isSplit,
type DecryptedTransaction,
type TransactionFilters as Filters,
type ServerTransaction,
@ -1052,6 +1053,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 +1067,8 @@ export default function Transactions({
handleReEvaluateRules,
columnVisibility,
categorizingIds,
filters.categoryIds,
filters.labelIds,
],
);
@ -1120,19 +1125,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(
__('Split transactions skipped: :count', {
count: response.data.omitted_splits,
}),
);
}
setRowSelection({});
setIsSelectingAll(false);
refreshTransactions();
@ -1144,28 +1156,48 @@ 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) => isSplit(transaction))
.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(
__('Split transactions skipped: :count', {
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,14 +47,21 @@ export interface Transaction {
ai_confidence?: number | null;
ai_categorized?: boolean;
label_ids?: UUID[];
splits?: TransactionSplit[];
created_at: string;
updated_at: string;
}
/** Whether a transaction is split into per-category lines. */
export function isSplit(transaction: Pick<Transaction, 'splits'>): boolean {
return (transaction.splits?.length ?? 0) > 0;
}
export interface ServerTransaction extends Transaction {
account?: Account;
category?: Category | null;
labels?: Label[];
splits?: TransactionSplit[];
}
export interface DecryptedTransaction extends Transaction {
@ -48,6 +71,7 @@ export interface DecryptedTransaction extends Transaction {
category?: Category | null;
bank?: Bank;
labels?: Label[];
splits?: TransactionSplit[];
}
export interface TransactionFilters {

View File

@ -100,6 +100,7 @@ test('shared feature flags do not include coinbase flag', function () {
'cashflow' => true,
'calculateBalancesOnImport' => false,
'mcp' => false,
'transactionSplitting' => false,
]);
});

View File

@ -0,0 +1,403 @@
<?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 splitTestExpenseCategory(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 = splitTestExpenseCategory($this->user, 'Restaurant');
$travel = splitTestExpenseCategory($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 = splitTestExpenseCategory($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 = splitTestExpenseCategory($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 = splitTestExpenseCategory($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 = splitTestExpenseCategory($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 = splitTestExpenseCategory($this->user);
$target = splitTestExpenseCategory($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 = splitTestExpenseCategory($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 = splitTestExpenseCategory($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 = splitTestExpenseCategory($this->user, 'Restaurant');
$travel = splitTestExpenseCategory($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 = splitTestExpenseCategory($this->user, 'Restaurant');
$travel = splitTestExpenseCategory($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 = splitTestExpenseCategory($this->user, 'Tracked');
$other = splitTestExpenseCategory($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 = splitTestExpenseCategory($this->user, 'Restaurant');
$travel = splitTestExpenseCategory($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 = splitTestExpenseCategory($this->user, 'Restaurant');
$travel = splitTestExpenseCategory($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('rejects splitting a zero-amount transaction', function () {
$category = splitTestExpenseCategory($this->user);
$transaction = splittableTransaction($this->user, 0);
actingAs($this->user)
->patchJson(route('transactions.update', $transaction), [
'splits' => [
['category_id' => $category->id, 'amount' => 50],
['category_id' => $category->id, 'amount' => -50],
],
])
->assertJsonValidationErrors(['splits']);
});
it('classifies split lines by their own category type in the dashboard cash flow', function () {
$expense = splitTestExpenseCategory($this->user, 'Groceries');
$savings = Category::factory()->for($this->user)->create([
'name' => 'Savings',
'type' => CategoryType::Savings,
'cashflow_direction' => CategoryCashflowDirection::Outflow,
]);
$transaction = splittableTransaction($this->user, -10000);
$transaction->update(['transaction_date' => now()]);
app(TransactionSplitter::class)->apply($transaction, [
['category_id' => $expense->id, 'amount' => -3000],
['category_id' => $savings->id, 'amount' => -7000],
]);
$response = actingAs($this->user)->getJson(
'/api/dashboard/cash-flow?from='.now()->startOfMonth()->toDateString().'&to='.now()->endOfMonth()->toDateString(),
)->assertOk();
// Only the expense line counts as expense; the savings line is excluded.
expect($response->json('current.expense'))->toBe(3000);
});
it('attributes split lines per category on the analysis screen', function () {
$restaurant = splitTestExpenseCategory($this->user, 'Restaurant');
$travel = splitTestExpenseCategory($this->user, 'Travel');
$transaction = splittableTransaction($this->user, -10000);
$transaction->update(['transaction_date' => now()]);
app(TransactionSplitter::class)->apply($transaction, [
['category_id' => $restaurant->id, 'amount' => -2500],
['category_id' => $travel->id, 'amount' => -7500],
]);
$response = actingAs($this->user)->getJson(
'/api/transactions/analysis?date_from='.now()->subDay()->toDateString().'&date_to='.now()->addDay()->toDateString(),
)->assertOk();
$byCategory = collect($response->json('by_category'))->keyBy('category_id');
expect($byCategory[$restaurant->id]['amount'])->toBe(2500);
expect($byCategory[$travel->id]['amount'])->toBe(7500);
});
it('skips split transactions on a bulk label assignment', function () {
$category = splitTestExpenseCategory($this->user);
$label = Label::factory()->for($this->user)->create();
$split = splittableTransaction($this->user, -10000);
$plain = splittableTransaction($this->user, -5000);
app(TransactionSplitter::class)->apply($split, [
['category_id' => $category->id, 'amount' => -2500],
['category_id' => $category->id, 'amount' => -7500],
]);
actingAs($this->user)
->patchJson(route('transactions.bulk-update'), [
'transaction_ids' => [$split->id, $plain->id],
'label_ids' => [$label->id],
])
->assertOk();
expect($split->fresh()->labels)->toHaveCount(0);
expect($plain->fresh()->labels->pluck('id')->all())->toContain($label->id);
});
it('skips split transactions on a bulk category assignment', function () {
$category = splitTestExpenseCategory($this->user);
$newCategory = splitTestExpenseCategory($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);
});

View File

@ -78,7 +78,8 @@ test('cashflow trend API does not exceed query threshold', function () {
});
test('cashflow breakdown API does not exceed query threshold', function () {
assertMaxQueries(15, function () {
// +1 vs. pre-split: the breakdown eager-loads each transaction's splits.
assertMaxQueries(16, function () {
$this->getJson("/api/cashflow/breakdown?type=expense&{$this->dateParams}")->assertOk();
}, 'API Cashflow Breakdown');
});

View File

@ -57,7 +57,8 @@ test('account show page does not exceed query threshold', function () {
});
test('transactions index page does not exceed query threshold', function () {
assertMaxQueries(21, function () {
// +1 vs. pre-split: the list eager-loads each transaction's splits.
assertMaxQueries(22, function () {
$this->get(route('transactions.index'))->assertOk();
}, 'Transactions Index');
});
@ -163,8 +164,9 @@ test('transactions page query count does not scale with number of transactions',
'category_id' => $category->id,
]);
// Same threshold — paginated queries should not scale
assertMaxQueries(21, function () {
// Same threshold as the base case — the splits eager-load is a single bulk
// query, so it stays constant regardless of how many transactions there are.
assertMaxQueries(22, function () {
$this->get(route('transactions.index'))->assertOk();
}, 'Transactions with 120 records');
});