From b76a0de0746e334835c84e7aef96140e8ef7d00f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Fri, 19 Jun 2026 17:01:04 +0200 Subject: [PATCH] feat(transactions): default balance toggle on and apply it server-side (#566) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. --- .../Controllers/TransactionController.php | 6 +- app/Services/ManualBalanceAdjuster.php | 34 +++++ .../edit-transaction-dialog.test.tsx | 24 +++- .../transactions/edit-transaction-dialog.tsx | 119 ++++-------------- resources/js/services/transaction-sync.ts | 6 +- tests/Feature/TransactionTest.php | 117 +++++++++++++++++ 6 files changed, 204 insertions(+), 102 deletions(-) diff --git a/app/Http/Controllers/TransactionController.php b/app/Http/Controllers/TransactionController.php index 688c64b5..e4087fb3 100644 --- a/app/Http/Controllers/TransactionController.php +++ b/app/Http/Controllers/TransactionController.php @@ -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); diff --git a/app/Services/ManualBalanceAdjuster.php b/app/Services/ManualBalanceAdjuster.php index 3fada2bb..758ae109 100644 --- a/app/Services/ManualBalanceAdjuster.php +++ b/app/Services/ManualBalanceAdjuster.php @@ -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, + ], + ); + } } diff --git a/resources/js/components/transactions/edit-transaction-dialog.test.tsx b/resources/js/components/transactions/edit-transaction-dialog.test.tsx index c6c9c4a9..bd4fcccb 100644 --- a/resources/js/components/transactions/edit-transaction-dialog.test.tsx +++ b/resources/js/components/transactions/edit-transaction-dialog.test.tsx @@ -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: () =>
, })); @@ -215,4 +209,22 @@ describe('EditTransactionDialog', () => { 'account-1', ); }); + + it('checks "update account balance" by default in create mode', () => { + render( + , + ); + + expect(screen.getByRole('checkbox')).toBeChecked(); + }); }); diff --git a/resources/js/components/transactions/edit-transaction-dialog.tsx b/resources/js/components/transactions/edit-transaction-dialog.tsx index c5fe7f03..9a88590f 100644 --- a/resources/js/components/transactions/edit-transaction-dialog.tsx +++ b/resources/js/components/transactions/edit-transaction-dialog.tsx @@ -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( diff --git a/resources/js/services/transaction-sync.ts b/resources/js/services/transaction-sync.ts index 2f11d29c..81c30865 100644 --- a/resources/js/services/transaction-sync.ts +++ b/resources/js/services/transaction-sync.ts @@ -63,8 +63,12 @@ class TransactionSyncService { async create( data: Omit, + options?: { updateBalance?: boolean }, ): Promise { - 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); diff --git a/tests/Feature/TransactionTest.php b/tests/Feature/TransactionTest.php index 8a87196a..25618126 100644 --- a/tests/Feature/TransactionTest.php +++ b/tests/Feature/TransactionTest.php @@ -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();