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:
parent
c7719fe5ad
commit
5ee2892db4
|
|
@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
Loading…
Reference in New Issue