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