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