Merge 6e33f7942b into 2cca291773
This commit is contained in:
commit
bfcadda63e
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ use App\Models\Label;
|
|||
use App\Models\Transaction;
|
||||
use App\Services\CategoryTree;
|
||||
use App\Services\ExchangeRateService;
|
||||
use App\Services\TransactionAllocations;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Collection;
|
||||
|
|
@ -56,14 +57,19 @@ class TransactionAnalysisController extends Controller
|
|||
|
||||
$transactions = Transaction::query()
|
||||
->where('user_id', $user->id)
|
||||
->with(['account.bank', 'category', 'labels'])
|
||||
->with(['account.bank', 'category', 'labels', 'splits.category', 'splits.labels'])
|
||||
->applyFilters($filters)
|
||||
->get();
|
||||
|
||||
$this->preloadExchangeRates($transactions, $currency);
|
||||
|
||||
$byCategory = $this->categoryBreakdown($transactions, $currency, $user->id);
|
||||
$byTag = $this->tagBreakdown($transactions, $currency);
|
||||
// Category and tag breakdowns are per-line for split transactions; payee,
|
||||
// account and the totals stay at the transaction level (a split doesn't
|
||||
// change who was paid or the amount that moved).
|
||||
$allocations = TransactionAllocations::expand($transactions);
|
||||
|
||||
$byCategory = $this->categoryBreakdown($allocations, $currency, $user->id);
|
||||
$byTag = $this->tagBreakdown($allocations, $currency);
|
||||
$byPayee = $this->payeeBreakdown($transactions, $currency);
|
||||
$byAccount = $this->accountBreakdown($transactions, $currency);
|
||||
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@ namespace App\Http\Controllers;
|
|||
|
||||
use App\Enums\CategoryType;
|
||||
use App\Models\Account;
|
||||
use App\Models\Transaction;
|
||||
use App\Services\AccountMetricsService;
|
||||
use App\Services\CashflowSummaryService;
|
||||
use App\Services\CategorySpendingService;
|
||||
use App\Services\PeriodComparator;
|
||||
use App\Services\TransactionAllocations;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
|
@ -110,9 +110,11 @@ class DashboardController extends Controller
|
|||
|
||||
private function getTransactionSum(string $userId, Carbon $from, Carbon $to, CategoryType $type): int
|
||||
{
|
||||
return Transaction::query()
|
||||
->where('transactions.user_id', $userId)
|
||||
->whereBetween('transactions.transaction_date', [$from, $to])
|
||||
// Read effective allocations so a split's lines are classified by their
|
||||
// own category type (a savings/transfer line no longer counts as a plain
|
||||
// expense just because the parent row has no category).
|
||||
return DB::query()
|
||||
->fromSub(TransactionAllocations::query($userId, $from, $to), 'transactions')
|
||||
->where(function ($q) use ($type) {
|
||||
$q->whereExists(function ($sub) use ($type) {
|
||||
$sub->select(DB::raw(1))
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\CategorySource;
|
||||
use App\Features\TransactionSplitting;
|
||||
use App\Http\Requests\BulkUpdateTransactionsRequest;
|
||||
use App\Http\Requests\IndexTransactionRequest;
|
||||
use App\Http\Requests\StoreTransactionRequest;
|
||||
|
|
@ -15,11 +16,13 @@ use App\Models\Label;
|
|||
use App\Models\Transaction;
|
||||
use App\Services\Ai\CategoryOverrideHandler;
|
||||
use App\Services\ManualBalanceAdjuster;
|
||||
use App\Services\TransactionSplitter;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Laravel\Pennant\Feature;
|
||||
|
||||
class TransactionController extends Controller
|
||||
{
|
||||
|
|
@ -55,7 +58,7 @@ class TransactionController extends Controller
|
|||
|
||||
$query = Transaction::query()
|
||||
->where('user_id', $user->id)
|
||||
->with(['account.bank', 'category', 'labels', 'categorizedByRule:id,origin'])
|
||||
->with(['account.bank', 'category', 'labels', 'categorizedByRule:id,origin', 'splits.category', 'splits.labels'])
|
||||
->applyFilters($filters);
|
||||
|
||||
$nullableSortColumns = ['creditor_name', 'debtor_name'];
|
||||
|
|
@ -213,7 +216,7 @@ class TransactionController extends Controller
|
|||
], 201);
|
||||
}
|
||||
|
||||
public function update(UpdateTransactionRequest $request, Transaction $transaction, ManualBalanceAdjuster $balanceAdjuster): JsonResponse
|
||||
public function update(UpdateTransactionRequest $request, Transaction $transaction, ManualBalanceAdjuster $balanceAdjuster, TransactionSplitter $splitter): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $transaction);
|
||||
|
||||
|
|
@ -222,11 +225,25 @@ class TransactionController extends Controller
|
|||
$hasLabelUpdate = $request->has('label_ids');
|
||||
unset($data['label_ids']);
|
||||
|
||||
$splitLines = $request->has('splits') ? ($data['splits'] ?? []) : null;
|
||||
unset($data['splits']);
|
||||
$applyingSplit = is_array($splitLines) && $splitLines !== [];
|
||||
// An explicit empty array collapses an existing split back to a single
|
||||
// category; the normal category_id in this request restores it.
|
||||
$unsplitting = $splitLines === [] && $transaction->isSplit();
|
||||
|
||||
// The flag only gates creating/changing splits; unsplitting and reads of
|
||||
// existing splits always work so the feature can be rolled back safely.
|
||||
if ($applyingSplit) {
|
||||
abort_unless(Feature::for($request->user())->active(TransactionSplitting::class), 403);
|
||||
}
|
||||
|
||||
$learnedRule = null;
|
||||
|
||||
// A user-set category overrides any AI assignment: learn the correction as
|
||||
// a forward-looking rule, log/self-heal as needed, and reset provenance.
|
||||
if ($request->has('category_id')) {
|
||||
// Skipped while splitting — the lines own the categorisation, not the row.
|
||||
if ($request->has('category_id') && ! $applyingSplit) {
|
||||
$newCategoryId = $data['category_id'] ?? null;
|
||||
|
||||
if ($newCategoryId !== $transaction->category_id) {
|
||||
|
|
@ -247,21 +264,32 @@ class TransactionController extends Controller
|
|||
$transaction->fill($data);
|
||||
}
|
||||
|
||||
// Sync labels if provided
|
||||
if ($hasLabelUpdate) {
|
||||
$transaction->labels()->sync($labelIds ?? []);
|
||||
// Reload labels so the event listener has fresh data
|
||||
$transaction->load('labels');
|
||||
}
|
||||
|
||||
// Save to fire the updated event if there are any changes
|
||||
// We need to save even if just labels changed (isDirty won't detect pivot changes)
|
||||
if ($transaction->isDirty() || $hasLabelUpdate) {
|
||||
// Touch the model to ensure it's marked as changed for the event
|
||||
if (! $transaction->isDirty() && $hasLabelUpdate) {
|
||||
$transaction->touch();
|
||||
if ($applyingSplit) {
|
||||
// apply() persists the lines and saves the transaction (nulling its
|
||||
// own category/labels) in a single write, so the queued budget
|
||||
// listener sees the final split state.
|
||||
$splitter->apply($transaction, $splitLines);
|
||||
} else {
|
||||
if ($unsplitting) {
|
||||
$splitter->remove($transaction);
|
||||
}
|
||||
|
||||
// Sync labels if provided
|
||||
if ($hasLabelUpdate) {
|
||||
$transaction->labels()->sync($labelIds ?? []);
|
||||
// Reload labels so the event listener has fresh data
|
||||
$transaction->load('labels');
|
||||
}
|
||||
|
||||
// Save to fire the updated event if there are any changes
|
||||
// We need to save even if just labels changed (isDirty won't detect pivot changes)
|
||||
if ($transaction->isDirty() || $hasLabelUpdate || $unsplitting) {
|
||||
// Touch the model to ensure it's marked as changed for the event
|
||||
if (! $transaction->isDirty() && ($hasLabelUpdate || $unsplitting)) {
|
||||
$transaction->touch();
|
||||
}
|
||||
$transaction->save();
|
||||
}
|
||||
$transaction->save();
|
||||
}
|
||||
|
||||
// Move the manual account balance to match an edited amount/date/account:
|
||||
|
|
@ -277,7 +305,7 @@ class TransactionController extends Controller
|
|||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => $transaction->fresh()->load('labels'),
|
||||
'data' => $transaction->fresh()->load(['labels', 'splits.category', 'splits.labels']),
|
||||
'learned_rule' => $learnedRule === null ? null : [
|
||||
'id' => $learnedRule->id,
|
||||
'title' => $learnedRule->title,
|
||||
|
|
@ -325,13 +353,31 @@ class TransactionController extends Controller
|
|||
$transactions = $query->get();
|
||||
}
|
||||
|
||||
$labelIds = $request->input('label_ids');
|
||||
$hasLabelUpdate = $request->has('label_ids');
|
||||
$hasCategoryUpdate = $request->has('category_id');
|
||||
|
||||
// A bulk category or label assignment skips split transactions: setting a
|
||||
// single category would destroy their lines, and labels live on the lines,
|
||||
// not the row. Notes still apply (they are transaction-level). We report
|
||||
// how many splits were left untouched.
|
||||
$omittedSplitIds = collect();
|
||||
if ($hasCategoryUpdate || $hasLabelUpdate) {
|
||||
$transactions->loadMissing('splits:id,transaction_id');
|
||||
$omittedSplitIds = $transactions->filter->isSplit()->pluck('id');
|
||||
}
|
||||
|
||||
$updateData = [];
|
||||
if ($request->has('category_id')) {
|
||||
if ($hasCategoryUpdate) {
|
||||
$newCategoryId = $request->input('category_id');
|
||||
|
||||
$overrideHandler = app(CategoryOverrideHandler::class);
|
||||
|
||||
foreach ($transactions as $transaction) {
|
||||
if ($transaction->isSplit()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$overrideHandler->record($transaction, $newCategoryId);
|
||||
}
|
||||
|
||||
|
|
@ -347,9 +393,6 @@ class TransactionController extends Controller
|
|||
$updateData['notes_iv'] = $request->input('notes_iv');
|
||||
}
|
||||
|
||||
$labelIds = $request->input('label_ids');
|
||||
$hasLabelUpdate = $request->has('label_ids');
|
||||
|
||||
if (empty($updateData) && ! $hasLabelUpdate) {
|
||||
return response()->json([
|
||||
'message' => 'No update data provided.',
|
||||
|
|
@ -363,11 +406,18 @@ class TransactionController extends Controller
|
|||
} elseif ($filters !== null) {
|
||||
$updateQuery->applyFilters($filters);
|
||||
}
|
||||
if ($hasCategoryUpdate && $omittedSplitIds->isNotEmpty()) {
|
||||
$updateQuery->whereNotIn('id', $omittedSplitIds->all());
|
||||
}
|
||||
$updateQuery->update($updateData);
|
||||
}
|
||||
|
||||
if ($hasLabelUpdate) {
|
||||
foreach ($transactions as $transaction) {
|
||||
if ($transaction->isSplit()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$transaction->labels()->sync($labelIds ?? []);
|
||||
$transaction->save();
|
||||
}
|
||||
|
|
@ -375,7 +425,8 @@ class TransactionController extends Controller
|
|||
|
||||
return response()->json([
|
||||
'message' => 'Transactions updated successfully',
|
||||
'count' => $transactions->count(),
|
||||
'count' => $transactions->count() - $omittedSplitIds->count(),
|
||||
'omitted_splits' => $omittedSplitIds->count(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,71 @@ class UpdateTransactionRequest extends FormRequest
|
|||
return $rules;
|
||||
}
|
||||
|
||||
public function withValidator(Validator $validator): void
|
||||
{
|
||||
$validator->after(function (Validator $validator): void {
|
||||
if (! $this->has('splits')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$lines = $this->input('splits');
|
||||
|
||||
// An empty array is a valid "unsplit" instruction.
|
||||
if (! is_array($lines) || $lines === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (count($lines) < 2) {
|
||||
$validator->errors()->add('splits', __('A split needs at least two lines.'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$total = $this->effectiveAmount();
|
||||
|
||||
if ($total === 0) {
|
||||
$validator->errors()->add('splits', __("You can't split a transaction with no amount."));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$sum = 0;
|
||||
|
||||
foreach ($lines as $index => $line) {
|
||||
$amount = (int) ($line['amount'] ?? 0);
|
||||
$sum += $amount;
|
||||
|
||||
if ($amount === 0) {
|
||||
$validator->errors()->add("splits.{$index}.amount", __('Each split line must be a non-zero amount.'));
|
||||
} elseif (($amount > 0) !== ($total > 0)) {
|
||||
$validator->errors()->add("splits.{$index}.amount", __('Split lines must have the same sign as the transaction.'));
|
||||
}
|
||||
}
|
||||
|
||||
if ($sum !== $total) {
|
||||
$validator->errors()->add('splits', __('The split lines must add up to the transaction amount.'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The amount the split lines must reconcile against: the newly submitted
|
||||
* amount when a manual transaction edits it in the same request, otherwise
|
||||
* the transaction's stored amount (imported amounts are locked).
|
||||
*/
|
||||
private function effectiveAmount(): int
|
||||
{
|
||||
$transaction = $this->route('transaction');
|
||||
|
||||
if ($transaction instanceof Transaction
|
||||
&& $transaction->source === TransactionSource::ManuallyCreated
|
||||
&& $this->filled('amount')) {
|
||||
return (int) $this->input('amount');
|
||||
}
|
||||
|
||||
return $transaction instanceof Transaction ? $transaction->amount : 0;
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
|
|
@ -52,6 +126,7 @@ class UpdateTransactionRequest extends FormRequest
|
|||
'description_iv.size' => 'The description IV must be exactly 16 characters.',
|
||||
'notes_iv.size' => 'The notes IV must be exactly 16 characters.',
|
||||
'label_ids.*.exists' => 'One or more selected labels do not exist or do not belong to you.',
|
||||
'splits.*.label_ids.*.exists' => 'One or more selected labels do not exist or do not belong to you.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,94 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Transaction;
|
||||
use App\Models\TransactionSplit;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Query\Builder as QueryBuilder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Effective category attribution: an unsplit transaction is attributed to its
|
||||
* own category, a split transaction to its lines. The category breakdowns read
|
||||
* through here — either the SQL {@see self::query()} or the collection
|
||||
* {@see self::expand()} — so a split is never double-counted or dropped. The two
|
||||
* encarnations share the "iterate lines vs the row itself" rule but not code:
|
||||
* SQL sums raw rows, expand() needs hydrated models (account/category/labels)
|
||||
* for currency conversion and side classification.
|
||||
*/
|
||||
class TransactionAllocations
|
||||
{
|
||||
/**
|
||||
* SQL allocation rows for a user's transactions in a period — one row per
|
||||
* unsplit transaction (its own category + amount) and one per split line
|
||||
* (the line's category + amount). Soft-deleted transactions are excluded.
|
||||
* Returned as a query builder so callers can alias it as `transactions` and
|
||||
* keep their existing joins/aggregations unchanged.
|
||||
*/
|
||||
public static function query(string $userId, Carbon $from, Carbon $to): QueryBuilder
|
||||
{
|
||||
$unsplit = DB::table('transactions as t')
|
||||
->select('t.category_id', 't.amount', 't.transaction_date')
|
||||
->whereNull('t.deleted_at')
|
||||
->where('t.user_id', $userId)
|
||||
->whereBetween('t.transaction_date', [$from, $to])
|
||||
->whereNotExists(function (QueryBuilder $query): void {
|
||||
$query->select(DB::raw(1))
|
||||
->from('transaction_splits as s')
|
||||
->whereColumn('s.transaction_id', 't.id');
|
||||
});
|
||||
|
||||
$lines = DB::table('transaction_splits as s')
|
||||
->join('transactions as t', 't.id', '=', 's.transaction_id')
|
||||
->select('s.category_id', 's.amount', 't.transaction_date')
|
||||
->whereNull('t.deleted_at')
|
||||
->where('t.user_id', $userId)
|
||||
->whereBetween('t.transaction_date', [$from, $to]);
|
||||
|
||||
return $unsplit->unionAll($lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a loaded transaction collection into effective allocations: a split
|
||||
* transaction becomes one synthetic Transaction per line (carrying the line's
|
||||
* amount + category + labels while keeping the parent's account/currency/date
|
||||
* for FX and side classification); unsplit transactions pass through
|
||||
* unchanged.
|
||||
*
|
||||
* Callers must eager-load `splits.category` (and `splits.labels`/`account`
|
||||
* where they read those). Synthetic line instances deliberately keep the
|
||||
* parent's `id`, so consumers must aggregate by `category_id`/labels, never
|
||||
* `keyBy('id')`/`unique('id')` (that would collapse a split's lines).
|
||||
*
|
||||
* @param Collection<int, Transaction> $transactions
|
||||
* @return Collection<int, Transaction>
|
||||
*/
|
||||
public static function expand(Collection $transactions): Collection
|
||||
{
|
||||
return $transactions->flatMap(function (Transaction $transaction): array {
|
||||
$splits = $transaction->relationLoaded('splits') ? $transaction->splits : null;
|
||||
|
||||
if ($splits === null || $splits->isEmpty()) {
|
||||
return [$transaction];
|
||||
}
|
||||
|
||||
return $splits->map(function (TransactionSplit $line) use ($transaction): Transaction {
|
||||
$allocation = (new Transaction)->setRawAttributes($transaction->getAttributes());
|
||||
$allocation->forceFill([
|
||||
'amount' => $line->amount,
|
||||
'category_id' => $line->category_id,
|
||||
]);
|
||||
$allocation->setRelation('account', $transaction->relationLoaded('account') ? $transaction->account : null);
|
||||
$allocation->setRelation('category', $line->relationLoaded('category') ? $line->category : null);
|
||||
|
||||
if ($line->relationLoaded('labels')) {
|
||||
$allocation->setRelation('labels', $line->labels);
|
||||
}
|
||||
|
||||
return $allocation;
|
||||
})->all();
|
||||
})->values();
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
15
lang/en.json
15
lang/en.json
|
|
@ -1,3 +1,16 @@
|
|||
{
|
||||
"Saved (cashflow)": "Saved"
|
||||
"Saved (cashflow)": "Saved",
|
||||
"Split": "Split",
|
||||
"Rest": "Rest",
|
||||
"Add line": "Add line",
|
||||
"Remove line": "Remove line",
|
||||
"Undo split": "Undo split",
|
||||
"Remaining: :amount": "Remaining: :amount",
|
||||
"Each line needs a non-zero amount": "Each line needs a non-zero amount",
|
||||
"Split transactions skipped: :count": "Split transactions skipped: :count",
|
||||
"A split needs at least two lines.": "A split needs at least two lines.",
|
||||
"The split lines must add up to the transaction amount.": "The split lines must add up to the transaction amount.",
|
||||
"Each split line must be a non-zero amount.": "Each split line must be a non-zero amount.",
|
||||
"You can't split a transaction with no amount.": "You can't split a transaction with no amount.",
|
||||
"Split lines must have the same sign as the transaction.": "Split lines must have the same sign as the transaction."
|
||||
}
|
||||
|
|
|
|||
15
lang/es.json
15
lang/es.json
|
|
@ -2294,5 +2294,18 @@
|
|||
"Read and analyse your transactions, balances, categories and spending.": "Leer y analizar tus transacciones, saldos, categorías y gastos.",
|
||||
"Create, edit and delete transactions, categories, labels and automation rules.": "Crear, editar y eliminar transacciones, categorías, etiquetas y reglas de automatización.",
|
||||
"Bank-connected accounts and their transactions stay read-only. You can disconnect it at any time from the connected app.": "Las cuentas conectadas a un banco y sus transacciones siguen siendo de solo lectura. Puedes desconectarla cuando quieras desde la app conectada.",
|
||||
"Connecting…": "Conectando…"
|
||||
"Connecting…": "Conectando…",
|
||||
"Split": "Dividir",
|
||||
"Rest": "Resto",
|
||||
"Add line": "Añadir línea",
|
||||
"Remove line": "Eliminar línea",
|
||||
"Undo split": "Deshacer división",
|
||||
"Remaining: :amount": "Restante: :amount",
|
||||
"Each line needs a non-zero amount": "Cada línea necesita un importe distinto de cero",
|
||||
"Split transactions skipped: :count": "Transacciones divididas omitidas: :count",
|
||||
"A split needs at least two lines.": "Una división necesita al menos dos líneas.",
|
||||
"The split lines must add up to the transaction amount.": "Las líneas de la división deben sumar el importe de la transacción.",
|
||||
"Each split line must be a non-zero amount.": "Cada línea de la división debe tener un importe distinto de cero.",
|
||||
"You can't split a transaction with no amount.": "No puedes dividir una transacción sin importe.",
|
||||
"Split lines must have the same sign as the transaction.": "Las líneas de la división deben tener el mismo signo que la transacción."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,17 +9,23 @@ import {
|
|||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useLocale } from '@/hooks/use-locale';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { billing } from '@/routes/settings';
|
||||
import { transactionSyncService } from '@/services/transaction-sync';
|
||||
import { type SharedData } from '@/types';
|
||||
import { type Account, type Bank } from '@/types/account';
|
||||
import { type Category } from '@/types/category';
|
||||
import { type DecryptedTransaction } from '@/types/transaction';
|
||||
import { getCategoryColorClasses, type Category } from '@/types/category';
|
||||
import {
|
||||
isSplit,
|
||||
type DecryptedTransaction,
|
||||
type TransactionSplit,
|
||||
} from '@/types/transaction';
|
||||
import { __ } from '@/utils/i18n';
|
||||
import { router, usePage } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import * as Icons from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface CategoryCellProps {
|
||||
|
|
@ -37,6 +43,9 @@ interface CategoryCellProps {
|
|||
withoutChevronIcon?: boolean;
|
||||
/** AI is currently categorizing this row in the background. */
|
||||
isCategorizing?: boolean;
|
||||
/** Active filters, used to collapse non-matching split lines into a "Rest" row. */
|
||||
appliedCategoryIds?: string[];
|
||||
appliedLabelIds?: string[];
|
||||
}
|
||||
|
||||
export function CategoryCell({
|
||||
|
|
@ -49,12 +58,28 @@ export function CategoryCell({
|
|||
className,
|
||||
withoutChevronIcon,
|
||||
isCategorizing,
|
||||
appliedCategoryIds = [],
|
||||
appliedLabelIds = [],
|
||||
}: CategoryCellProps) {
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const { auth, subscriptionsEnabled, aiCategorizationUpsellRate } =
|
||||
usePage<SharedData>().props;
|
||||
|
||||
// A split transaction shows its per-line breakdown instead of a single,
|
||||
// editable category. Editing happens in the modal (click the row).
|
||||
if (isSplit(transaction)) {
|
||||
return (
|
||||
<SplitBreakdown
|
||||
transaction={transaction}
|
||||
categories={categories}
|
||||
appliedCategoryIds={appliedCategoryIds}
|
||||
appliedLabelIds={appliedLabelIds}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Free-plan nudge: AI could categorize this row. Sampled to a configurable
|
||||
// share of rows so it stays subtle instead of marking every uncategorized one.
|
||||
const showAiUpsell =
|
||||
|
|
@ -257,3 +282,160 @@ export function CategoryCell({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SplitCategoryChip({ category }: { category?: Category | null }) {
|
||||
if (!category) {
|
||||
return (
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{__('Uncategorized')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const colorClasses = getCategoryColorClasses(category.color);
|
||||
const IconComponent = Icons[
|
||||
category.icon as keyof typeof Icons
|
||||
] as Icons.LucideIcon;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex min-w-0 items-center gap-1 rounded px-1.5 py-0.5 text-xs',
|
||||
colorClasses.bg,
|
||||
colorClasses.text,
|
||||
)}
|
||||
>
|
||||
{IconComponent && (
|
||||
<IconComponent className="h-3 w-3 shrink-0 opacity-80" />
|
||||
)}
|
||||
<span className="truncate">{category.name}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-line breakdown shown in the list for a split transaction. Without an
|
||||
* active category/label filter every line is listed; with one, only the lines
|
||||
* matching the filter are shown plus a single "Rest" row for the remainder, so
|
||||
* the visible parts still add up to the transaction total.
|
||||
*/
|
||||
function SplitBreakdown({
|
||||
transaction,
|
||||
categories,
|
||||
appliedCategoryIds,
|
||||
appliedLabelIds,
|
||||
className,
|
||||
}: {
|
||||
transaction: DecryptedTransaction;
|
||||
categories: Category[];
|
||||
appliedCategoryIds: string[];
|
||||
appliedLabelIds: string[];
|
||||
className?: string;
|
||||
}) {
|
||||
const locale = useLocale();
|
||||
const splits = transaction.splits ?? [];
|
||||
const currencyCode = transaction.currency_code;
|
||||
|
||||
const parentOf = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
categories.map((category) => [category.id, category.parent_id]),
|
||||
),
|
||||
[categories],
|
||||
);
|
||||
|
||||
const formatAmount = (amount: number) =>
|
||||
new Intl.NumberFormat(locale, {
|
||||
style: 'currency',
|
||||
currency: currencyCode,
|
||||
}).format(amount / 100);
|
||||
|
||||
const filterActive =
|
||||
appliedCategoryIds.length > 0 || appliedLabelIds.length > 0;
|
||||
|
||||
const lineMatchesFilter = (split: TransactionSplit): boolean => {
|
||||
let matches = false;
|
||||
|
||||
if (appliedCategoryIds.length > 0) {
|
||||
if (split.category_id === null) {
|
||||
matches ||= appliedCategoryIds.includes('uncategorized');
|
||||
} else {
|
||||
const chain: string[] = [];
|
||||
let current: string | null | undefined = split.category_id;
|
||||
let guard = 0;
|
||||
while (current && guard++ < 10) {
|
||||
chain.push(current);
|
||||
current = parentOf.get(current) ?? null;
|
||||
}
|
||||
matches ||= chain.some((id) => appliedCategoryIds.includes(id));
|
||||
}
|
||||
}
|
||||
|
||||
if (appliedLabelIds.length > 0) {
|
||||
matches ||= (split.labels ?? []).some((label) =>
|
||||
appliedLabelIds.includes(label.id),
|
||||
);
|
||||
}
|
||||
|
||||
return matches;
|
||||
};
|
||||
|
||||
const rows: {
|
||||
key: string;
|
||||
category?: Category | null;
|
||||
amount: number;
|
||||
isRest?: boolean;
|
||||
}[] = [];
|
||||
|
||||
if (filterActive) {
|
||||
const matching = splits.filter(lineMatchesFilter);
|
||||
const restAmount = splits
|
||||
.filter((split) => !matching.includes(split))
|
||||
.reduce((sum, split) => sum + split.amount, 0);
|
||||
|
||||
matching.forEach((split) =>
|
||||
rows.push({
|
||||
key: split.id,
|
||||
category: split.category,
|
||||
amount: split.amount,
|
||||
}),
|
||||
);
|
||||
|
||||
if (restAmount !== 0) {
|
||||
rows.push({ key: 'rest', amount: restAmount, isRest: true });
|
||||
}
|
||||
} else {
|
||||
splits.forEach((split) =>
|
||||
rows.push({
|
||||
key: split.id,
|
||||
category: split.category,
|
||||
amount: split.amount,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex w-full flex-col gap-1', className)}
|
||||
data-testid="split-breakdown"
|
||||
>
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.key}
|
||||
className="flex items-center justify-between gap-2"
|
||||
>
|
||||
{row.isRest ? (
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{__('Rest')}
|
||||
</span>
|
||||
) : (
|
||||
<SplitCategoryChip category={row.category} />
|
||||
)}
|
||||
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
|
||||
{formatAmount(row.amount)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -37,12 +44,12 @@ import {
|
|||
import { type AutomationRule } from '@/types/automation-rule';
|
||||
import { type Category } from '@/types/category';
|
||||
import { type Label } from '@/types/label';
|
||||
import { type DecryptedTransaction } from '@/types/transaction';
|
||||
import { isSplit, type DecryptedTransaction } from '@/types/transaction';
|
||||
import { formatDate } from '@/utils/date';
|
||||
import { __ } from '@/utils/i18n';
|
||||
import { router } from '@inertiajs/react';
|
||||
import { router, usePage } from '@inertiajs/react';
|
||||
import { getYear, parseISO } from 'date-fns';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { Split, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
|
|
@ -95,6 +102,7 @@ export function EditTransactionDialog({
|
|||
const [categoryId, setCategoryId] = useState<string>('null');
|
||||
const [selectedLabelIds, setSelectedLabelIds] = useState<string[]>([]);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [splitLines, setSplitLines] = useState<SplitLineDraft[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [decryptedAccountNames, setDecryptedAccountNames] = useState<
|
||||
Map<string, string>
|
||||
|
|
@ -113,6 +121,24 @@ export function EditTransactionDialog({
|
|||
const canEditAllFields =
|
||||
mode === 'create' || transaction?.source === 'manually_created';
|
||||
|
||||
const splittingEnabled =
|
||||
usePage<SharedData>().props.features.transactionSplitting;
|
||||
const isSplitting = splitLines.length > 0;
|
||||
const wasSplit = transaction ? isSplit(transaction) : false;
|
||||
|
||||
function startSplit() {
|
||||
// Seed with the current category as the first line at the full amount,
|
||||
// plus an empty second line the user fills as they split it up.
|
||||
setSplitLines([
|
||||
newSplitLine({
|
||||
category_id: categoryId === 'null' ? null : categoryId,
|
||||
amount,
|
||||
label_ids: selectedLabelIds,
|
||||
}),
|
||||
newSplitLine(),
|
||||
]);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === 'edit' && transaction) {
|
||||
setTransactionDate(transaction.transaction_date);
|
||||
|
|
@ -126,6 +152,15 @@ export function EditTransactionDialog({
|
|||
[],
|
||||
);
|
||||
setNotes(transaction.decryptedNotes || '');
|
||||
setSplitLines(
|
||||
(transaction.splits ?? []).map((split) =>
|
||||
newSplitLine({
|
||||
category_id: split.category_id,
|
||||
amount: split.amount,
|
||||
label_ids: split.labels?.map((label) => label.id) ?? [],
|
||||
}),
|
||||
),
|
||||
);
|
||||
} else if (mode === 'create' && open) {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
setTransactionDate(today);
|
||||
|
|
@ -139,6 +174,7 @@ export function EditTransactionDialog({
|
|||
setCategoryId('null');
|
||||
setSelectedLabelIds([]);
|
||||
setNotes('');
|
||||
setSplitLines([]);
|
||||
}
|
||||
}, [mode, transaction, open, accounts, initialAccountId]);
|
||||
|
||||
|
|
@ -427,6 +463,11 @@ export function EditTransactionDialog({
|
|||
transaction_date?: string;
|
||||
account_id?: string;
|
||||
currency_code?: string;
|
||||
splits?: {
|
||||
category_id: string | null;
|
||||
amount: number;
|
||||
label_ids: string[];
|
||||
}[];
|
||||
} = {
|
||||
category_id: selectedCategoryId,
|
||||
notes: encryptedNotes,
|
||||
|
|
@ -453,6 +494,75 @@ export function EditTransactionDialog({
|
|||
updateData.currency_code = editedCurrencyCode;
|
||||
}
|
||||
|
||||
// A split change turns one row into several (or back). Persist it
|
||||
// and let the server-rendered list re-fetch rather than patch the
|
||||
// grouped rows by hand. Only send `splits` when the split content
|
||||
// actually changed (or was undone) — editing another field of an
|
||||
// already-split transaction must not re-trigger the gated split
|
||||
// write, so it keeps working even if the feature is rolled back.
|
||||
const originalSplitSignature = JSON.stringify(
|
||||
(transaction.splits ?? []).map((split) => ({
|
||||
category_id: split.category_id,
|
||||
amount: split.amount,
|
||||
label_ids: [
|
||||
...(split.labels?.map((label) => label.id) ?? []),
|
||||
].sort(),
|
||||
})),
|
||||
);
|
||||
const currentSplitSignature = JSON.stringify(
|
||||
splitLines.map((line) => ({
|
||||
category_id: line.category_id,
|
||||
amount: line.amount,
|
||||
label_ids: [...line.label_ids].sort(),
|
||||
})),
|
||||
);
|
||||
const splitDirty =
|
||||
(isSplitting &&
|
||||
currentSplitSignature !== originalSplitSignature) ||
|
||||
(!isSplitting && wasSplit);
|
||||
|
||||
if (splitDirty) {
|
||||
if (isSplitting) {
|
||||
if (!isSplitValid(amount, splitLines)) {
|
||||
toast.error(
|
||||
__(
|
||||
'The split lines must add up to the transaction amount.',
|
||||
),
|
||||
);
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// The lines own categorisation and labels now; drop the
|
||||
// transaction-level values so the backend nulls them.
|
||||
updateData.category_id = null;
|
||||
delete updateData.label_ids;
|
||||
updateData.splits = splitLines.map((line) => ({
|
||||
category_id: line.category_id,
|
||||
amount: line.amount,
|
||||
label_ids: line.label_ids,
|
||||
}));
|
||||
} else {
|
||||
// Collapse an existing split back to a single category.
|
||||
updateData.splits = [];
|
||||
}
|
||||
|
||||
await transactionSyncService.update(
|
||||
transaction.id,
|
||||
updateData,
|
||||
{
|
||||
updateBalance: canEditAllFields
|
||||
? updateAccountBalance
|
||||
: false,
|
||||
},
|
||||
);
|
||||
|
||||
toast.success(__('Transaction updated successfully'));
|
||||
onOpenChange(false);
|
||||
router.reload({ only: ['transactions'] });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await transactionSyncService.update(
|
||||
transaction.id,
|
||||
updateData,
|
||||
|
|
@ -827,34 +937,83 @@ export function EditTransactionDialog({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="category">
|
||||
{__('Category')}
|
||||
</FormLabel>
|
||||
<CategorySelect
|
||||
value={categoryId}
|
||||
onValueChange={setCategoryId}
|
||||
categories={categories}
|
||||
disabled={isSubmitting}
|
||||
placeholder={__('Uncategorized')}
|
||||
triggerClassName="w-full"
|
||||
showUncategorized={true}
|
||||
data-testid="category-select"
|
||||
/>
|
||||
</div>
|
||||
{isSplitting ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>{__('Split')}</FormLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSplitLines([])}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{__('Undo split')}
|
||||
</Button>
|
||||
</div>
|
||||
<SplitEditor
|
||||
total={amount}
|
||||
currencyCode={
|
||||
selectedAccount?.currency_code ??
|
||||
transaction?.currency_code ??
|
||||
'EUR'
|
||||
}
|
||||
categories={categories}
|
||||
labels={labels}
|
||||
lines={splitLines}
|
||||
onChange={setSplitLines}
|
||||
disabled={isSubmitting}
|
||||
onLabelCreated={onLabelCreated}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel htmlFor="category">
|
||||
{__('Category')}
|
||||
</FormLabel>
|
||||
{mode === 'edit' &&
|
||||
splittingEnabled && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={startSplit}
|
||||
disabled={isSubmitting}
|
||||
data-testid="split-button"
|
||||
>
|
||||
<Split className="mr-1 h-4 w-4" />
|
||||
{__('Split')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<CategorySelect
|
||||
value={categoryId}
|
||||
onValueChange={setCategoryId}
|
||||
categories={categories}
|
||||
disabled={isSubmitting}
|
||||
placeholder={__('Uncategorized')}
|
||||
triggerClassName="w-full"
|
||||
showUncategorized={true}
|
||||
data-testid="category-select"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{__('Labels')}</FormLabel>
|
||||
<LabelCombobox
|
||||
value={selectedLabelIds}
|
||||
onValueChange={setSelectedLabelIds}
|
||||
labels={labels}
|
||||
disabled={isSubmitting}
|
||||
placeholder={__('Add labels...')}
|
||||
allowCreate={true}
|
||||
onLabelCreated={onLabelCreated}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{__('Labels')}</FormLabel>
|
||||
<LabelCombobox
|
||||
value={selectedLabelIds}
|
||||
onValueChange={setSelectedLabelIds}
|
||||
labels={labels}
|
||||
disabled={isSubmitting}
|
||||
placeholder={__('Add labels...')}
|
||||
allowCreate={true}
|
||||
onLabelCreated={onLabelCreated}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="notes">{__('Notes')}</FormLabel>
|
||||
|
|
@ -895,7 +1054,11 @@ export function EditTransactionDialog({
|
|||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
(isSplitting &&
|
||||
!isSplitValid(amount, splitLines))
|
||||
}
|
||||
data-testid="submit-transaction"
|
||||
>
|
||||
{isSubmitting
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
isSplitValid,
|
||||
newSplitLine,
|
||||
splitRemaining,
|
||||
type SplitLineDraft,
|
||||
} from './split-editor';
|
||||
|
||||
function lines(...amounts: number[]): SplitLineDraft[] {
|
||||
return amounts.map((amount) => newSplitLine({ amount }));
|
||||
}
|
||||
|
||||
describe('splitRemaining', () => {
|
||||
it('is the total minus the sum of the lines', () => {
|
||||
expect(splitRemaining(-10000, lines(-2500, -7500))).toBe(0);
|
||||
expect(splitRemaining(-10000, lines(-2500))).toBe(-7500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSplitValid', () => {
|
||||
it('accepts at least two same-sign lines that reconcile to the total', () => {
|
||||
expect(isSplitValid(-10000, lines(-2500, -7500))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects fewer than two lines', () => {
|
||||
expect(isSplitValid(-10000, lines(-10000))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects lines that do not add up to the total', () => {
|
||||
expect(isSplitValid(-10000, lines(-2500, -2500))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a zero-amount line', () => {
|
||||
expect(isSplitValid(-10000, lines(-10000, 0))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a line whose sign differs from the total', () => {
|
||||
expect(isSplitValid(-10000, lines(-12500, 2500))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects splitting a zero-total transaction', () => {
|
||||
expect(isSplitValid(0, lines(50, -50))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
import { LabelCombobox } from '@/components/shared/label-combobox';
|
||||
import { CategorySelect } from '@/components/transactions/category-select';
|
||||
import { AmountInput } from '@/components/ui/amount-input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useLocale } from '@/hooks/use-locale';
|
||||
import { type Category } from '@/types/category';
|
||||
import { type Label } from '@/types/label';
|
||||
import { __ } from '@/utils/i18n';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
|
||||
export interface SplitLineDraft {
|
||||
key: string;
|
||||
category_id: string | null;
|
||||
amount: number;
|
||||
label_ids: string[];
|
||||
}
|
||||
|
||||
interface SplitEditorProps {
|
||||
total: number;
|
||||
currencyCode: string;
|
||||
categories: Category[];
|
||||
labels: Label[];
|
||||
lines: SplitLineDraft[];
|
||||
onChange: (lines: SplitLineDraft[]) => void;
|
||||
disabled?: boolean;
|
||||
onLabelCreated?: (label: Label) => void;
|
||||
}
|
||||
|
||||
export function newSplitLine(
|
||||
partial?: Partial<SplitLineDraft>,
|
||||
): SplitLineDraft {
|
||||
return {
|
||||
key: crypto.randomUUID(),
|
||||
category_id: null,
|
||||
amount: 0,
|
||||
label_ids: [],
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
export function splitRemaining(total: number, lines: SplitLineDraft[]): number {
|
||||
return total - lines.reduce((sum, line) => sum + line.amount, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* A split is valid once it has at least two lines that each carry a non-zero
|
||||
* amount on the same side as the (non-zero) transaction total and together
|
||||
* reconcile to it to the cent.
|
||||
*/
|
||||
export function isSplitValid(total: number, lines: SplitLineDraft[]): boolean {
|
||||
if (total === 0 || lines.length < 2 || splitRemaining(total, lines) !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return lines.every(
|
||||
(line) => line.amount !== 0 && line.amount > 0 === total > 0,
|
||||
);
|
||||
}
|
||||
|
||||
export function SplitEditor({
|
||||
total,
|
||||
currencyCode,
|
||||
categories,
|
||||
labels,
|
||||
lines,
|
||||
onChange,
|
||||
disabled,
|
||||
onLabelCreated,
|
||||
}: SplitEditorProps) {
|
||||
const locale = useLocale();
|
||||
const remaining = splitRemaining(total, lines);
|
||||
|
||||
const updateLine = (key: string, patch: Partial<SplitLineDraft>) =>
|
||||
onChange(
|
||||
lines.map((line) =>
|
||||
line.key === key ? { ...line, ...patch } : line,
|
||||
),
|
||||
);
|
||||
|
||||
const removeLine = (key: string) =>
|
||||
onChange(lines.filter((line) => line.key !== key));
|
||||
|
||||
const addLine = () =>
|
||||
onChange([...lines, newSplitLine({ amount: remaining })]);
|
||||
|
||||
const valid = isSplitValid(total, lines);
|
||||
// Remaining can be 0 while the split is still invalid (e.g. a seeded
|
||||
// zero-amount line), so key the styling off validity, not just the balance.
|
||||
const hasZeroLine = lines.some((line) => line.amount === 0);
|
||||
|
||||
const formattedRemaining = new Intl.NumberFormat(locale, {
|
||||
style: 'currency',
|
||||
currency: currencyCode,
|
||||
}).format(remaining / 100);
|
||||
|
||||
return (
|
||||
<div className="space-y-3" data-testid="split-editor">
|
||||
{lines.map((line) => (
|
||||
<div
|
||||
key={line.key}
|
||||
className="space-y-2 rounded-md border border-border p-3"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1">
|
||||
<CategorySelect
|
||||
value={line.category_id ?? 'null'}
|
||||
onValueChange={(value) =>
|
||||
updateLine(line.key, {
|
||||
category_id:
|
||||
value === 'null' ||
|
||||
value === 'uncategorized'
|
||||
? null
|
||||
: value,
|
||||
})
|
||||
}
|
||||
categories={categories}
|
||||
disabled={disabled}
|
||||
showUncategorized={true}
|
||||
triggerClassName="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<AmountInput
|
||||
value={line.amount}
|
||||
onChange={(value) =>
|
||||
updateLine(line.key, { amount: value })
|
||||
}
|
||||
currencyCode={currencyCode}
|
||||
disabled={disabled}
|
||||
allowNegative={true}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeLine(line.key)}
|
||||
disabled={disabled || lines.length <= 2}
|
||||
aria-label={__('Remove line')}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<LabelCombobox
|
||||
value={line.label_ids}
|
||||
onValueChange={(ids) =>
|
||||
updateLine(line.key, { label_ids: ids })
|
||||
}
|
||||
labels={labels}
|
||||
disabled={disabled}
|
||||
placeholder={__('Add labels...')}
|
||||
allowCreate={true}
|
||||
onLabelCreated={onLabelCreated}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addLine}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
{__('Add line')}
|
||||
</Button>
|
||||
<span
|
||||
className={
|
||||
valid
|
||||
? 'text-sm text-muted-foreground'
|
||||
: 'text-sm font-medium text-destructive'
|
||||
}
|
||||
data-testid="split-remaining"
|
||||
>
|
||||
{remaining === 0 && hasZeroLine
|
||||
? __('Each line needs a non-zero amount')
|
||||
: __('Remaining: :amount', {
|
||||
amount: formattedRemaining,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ import {
|
|||
import { type Category } from '@/types/category';
|
||||
import { type Label } from '@/types/label';
|
||||
import {
|
||||
isSplit,
|
||||
type DecryptedTransaction,
|
||||
type TransactionFilters as Filters,
|
||||
type ServerTransaction,
|
||||
|
|
@ -1052,6 +1053,8 @@ export default function Transactions({
|
|||
onReEvaluateRules: handleReEvaluateRules,
|
||||
isDateHidden: columnVisibility.transaction_date === false,
|
||||
categorizingIds,
|
||||
appliedCategoryIds: filters.categoryIds.map(String),
|
||||
appliedLabelIds: filters.labelIds.map(String),
|
||||
}),
|
||||
[
|
||||
accounts,
|
||||
|
|
@ -1064,6 +1067,8 @@ export default function Transactions({
|
|||
handleReEvaluateRules,
|
||||
columnVisibility,
|
||||
categorizingIds,
|
||||
filters.categoryIds,
|
||||
filters.labelIds,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
@ -1120,19 +1125,26 @@ export default function Transactions({
|
|||
try {
|
||||
if (isSelectingAll) {
|
||||
const toastId = toast.loading(__('Updating transactions...'));
|
||||
const response = await axios.patch<{ count: number }>(
|
||||
'/transactions/bulk',
|
||||
{
|
||||
filters: clientFiltersToBackendFilters(filters),
|
||||
category_id: categoryId,
|
||||
},
|
||||
);
|
||||
const response = await axios.patch<{
|
||||
count: number;
|
||||
omitted_splits: number;
|
||||
}>('/transactions/bulk', {
|
||||
filters: clientFiltersToBackendFilters(filters),
|
||||
category_id: categoryId,
|
||||
});
|
||||
toast.dismiss(toastId);
|
||||
toast.success(
|
||||
__(`Updated :count transactions`, {
|
||||
count: response.data.count,
|
||||
}),
|
||||
);
|
||||
if (response.data.omitted_splits > 0) {
|
||||
toast.info(
|
||||
__('Split transactions skipped: :count', {
|
||||
count: response.data.omitted_splits,
|
||||
}),
|
||||
);
|
||||
}
|
||||
setRowSelection({});
|
||||
setIsSelectingAll(false);
|
||||
refreshTransactions();
|
||||
|
|
@ -1144,28 +1156,48 @@ export default function Transactions({
|
|||
? categoriesMap.get(categoryId) || null
|
||||
: null;
|
||||
|
||||
await transactionSyncService.updateMany(selectedIds, {
|
||||
category_id: categoryId,
|
||||
});
|
||||
|
||||
setAllTransactions((previous) =>
|
||||
previous.map((transaction) => {
|
||||
if (selectedIds.includes(transaction.id.toString())) {
|
||||
return {
|
||||
...transaction,
|
||||
category_id: categoryId,
|
||||
category: selectedCategory,
|
||||
};
|
||||
}
|
||||
return transaction;
|
||||
}),
|
||||
// A bulk category assignment can't apply to split transactions
|
||||
// (it would wipe their lines), so skip them and report how many.
|
||||
const splitIds = new Set(
|
||||
allTransactions
|
||||
.filter((transaction) => isSplit(transaction))
|
||||
.map((transaction) => transaction.id.toString()),
|
||||
);
|
||||
const targetIds = selectedIds.filter((id) => !splitIds.has(id));
|
||||
const omitted = selectedIds.length - targetIds.length;
|
||||
|
||||
toast.success(
|
||||
__(`Updated :count transactions`, {
|
||||
count: selectedIds.length,
|
||||
}),
|
||||
);
|
||||
if (targetIds.length > 0) {
|
||||
await transactionSyncService.updateMany(targetIds, {
|
||||
category_id: categoryId,
|
||||
});
|
||||
|
||||
setAllTransactions((previous) =>
|
||||
previous.map((transaction) => {
|
||||
if (targetIds.includes(transaction.id.toString())) {
|
||||
return {
|
||||
...transaction,
|
||||
category_id: categoryId,
|
||||
category: selectedCategory,
|
||||
};
|
||||
}
|
||||
return transaction;
|
||||
}),
|
||||
);
|
||||
|
||||
toast.success(
|
||||
__(`Updated :count transactions`, {
|
||||
count: targetIds.length,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (omitted > 0) {
|
||||
toast.info(
|
||||
__('Split transactions skipped: :count', {
|
||||
count: omitted,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
setRowSelection({});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,14 +47,21 @@ export interface Transaction {
|
|||
ai_confidence?: number | null;
|
||||
ai_categorized?: boolean;
|
||||
label_ids?: UUID[];
|
||||
splits?: TransactionSplit[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** Whether a transaction is split into per-category lines. */
|
||||
export function isSplit(transaction: Pick<Transaction, 'splits'>): boolean {
|
||||
return (transaction.splits?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
export interface ServerTransaction extends Transaction {
|
||||
account?: Account;
|
||||
category?: Category | null;
|
||||
labels?: Label[];
|
||||
splits?: TransactionSplit[];
|
||||
}
|
||||
|
||||
export interface DecryptedTransaction extends Transaction {
|
||||
|
|
@ -48,6 +71,7 @@ export interface DecryptedTransaction extends Transaction {
|
|||
category?: Category | null;
|
||||
bank?: Bank;
|
||||
labels?: Label[];
|
||||
splits?: TransactionSplit[];
|
||||
}
|
||||
|
||||
export interface TransactionFilters {
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ test('shared feature flags do not include coinbase flag', function () {
|
|||
'cashflow' => true,
|
||||
'calculateBalancesOnImport' => false,
|
||||
'mcp' => false,
|
||||
'transactionSplitting' => false,
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,403 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\CategoryCashflowDirection;
|
||||
use App\Enums\CategoryType;
|
||||
use App\Enums\TransactionSource;
|
||||
use App\Features\TransactionSplitting;
|
||||
use App\Models\Budget;
|
||||
use App\Models\BudgetPeriod;
|
||||
use App\Models\BudgetTransaction;
|
||||
use App\Models\Category;
|
||||
use App\Models\Label;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\TransactionSplit;
|
||||
use App\Models\User;
|
||||
use App\Services\BudgetTransactionService;
|
||||
use App\Services\CategorySpendingService;
|
||||
use App\Services\TransactionSplitter;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Laravel\Pennant\Feature;
|
||||
|
||||
use function Pest\Laravel\actingAs;
|
||||
|
||||
function splitTestExpenseCategory(User $user, string $name = 'Cat'): Category
|
||||
{
|
||||
return Category::factory()->for($user)->create([
|
||||
'name' => $name,
|
||||
'type' => CategoryType::Expense,
|
||||
'cashflow_direction' => CategoryCashflowDirection::Outflow,
|
||||
]);
|
||||
}
|
||||
|
||||
function splittableTransaction(User $user, int $amount = -10000): Transaction
|
||||
{
|
||||
return Transaction::factory()->for($user)->create([
|
||||
'amount' => $amount,
|
||||
'category_id' => null,
|
||||
'currency_code' => $user->currency_code,
|
||||
'source' => TransactionSource::Imported,
|
||||
]);
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
// Keep every amount in the user's own currency so analytics never reaches
|
||||
// for an exchange rate (which would be a stray HTTP request under test).
|
||||
$this->user = User::factory()->create(['currency_code' => 'EUR']);
|
||||
Feature::for($this->user)->activate(TransactionSplitting::class);
|
||||
});
|
||||
|
||||
it('splits a transaction into lines and nulls its own category', function () {
|
||||
$restaurant = splitTestExpenseCategory($this->user, 'Restaurant');
|
||||
$travel = splitTestExpenseCategory($this->user, 'Travel');
|
||||
$transaction = splittableTransaction($this->user);
|
||||
|
||||
actingAs($this->user)
|
||||
->patchJson(route('transactions.update', $transaction), [
|
||||
'splits' => [
|
||||
['category_id' => $restaurant->id, 'amount' => -2500, 'label_ids' => []],
|
||||
['category_id' => $travel->id, 'amount' => -7500, 'label_ids' => []],
|
||||
],
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$transaction->refresh();
|
||||
|
||||
expect($transaction->category_id)->toBeNull();
|
||||
expect($transaction->splits)->toHaveCount(2);
|
||||
expect($transaction->splits->sum('amount'))->toBe(-10000);
|
||||
expect($transaction->splits->pluck('category_id')->all())
|
||||
->toContain($restaurant->id, $travel->id);
|
||||
});
|
||||
|
||||
it('moves transaction labels onto the split lines', function () {
|
||||
$category = splitTestExpenseCategory($this->user);
|
||||
$label = Label::factory()->for($this->user)->create();
|
||||
$transaction = splittableTransaction($this->user);
|
||||
$transaction->labels()->attach($label->id);
|
||||
|
||||
actingAs($this->user)
|
||||
->patchJson(route('transactions.update', $transaction), [
|
||||
'splits' => [
|
||||
['category_id' => $category->id, 'amount' => -2500, 'label_ids' => [$label->id]],
|
||||
['category_id' => $category->id, 'amount' => -7500, 'label_ids' => []],
|
||||
],
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$transaction->refresh();
|
||||
|
||||
expect($transaction->labels)->toHaveCount(0);
|
||||
expect($transaction->splits->first()->labels->pluck('id')->all())->toContain($label->id);
|
||||
});
|
||||
|
||||
it('rejects splits that do not add up to the transaction amount', function () {
|
||||
$category = splitTestExpenseCategory($this->user);
|
||||
$transaction = splittableTransaction($this->user);
|
||||
|
||||
actingAs($this->user)
|
||||
->patchJson(route('transactions.update', $transaction), [
|
||||
'splits' => [
|
||||
['category_id' => $category->id, 'amount' => -2500],
|
||||
['category_id' => $category->id, 'amount' => -2500],
|
||||
],
|
||||
])
|
||||
->assertJsonValidationErrors(['splits']);
|
||||
|
||||
expect($transaction->fresh()->splits)->toHaveCount(0);
|
||||
});
|
||||
|
||||
it('rejects a single split line', function () {
|
||||
$category = splitTestExpenseCategory($this->user);
|
||||
$transaction = splittableTransaction($this->user);
|
||||
|
||||
actingAs($this->user)
|
||||
->patchJson(route('transactions.update', $transaction), [
|
||||
'splits' => [
|
||||
['category_id' => $category->id, 'amount' => -10000],
|
||||
],
|
||||
])
|
||||
->assertJsonValidationErrors(['splits']);
|
||||
});
|
||||
|
||||
it('rejects a split line whose sign differs from the transaction', function () {
|
||||
$category = splitTestExpenseCategory($this->user);
|
||||
$transaction = splittableTransaction($this->user);
|
||||
|
||||
actingAs($this->user)
|
||||
->patchJson(route('transactions.update', $transaction), [
|
||||
'splits' => [
|
||||
['category_id' => $category->id, 'amount' => -12500],
|
||||
['category_id' => $category->id, 'amount' => 2500],
|
||||
],
|
||||
])
|
||||
->assertJsonValidationErrors(['splits.1.amount']);
|
||||
});
|
||||
|
||||
it('collapses a split back to a single category', function () {
|
||||
$category = splitTestExpenseCategory($this->user);
|
||||
$target = splitTestExpenseCategory($this->user, 'Target');
|
||||
$transaction = splittableTransaction($this->user);
|
||||
|
||||
app(TransactionSplitter::class)->apply($transaction, [
|
||||
['category_id' => $category->id, 'amount' => -2500],
|
||||
['category_id' => $category->id, 'amount' => -7500],
|
||||
]);
|
||||
|
||||
actingAs($this->user)
|
||||
->patchJson(route('transactions.update', $transaction), [
|
||||
'category_id' => $target->id,
|
||||
'splits' => [],
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$transaction->refresh();
|
||||
|
||||
expect($transaction->splits)->toHaveCount(0);
|
||||
expect($transaction->category_id)->toBe($target->id);
|
||||
});
|
||||
|
||||
it('forbids splitting when the feature is disabled', function () {
|
||||
Feature::for($this->user)->deactivate(TransactionSplitting::class);
|
||||
|
||||
$category = splitTestExpenseCategory($this->user);
|
||||
$transaction = splittableTransaction($this->user);
|
||||
|
||||
actingAs($this->user)
|
||||
->patchJson(route('transactions.update', $transaction), [
|
||||
'splits' => [
|
||||
['category_id' => $category->id, 'amount' => -2500],
|
||||
['category_id' => $category->id, 'amount' => -7500],
|
||||
],
|
||||
])
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('cascade-deletes split lines when the transaction row is removed', function () {
|
||||
$category = splitTestExpenseCategory($this->user);
|
||||
$transaction = splittableTransaction($this->user);
|
||||
|
||||
app(TransactionSplitter::class)->apply($transaction, [
|
||||
['category_id' => $category->id, 'amount' => -2500],
|
||||
['category_id' => $category->id, 'amount' => -7500],
|
||||
]);
|
||||
|
||||
// Hit the DB-level foreign-key cascade directly (a model force-delete would
|
||||
// additionally fire the queued TransactionDeleted listener).
|
||||
DB::table('transactions')->where('id', $transaction->id)->delete();
|
||||
|
||||
expect(TransactionSplit::query()->where('transaction_id', $transaction->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('attributes split lines to their categories in the spending breakdown', function () {
|
||||
$restaurant = splitTestExpenseCategory($this->user, 'Restaurant');
|
||||
$travel = splitTestExpenseCategory($this->user, 'Travel');
|
||||
$transaction = splittableTransaction($this->user, -10000);
|
||||
$transaction->update(['transaction_date' => now()]);
|
||||
|
||||
app(TransactionSplitter::class)->apply($transaction, [
|
||||
['category_id' => $restaurant->id, 'amount' => -2500],
|
||||
['category_id' => $travel->id, 'amount' => -7500],
|
||||
]);
|
||||
|
||||
$spending = app(CategorySpendingService::class)->forPeriod(
|
||||
$this->user->id,
|
||||
now()->subDay(),
|
||||
now()->addDay(),
|
||||
);
|
||||
|
||||
$byCategory = $spending->keyBy('category_id');
|
||||
|
||||
expect($byCategory[$restaurant->id]['amount'])->toBe(2500);
|
||||
expect($byCategory[$travel->id]['amount'])->toBe(7500);
|
||||
});
|
||||
|
||||
it('reflects split lines in the cashflow expense breakdown endpoint', function () {
|
||||
$restaurant = splitTestExpenseCategory($this->user, 'Restaurant');
|
||||
$travel = splitTestExpenseCategory($this->user, 'Travel');
|
||||
$transaction = splittableTransaction($this->user, -10000);
|
||||
$transaction->update(['transaction_date' => now()]);
|
||||
|
||||
app(TransactionSplitter::class)->apply($transaction, [
|
||||
['category_id' => $restaurant->id, 'amount' => -2500],
|
||||
['category_id' => $travel->id, 'amount' => -7500],
|
||||
]);
|
||||
|
||||
$response = actingAs($this->user)->getJson(
|
||||
'/api/cashflow/breakdown?type=expense&from='.now()->subDay()->toDateString().'&to='.now()->addDay()->toDateString(),
|
||||
)->assertOk();
|
||||
|
||||
$amounts = collect($response->json('data'))->keyBy('category_id');
|
||||
|
||||
expect($amounts[$restaurant->id]['amount'])->toBe(2500);
|
||||
expect($amounts[$travel->id]['amount'])->toBe(7500);
|
||||
});
|
||||
|
||||
it('attributes only the matching split line to a category budget', function () {
|
||||
$tracked = splitTestExpenseCategory($this->user, 'Tracked');
|
||||
$other = splitTestExpenseCategory($this->user, 'Other');
|
||||
$transaction = splittableTransaction($this->user, -10000);
|
||||
$transaction->update(['transaction_date' => now()]);
|
||||
|
||||
app(TransactionSplitter::class)->apply($transaction, [
|
||||
['category_id' => $tracked->id, 'amount' => -2500],
|
||||
['category_id' => $other->id, 'amount' => -7500],
|
||||
]);
|
||||
|
||||
$budget = Budget::factory()->forCategories($tracked)->create(['user_id' => $this->user->id]);
|
||||
$period = BudgetPeriod::factory()->create([
|
||||
'budget_id' => $budget->id,
|
||||
'start_date' => now()->subDays(15),
|
||||
'end_date' => now()->addDays(15),
|
||||
]);
|
||||
|
||||
app(BudgetTransactionService::class)->assignTransaction($transaction->fresh());
|
||||
|
||||
$budgetTransaction = BudgetTransaction::query()
|
||||
->where('transaction_id', $transaction->id)
|
||||
->where('budget_period_id', $period->id)
|
||||
->first();
|
||||
|
||||
expect($budgetTransaction)->not->toBeNull();
|
||||
expect($budgetTransaction->amount)->toBe(2500);
|
||||
});
|
||||
|
||||
it('finds a split transaction when filtering the list by a line category', function () {
|
||||
$restaurant = splitTestExpenseCategory($this->user, 'Restaurant');
|
||||
$travel = splitTestExpenseCategory($this->user, 'Travel');
|
||||
$transaction = splittableTransaction($this->user, -10000);
|
||||
|
||||
app(TransactionSplitter::class)->apply($transaction, [
|
||||
['category_id' => $restaurant->id, 'amount' => -2500],
|
||||
['category_id' => $travel->id, 'amount' => -7500],
|
||||
]);
|
||||
|
||||
$matches = Transaction::query()
|
||||
->where('user_id', $this->user->id)
|
||||
->applyFilters(['category_ids' => [$restaurant->id], 'user_id' => $this->user->id])
|
||||
->pluck('id');
|
||||
|
||||
expect($matches->all())->toContain($transaction->id);
|
||||
});
|
||||
|
||||
it('does not treat a fully-categorized split as uncategorized', function () {
|
||||
$restaurant = splitTestExpenseCategory($this->user, 'Restaurant');
|
||||
$travel = splitTestExpenseCategory($this->user, 'Travel');
|
||||
$split = splittableTransaction($this->user, -10000);
|
||||
$uncategorized = splittableTransaction($this->user, -5000);
|
||||
|
||||
app(TransactionSplitter::class)->apply($split, [
|
||||
['category_id' => $restaurant->id, 'amount' => -2500],
|
||||
['category_id' => $travel->id, 'amount' => -7500],
|
||||
]);
|
||||
|
||||
$matches = Transaction::query()
|
||||
->where('user_id', $this->user->id)
|
||||
->applyFilters(['category_ids' => ['uncategorized'], 'user_id' => $this->user->id])
|
||||
->pluck('id');
|
||||
|
||||
expect($matches->all())->toContain($uncategorized->id);
|
||||
expect($matches->all())->not->toContain($split->id);
|
||||
});
|
||||
|
||||
it('rejects splitting a zero-amount transaction', function () {
|
||||
$category = splitTestExpenseCategory($this->user);
|
||||
$transaction = splittableTransaction($this->user, 0);
|
||||
|
||||
actingAs($this->user)
|
||||
->patchJson(route('transactions.update', $transaction), [
|
||||
'splits' => [
|
||||
['category_id' => $category->id, 'amount' => 50],
|
||||
['category_id' => $category->id, 'amount' => -50],
|
||||
],
|
||||
])
|
||||
->assertJsonValidationErrors(['splits']);
|
||||
});
|
||||
|
||||
it('classifies split lines by their own category type in the dashboard cash flow', function () {
|
||||
$expense = splitTestExpenseCategory($this->user, 'Groceries');
|
||||
$savings = Category::factory()->for($this->user)->create([
|
||||
'name' => 'Savings',
|
||||
'type' => CategoryType::Savings,
|
||||
'cashflow_direction' => CategoryCashflowDirection::Outflow,
|
||||
]);
|
||||
$transaction = splittableTransaction($this->user, -10000);
|
||||
$transaction->update(['transaction_date' => now()]);
|
||||
|
||||
app(TransactionSplitter::class)->apply($transaction, [
|
||||
['category_id' => $expense->id, 'amount' => -3000],
|
||||
['category_id' => $savings->id, 'amount' => -7000],
|
||||
]);
|
||||
|
||||
$response = actingAs($this->user)->getJson(
|
||||
'/api/dashboard/cash-flow?from='.now()->startOfMonth()->toDateString().'&to='.now()->endOfMonth()->toDateString(),
|
||||
)->assertOk();
|
||||
|
||||
// Only the expense line counts as expense; the savings line is excluded.
|
||||
expect($response->json('current.expense'))->toBe(3000);
|
||||
});
|
||||
|
||||
it('attributes split lines per category on the analysis screen', function () {
|
||||
$restaurant = splitTestExpenseCategory($this->user, 'Restaurant');
|
||||
$travel = splitTestExpenseCategory($this->user, 'Travel');
|
||||
$transaction = splittableTransaction($this->user, -10000);
|
||||
$transaction->update(['transaction_date' => now()]);
|
||||
|
||||
app(TransactionSplitter::class)->apply($transaction, [
|
||||
['category_id' => $restaurant->id, 'amount' => -2500],
|
||||
['category_id' => $travel->id, 'amount' => -7500],
|
||||
]);
|
||||
|
||||
$response = actingAs($this->user)->getJson(
|
||||
'/api/transactions/analysis?date_from='.now()->subDay()->toDateString().'&date_to='.now()->addDay()->toDateString(),
|
||||
)->assertOk();
|
||||
|
||||
$byCategory = collect($response->json('by_category'))->keyBy('category_id');
|
||||
|
||||
expect($byCategory[$restaurant->id]['amount'])->toBe(2500);
|
||||
expect($byCategory[$travel->id]['amount'])->toBe(7500);
|
||||
});
|
||||
|
||||
it('skips split transactions on a bulk label assignment', function () {
|
||||
$category = splitTestExpenseCategory($this->user);
|
||||
$label = Label::factory()->for($this->user)->create();
|
||||
$split = splittableTransaction($this->user, -10000);
|
||||
$plain = splittableTransaction($this->user, -5000);
|
||||
|
||||
app(TransactionSplitter::class)->apply($split, [
|
||||
['category_id' => $category->id, 'amount' => -2500],
|
||||
['category_id' => $category->id, 'amount' => -7500],
|
||||
]);
|
||||
|
||||
actingAs($this->user)
|
||||
->patchJson(route('transactions.bulk-update'), [
|
||||
'transaction_ids' => [$split->id, $plain->id],
|
||||
'label_ids' => [$label->id],
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
expect($split->fresh()->labels)->toHaveCount(0);
|
||||
expect($plain->fresh()->labels->pluck('id')->all())->toContain($label->id);
|
||||
});
|
||||
|
||||
it('skips split transactions on a bulk category assignment', function () {
|
||||
$category = splitTestExpenseCategory($this->user);
|
||||
$newCategory = splitTestExpenseCategory($this->user, 'New');
|
||||
$split = splittableTransaction($this->user, -10000);
|
||||
$plain = splittableTransaction($this->user, -5000);
|
||||
|
||||
app(TransactionSplitter::class)->apply($split, [
|
||||
['category_id' => $category->id, 'amount' => -2500],
|
||||
['category_id' => $category->id, 'amount' => -7500],
|
||||
]);
|
||||
|
||||
$response = actingAs($this->user)
|
||||
->patchJson(route('transactions.bulk-update'), [
|
||||
'transaction_ids' => [$split->id, $plain->id],
|
||||
'category_id' => $newCategory->id,
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
expect($response->json('omitted_splits'))->toBe(1);
|
||||
expect($split->fresh()->category_id)->toBeNull();
|
||||
expect($plain->fresh()->category_id)->toBe($newCategory->id);
|
||||
});
|
||||
|
|
@ -78,7 +78,8 @@ test('cashflow trend API does not exceed query threshold', function () {
|
|||
});
|
||||
|
||||
test('cashflow breakdown API does not exceed query threshold', function () {
|
||||
assertMaxQueries(15, function () {
|
||||
// +1 vs. pre-split: the breakdown eager-loads each transaction's splits.
|
||||
assertMaxQueries(16, function () {
|
||||
$this->getJson("/api/cashflow/breakdown?type=expense&{$this->dateParams}")->assertOk();
|
||||
}, 'API Cashflow Breakdown');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -57,7 +57,8 @@ test('account show page does not exceed query threshold', function () {
|
|||
});
|
||||
|
||||
test('transactions index page does not exceed query threshold', function () {
|
||||
assertMaxQueries(21, function () {
|
||||
// +1 vs. pre-split: the list eager-loads each transaction's splits.
|
||||
assertMaxQueries(22, function () {
|
||||
$this->get(route('transactions.index'))->assertOk();
|
||||
}, 'Transactions Index');
|
||||
});
|
||||
|
|
@ -163,8 +164,9 @@ test('transactions page query count does not scale with number of transactions',
|
|||
'category_id' => $category->id,
|
||||
]);
|
||||
|
||||
// Same threshold — paginated queries should not scale
|
||||
assertMaxQueries(21, function () {
|
||||
// Same threshold as the base case — the splits eager-load is a single bulk
|
||||
// query, so it stays constant regardless of how many transactions there are.
|
||||
assertMaxQueries(22, function () {
|
||||
$this->get(route('transactions.index'))->assertOk();
|
||||
}, 'Transactions with 120 records');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue