Files
fin/cli/tests/test_adjustment.py
T
EislM0203andClaude Opus 5.5 ec21078bf8 fix: balance adjustments counted as spending and income — add 'adjustment' type
POST /api/accounts/{id}/adjust-balance wrote its delta as 'expense' (down)
or 'income' (up), so every downward correction surfaced as uncategorized
spending in get_monthly_spending, and every upward one as income. Opening
balances were written as 'income' too, kept out of the income total only by
a description != 'Opening balance' filter.

Backend:
- adjust_balance (routers/accounts.py) and create_account (crud.py) now
  write type='adjustment' with the signed amount. Balances are unaffected:
  SUM(amount - fee) ignores type.
- get_monthly_spending drops the description filter; spending and income
  select purely by type.
- TransactionBase gains 'adjustment' for reads; TransactionCreate does not,
  so only the server can write one.
- Migration j0e1f2a3b4c5 retypes historical rows: unlinked income/expense
  rows with the exact generated descriptions. Downgrade restores the
  typing from the sign of the amount.

Tests: cli/tests/test_adjustment.py covers spending/income exclusion, the
balance landing on target, and the migration + downgrade on a scratch DB.
test_cli's opening-balance assertion updated to the new type. 65 passed.

Docs: CLAUDE.md type list and invariant 6; spec in
docs/superpowers/specs/2026-09-30-adjustment-transaction-type.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 09:10:45 +00:00

120 lines
4.2 KiB
Python

"""Balance corrections — the 'adjustment' transaction type.
A balance adjustment or opening balance moves the account balance but is
neither earnings nor consumption, so it must stay out of the spending and
income totals. Before this type existed, a downward adjustment was written as
an 'expense' and surfaced as uncategorized spending.
"""
import os
import sqlite3
import subprocess
import sys
from datetime import date
from pathlib import Path
API_DIR = Path(__file__).resolve().parents[2] / "api"
def _bank(client, **extra):
return client.create_account({"name": "Checking", "type": "bank", "currency": "EUR", **extra})
def _balance(client, account_id):
return sum(t["amount"] - t["fee"] for t in client.list_transactions(account_id=account_id))
def _month():
return date.today().strftime("%Y-%m")
class TestAdjustBalance:
def test_downward_adjustment_is_not_spending(self, client):
acct = _bank(client, initial_balance=1000)
txn = client.adjust_account_balance(acct["id"], 700)
assert txn["type"] == "adjustment"
assert txn["amount"] == -300
assert _balance(client, acct["id"]) == 700
spending = client.get_spending_month(_month())
assert spending["total"] == 0
assert spending["categories"] == []
def test_upward_adjustment_is_not_income(self, client):
acct = _bank(client)
txn = client.adjust_account_balance(acct["id"], 250)
assert txn["type"] == "adjustment"
assert _balance(client, acct["id"]) == 250
assert client.get_spending_month(_month())["income_total"] == 0
def test_real_spending_still_counts_next_to_an_adjustment(self, client):
acct = _bank(client, initial_balance=1000)
client.create_transaction({
"account_id": acct["id"], "date": date.today().isoformat(),
"amount": -40, "type": "expense", "description": "Rewe",
})
client.adjust_account_balance(acct["id"], 900)
assert client.get_spending_month(_month())["total"] == 40
assert _balance(client, acct["id"]) == 900
class TestOpeningBalance:
def test_opening_balance_is_an_adjustment_and_not_income(self, client):
acct = _bank(client, initial_balance=500)
[opening] = client.list_transactions(account_id=acct["id"])
assert opening["type"] == "adjustment"
assert opening["description"] == "Opening balance"
assert client.get_spending_month(_month())["income_total"] == 0
def _alembic(db_path, *args):
env = {**os.environ, "DATABASE_URL": f"sqlite:///{db_path}"}
subprocess.run(
[sys.executable, "-m", "alembic", *args],
cwd=API_DIR, env=env, check=True, capture_output=True,
)
class TestMigration:
def test_retypes_generated_rows_only_and_downgrades_by_sign(self, tmp_path):
db = tmp_path / "mig.db"
_alembic(db, "upgrade", "i9d0e1f2a3b4")
conn = sqlite3.connect(db)
conn.execute(
"INSERT INTO accounts (id, name, type, currency, is_active) "
"VALUES (1, 'Checking', 'bank', 'EUR', 1), (2, 'Savings', 'bank', 'EUR', 1)"
)
rows = [
# id, type, amount, description, linked_account_id
(1, "income", 500, "Opening balance", None),
(2, "expense", -300, "Balance adjustment", None),
(3, "income", 20, "Balance adjustment", None),
(4, "expense", -40, "Rewe", None),
(5, "income", 2000, "Salary", None),
(6, "transfer", -100, "Balance adjustment", 2),
]
conn.executemany(
"INSERT INTO transactions (id, account_id, type, amount, fee, currency, description, "
"linked_account_id, date) VALUES (?, 1, ?, ?, 0, 'EUR', ?, ?, '2026-09-01')",
rows,
)
conn.commit()
conn.close()
_alembic(db, "upgrade", "head")
types = dict(sqlite3.connect(db).execute("SELECT id, type FROM transactions"))
assert types == {
1: "adjustment", 2: "adjustment", 3: "adjustment",
4: "expense", 5: "income", 6: "transfer",
}
_alembic(db, "downgrade", "i9d0e1f2a3b4")
types = dict(sqlite3.connect(db).execute("SELECT id, type FROM transactions"))
assert types == {r[0]: r[1] for r in rows}