fix(budgets): handle refunds correctly in budget spending calculations (#152)

## Why

### Problem

When a refund (positive transaction amount) is assigned to a budget, it
incorrectly **increases** the cumulative spending instead of
**reducing** it. This causes:

- The spending chart line to go **up** on refunds instead of down
- The "Spent" amount to be inflated
- The "Remaining" amount to be understated
- Period rollover calculations to carry over incorrect amounts

### Root Cause

`BudgetTransactionService` uses `abs($transaction->amount)` when
creating budget transactions, which forces all amounts to be positive —
including refunds. Since expenses are stored as negative in the
`transactions` table and refunds as positive, `abs()` treats both as
spending.

## What

### Changes

- Replace `abs($transaction->amount)` with `-$transaction->amount` in
both `assignTransaction()` and `assignHistoricalTransactionsToPeriod()`
— expenses (`-5000`) become positive spending (`5000`), refunds
(`+1000`) become negative spending (`-1000`)
- Remove redundant `abs()` in `BudgetPeriodService::closePeriod()`
rollover calculation
- Add data migration to fix existing `budget_transactions` rows where
the original transaction was a refund
- No frontend changes needed — the chart and budget card already sum
`t.amount` directly

## Verification

### Tests

- Updated `assignHistoricalTransactionsToPeriod stores negated
transaction amount for expenses` — verifies expense sign is preserved
- Added `assignHistoricalTransactionsToPeriod stores refund as negative
amount` — verifies refunds reduce spending
- Added `budget spending correctly reflects mix of expenses and refunds`
— verifies net spending (expense - refund)
- Added `assignTransaction stores refund as negative budget transaction
amount` — verifies real-time assignment handles refunds
This commit is contained in:
Víctor Falcón 2026-02-24 21:12:36 +01:00 committed by GitHub
parent 545cc66024
commit f2a7f955e6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 135 additions and 7 deletions

View File

@ -40,7 +40,7 @@ class BudgetPeriodService
if ($budget->rollover_type->value === 'carry_over') {
$totalSpent = $period->budgetTransactions()->sum('amount');
$remaining = $period->allocated_amount - abs($totalSpent);
$remaining = $period->allocated_amount - $totalSpent;
if ($remaining > 0) {
$carriedOverAmount = $remaining;

View File

@ -62,7 +62,7 @@ class BudgetTransactionService
BudgetTransaction::create([
'transaction_id' => $transaction->id,
'budget_period_id' => $period->id,
'amount' => abs($transaction->amount),
'amount' => -$transaction->amount,
]);
}
}
@ -126,7 +126,7 @@ class BudgetTransactionService
BudgetTransaction::create([
'transaction_id' => $transaction->id,
'budget_period_id' => $period->id,
'amount' => abs($transaction->amount),
'amount' => -$transaction->amount,
]);
$assignedCount++;

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Fix budget_transactions where the original transaction was a refund (positive amount).
* These were incorrectly stored as positive via abs() they should be negative
* to correctly reduce the budget's cumulative spending.
*/
public function up(): void
{
DB::statement('
UPDATE budget_transactions
SET amount = -amount
WHERE transaction_id IN (
SELECT id FROM transactions WHERE amount > 0
)
');
}
/**
* Revert: make all budget_transaction amounts positive again (original behavior).
*/
public function down(): void
{
DB::statement('
UPDATE budget_transactions
SET amount = ABS(amount)
');
}
};

View File

@ -240,14 +240,14 @@ test('assignHistoricalTransactionsToPeriod works with transactions having multip
expect($count)->toBe(1);
});
test('assignHistoricalTransactionsToPeriod stores absolute value of amount', function () {
test('assignHistoricalTransactionsToPeriod stores negated transaction amount for expenses', function () {
$category = Category::factory()->create(['user_id' => $this->user->id]);
$transaction = Transaction::factory()->create([
Transaction::factory()->create([
'user_id' => $this->user->id,
'category_id' => $category->id,
'transaction_date' => now()->subDays(5),
'amount' => -5000, // Negative amount
'amount' => -5000, // Expense (negative)
]);
$budget = Budget::factory()->create([
@ -265,7 +265,101 @@ test('assignHistoricalTransactionsToPeriod stores absolute value of amount', fun
$budgetTransaction = $period->budgetTransactions()->first();
expect($budgetTransaction->amount)->toBe(5000); // Should be positive
expect($budgetTransaction->amount)->toBe(5000); // -(-5000) = 5000, adds to spending
});
test('assignHistoricalTransactionsToPeriod stores refund as negative amount', function () {
$category = Category::factory()->create(['user_id' => $this->user->id]);
Transaction::factory()->create([
'user_id' => $this->user->id,
'category_id' => $category->id,
'transaction_date' => now()->subDays(5),
'amount' => 1000, // Refund (positive)
]);
$budget = Budget::factory()->create([
'user_id' => $this->user->id,
'category_id' => $category->id,
]);
$period = BudgetPeriod::factory()->create([
'budget_id' => $budget->id,
'start_date' => now()->subDays(30),
'end_date' => now()->addDays(30),
]);
$this->service->assignHistoricalTransactionsToPeriod($period);
$budgetTransaction = $period->budgetTransactions()->first();
expect($budgetTransaction->amount)->toBe(-1000); // -(+1000) = -1000, reduces spending
});
test('budget spending correctly reflects mix of expenses and refunds', function () {
$category = Category::factory()->create(['user_id' => $this->user->id]);
// $50 expense
Transaction::factory()->create([
'user_id' => $this->user->id,
'category_id' => $category->id,
'transaction_date' => now()->subDays(5),
'amount' => -5000,
]);
// $10 refund
Transaction::factory()->create([
'user_id' => $this->user->id,
'category_id' => $category->id,
'transaction_date' => now()->subDays(3),
'amount' => 1000,
]);
$budget = Budget::factory()->create([
'user_id' => $this->user->id,
'category_id' => $category->id,
]);
$period = BudgetPeriod::factory()->create([
'budget_id' => $budget->id,
'start_date' => now()->subDays(30),
'end_date' => now()->addDays(30),
'allocated_amount' => 10000,
]);
$this->service->assignHistoricalTransactionsToPeriod($period);
// Net spending should be $40 (5000 - 1000 = 4000)
$totalSpent = (int) $period->budgetTransactions()->sum('amount');
expect($totalSpent)->toBe(4000);
});
test('assignTransaction stores refund as negative budget transaction amount', function () {
$category = Category::factory()->create(['user_id' => $this->user->id]);
$budget = Budget::factory()->create([
'user_id' => $this->user->id,
'category_id' => $category->id,
]);
$period = BudgetPeriod::factory()->create([
'budget_id' => $budget->id,
'start_date' => now()->subDays(30),
'end_date' => now()->addDays(30),
]);
// Create a refund transaction
$refund = Transaction::factory()->create([
'user_id' => $this->user->id,
'category_id' => $category->id,
'transaction_date' => now()->subDays(2),
'amount' => 2000, // positive = refund
]);
$this->service->assignTransaction($refund);
$budgetTransaction = $period->budgetTransactions()->first();
expect($budgetTransaction->amount)->toBe(-2000);
});
test('assignHistoricalTransactionsToPeriod only assigns to correct user', function () {