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>
120 lines
4.2 KiB
Python
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}
|