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>
309 lines
11 KiB
Python
309 lines
11 KiB
Python
import json
|
|
|
|
import pytest
|
|
|
|
from fin_cli.client import FinClientError
|
|
|
|
|
|
def _bank_account(client, name="Checking", initial_balance=None):
|
|
data = {"name": name, "type": "bank", "currency": "EUR"}
|
|
if initial_balance is not None:
|
|
data["initial_balance"] = initial_balance
|
|
return client.create_account(data)
|
|
|
|
|
|
def _account(client, name, type_):
|
|
return client.create_account({"name": name, "type": type_, "currency": "EUR"})
|
|
|
|
|
|
class TestAuth:
|
|
def test_auth_off_allows_requests(self, client):
|
|
assert client.list_accounts() == []
|
|
|
|
def test_health_ok(self, client):
|
|
assert client.health() == {"status": "ok"}
|
|
|
|
def test_auth_on_rejects_missing_token(self, client, monkeypatch):
|
|
monkeypatch.setenv("FIN_API_TOKEN", "sekret")
|
|
with pytest.raises(FinClientError) as exc:
|
|
client.list_accounts()
|
|
assert exc.value.status_code == 401
|
|
|
|
def test_auth_on_rejects_wrong_token(self, make_client, monkeypatch):
|
|
monkeypatch.setenv("FIN_API_TOKEN", "sekret")
|
|
with pytest.raises(FinClientError) as exc:
|
|
make_client(token="wrong").list_accounts()
|
|
assert exc.value.status_code == 401
|
|
|
|
def test_auth_on_accepts_correct_token(self, make_client, monkeypatch):
|
|
monkeypatch.setenv("FIN_API_TOKEN", "sekret")
|
|
assert make_client(token="sekret").list_accounts() == []
|
|
|
|
def test_health_bypasses_auth(self, client, monkeypatch):
|
|
monkeypatch.setenv("FIN_API_TOKEN", "sekret")
|
|
assert client.health() == {"status": "ok"}
|
|
|
|
def test_bearer_scheme_is_case_insensitive(self, make_client, monkeypatch):
|
|
monkeypatch.setenv("FIN_API_TOKEN", "sekret")
|
|
http_client = make_client(token="sekret")
|
|
http_client._http.headers["Authorization"] = "bearer sekret"
|
|
assert http_client.list_accounts() == []
|
|
|
|
def test_openapi_gated_when_auth_on(self, client, make_client, monkeypatch):
|
|
monkeypatch.setenv("FIN_API_TOKEN", "sekret")
|
|
with pytest.raises(FinClientError) as exc:
|
|
client.openapi_spec()
|
|
assert exc.value.status_code == 401
|
|
spec = make_client(token="sekret").openapi_spec()
|
|
assert "/api/accounts" in spec["paths"]
|
|
|
|
def test_openapi_open_when_auth_off(self, client):
|
|
spec = client.openapi_spec()
|
|
assert "/api/accounts" in spec["paths"]
|
|
|
|
|
|
class TestAccounts:
|
|
def test_create_and_list(self, client):
|
|
created = _bank_account(client, "Giro")
|
|
assert created["name"] == "Giro"
|
|
assert created["type"] == "bank"
|
|
accounts = client.list_accounts()
|
|
assert [a["id"] for a in accounts] == [created["id"]]
|
|
|
|
def test_initial_balance_creates_opening_transaction(self, client):
|
|
acct = _bank_account(client, "Giro", initial_balance=250.0)
|
|
txns = client.list_transactions(account_id=acct["id"])
|
|
assert len(txns) == 1
|
|
assert txns[0]["amount"] == 250.0
|
|
assert txns[0]["type"] == "adjustment"
|
|
|
|
|
|
class TestTransactions:
|
|
def test_add_and_list_with_limit(self, client):
|
|
acct = _bank_account(client)
|
|
for day in (1, 2, 3):
|
|
client.create_transaction(
|
|
{
|
|
"account_id": acct["id"],
|
|
"amount": -10.0 * day,
|
|
"date": f"2026-06-0{day}",
|
|
"type": "expense",
|
|
}
|
|
)
|
|
all_rows = client.list_transactions(account_id=acct["id"])
|
|
assert len(all_rows) == 3
|
|
limited = client.list_transactions(account_id=acct["id"], limit=2)
|
|
assert len(limited) == 2
|
|
# newest first
|
|
assert limited[0]["date"] == "2026-06-03"
|
|
|
|
def test_limit_must_be_positive(self, client):
|
|
with pytest.raises(FinClientError) as exc:
|
|
client.list_transactions(limit=0)
|
|
assert exc.value.status_code == 422
|
|
|
|
|
|
class TestTransfer:
|
|
def test_two_legs_created_and_linked(self, client):
|
|
a = _bank_account(client, "A", initial_balance=100)
|
|
b = _bank_account(client, "B")
|
|
legs = client.create_transfer(
|
|
{
|
|
"from_account_id": a["id"],
|
|
"to_account_id": b["id"],
|
|
"amount": 40.0,
|
|
"date": "2026-06-15",
|
|
}
|
|
)
|
|
assert len(legs) == 2
|
|
by_account = {leg["account_id"]: leg for leg in legs}
|
|
assert by_account[a["id"]]["amount"] == -40.0
|
|
assert by_account[a["id"]]["linked_account_id"] == b["id"]
|
|
assert by_account[b["id"]]["amount"] == 40.0
|
|
assert by_account[b["id"]]["linked_account_id"] == a["id"]
|
|
|
|
def test_deleting_one_leg_removes_whole_group(self, client):
|
|
a = _bank_account(client, "A")
|
|
b = _bank_account(client, "B")
|
|
legs = client.create_transfer(
|
|
{
|
|
"from_account_id": a["id"],
|
|
"to_account_id": b["id"],
|
|
"amount": 40.0,
|
|
"date": "2026-06-15",
|
|
}
|
|
)
|
|
client.delete_transaction(legs[0]["id"])
|
|
assert client.list_transactions() == []
|
|
|
|
def test_editing_one_leg_is_rejected(self, client):
|
|
a = _bank_account(client, "A")
|
|
b = _bank_account(client, "B")
|
|
legs = client.create_transfer(
|
|
{
|
|
"from_account_id": a["id"],
|
|
"to_account_id": b["id"],
|
|
"amount": 40.0,
|
|
"date": "2026-06-15",
|
|
}
|
|
)
|
|
with pytest.raises(FinClientError) as exc:
|
|
client.update_transaction(legs[0]["id"], {"amount": -30.0})
|
|
assert exc.value.status_code == 422
|
|
|
|
|
|
class TestSettlement:
|
|
def test_bank_to_splitwise_two_legs_linked(self, client):
|
|
bank = _bank_account(client, "Giro")
|
|
sw = _account(client, "Splitwise", "splitwise")
|
|
legs = client.create_settlement(
|
|
{
|
|
"bank_account_id": bank["id"],
|
|
"splitwise_account_id": sw["id"],
|
|
"amount": 30.0,
|
|
"date": "2026-06-20",
|
|
"direction": "bank_to_splitwise",
|
|
}
|
|
)
|
|
assert len(legs) == 2
|
|
by_account = {leg["account_id"]: leg for leg in legs}
|
|
assert by_account[bank["id"]]["amount"] == -30.0
|
|
assert by_account[bank["id"]]["linked_account_id"] == sw["id"]
|
|
assert by_account[sw["id"]]["amount"] == 30.0
|
|
assert by_account[sw["id"]]["linked_account_id"] == bank["id"]
|
|
assert all(leg["type"] == "settlement" for leg in legs)
|
|
|
|
|
|
class TestSplitExpense:
|
|
def test_three_rows_and_user_share_spending(self, client):
|
|
bank = _bank_account(client, "Giro")
|
|
recv = _account(client, "Partner owes me", "receivable")
|
|
rows = client.create_split_expense(
|
|
{
|
|
"bank_account_id": bank["id"],
|
|
"receivable_account_id": recv["id"],
|
|
"total_amount": 100.0,
|
|
"partner_share": 40.0,
|
|
"date": "2026-06-21",
|
|
}
|
|
)
|
|
assert len(rows) == 3
|
|
expense = [r for r in rows if r["type"] == "expense"]
|
|
transfers = [r for r in rows if r["type"] == "transfer"]
|
|
assert len(expense) == 1 and len(transfers) == 2
|
|
# user's own share on the bank account
|
|
assert expense[0]["amount"] == -60.0
|
|
by_account_amounts = sorted(t["amount"] for t in transfers)
|
|
assert by_account_amounts == [-40.0, 40.0]
|
|
# spending counts only the user's share; the transfer legs are excluded
|
|
spending = client.get_spending_month("2026-06")
|
|
assert spending["total"] == pytest.approx(60.0)
|
|
|
|
|
|
class TestSplitwisePaid:
|
|
def test_spending_counts_net_share(self, client):
|
|
bank = _bank_account(client, "Giro")
|
|
sw = _account(client, "Splitwise", "splitwise")
|
|
legs = client.create_splitwise_i_paid(
|
|
{
|
|
"bank_account_id": bank["id"],
|
|
"splitwise_account_id": sw["id"],
|
|
"total_amount": 100.0,
|
|
"partner_share": 45.0,
|
|
"date": "2026-06-22",
|
|
}
|
|
)
|
|
by_account = {leg["account_id"]: leg for leg in legs}
|
|
assert by_account[bank["id"]]["amount"] == -100.0
|
|
assert by_account[bank["id"]]["splitwise_partner_share"] == 45.0
|
|
assert by_account[sw["id"]]["amount"] == 45.0
|
|
# CLAUDE.md invariant 6: spending = amount + splitwise_partner_share
|
|
spending = client.get_spending_month("2026-06")
|
|
assert spending["total"] == pytest.approx(55.0)
|
|
|
|
|
|
class TestNetWorth:
|
|
def test_current_reflects_bank_balance(self, client):
|
|
_bank_account(client, "Giro", initial_balance=100.0)
|
|
nw = client.get_net_worth()
|
|
assert nw["total_eur"] == pytest.approx(100.0)
|
|
assert len(nw["accounts"]) == 1
|
|
|
|
def test_fee_reduces_balance(self, client):
|
|
# CLAUDE.md invariant 5: balance = SUM(amount - fee)
|
|
acct = _bank_account(client, "Giro", initial_balance=100.0)
|
|
client.create_transaction(
|
|
{
|
|
"account_id": acct["id"],
|
|
"amount": -50.0,
|
|
"fee": 2.0,
|
|
"date": "2026-06-10",
|
|
"type": "expense",
|
|
}
|
|
)
|
|
nw = client.get_net_worth()
|
|
assert nw["total_eur"] == pytest.approx(48.0)
|
|
|
|
|
|
class TestSpending:
|
|
def test_monthly_totals(self, client):
|
|
acct = _bank_account(client)
|
|
cat = client.create_category(
|
|
{"name": "Dining", "color": "#f97316", "icon": "🍽️"}
|
|
)
|
|
client.create_transaction(
|
|
{
|
|
"account_id": acct["id"],
|
|
"amount": -50.0,
|
|
"date": "2026-06-10",
|
|
"type": "expense",
|
|
"category_id": cat["id"],
|
|
}
|
|
)
|
|
spending = client.get_spending_month("2026-06")
|
|
assert spending["total"] == pytest.approx(50.0)
|
|
cats = {c["category_name"]: c for c in spending["categories"]}
|
|
assert cats["Dining"]["total"] == pytest.approx(50.0)
|
|
|
|
|
|
class TestCliWiring:
|
|
"""Smoke test that the typer app is wired to the client correctly."""
|
|
|
|
def test_accounts_list_json(self, client, monkeypatch):
|
|
from typer.testing import CliRunner
|
|
|
|
import fin_cli.output
|
|
from fin_cli.main import app
|
|
|
|
_bank_account(client, "Giro")
|
|
monkeypatch.setattr(fin_cli.output, "get_client", lambda: client)
|
|
result = CliRunner().invoke(app, ["accounts", "list", "--json"])
|
|
assert result.exit_code == 0, result.output
|
|
rows = json.loads(result.output)
|
|
assert rows[0]["name"] == "Giro"
|
|
|
|
def test_error_exits_nonzero(self, client, monkeypatch):
|
|
from typer.testing import CliRunner
|
|
|
|
import fin_cli.output
|
|
from fin_cli.main import app
|
|
|
|
monkeypatch.setattr(fin_cli.output, "get_client", lambda: client)
|
|
result = CliRunner().invoke(app, ["tx", "rm", "99999"])
|
|
assert result.exit_code == 1
|
|
|
|
def test_tx_add_refuses_transfer_type(self, client, monkeypatch):
|
|
from typer.testing import CliRunner
|
|
|
|
import fin_cli.output
|
|
from fin_cli.main import app
|
|
|
|
monkeypatch.setattr(fin_cli.output, "get_client", lambda: client)
|
|
result = CliRunner().invoke(
|
|
app,
|
|
["tx", "add", "--account", "1", "--amount", "-5",
|
|
"--date", "2026-06-01", "--type", "transfer"],
|
|
)
|
|
assert result.exit_code == 1
|
|
assert client.list_transactions() == []
|