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