feat(transactions): split a transaction across multiple categories
Add per-line splitting: a transaction can be divided into lines, each with its own category, amount and labels, summing to the transaction total. The transaction's own category is nulled and the lines become the source of truth. - transaction_splits + label_split tables, TransactionSplit model - split/unsplit via the existing PATCH update (validated: sum, sign, min 2) - shared effective-allocation layer (SQL + collection) so every category breakdown (dashboard top categories, cashflow sankey/trend/breakdown, dashboard analytics, MCP spending) attributes split lines correctly - budgets attribute per line (category + label match), aggregated per period - list shows the per-line breakdown, collapsing non-matching lines into a 'Rest' row under a category/label filter; bulk category assignment skips split transactions - gated behind the TransactionSplitting Pennant feature
This commit is contained in:
parent
89174af4e6
commit
0b46fff3c2
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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) === [];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Transaction;
|
||||
use App\Models\TransactionSplit;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Query\Builder as QueryBuilder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* The single source of truth for "effective category attribution": an unsplit
|
||||
* transaction is attributed to its own category, a split transaction to its
|
||||
* lines. Every category breakdown reads through here so a split never gets
|
||||
* double-counted or dropped.
|
||||
*/
|
||||
class TransactionAllocations
|
||||
{
|
||||
/**
|
||||
* SQL allocation rows for a user's transactions in a period — one row per
|
||||
* unsplit transaction (its own category + amount) and one per split line
|
||||
* (the line's category + amount). Soft-deleted transactions are excluded.
|
||||
* Returned as a query builder so callers can alias it as `transactions` and
|
||||
* keep their existing joins/aggregations unchanged.
|
||||
*/
|
||||
public static function query(string $userId, Carbon $from, Carbon $to): QueryBuilder
|
||||
{
|
||||
$unsplit = DB::table('transactions as t')
|
||||
->select('t.category_id', 't.amount', 't.transaction_date')
|
||||
->whereNull('t.deleted_at')
|
||||
->where('t.user_id', $userId)
|
||||
->whereBetween('t.transaction_date', [$from, $to])
|
||||
->whereNotExists(function (QueryBuilder $query): void {
|
||||
$query->select(DB::raw(1))
|
||||
->from('transaction_splits as s')
|
||||
->whereColumn('s.transaction_id', 't.id');
|
||||
});
|
||||
|
||||
$lines = DB::table('transaction_splits as s')
|
||||
->join('transactions as t', 't.id', '=', 's.transaction_id')
|
||||
->select('s.category_id', 's.amount', 't.transaction_date')
|
||||
->whereNull('t.deleted_at')
|
||||
->where('t.user_id', $userId)
|
||||
->whereBetween('t.transaction_date', [$from, $to]);
|
||||
|
||||
return $unsplit->unionAll($lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a loaded transaction collection into effective allocations: a split
|
||||
* transaction becomes one synthetic Transaction per line (carrying the line's
|
||||
* amount + category while keeping the parent's account/currency/date for FX
|
||||
* and side classification); unsplit transactions pass through unchanged.
|
||||
*
|
||||
* Callers must eager-load `splits.category` (and `account` for FX).
|
||||
*
|
||||
* @param Collection<int, Transaction> $transactions
|
||||
* @return Collection<int, Transaction>
|
||||
*/
|
||||
public static function expand(Collection $transactions): Collection
|
||||
{
|
||||
return $transactions->flatMap(function (Transaction $transaction): array {
|
||||
$splits = $transaction->relationLoaded('splits') ? $transaction->splits : null;
|
||||
|
||||
if ($splits === null || $splits->isEmpty()) {
|
||||
return [$transaction];
|
||||
}
|
||||
|
||||
return $splits->map(function (TransactionSplit $line) use ($transaction): Transaction {
|
||||
$allocation = (new Transaction)->setRawAttributes($transaction->getAttributes());
|
||||
$allocation->forceFill([
|
||||
'amount' => $line->amount,
|
||||
'category_id' => $line->category_id,
|
||||
]);
|
||||
$allocation->setRelation('account', $transaction->relationLoaded('account') ? $transaction->account : null);
|
||||
$allocation->setRelation('category', $line->relationLoaded('category') ? $line->category : null);
|
||||
|
||||
return $allocation;
|
||||
})->all();
|
||||
})->values();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
13
lang/en.json
13
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."
|
||||
}
|
||||
|
|
|
|||
13
lang/es.json
13
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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,17 +9,22 @@ import {
|
|||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useLocale } from '@/hooks/use-locale';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { billing } from '@/routes/settings';
|
||||
import { transactionSyncService } from '@/services/transaction-sync';
|
||||
import { type SharedData } from '@/types';
|
||||
import { type Account, type Bank } from '@/types/account';
|
||||
import { type Category } from '@/types/category';
|
||||
import { type DecryptedTransaction } from '@/types/transaction';
|
||||
import { getCategoryColorClasses, type Category } from '@/types/category';
|
||||
import {
|
||||
type DecryptedTransaction,
|
||||
type TransactionSplit,
|
||||
} from '@/types/transaction';
|
||||
import { __ } from '@/utils/i18n';
|
||||
import { router, usePage } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import * as Icons from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface CategoryCellProps {
|
||||
|
|
@ -37,6 +42,9 @@ interface CategoryCellProps {
|
|||
withoutChevronIcon?: boolean;
|
||||
/** AI is currently categorizing this row in the background. */
|
||||
isCategorizing?: boolean;
|
||||
/** Active filters, used to collapse non-matching split lines into a "Rest" row. */
|
||||
appliedCategoryIds?: string[];
|
||||
appliedLabelIds?: string[];
|
||||
}
|
||||
|
||||
export function CategoryCell({
|
||||
|
|
@ -49,12 +57,28 @@ export function CategoryCell({
|
|||
className,
|
||||
withoutChevronIcon,
|
||||
isCategorizing,
|
||||
appliedCategoryIds = [],
|
||||
appliedLabelIds = [],
|
||||
}: CategoryCellProps) {
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const { auth, subscriptionsEnabled, aiCategorizationUpsellRate } =
|
||||
usePage<SharedData>().props;
|
||||
|
||||
// A split transaction shows its per-line breakdown instead of a single,
|
||||
// editable category. Editing happens in the modal (click the row).
|
||||
if (transaction.splits && transaction.splits.length > 0) {
|
||||
return (
|
||||
<SplitBreakdown
|
||||
transaction={transaction}
|
||||
categories={categories}
|
||||
appliedCategoryIds={appliedCategoryIds}
|
||||
appliedLabelIds={appliedLabelIds}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Free-plan nudge: AI could categorize this row. Sampled to a configurable
|
||||
// share of rows so it stays subtle instead of marking every uncategorized one.
|
||||
const showAiUpsell =
|
||||
|
|
@ -257,3 +281,160 @@ export function CategoryCell({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SplitCategoryChip({ category }: { category?: Category | null }) {
|
||||
if (!category) {
|
||||
return (
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{__('Uncategorized')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const colorClasses = getCategoryColorClasses(category.color);
|
||||
const IconComponent = Icons[
|
||||
category.icon as keyof typeof Icons
|
||||
] as Icons.LucideIcon;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex min-w-0 items-center gap-1 rounded px-1.5 py-0.5 text-xs',
|
||||
colorClasses.bg,
|
||||
colorClasses.text,
|
||||
)}
|
||||
>
|
||||
{IconComponent && (
|
||||
<IconComponent className="h-3 w-3 shrink-0 opacity-80" />
|
||||
)}
|
||||
<span className="truncate">{category.name}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-line breakdown shown in the list for a split transaction. Without an
|
||||
* active category/label filter every line is listed; with one, only the lines
|
||||
* matching the filter are shown plus a single "Rest" row for the remainder, so
|
||||
* the visible parts still add up to the transaction total.
|
||||
*/
|
||||
function SplitBreakdown({
|
||||
transaction,
|
||||
categories,
|
||||
appliedCategoryIds,
|
||||
appliedLabelIds,
|
||||
className,
|
||||
}: {
|
||||
transaction: DecryptedTransaction;
|
||||
categories: Category[];
|
||||
appliedCategoryIds: string[];
|
||||
appliedLabelIds: string[];
|
||||
className?: string;
|
||||
}) {
|
||||
const locale = useLocale();
|
||||
const splits = transaction.splits ?? [];
|
||||
const currencyCode = transaction.currency_code;
|
||||
|
||||
const parentOf = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
categories.map((category) => [category.id, category.parent_id]),
|
||||
),
|
||||
[categories],
|
||||
);
|
||||
|
||||
const formatAmount = (amount: number) =>
|
||||
new Intl.NumberFormat(locale, {
|
||||
style: 'currency',
|
||||
currency: currencyCode,
|
||||
}).format(amount / 100);
|
||||
|
||||
const filterActive =
|
||||
appliedCategoryIds.length > 0 || appliedLabelIds.length > 0;
|
||||
|
||||
const lineMatchesFilter = (split: TransactionSplit): boolean => {
|
||||
let matches = false;
|
||||
|
||||
if (appliedCategoryIds.length > 0) {
|
||||
if (split.category_id === null) {
|
||||
matches ||= appliedCategoryIds.includes('uncategorized');
|
||||
} else {
|
||||
const chain: string[] = [];
|
||||
let current: string | null | undefined = split.category_id;
|
||||
let guard = 0;
|
||||
while (current && guard++ < 10) {
|
||||
chain.push(current);
|
||||
current = parentOf.get(current) ?? null;
|
||||
}
|
||||
matches ||= chain.some((id) => appliedCategoryIds.includes(id));
|
||||
}
|
||||
}
|
||||
|
||||
if (appliedLabelIds.length > 0) {
|
||||
matches ||= (split.labels ?? []).some((label) =>
|
||||
appliedLabelIds.includes(label.id),
|
||||
);
|
||||
}
|
||||
|
||||
return matches;
|
||||
};
|
||||
|
||||
const rows: {
|
||||
key: string;
|
||||
category?: Category | null;
|
||||
amount: number;
|
||||
isRest?: boolean;
|
||||
}[] = [];
|
||||
|
||||
if (filterActive) {
|
||||
const matching = splits.filter(lineMatchesFilter);
|
||||
const restAmount = splits
|
||||
.filter((split) => !matching.includes(split))
|
||||
.reduce((sum, split) => sum + split.amount, 0);
|
||||
|
||||
matching.forEach((split) =>
|
||||
rows.push({
|
||||
key: split.id,
|
||||
category: split.category,
|
||||
amount: split.amount,
|
||||
}),
|
||||
);
|
||||
|
||||
if (restAmount !== 0) {
|
||||
rows.push({ key: 'rest', amount: restAmount, isRest: true });
|
||||
}
|
||||
} else {
|
||||
splits.forEach((split) =>
|
||||
rows.push({
|
||||
key: split.id,
|
||||
category: split.category,
|
||||
amount: split.amount,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex w-full flex-col gap-1', className)}
|
||||
data-testid="split-breakdown"
|
||||
>
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.key}
|
||||
className="flex items-center justify-between gap-2"
|
||||
>
|
||||
{row.isRest ? (
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{__('Rest')}
|
||||
</span>
|
||||
) : (
|
||||
<SplitCategoryChip category={row.category} />
|
||||
)}
|
||||
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
|
||||
{formatAmount(row.amount)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import { destroy } from '@/actions/App/Http/Controllers/Settings/AutomationRuleController';
|
||||
import { LabelCombobox } from '@/components/shared/label-combobox';
|
||||
import { CategorySelect } from '@/components/transactions/category-select';
|
||||
import {
|
||||
isSplitValid,
|
||||
newSplitLine,
|
||||
SplitEditor,
|
||||
type SplitLineDraft,
|
||||
} from '@/components/transactions/split-editor';
|
||||
import { AmountInput } from '@/components/ui/amount-input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
|
|
@ -29,6 +35,7 @@ import { getStoredKey } from '@/lib/key-storage';
|
|||
import { evaluateRulesForNewTransaction } from '@/lib/rule-engine';
|
||||
import { appendNoteIfNotPresent } from '@/lib/utils';
|
||||
import { transactionSyncService } from '@/services/transaction-sync';
|
||||
import { type SharedData } from '@/types';
|
||||
import {
|
||||
filterTransactionalAccounts,
|
||||
type Account,
|
||||
|
|
@ -40,9 +47,9 @@ import { type Label } from '@/types/label';
|
|||
import { type DecryptedTransaction } from '@/types/transaction';
|
||||
import { formatDate } from '@/utils/date';
|
||||
import { __ } from '@/utils/i18n';
|
||||
import { router } from '@inertiajs/react';
|
||||
import { router, usePage } from '@inertiajs/react';
|
||||
import { getYear, parseISO } from 'date-fns';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { Split, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
|
|
@ -95,6 +102,7 @@ export function EditTransactionDialog({
|
|||
const [categoryId, setCategoryId] = useState<string>('null');
|
||||
const [selectedLabelIds, setSelectedLabelIds] = useState<string[]>([]);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [splitLines, setSplitLines] = useState<SplitLineDraft[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [decryptedAccountNames, setDecryptedAccountNames] = useState<
|
||||
Map<string, string>
|
||||
|
|
@ -113,6 +121,24 @@ export function EditTransactionDialog({
|
|||
const canEditAllFields =
|
||||
mode === 'create' || transaction?.source === 'manually_created';
|
||||
|
||||
const splittingEnabled =
|
||||
usePage<SharedData>().props.features.transactionSplitting;
|
||||
const isSplitting = splitLines.length > 0;
|
||||
const wasSplit = (transaction?.splits?.length ?? 0) > 0;
|
||||
|
||||
function startSplit() {
|
||||
// Seed with the current category as the first line at the full amount,
|
||||
// plus an empty second line the user fills as they split it up.
|
||||
setSplitLines([
|
||||
newSplitLine({
|
||||
category_id: categoryId === 'null' ? null : categoryId,
|
||||
amount,
|
||||
label_ids: selectedLabelIds,
|
||||
}),
|
||||
newSplitLine(),
|
||||
]);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === 'edit' && transaction) {
|
||||
setTransactionDate(transaction.transaction_date);
|
||||
|
|
@ -126,6 +152,15 @@ export function EditTransactionDialog({
|
|||
[],
|
||||
);
|
||||
setNotes(transaction.decryptedNotes || '');
|
||||
setSplitLines(
|
||||
(transaction.splits ?? []).map((split) =>
|
||||
newSplitLine({
|
||||
category_id: split.category_id,
|
||||
amount: split.amount,
|
||||
label_ids: split.labels?.map((label) => label.id) ?? [],
|
||||
}),
|
||||
),
|
||||
);
|
||||
} else if (mode === 'create' && open) {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
setTransactionDate(today);
|
||||
|
|
@ -139,6 +174,7 @@ export function EditTransactionDialog({
|
|||
setCategoryId('null');
|
||||
setSelectedLabelIds([]);
|
||||
setNotes('');
|
||||
setSplitLines([]);
|
||||
}
|
||||
}, [mode, transaction, open, accounts, initialAccountId]);
|
||||
|
||||
|
|
@ -427,6 +463,11 @@ export function EditTransactionDialog({
|
|||
transaction_date?: string;
|
||||
account_id?: string;
|
||||
currency_code?: string;
|
||||
splits?: {
|
||||
category_id: string | null;
|
||||
amount: number;
|
||||
label_ids: string[];
|
||||
}[];
|
||||
} = {
|
||||
category_id: selectedCategoryId,
|
||||
notes: encryptedNotes,
|
||||
|
|
@ -453,6 +494,51 @@ export function EditTransactionDialog({
|
|||
updateData.currency_code = editedCurrencyCode;
|
||||
}
|
||||
|
||||
// A split change turns one row into several (or back). Persist it
|
||||
// and let the server-rendered list re-fetch rather than patch the
|
||||
// grouped rows by hand.
|
||||
if (isSplitting || wasSplit) {
|
||||
if (isSplitting) {
|
||||
if (!isSplitValid(amount, splitLines)) {
|
||||
toast.error(
|
||||
__(
|
||||
'The split lines must add up to the transaction amount.',
|
||||
),
|
||||
);
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// The lines own categorisation and labels now; drop the
|
||||
// transaction-level values so the backend nulls them.
|
||||
updateData.category_id = null;
|
||||
delete updateData.label_ids;
|
||||
updateData.splits = splitLines.map((line) => ({
|
||||
category_id: line.category_id,
|
||||
amount: line.amount,
|
||||
label_ids: line.label_ids,
|
||||
}));
|
||||
} else {
|
||||
// Collapse an existing split back to a single category.
|
||||
updateData.splits = [];
|
||||
}
|
||||
|
||||
await transactionSyncService.update(
|
||||
transaction.id,
|
||||
updateData,
|
||||
{
|
||||
updateBalance: canEditAllFields
|
||||
? updateAccountBalance
|
||||
: false,
|
||||
},
|
||||
);
|
||||
|
||||
toast.success(__('Transaction updated successfully'));
|
||||
onOpenChange(false);
|
||||
router.reload({ only: ['transactions'] });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await transactionSyncService.update(
|
||||
transaction.id,
|
||||
updateData,
|
||||
|
|
@ -827,34 +913,83 @@ export function EditTransactionDialog({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="category">
|
||||
{__('Category')}
|
||||
</FormLabel>
|
||||
<CategorySelect
|
||||
value={categoryId}
|
||||
onValueChange={setCategoryId}
|
||||
categories={categories}
|
||||
disabled={isSubmitting}
|
||||
placeholder={__('Uncategorized')}
|
||||
triggerClassName="w-full"
|
||||
showUncategorized={true}
|
||||
data-testid="category-select"
|
||||
/>
|
||||
</div>
|
||||
{isSplitting ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>{__('Split')}</FormLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSplitLines([])}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{__('Undo split')}
|
||||
</Button>
|
||||
</div>
|
||||
<SplitEditor
|
||||
total={amount}
|
||||
currencyCode={
|
||||
selectedAccount?.currency_code ??
|
||||
transaction?.currency_code ??
|
||||
'EUR'
|
||||
}
|
||||
categories={categories}
|
||||
labels={labels}
|
||||
lines={splitLines}
|
||||
onChange={setSplitLines}
|
||||
disabled={isSubmitting}
|
||||
onLabelCreated={onLabelCreated}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel htmlFor="category">
|
||||
{__('Category')}
|
||||
</FormLabel>
|
||||
{mode === 'edit' &&
|
||||
splittingEnabled && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={startSplit}
|
||||
disabled={isSubmitting}
|
||||
data-testid="split-button"
|
||||
>
|
||||
<Split className="mr-1 h-4 w-4" />
|
||||
{__('Split')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<CategorySelect
|
||||
value={categoryId}
|
||||
onValueChange={setCategoryId}
|
||||
categories={categories}
|
||||
disabled={isSubmitting}
|
||||
placeholder={__('Uncategorized')}
|
||||
triggerClassName="w-full"
|
||||
showUncategorized={true}
|
||||
data-testid="category-select"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{__('Labels')}</FormLabel>
|
||||
<LabelCombobox
|
||||
value={selectedLabelIds}
|
||||
onValueChange={setSelectedLabelIds}
|
||||
labels={labels}
|
||||
disabled={isSubmitting}
|
||||
placeholder={__('Add labels...')}
|
||||
allowCreate={true}
|
||||
onLabelCreated={onLabelCreated}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{__('Labels')}</FormLabel>
|
||||
<LabelCombobox
|
||||
value={selectedLabelIds}
|
||||
onValueChange={setSelectedLabelIds}
|
||||
labels={labels}
|
||||
disabled={isSubmitting}
|
||||
placeholder={__('Add labels...')}
|
||||
allowCreate={true}
|
||||
onLabelCreated={onLabelCreated}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="notes">{__('Notes')}</FormLabel>
|
||||
|
|
@ -895,7 +1030,11 @@ export function EditTransactionDialog({
|
|||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
(isSplitting &&
|
||||
!isSplitValid(amount, splitLines))
|
||||
}
|
||||
data-testid="submit-transaction"
|
||||
>
|
||||
{isSubmitting
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
isSplitValid,
|
||||
newSplitLine,
|
||||
splitRemaining,
|
||||
type SplitLineDraft,
|
||||
} from './split-editor';
|
||||
|
||||
function lines(...amounts: number[]): SplitLineDraft[] {
|
||||
return amounts.map((amount) => newSplitLine({ amount }));
|
||||
}
|
||||
|
||||
describe('splitRemaining', () => {
|
||||
it('is the total minus the sum of the lines', () => {
|
||||
expect(splitRemaining(-10000, lines(-2500, -7500))).toBe(0);
|
||||
expect(splitRemaining(-10000, lines(-2500))).toBe(-7500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSplitValid', () => {
|
||||
it('accepts at least two same-sign lines that reconcile to the total', () => {
|
||||
expect(isSplitValid(-10000, lines(-2500, -7500))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects fewer than two lines', () => {
|
||||
expect(isSplitValid(-10000, lines(-10000))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects lines that do not add up to the total', () => {
|
||||
expect(isSplitValid(-10000, lines(-2500, -2500))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a zero-amount line', () => {
|
||||
expect(isSplitValid(-10000, lines(-10000, 0))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a line whose sign differs from the total', () => {
|
||||
expect(isSplitValid(-10000, lines(-12500, 2500))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
import { LabelCombobox } from '@/components/shared/label-combobox';
|
||||
import { CategorySelect } from '@/components/transactions/category-select';
|
||||
import { AmountInput } from '@/components/ui/amount-input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useLocale } from '@/hooks/use-locale';
|
||||
import { type Category } from '@/types/category';
|
||||
import { type Label } from '@/types/label';
|
||||
import { __ } from '@/utils/i18n';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
|
||||
export interface SplitLineDraft {
|
||||
key: string;
|
||||
category_id: string | null;
|
||||
amount: number;
|
||||
label_ids: string[];
|
||||
}
|
||||
|
||||
interface SplitEditorProps {
|
||||
total: number;
|
||||
currencyCode: string;
|
||||
categories: Category[];
|
||||
labels: Label[];
|
||||
lines: SplitLineDraft[];
|
||||
onChange: (lines: SplitLineDraft[]) => void;
|
||||
disabled?: boolean;
|
||||
onLabelCreated?: (label: Label) => void;
|
||||
}
|
||||
|
||||
let keyCounter = 0;
|
||||
|
||||
export function newSplitLine(
|
||||
partial?: Partial<SplitLineDraft>,
|
||||
): SplitLineDraft {
|
||||
keyCounter += 1;
|
||||
|
||||
return {
|
||||
key: `split-${keyCounter}`,
|
||||
category_id: null,
|
||||
amount: 0,
|
||||
label_ids: [],
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
export function splitRemaining(total: number, lines: SplitLineDraft[]): number {
|
||||
return total - lines.reduce((sum, line) => sum + line.amount, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* A split is valid once it has at least two lines that each carry a non-zero
|
||||
* amount on the same side as the transaction and together reconcile to its
|
||||
* total to the cent.
|
||||
*/
|
||||
export function isSplitValid(total: number, lines: SplitLineDraft[]): boolean {
|
||||
if (lines.length < 2 || splitRemaining(total, lines) !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return lines.every(
|
||||
(line) =>
|
||||
line.amount !== 0 && (total === 0 || line.amount > 0 === total > 0),
|
||||
);
|
||||
}
|
||||
|
||||
export function SplitEditor({
|
||||
total,
|
||||
currencyCode,
|
||||
categories,
|
||||
labels,
|
||||
lines,
|
||||
onChange,
|
||||
disabled,
|
||||
onLabelCreated,
|
||||
}: SplitEditorProps) {
|
||||
const locale = useLocale();
|
||||
const remaining = splitRemaining(total, lines);
|
||||
|
||||
const updateLine = (key: string, patch: Partial<SplitLineDraft>) =>
|
||||
onChange(
|
||||
lines.map((line) =>
|
||||
line.key === key ? { ...line, ...patch } : line,
|
||||
),
|
||||
);
|
||||
|
||||
const removeLine = (key: string) =>
|
||||
onChange(lines.filter((line) => line.key !== key));
|
||||
|
||||
const addLine = () =>
|
||||
onChange([...lines, newSplitLine({ amount: remaining })]);
|
||||
|
||||
const formattedRemaining = new Intl.NumberFormat(locale, {
|
||||
style: 'currency',
|
||||
currency: currencyCode,
|
||||
}).format(remaining / 100);
|
||||
|
||||
return (
|
||||
<div className="space-y-3" data-testid="split-editor">
|
||||
{lines.map((line) => (
|
||||
<div
|
||||
key={line.key}
|
||||
className="space-y-2 rounded-md border border-border p-3"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1">
|
||||
<CategorySelect
|
||||
value={line.category_id ?? 'null'}
|
||||
onValueChange={(value) =>
|
||||
updateLine(line.key, {
|
||||
category_id:
|
||||
value === 'null' ||
|
||||
value === 'uncategorized'
|
||||
? null
|
||||
: value,
|
||||
})
|
||||
}
|
||||
categories={categories}
|
||||
disabled={disabled}
|
||||
showUncategorized={true}
|
||||
triggerClassName="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<AmountInput
|
||||
value={line.amount}
|
||||
onChange={(value) =>
|
||||
updateLine(line.key, { amount: value })
|
||||
}
|
||||
currencyCode={currencyCode}
|
||||
disabled={disabled}
|
||||
allowNegative={true}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeLine(line.key)}
|
||||
disabled={disabled || lines.length <= 2}
|
||||
aria-label={__('Remove line')}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<LabelCombobox
|
||||
value={line.label_ids}
|
||||
onValueChange={(ids) =>
|
||||
updateLine(line.key, { label_ids: ids })
|
||||
}
|
||||
labels={labels}
|
||||
disabled={disabled}
|
||||
placeholder={__('Add labels...')}
|
||||
allowCreate={true}
|
||||
onLabelCreated={onLabelCreated}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addLine}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
{__('Add line')}
|
||||
</Button>
|
||||
<span
|
||||
className={
|
||||
remaining === 0
|
||||
? 'text-sm text-muted-foreground'
|
||||
: 'text-sm font-medium text-destructive'
|
||||
}
|
||||
data-testid="split-remaining"
|
||||
>
|
||||
{__('Remaining: :amount', { amount: formattedRemaining })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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({});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ export interface Features {
|
|||
cashflow: boolean;
|
||||
calculateBalancesOnImport: boolean;
|
||||
mcp: boolean;
|
||||
transactionSplitting: boolean;
|
||||
}
|
||||
|
||||
export interface ExpiredBankingConnectionNotification {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,323 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\CategoryCashflowDirection;
|
||||
use App\Enums\CategoryType;
|
||||
use App\Enums\TransactionSource;
|
||||
use App\Features\TransactionSplitting;
|
||||
use App\Models\Budget;
|
||||
use App\Models\BudgetPeriod;
|
||||
use App\Models\BudgetTransaction;
|
||||
use App\Models\Category;
|
||||
use App\Models\Label;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\TransactionSplit;
|
||||
use App\Models\User;
|
||||
use App\Services\BudgetTransactionService;
|
||||
use App\Services\CategorySpendingService;
|
||||
use App\Services\TransactionSplitter;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Laravel\Pennant\Feature;
|
||||
|
||||
use function Pest\Laravel\actingAs;
|
||||
|
||||
function expenseCategory(User $user, string $name = 'Cat'): Category
|
||||
{
|
||||
return Category::factory()->for($user)->create([
|
||||
'name' => $name,
|
||||
'type' => CategoryType::Expense,
|
||||
'cashflow_direction' => CategoryCashflowDirection::Outflow,
|
||||
]);
|
||||
}
|
||||
|
||||
function splittableTransaction(User $user, int $amount = -10000): Transaction
|
||||
{
|
||||
return Transaction::factory()->for($user)->create([
|
||||
'amount' => $amount,
|
||||
'category_id' => null,
|
||||
'currency_code' => $user->currency_code,
|
||||
'source' => TransactionSource::Imported,
|
||||
]);
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
// Keep every amount in the user's own currency so analytics never reaches
|
||||
// for an exchange rate (which would be a stray HTTP request under test).
|
||||
$this->user = User::factory()->create(['currency_code' => 'EUR']);
|
||||
Feature::for($this->user)->activate(TransactionSplitting::class);
|
||||
});
|
||||
|
||||
it('splits a transaction into lines and nulls its own category', function () {
|
||||
$restaurant = expenseCategory($this->user, 'Restaurant');
|
||||
$travel = expenseCategory($this->user, 'Travel');
|
||||
$transaction = splittableTransaction($this->user);
|
||||
|
||||
actingAs($this->user)
|
||||
->patchJson(route('transactions.update', $transaction), [
|
||||
'splits' => [
|
||||
['category_id' => $restaurant->id, 'amount' => -2500, 'label_ids' => []],
|
||||
['category_id' => $travel->id, 'amount' => -7500, 'label_ids' => []],
|
||||
],
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$transaction->refresh();
|
||||
|
||||
expect($transaction->category_id)->toBeNull();
|
||||
expect($transaction->splits)->toHaveCount(2);
|
||||
expect($transaction->splits->sum('amount'))->toBe(-10000);
|
||||
expect($transaction->splits->pluck('category_id')->all())
|
||||
->toContain($restaurant->id, $travel->id);
|
||||
});
|
||||
|
||||
it('moves transaction labels onto the split lines', function () {
|
||||
$category = expenseCategory($this->user);
|
||||
$label = Label::factory()->for($this->user)->create();
|
||||
$transaction = splittableTransaction($this->user);
|
||||
$transaction->labels()->attach($label->id);
|
||||
|
||||
actingAs($this->user)
|
||||
->patchJson(route('transactions.update', $transaction), [
|
||||
'splits' => [
|
||||
['category_id' => $category->id, 'amount' => -2500, 'label_ids' => [$label->id]],
|
||||
['category_id' => $category->id, 'amount' => -7500, 'label_ids' => []],
|
||||
],
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$transaction->refresh();
|
||||
|
||||
expect($transaction->labels)->toHaveCount(0);
|
||||
expect($transaction->splits->first()->labels->pluck('id')->all())->toContain($label->id);
|
||||
});
|
||||
|
||||
it('rejects splits that do not add up to the transaction amount', function () {
|
||||
$category = expenseCategory($this->user);
|
||||
$transaction = splittableTransaction($this->user);
|
||||
|
||||
actingAs($this->user)
|
||||
->patchJson(route('transactions.update', $transaction), [
|
||||
'splits' => [
|
||||
['category_id' => $category->id, 'amount' => -2500],
|
||||
['category_id' => $category->id, 'amount' => -2500],
|
||||
],
|
||||
])
|
||||
->assertJsonValidationErrors(['splits']);
|
||||
|
||||
expect($transaction->fresh()->splits)->toHaveCount(0);
|
||||
});
|
||||
|
||||
it('rejects a single split line', function () {
|
||||
$category = expenseCategory($this->user);
|
||||
$transaction = splittableTransaction($this->user);
|
||||
|
||||
actingAs($this->user)
|
||||
->patchJson(route('transactions.update', $transaction), [
|
||||
'splits' => [
|
||||
['category_id' => $category->id, 'amount' => -10000],
|
||||
],
|
||||
])
|
||||
->assertJsonValidationErrors(['splits']);
|
||||
});
|
||||
|
||||
it('rejects a split line whose sign differs from the transaction', function () {
|
||||
$category = expenseCategory($this->user);
|
||||
$transaction = splittableTransaction($this->user);
|
||||
|
||||
actingAs($this->user)
|
||||
->patchJson(route('transactions.update', $transaction), [
|
||||
'splits' => [
|
||||
['category_id' => $category->id, 'amount' => -12500],
|
||||
['category_id' => $category->id, 'amount' => 2500],
|
||||
],
|
||||
])
|
||||
->assertJsonValidationErrors(['splits.1.amount']);
|
||||
});
|
||||
|
||||
it('collapses a split back to a single category', function () {
|
||||
$category = expenseCategory($this->user);
|
||||
$target = expenseCategory($this->user, 'Target');
|
||||
$transaction = splittableTransaction($this->user);
|
||||
|
||||
app(TransactionSplitter::class)->apply($transaction, [
|
||||
['category_id' => $category->id, 'amount' => -2500],
|
||||
['category_id' => $category->id, 'amount' => -7500],
|
||||
]);
|
||||
|
||||
actingAs($this->user)
|
||||
->patchJson(route('transactions.update', $transaction), [
|
||||
'category_id' => $target->id,
|
||||
'splits' => [],
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$transaction->refresh();
|
||||
|
||||
expect($transaction->splits)->toHaveCount(0);
|
||||
expect($transaction->category_id)->toBe($target->id);
|
||||
});
|
||||
|
||||
it('forbids splitting when the feature is disabled', function () {
|
||||
Feature::for($this->user)->deactivate(TransactionSplitting::class);
|
||||
|
||||
$category = expenseCategory($this->user);
|
||||
$transaction = splittableTransaction($this->user);
|
||||
|
||||
actingAs($this->user)
|
||||
->patchJson(route('transactions.update', $transaction), [
|
||||
'splits' => [
|
||||
['category_id' => $category->id, 'amount' => -2500],
|
||||
['category_id' => $category->id, 'amount' => -7500],
|
||||
],
|
||||
])
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('cascade-deletes split lines when the transaction row is removed', function () {
|
||||
$category = expenseCategory($this->user);
|
||||
$transaction = splittableTransaction($this->user);
|
||||
|
||||
app(TransactionSplitter::class)->apply($transaction, [
|
||||
['category_id' => $category->id, 'amount' => -2500],
|
||||
['category_id' => $category->id, 'amount' => -7500],
|
||||
]);
|
||||
|
||||
// Hit the DB-level foreign-key cascade directly (a model force-delete would
|
||||
// additionally fire the queued TransactionDeleted listener).
|
||||
DB::table('transactions')->where('id', $transaction->id)->delete();
|
||||
|
||||
expect(TransactionSplit::query()->where('transaction_id', $transaction->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('attributes split lines to their categories in the spending breakdown', function () {
|
||||
$restaurant = expenseCategory($this->user, 'Restaurant');
|
||||
$travel = expenseCategory($this->user, 'Travel');
|
||||
$transaction = splittableTransaction($this->user, -10000);
|
||||
$transaction->update(['transaction_date' => now()]);
|
||||
|
||||
app(TransactionSplitter::class)->apply($transaction, [
|
||||
['category_id' => $restaurant->id, 'amount' => -2500],
|
||||
['category_id' => $travel->id, 'amount' => -7500],
|
||||
]);
|
||||
|
||||
$spending = app(CategorySpendingService::class)->forPeriod(
|
||||
$this->user->id,
|
||||
now()->subDay(),
|
||||
now()->addDay(),
|
||||
);
|
||||
|
||||
$byCategory = $spending->keyBy('category_id');
|
||||
|
||||
expect($byCategory[$restaurant->id]['amount'])->toBe(2500);
|
||||
expect($byCategory[$travel->id]['amount'])->toBe(7500);
|
||||
});
|
||||
|
||||
it('reflects split lines in the cashflow expense breakdown endpoint', function () {
|
||||
$restaurant = expenseCategory($this->user, 'Restaurant');
|
||||
$travel = expenseCategory($this->user, 'Travel');
|
||||
$transaction = splittableTransaction($this->user, -10000);
|
||||
$transaction->update(['transaction_date' => now()]);
|
||||
|
||||
app(TransactionSplitter::class)->apply($transaction, [
|
||||
['category_id' => $restaurant->id, 'amount' => -2500],
|
||||
['category_id' => $travel->id, 'amount' => -7500],
|
||||
]);
|
||||
|
||||
$response = actingAs($this->user)->getJson(
|
||||
'/api/cashflow/breakdown?type=expense&from='.now()->subDay()->toDateString().'&to='.now()->addDay()->toDateString(),
|
||||
)->assertOk();
|
||||
|
||||
$amounts = collect($response->json('data'))->keyBy('category_id');
|
||||
|
||||
expect($amounts[$restaurant->id]['amount'])->toBe(2500);
|
||||
expect($amounts[$travel->id]['amount'])->toBe(7500);
|
||||
});
|
||||
|
||||
it('attributes only the matching split line to a category budget', function () {
|
||||
$tracked = expenseCategory($this->user, 'Tracked');
|
||||
$other = expenseCategory($this->user, 'Other');
|
||||
$transaction = splittableTransaction($this->user, -10000);
|
||||
$transaction->update(['transaction_date' => now()]);
|
||||
|
||||
app(TransactionSplitter::class)->apply($transaction, [
|
||||
['category_id' => $tracked->id, 'amount' => -2500],
|
||||
['category_id' => $other->id, 'amount' => -7500],
|
||||
]);
|
||||
|
||||
$budget = Budget::factory()->forCategories($tracked)->create(['user_id' => $this->user->id]);
|
||||
$period = BudgetPeriod::factory()->create([
|
||||
'budget_id' => $budget->id,
|
||||
'start_date' => now()->subDays(15),
|
||||
'end_date' => now()->addDays(15),
|
||||
]);
|
||||
|
||||
app(BudgetTransactionService::class)->assignTransaction($transaction->fresh());
|
||||
|
||||
$budgetTransaction = BudgetTransaction::query()
|
||||
->where('transaction_id', $transaction->id)
|
||||
->where('budget_period_id', $period->id)
|
||||
->first();
|
||||
|
||||
expect($budgetTransaction)->not->toBeNull();
|
||||
expect($budgetTransaction->amount)->toBe(2500);
|
||||
});
|
||||
|
||||
it('finds a split transaction when filtering the list by a line category', function () {
|
||||
$restaurant = expenseCategory($this->user, 'Restaurant');
|
||||
$travel = expenseCategory($this->user, 'Travel');
|
||||
$transaction = splittableTransaction($this->user, -10000);
|
||||
|
||||
app(TransactionSplitter::class)->apply($transaction, [
|
||||
['category_id' => $restaurant->id, 'amount' => -2500],
|
||||
['category_id' => $travel->id, 'amount' => -7500],
|
||||
]);
|
||||
|
||||
$matches = Transaction::query()
|
||||
->where('user_id', $this->user->id)
|
||||
->applyFilters(['category_ids' => [$restaurant->id], 'user_id' => $this->user->id])
|
||||
->pluck('id');
|
||||
|
||||
expect($matches->all())->toContain($transaction->id);
|
||||
});
|
||||
|
||||
it('does not treat a fully-categorized split as uncategorized', function () {
|
||||
$restaurant = expenseCategory($this->user, 'Restaurant');
|
||||
$travel = expenseCategory($this->user, 'Travel');
|
||||
$split = splittableTransaction($this->user, -10000);
|
||||
$uncategorized = splittableTransaction($this->user, -5000);
|
||||
|
||||
app(TransactionSplitter::class)->apply($split, [
|
||||
['category_id' => $restaurant->id, 'amount' => -2500],
|
||||
['category_id' => $travel->id, 'amount' => -7500],
|
||||
]);
|
||||
|
||||
$matches = Transaction::query()
|
||||
->where('user_id', $this->user->id)
|
||||
->applyFilters(['category_ids' => ['uncategorized'], 'user_id' => $this->user->id])
|
||||
->pluck('id');
|
||||
|
||||
expect($matches->all())->toContain($uncategorized->id);
|
||||
expect($matches->all())->not->toContain($split->id);
|
||||
});
|
||||
|
||||
it('skips split transactions on a bulk category assignment', function () {
|
||||
$category = expenseCategory($this->user);
|
||||
$newCategory = expenseCategory($this->user, 'New');
|
||||
$split = splittableTransaction($this->user, -10000);
|
||||
$plain = splittableTransaction($this->user, -5000);
|
||||
|
||||
app(TransactionSplitter::class)->apply($split, [
|
||||
['category_id' => $category->id, 'amount' => -2500],
|
||||
['category_id' => $category->id, 'amount' => -7500],
|
||||
]);
|
||||
|
||||
$response = actingAs($this->user)
|
||||
->patchJson(route('transactions.bulk-update'), [
|
||||
'transaction_ids' => [$split->id, $plain->id],
|
||||
'category_id' => $newCategory->id,
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
expect($response->json('omitted_splits'))->toBe(1);
|
||||
expect($split->fresh()->category_id)->toBeNull();
|
||||
expect($plain->fresh()->category_id)->toBe($newCategory->id);
|
||||
});
|
||||
Loading…
Reference in New Issue