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:
Víctor Falcón 2026-06-19 17:01:04 +02:00 committed by GitHub
parent 50dba4334a
commit b76a0de074
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 204 additions and 102 deletions

View File

@ -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);

View File

@ -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,
],
);
}
}

View File

@ -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();
});
});

View File

@ -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(

View File

@ -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);

View File

@ -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();