feat: optionally update manual account balance on transaction delete (#491)

## What

When deleting a transaction that belongs to a **manual**
(non-bank-connected) account, the user can now opt to update the
account's **current balance** for today, so it stays accurate without
re-syncing.

- Deleting an **expense** (amount < 0) → balance **increases** by the
amount.
- Deleting **income** (amount > 0) → balance **decreases** by the
amount.
- Formula: `new_balance = current_balance − transaction.amount`. If
today has no balance row, it's seeded from the latest known balance.
- Available for **single delete** and **bulk delete** (a checkbox in the
confirmation dialog).
- Connected accounts are never touched — their balances come from bank
sync.
- On the account page, the balance display refreshes automatically after
the delete (no page reload).

## How

- New `ManualBalanceAdjuster` service; `TransactionController@destroy`
calls it when the request carries `update_balance` and the account is
manual.
- Frontend `transactionSyncService.delete/deleteMany` accept an
`updateBalance` option; both delete dialogs show the checkbox only when
a manual account is affected.
- `TransactionList` gains an `onBalanceUpdated` callback;
`Accounts/Show` uses it to bump the balance chart's refresh key.
- Added `banking_connection_id` to the accounts payload (transactions,
budgets, and the shared Inertia props feeding accounts/show) so the
frontend can identify manual accounts.

<img width="896" height="400" alt="image"
src="https://github.com/user-attachments/assets/e7f35ccb-13a1-40a1-bf04-615752bddf1a"
/>
This commit is contained in:
Víctor Falcón 2026-06-05 11:30:31 +02:00 committed by GitHub
parent 14b79557d7
commit 45e25e018d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 334 additions and 13 deletions

View File

@ -92,7 +92,7 @@ class BudgetController extends Controller
->where('user_id', $user->id)
->with('bank:id,name,logo')
->orderBy('name')
->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code']);
->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code', 'banking_connection_id']);
$banks = Bank::query()
->availableForUser($user)

View File

@ -12,6 +12,7 @@ use App\Models\Bank;
use App\Models\Category;
use App\Models\Label;
use App\Models\Transaction;
use App\Services\ManualBalanceAdjuster;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@ -79,7 +80,7 @@ class TransactionController extends Controller
->where('user_id', $user->id)
->with('bank:id,name,logo')
->orderBy('name')
->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code']);
->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code', 'banking_connection_id']);
$banks = Bank::query()
->availableForUser($user)
@ -121,7 +122,7 @@ class TransactionController extends Controller
->where('user_id', $user->id)
->with('bank:id,name,logo')
->orderBy('name')
->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code']);
->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code', 'banking_connection_id']);
$banks = Bank::query()
->availableForUser($user)
@ -213,10 +214,14 @@ class TransactionController extends Controller
]);
}
public function destroy(Request $request, Transaction $transaction): JsonResponse
public function destroy(Request $request, Transaction $transaction, ManualBalanceAdjuster $balanceAdjuster): JsonResponse
{
$this->authorize('delete', $transaction);
if ($request->boolean('update_balance')) {
$balanceAdjuster->reverseDeletedTransaction($transaction);
}
$transaction->delete();
return response()->json([

View File

@ -124,7 +124,7 @@ class HandleInertiaRequests extends Middleware
'accounts' => fn () => $user ? $user->accounts()
->with(['bank:id,name,logo', 'realEstateDetail:account_id,linked_loan_account_id'])
->orderBy('name')
->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code'])
->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code', 'banking_connection_id'])
->map(function ($account) {
$data = $account->only([
'id',
@ -134,6 +134,7 @@ class HandleInertiaRequests extends Middleware
'bank_id',
'type',
'currency_code',
'banking_connection_id',
'bank',
]);

View File

@ -0,0 +1,43 @@
<?php
namespace App\Services;
use App\Models\AccountBalance;
use App\Models\Transaction;
use Illuminate\Support\Carbon;
class ManualBalanceAdjuster
{
/**
* Reverse a deleted transaction's effect on its manual account's current balance.
*
* Adjusts today's balance by the inverse of the transaction amount: an expense
* (negative amount) increases the balance, income (positive amount) decreases it.
* Connected accounts are skipped because their balances come from bank sync.
*/
public function reverseDeletedTransaction(Transaction $transaction): void
{
$account = $transaction->account;
if ($account === null || $account->isConnected()) {
return;
}
$today = Carbon::now()->toDateString();
$currentBalance = $account->balances()
->where('balance_date', '<=', $today)
->orderByDesc('balance_date')
->value('balance') ?? 0;
AccountBalance::updateOrCreate(
[
'account_id' => $account->id,
'balance_date' => $today,
],
[
'balance' => $currentBalance - $transaction->amount,
],
);
}
}

View File

@ -1560,6 +1560,8 @@
"Update the balance record.": "Actualiza el registro de balance.",
"Update the category and notes for this transaction.": "Actualiza la categoría y notas de esta transacción.",
"Update the category information.": "Actualiza la información de la categoría.",
"Update the current balance of the affected manual accounts to reflect this change.": "Actualiza el saldo actual de las cuentas manuales afectadas para reflejar este cambio.",
"Update the current balance of the manual account to reflect this change.": "Actualiza el saldo actual de la cuenta manual para reflejar este cambio.",
"Update the label information.": "Actualiza la información de la etiqueta.",
"Update the owed amount record.": "Actualiza el registro de monto adeudado.",
"Update the rule to automatically categorize transactions and add labels.": "Actualiza la regla para categorizar automáticamente transacciones y agregar etiquetas.",

View File

@ -50,6 +50,7 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
ContextMenu,
ContextMenuContent,
@ -240,6 +241,7 @@ export interface TransactionListProps {
headerActions?: ReactNode;
maxHeight?: number;
hideColumns?: string[];
onBalanceUpdated?: () => void;
}
export function TransactionList({
@ -256,6 +258,7 @@ export function TransactionList({
headerActions,
maxHeight,
hideColumns = [],
onBalanceUpdated,
}: TransactionListProps) {
const { isKeySet } = useEncryptionKey();
const locale = useLocale();
@ -308,6 +311,7 @@ export function TransactionList({
const [deleteTransaction, setDeleteTransaction] =
useState<DecryptedTransaction | null>(null);
const [isBulkDeleteMode, setIsBulkDeleteMode] = useState(false);
const [updateBalanceOnDelete, setUpdateBalanceOnDelete] = useState(true);
const [isDeleting, setIsDeleting] = useState(false);
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
const [isBulkUpdating, setIsBulkUpdating] = useState(false);
@ -766,6 +770,16 @@ export function TransactionList({
searchInIndexedDB();
}, [filters.searchText, isKeySet, accountId]);
const manualAccountIds = useMemo(
() =>
new Set(
accounts
.filter((account) => account.banking_connection_id === null)
.map((account) => account.id),
),
[accounts],
);
const filteredTransactions = useMemo(() => {
return transactions.filter((transaction) => {
if (filters.searchText && isKeySet) {
@ -1116,8 +1130,16 @@ export function TransactionList({
}
setIsDeleting(true);
const balanceWasUpdated =
updateBalanceOnDelete &&
manualAccountIds.has(deleteTransaction.account_id);
try {
await transactionSyncService.delete(deleteTransaction.id);
await transactionSyncService.delete(deleteTransaction.id, {
updateBalance: balanceWasUpdated,
});
if (balanceWasUpdated) {
onBalanceUpdated?.();
}
setTransactions((previous) =>
previous.filter(
(transaction) => transaction.id !== deleteTransaction.id,
@ -1245,8 +1267,20 @@ export function TransactionList({
}
setIsBulkDeleting(true);
const balanceMayHaveUpdated =
updateBalanceOnDelete &&
transactions.some(
(transaction) =>
selectedIds.includes(transaction.id) &&
manualAccountIds.has(transaction.account_id),
);
try {
await transactionSyncService.deleteMany(selectedIds);
await transactionSyncService.deleteMany(selectedIds, {
updateBalance: updateBalanceOnDelete,
});
if (balanceMayHaveUpdated) {
onBalanceUpdated?.();
}
setTransactions((previous) =>
previous.filter(
(transaction) => !selectedIds.includes(transaction.id),
@ -1461,6 +1495,7 @@ export function TransactionList({
if (!open) {
setDeleteTransaction(null);
setIsBulkDeleteMode(false);
setUpdateBalanceOnDelete(true);
}
}}
>
@ -1477,6 +1512,33 @@ export function TransactionList({
: 'Are you sure you want to delete this transaction? This action cannot be undone.'}
</AlertDialogDescription>
</AlertDialogHeader>
{(isBulkDeleteMode
? transactions.some(
(transaction) =>
Object.keys(rowSelection).includes(
transaction.id,
) &&
manualAccountIds.has(transaction.account_id),
)
: deleteTransaction !== null &&
manualAccountIds.has(
deleteTransaction.account_id,
)) && (
<label className="flex cursor-pointer items-start gap-3 rounded-md border border-border bg-muted/40 p-3 text-sm">
<Checkbox
checked={updateBalanceOnDelete}
onCheckedChange={(checked) =>
setUpdateBalanceOnDelete(checked === true)
}
className="mt-0.5"
/>
<span className="text-muted-foreground">
{__(
'Update the current balance of the manual account to reflect this change.',
)}
</span>
</label>
)}
<AlertDialogFooter>
<AlertDialogCancel
disabled={isDeleting || isBulkDeleting}

View File

@ -403,6 +403,9 @@ export default function AccountShow({
showActionsMenu={false}
maxHeight={600}
hideColumns={['bank', 'account']}
onBalanceUpdated={() =>
setChartRefreshKey((key) => key + 1)
}
/>
)}
</div>

View File

@ -51,6 +51,7 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
ContextMenu,
ContextMenuContent,
@ -457,6 +458,7 @@ export default function Transactions({
const [deleteTransaction, setDeleteTransaction] =
useState<DecryptedTransaction | null>(null);
const [isBulkDeleteMode, setIsBulkDeleteMode] = useState(false);
const [updateBalanceOnDelete, setUpdateBalanceOnDelete] = useState(true);
const [bulkDeleteConfirmation, setBulkDeleteConfirmation] = useState('');
const [isDeleting, setIsDeleting] = useState(false);
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
@ -874,7 +876,11 @@ export default function Transactions({
setIsDeleting(true);
try {
await transactionSyncService.delete(deleteTransaction.id);
await transactionSyncService.delete(deleteTransaction.id, {
updateBalance:
updateBalanceOnDelete &&
manualAccountIds.has(deleteTransaction.account_id),
});
setAllTransactions((previous) =>
previous.filter(
(transaction) => transaction.id !== deleteTransaction.id,
@ -963,6 +969,28 @@ export default function Transactions({
const selectedCount = useMemo(() => selectedIds.length, [selectedIds]);
const manualAccountIds = useMemo(
() =>
new Set(
accounts
.filter((account) => account.banking_connection_id === null)
.map((account) => account.id),
),
[accounts],
);
const bulkDeleteTouchesManualAccount = useMemo(
() =>
isSelectingAll
? manualAccountIds.size > 0
: allTransactions.some(
(transaction) =>
selectedIds.includes(transaction.id) &&
manualAccountIds.has(transaction.account_id),
),
[isSelectingAll, manualAccountIds, allTransactions, selectedIds],
);
const bulkDeleteConfirmationText = useMemo(
() => getBulkDeleteConfirmationText(selectedCount),
[selectedCount],
@ -990,7 +1018,9 @@ export default function Transactions({
setIsBulkDeleting(true);
try {
await transactionSyncService.deleteMany(selectedIds);
await transactionSyncService.deleteMany(selectedIds, {
updateBalance: updateBalanceOnDelete,
});
setAllTransactions((previous) =>
previous.filter(
(transaction) => !selectedIds.includes(transaction.id),
@ -1280,6 +1310,7 @@ export default function Transactions({
onOpenChange={(open) => {
if (!open) {
setDeleteTransaction(null);
setUpdateBalanceOnDelete(true);
}
}}
>
@ -1294,6 +1325,25 @@ export default function Transactions({
)}
</AlertDialogDescription>
</AlertDialogHeader>
{deleteTransaction !== null &&
manualAccountIds.has(deleteTransaction.account_id) && (
<label className="flex cursor-pointer items-start gap-3 rounded-md border border-border bg-muted/40 p-3 text-sm">
<Checkbox
checked={updateBalanceOnDelete}
onCheckedChange={(checked) =>
setUpdateBalanceOnDelete(
checked === true,
)
}
className="mt-0.5"
/>
<span className="text-muted-foreground">
{__(
'Update the current balance of the manual account to reflect this change.',
)}
</span>
</label>
)}
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>
{__('Cancel')}
@ -1316,6 +1366,7 @@ export default function Transactions({
setDeleteTransaction(null);
setIsBulkDeleteMode(false);
setBulkDeleteConfirmation('');
setUpdateBalanceOnDelete(true);
}
}}
>
@ -1340,6 +1391,22 @@ export default function Transactions({
disabled={isBulkDeleting}
autoFocus
/>
{bulkDeleteTouchesManualAccount && (
<label className="flex cursor-pointer items-start gap-3 rounded-md border border-border bg-muted/40 p-3 text-sm">
<Checkbox
checked={updateBalanceOnDelete}
onCheckedChange={(checked) =>
setUpdateBalanceOnDelete(checked === true)
}
className="mt-0.5"
/>
<span className="text-muted-foreground">
{__(
'Update the current balance of the affected manual accounts to reflect this change.',
)}
</span>
</label>
)}
<DialogFooter>
<Button
variant="outline"

View File

@ -176,8 +176,13 @@ class TransactionSyncService {
return response.data.count || 0;
}
async delete(id: string): Promise<void> {
await axios.delete(`/transactions/${id}`);
async delete(
id: string,
options?: { updateBalance?: boolean },
): Promise<void> {
await axios.delete(`/transactions/${id}`, {
data: options?.updateBalance ? { update_balance: true } : undefined,
});
await db.transactions.delete(id);
}
@ -189,9 +194,12 @@ class TransactionSyncService {
}
}
async deleteMany(ids: string[]): Promise<void> {
async deleteMany(
ids: string[],
options?: { updateBalance?: boolean },
): Promise<void> {
for (const id of ids) {
await this.delete(id);
await this.delete(id, options);
}
}

View File

@ -238,6 +238,136 @@ test('users cannot delete other users transactions', function () {
]);
});
test('deleting a manual account expense increases the current balance when requested', function () {
$user = User::factory()->onboarded()->create();
$account = Account::factory()->create(['user_id' => $user->id]);
$account->balances()->create([
'balance_date' => now()->toDateString(),
'balance' => 100000,
]);
$transaction = Transaction::factory()->create([
'user_id' => $user->id,
'account_id' => $account->id,
'amount' => -2500,
]);
actingAs($user)
->deleteJson(route('transactions.destroy', $transaction), ['update_balance' => true])
->assertSuccessful();
$this->assertDatabaseHas('account_balances', [
'account_id' => $account->id,
'balance_date' => now()->toDateString(),
'balance' => 102500,
]);
});
test('deleting a manual account income decreases the current balance when requested', function () {
$user = User::factory()->onboarded()->create();
$account = Account::factory()->create(['user_id' => $user->id]);
$account->balances()->create([
'balance_date' => now()->toDateString(),
'balance' => 100000,
]);
$transaction = Transaction::factory()->create([
'user_id' => $user->id,
'account_id' => $account->id,
'amount' => 3000,
]);
actingAs($user)
->deleteJson(route('transactions.destroy', $transaction), ['update_balance' => true])
->assertSuccessful();
$this->assertDatabaseHas('account_balances', [
'account_id' => $account->id,
'balance_date' => now()->toDateString(),
'balance' => 97000,
]);
});
test('deleting a transaction creates a current balance from the latest known balance', function () {
$user = User::factory()->onboarded()->create();
$account = Account::factory()->create(['user_id' => $user->id]);
$account->balances()->create([
'balance_date' => now()->subDays(5)->toDateString(),
'balance' => 50000,
]);
$transaction = Transaction::factory()->create([
'user_id' => $user->id,
'account_id' => $account->id,
'amount' => -1500,
]);
actingAs($user)
->deleteJson(route('transactions.destroy', $transaction), ['update_balance' => true])
->assertSuccessful();
$this->assertDatabaseHas('account_balances', [
'account_id' => $account->id,
'balance_date' => now()->toDateString(),
'balance' => 51500,
]);
});
test('deleting a transaction does not change the balance when not requested', function () {
$user = User::factory()->onboarded()->create();
$account = Account::factory()->create(['user_id' => $user->id]);
$account->balances()->create([
'balance_date' => now()->toDateString(),
'balance' => 100000,
]);
$transaction = Transaction::factory()->create([
'user_id' => $user->id,
'account_id' => $account->id,
'amount' => -2500,
]);
actingAs($user)
->deleteJson(route('transactions.destroy', $transaction))
->assertSuccessful();
$this->assertDatabaseHas('account_balances', [
'account_id' => $account->id,
'balance_date' => now()->toDateString(),
'balance' => 100000,
]);
});
test('deleting 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' => now()->toDateString(),
'balance' => 100000,
]);
$transaction = Transaction::factory()->create([
'user_id' => $user->id,
'account_id' => $account->id,
'amount' => -2500,
]);
actingAs($user)
->deleteJson(route('transactions.destroy', $transaction), ['update_balance' => true])
->assertSuccessful();
$this->assertDatabaseHas('account_balances', [
'account_id' => $account->id,
'balance_date' => now()->toDateString(),
'balance' => 100000,
]);
});
test('transactions index page passes user categories', function () {
$user = User::factory()->onboarded()->create();
$otherUser = User::factory()->create();