feat(transactions): default balance toggle on and apply it server-side (#566)
## What In the add-transaction modal, the **Update account balance** checkbox now defaults to **on**. An explicit opt-out is still remembered in `localStorage`, so users who turned it off keep their preference. Balance updates also move from the client to the backend, mirroring the existing delete flow (`ManualBalanceAdjuster::reverseDeletedTransaction`). ## How the balance is applied When the toggle is on, `TransactionController::store` calls the new `ManualBalanceAdjuster::applyCreatedTransaction`, which adjusts the balance on the **transaction's own date**: - A balance already on that date → increased by the transaction amount. - No balance on that date but an earlier one exists → a new balance for that date is created from the **closest earlier balance** plus the amount. - No prior balances at all (first transaction on the account) → the balance is set to the transaction amount. Connected accounts are skipped, since their balances come from bank sync. ## Why The previous client-side flow fetched all balances, computed from the **latest balance overall** (not the transaction's date), and stored via a separate request. This replaces those three round-trips with one atomic server-side write and fixes the wrong base balance. ## Tests - Feature tests for all three balance cases plus the not-requested and connected-account guards (`tests/Feature/TransactionTest.php`). - Frontend test asserting the toggle is checked by default in create mode. Full suite green locally: Pest (1690 passed), vitest (221 passed), Pint, ESLint, Prettier.
This commit is contained in:
parent
50dba4334a
commit
b76a0de074
|
|
@ -172,7 +172,7 @@ class TransactionController extends Controller
|
|||
]);
|
||||
}
|
||||
|
||||
public function store(StoreTransactionRequest $request): JsonResponse
|
||||
public function store(StoreTransactionRequest $request, ManualBalanceAdjuster $balanceAdjuster): JsonResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$labelIds = $data['label_ids'] ?? null;
|
||||
|
|
@ -194,6 +194,10 @@ class TransactionController extends Controller
|
|||
$transaction->labels()->sync($labelIds);
|
||||
}
|
||||
|
||||
if ($request->boolean('update_balance')) {
|
||||
$balanceAdjuster->applyCreatedTransaction($transaction);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => $transaction->load('labels'),
|
||||
], 201);
|
||||
|
|
|
|||
|
|
@ -40,4 +40,38 @@ class ManualBalanceAdjuster
|
|||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a newly created transaction to its manual account's balance.
|
||||
*
|
||||
* Adjusts the balance on the transaction's own date. The base is that day's
|
||||
* balance if one exists, otherwise the closest earlier balance, otherwise
|
||||
* zero (the first transaction on the account). Connected accounts are
|
||||
* skipped because their balances come from bank sync.
|
||||
*/
|
||||
public function applyCreatedTransaction(Transaction $transaction): void
|
||||
{
|
||||
$account = $transaction->account;
|
||||
|
||||
if ($account === null || $account->isConnected()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$transactionDate = $transaction->transaction_date->toDateString();
|
||||
|
||||
$baseBalance = $account->balances()
|
||||
->where('balance_date', '<=', $transactionDate)
|
||||
->orderByDesc('balance_date')
|
||||
->value('balance') ?? 0;
|
||||
|
||||
AccountBalance::updateOrCreate(
|
||||
[
|
||||
'account_id' => $account->id,
|
||||
'balance_date' => $transactionDate,
|
||||
],
|
||||
[
|
||||
'balance' => $baseBalance + $transaction->amount,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,6 @@ import type React from 'react';
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { EditTransactionDialog } from './edit-transaction-dialog';
|
||||
|
||||
vi.mock('@/actions/App/Http/Controllers/AccountBalanceController', () => ({
|
||||
indexBalances: () => ({ url: '/balances' }),
|
||||
store: () => ({ url: '/balances' }),
|
||||
index: () => ({ url: '/balances' }),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/shared/label-combobox', () => ({
|
||||
LabelCombobox: () => <div />,
|
||||
}));
|
||||
|
|
@ -215,4 +209,22 @@ describe('EditTransactionDialog', () => {
|
|||
'account-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('checks "update account balance" by default in create mode', () => {
|
||||
render(
|
||||
<EditTransactionDialog
|
||||
transaction={null}
|
||||
categories={[]}
|
||||
accounts={[checkingAccount]}
|
||||
banks={[]}
|
||||
labels={[]}
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
mode="create"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('checkbox')).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,3 @@
|
|||
import {
|
||||
index as indexBalances,
|
||||
store as storeBalance,
|
||||
} from '@/actions/App/Http/Controllers/AccountBalanceController';
|
||||
import { LabelCombobox } from '@/components/shared/label-combobox';
|
||||
import { CategorySelect } from '@/components/transactions/category-select';
|
||||
import { AmountInput } from '@/components/ui/amount-input';
|
||||
|
|
@ -28,7 +24,6 @@ import { Textarea } from '@/components/ui/textarea';
|
|||
import { useSyncContext } from '@/contexts/sync-context';
|
||||
import { useLocale } from '@/hooks/use-locale';
|
||||
import { decrypt, importKey } from '@/lib/crypto';
|
||||
import { getCsrfToken } from '@/lib/csrf';
|
||||
import { getStoredKey } from '@/lib/key-storage';
|
||||
import { evaluateRulesForNewTransaction } from '@/lib/rule-engine';
|
||||
import { appendNoteIfNotPresent } from '@/lib/utils';
|
||||
|
|
@ -102,9 +97,10 @@ export function EditTransactionDialog({
|
|||
const [updateAccountBalance, setUpdateAccountBalance] = useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const stored = localStorage.getItem(STORAGE_KEY_UPDATE_BALANCE);
|
||||
return stored === 'true';
|
||||
// Active by default; only an explicit opt-out turns it off.
|
||||
return stored === null ? true : stored === 'true';
|
||||
}
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -272,68 +268,6 @@ export function EditTransactionDialog({
|
|||
localStorage.setItem(STORAGE_KEY_UPDATE_BALANCE, String(checked));
|
||||
}
|
||||
|
||||
async function updateBalanceForTransaction(
|
||||
accountIdToUpdate: string,
|
||||
transactionDateStr: string,
|
||||
transactionAmount: number,
|
||||
) {
|
||||
const xsrfToken = getCsrfToken();
|
||||
|
||||
try {
|
||||
// Fetch balances from backend
|
||||
const balancesResponse = await fetch(
|
||||
indexBalances.url(accountIdToUpdate),
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!balancesResponse.ok) {
|
||||
throw new Error('Failed to fetch balances');
|
||||
}
|
||||
|
||||
const balancesData = await balancesResponse.json();
|
||||
const accountBalances = (balancesData.data || []).sort(
|
||||
(a: { balance_date: string }, b: { balance_date: string }) =>
|
||||
new Date(b.balance_date).getTime() -
|
||||
new Date(a.balance_date).getTime(),
|
||||
);
|
||||
|
||||
const latestBalance =
|
||||
accountBalances.length > 0 ? accountBalances[0].balance : 0;
|
||||
|
||||
const newBalance = latestBalance + transactionAmount;
|
||||
|
||||
// Store new balance via backend
|
||||
const storeResponse = await fetch(
|
||||
storeBalance.url(accountIdToUpdate),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': xsrfToken,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
balance_date: transactionDateStr,
|
||||
balance: newBalance,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!storeResponse.ok) {
|
||||
throw new Error('Failed to store balance');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update account balance:', error);
|
||||
toast.error(
|
||||
__('Transaction created, but failed to update balance'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
|
|
@ -400,23 +334,28 @@ export function EditTransactionDialog({
|
|||
throw new Error(__('Selected account not found'));
|
||||
}
|
||||
|
||||
const createdTransaction = await transactionSyncService.create({
|
||||
user_id: '00000000-0000-0000-0000-000000000000',
|
||||
account_id: accountId,
|
||||
category_id: finalCategoryId,
|
||||
description: finalDescription,
|
||||
description_iv: finalDescriptionIv,
|
||||
transaction_date: transactionDate,
|
||||
amount: amount,
|
||||
currency_code: selectedAccount.currency_code,
|
||||
notes: encryptedNotes,
|
||||
notes_iv: notesIv,
|
||||
creditor_name: null,
|
||||
debtor_name: null,
|
||||
source: 'manually_created' as const,
|
||||
label_ids:
|
||||
finalLabelIds.length > 0 ? finalLabelIds : undefined,
|
||||
});
|
||||
const createdTransaction = await transactionSyncService.create(
|
||||
{
|
||||
user_id: '00000000-0000-0000-0000-000000000000',
|
||||
account_id: accountId,
|
||||
category_id: finalCategoryId,
|
||||
description: finalDescription,
|
||||
description_iv: finalDescriptionIv,
|
||||
transaction_date: transactionDate,
|
||||
amount: amount,
|
||||
currency_code: selectedAccount.currency_code,
|
||||
notes: encryptedNotes,
|
||||
notes_iv: notesIv,
|
||||
creditor_name: null,
|
||||
debtor_name: null,
|
||||
source: 'manually_created' as const,
|
||||
label_ids:
|
||||
finalLabelIds.length > 0
|
||||
? finalLabelIds
|
||||
: undefined,
|
||||
},
|
||||
{ updateBalance: updateAccountBalance },
|
||||
);
|
||||
|
||||
const updatedCategory = finalCategoryId
|
||||
? categories.find(
|
||||
|
|
@ -441,14 +380,6 @@ export function EditTransactionDialog({
|
|||
label_ids: finalLabelIds,
|
||||
};
|
||||
|
||||
if (updateAccountBalance) {
|
||||
await updateBalanceForTransaction(
|
||||
accountId,
|
||||
transactionDate,
|
||||
amount,
|
||||
);
|
||||
}
|
||||
|
||||
toast.success(__('Transaction created successfully'));
|
||||
if (ruleResult.ruleName) {
|
||||
toast.success(
|
||||
|
|
|
|||
|
|
@ -63,8 +63,12 @@ class TransactionSyncService {
|
|||
|
||||
async create(
|
||||
data: Omit<Transaction, 'id' | 'created_at' | 'updated_at'>,
|
||||
options?: { updateBalance?: boolean },
|
||||
): Promise<Transaction> {
|
||||
const response = await axios.post('/transactions', data);
|
||||
const response = await axios.post('/transactions', {
|
||||
...data,
|
||||
...(options?.updateBalance ? { update_balance: true } : {}),
|
||||
});
|
||||
const serverData = response.data.data || response.data;
|
||||
|
||||
const label_ids = serverData.labels?.map((l: { id: string }) => l.id);
|
||||
|
|
|
|||
|
|
@ -368,6 +368,123 @@ test('deleting a connected account transaction never changes the balance', funct
|
|||
]);
|
||||
});
|
||||
|
||||
test('creating a transaction updates the balance on its date when one exists', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$account = Account::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
$account->balances()->create([
|
||||
'balance_date' => '2025-11-11',
|
||||
'balance' => 100000,
|
||||
]);
|
||||
|
||||
actingAs($user)->postJson(route('transactions.store'), [
|
||||
'account_id' => $account->id,
|
||||
'description' => 'encrypted_description',
|
||||
'transaction_date' => '2025-11-11',
|
||||
'amount' => 2500,
|
||||
'currency_code' => 'USD',
|
||||
'source' => 'manually_created',
|
||||
'update_balance' => true,
|
||||
])->assertCreated();
|
||||
|
||||
$this->assertDatabaseCount('account_balances', 1);
|
||||
$this->assertDatabaseHas('account_balances', [
|
||||
'account_id' => $account->id,
|
||||
'balance_date' => '2025-11-11',
|
||||
'balance' => 102500,
|
||||
]);
|
||||
});
|
||||
|
||||
test('creating a transaction creates a balance on its date from the closest earlier balance', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$account = Account::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
$account->balances()->create([
|
||||
'balance_date' => '2025-11-01',
|
||||
'balance' => 50000,
|
||||
]);
|
||||
|
||||
actingAs($user)->postJson(route('transactions.store'), [
|
||||
'account_id' => $account->id,
|
||||
'description' => 'encrypted_description',
|
||||
'transaction_date' => '2025-11-11',
|
||||
'amount' => -1500,
|
||||
'currency_code' => 'USD',
|
||||
'source' => 'manually_created',
|
||||
'update_balance' => true,
|
||||
])->assertCreated();
|
||||
|
||||
$this->assertDatabaseHas('account_balances', [
|
||||
'account_id' => $account->id,
|
||||
'balance_date' => '2025-11-11',
|
||||
'balance' => 48500,
|
||||
]);
|
||||
});
|
||||
|
||||
test('creating the first transaction on an account creates a balance equal to its amount', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$account = Account::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
actingAs($user)->postJson(route('transactions.store'), [
|
||||
'account_id' => $account->id,
|
||||
'description' => 'encrypted_description',
|
||||
'transaction_date' => '2025-11-11',
|
||||
'amount' => 7500,
|
||||
'currency_code' => 'USD',
|
||||
'source' => 'manually_created',
|
||||
'update_balance' => true,
|
||||
])->assertCreated();
|
||||
|
||||
$this->assertDatabaseHas('account_balances', [
|
||||
'account_id' => $account->id,
|
||||
'balance_date' => '2025-11-11',
|
||||
'balance' => 7500,
|
||||
]);
|
||||
});
|
||||
|
||||
test('creating a transaction does not change the balance when not requested', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$account = Account::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
actingAs($user)->postJson(route('transactions.store'), [
|
||||
'account_id' => $account->id,
|
||||
'description' => 'encrypted_description',
|
||||
'transaction_date' => '2025-11-11',
|
||||
'amount' => 7500,
|
||||
'currency_code' => 'USD',
|
||||
'source' => 'manually_created',
|
||||
])->assertCreated();
|
||||
|
||||
$this->assertDatabaseCount('account_balances', 0);
|
||||
});
|
||||
|
||||
test('creating a connected account transaction never changes the balance', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$account = Account::factory()->connected()->create(['user_id' => $user->id]);
|
||||
|
||||
$account->balances()->create([
|
||||
'balance_date' => '2025-11-11',
|
||||
'balance' => 100000,
|
||||
]);
|
||||
|
||||
actingAs($user)->postJson(route('transactions.store'), [
|
||||
'account_id' => $account->id,
|
||||
'description' => 'encrypted_description',
|
||||
'transaction_date' => '2025-11-11',
|
||||
'amount' => 2500,
|
||||
'currency_code' => 'USD',
|
||||
'source' => 'manually_created',
|
||||
'update_balance' => true,
|
||||
])->assertCreated();
|
||||
|
||||
$this->assertDatabaseCount('account_balances', 1);
|
||||
$this->assertDatabaseHas('account_balances', [
|
||||
'account_id' => $account->id,
|
||||
'balance_date' => '2025-11-11',
|
||||
'balance' => 100000,
|
||||
]);
|
||||
});
|
||||
|
||||
test('transactions index page passes user categories', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$otherUser = User::factory()->create();
|
||||
|
|
|
|||
Loading…
Reference in New Issue