fix(balances): propagate retroactive transaction changes to later balances

When a transaction is created or deleted with the update-balance option on a
past date, only that day's balance snapshot was adjusted. Because balances
carry forward, later snapshots (e.g. today's current balance) kept their stale
value, so a -5 expense added to yesterday left today unchanged.

Shift the transaction's own day and every later snapshot by the same delta on
both create and delete, keeping the running balance consistent.
This commit is contained in:
Víctor Falcón 2026-07-16 08:02:29 +02:00
parent c7719fe5ad
commit 5ee2892db4
2 changed files with 83 additions and 45 deletions

View File

@ -2,18 +2,17 @@
namespace App\Services;
use App\Models\AccountBalance;
use App\Models\Account;
use App\Models\Transaction;
use Illuminate\Support\Carbon;
class ManualBalanceAdjuster
{
/**
* Reverse a deleted transaction's effect on its manual account's current balance.
* Reverse a deleted transaction's effect on its manual account's balances.
*
* 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.
* Subtracts the transaction amount from its own day and every later
* snapshot, mirroring the forward shift applied on creation. Connected
* accounts are skipped because their balances come from bank sync.
*/
public function reverseDeletedTransaction(Transaction $transaction): void
{
@ -23,31 +22,20 @@ class ManualBalanceAdjuster
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,
],
$this->shiftBalancesFrom(
$account,
$transaction->transaction_date->toDateString(),
-$transaction->amount,
);
}
/**
* Apply a newly created transaction to its manual account's balance.
* Apply a newly created transaction to its manual account's balances.
*
* 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.
* Seeds a snapshot on the transaction's own date (from the carried-forward
* balance when none exists yet), then shifts that day and every later
* snapshot by the transaction amount. Connected accounts are skipped
* because their balances come from bank sync.
*/
public function applyCreatedTransaction(Transaction $transaction): void
{
@ -59,19 +47,36 @@ class ManualBalanceAdjuster
$transactionDate = $transaction->transaction_date->toDateString();
$baseBalance = $account->balances()
->where('balance_date', '<=', $transactionDate)
$account->balances()->firstOrCreate(
['balance_date' => $transactionDate],
['balance' => $this->carriedForwardBalance($account, $transactionDate)],
);
$this->shiftBalancesFrom($account, $transactionDate, $transaction->amount);
}
/**
* Shift every balance snapshot on or after the given date by the delta.
*
* Balances carry forward, so a retroactive change must move the
* transaction's own day and every later snapshot (such as today's current
* balance) by the same amount to keep the running balance consistent.
*/
private function shiftBalancesFrom(Account $account, string $fromDate, int $delta): void
{
$account->balances()
->where('balance_date', '>=', $fromDate)
->increment('balance', $delta);
}
/**
* The most recent balance strictly before the given date, or 0 if none.
*/
private function carriedForwardBalance(Account $account, string $date): int
{
return $account->balances()
->where('balance_date', '<', $date)
->orderByDesc('balance_date')
->value('balance') ?? 0;
AccountBalance::updateOrCreate(
[
'account_id' => $account->id,
'balance_date' => $transactionDate,
],
[
'balance' => $baseBalance + $transaction->amount,
],
);
}
}

View File

@ -290,19 +290,18 @@ test('deleting a manual account income decreases the current balance when reques
]);
});
test('deleting a transaction creates a current balance from the latest known balance', function () {
test('deleting a past-dated transaction reverses it on that date and every later 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,
]);
$account->balances()->create(['balance_date' => '2025-11-10', 'balance' => 5000]);
$account->balances()->create(['balance_date' => '2025-11-11', 'balance' => 5000]);
$transaction = Transaction::factory()->create([
'user_id' => $user->id,
'account_id' => $account->id,
'amount' => -1500,
'transaction_date' => '2025-11-10',
]);
actingAs($user)
@ -311,8 +310,13 @@ test('deleting a transaction creates a current balance from the latest known bal
$this->assertDatabaseHas('account_balances', [
'account_id' => $account->id,
'balance_date' => now()->toDateString(),
'balance' => 51500,
'balance_date' => '2025-11-10',
'balance' => 6500,
]);
$this->assertDatabaseHas('account_balances', [
'account_id' => $account->id,
'balance_date' => '2025-11-11',
'balance' => 6500,
]);
});
@ -421,6 +425,35 @@ test('creating a transaction creates a balance on its date from the closest earl
]);
});
test('creating a past-dated transaction updates that date and every later balance', function () {
$user = User::factory()->onboarded()->create();
$account = Account::factory()->create(['user_id' => $user->id]);
$account->balances()->create(['balance_date' => '2025-11-10', 'balance' => 1000]);
$account->balances()->create(['balance_date' => '2025-11-11', 'balance' => 1000]);
actingAs($user)->postJson(route('transactions.store'), [
'account_id' => $account->id,
'description' => 'encrypted_description',
'transaction_date' => '2025-11-10',
'amount' => -500,
'currency_code' => 'USD',
'source' => 'manually_created',
'update_balance' => true,
])->assertCreated();
$this->assertDatabaseHas('account_balances', [
'account_id' => $account->id,
'balance_date' => '2025-11-10',
'balance' => 500,
]);
$this->assertDatabaseHas('account_balances', [
'account_id' => $account->id,
'balance_date' => '2025-11-11',
'balance' => 500,
]);
});
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]);