Files
fin/api/app/routers/accounts.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

82 lines
2.8 KiB
Python

from datetime import date as _date
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import func
from sqlalchemy.orm import Session
from app import crud, models, schemas
from app.database import get_db
router = APIRouter(prefix="/api/accounts", tags=["accounts"])
@router.get("", response_model=list[schemas.AccountRead])
def list_accounts(db: Session = Depends(get_db)):
return crud.get_accounts(db, include_inactive=False)
@router.post("", response_model=schemas.AccountRead, status_code=201)
def create_account(account: schemas.AccountCreate, db: Session = Depends(get_db)):
return crud.create_account(db, account)
@router.put("/{account_id}", response_model=schemas.AccountRead)
def update_account(
account_id: int, account: schemas.AccountUpdate, db: Session = Depends(get_db)
):
db_account = crud.update_account(db, account_id, account)
if db_account is None:
raise HTTPException(status_code=404, detail="Account not found")
return db_account
@router.delete("/{account_id}", response_model=schemas.AccountRead)
def deactivate_account(account_id: int, db: Session = Depends(get_db)):
db_account = crud.deactivate_account(db, account_id)
if db_account is None:
raise HTTPException(status_code=404, detail="Account not found")
return db_account
class BalanceAdjustRequest(BaseModel):
target_balance: float
@router.post("/{account_id}/adjust-balance", response_model=schemas.TransactionRead, status_code=201)
def adjust_balance(account_id: int, req: BalanceAdjustRequest, db: Session = Depends(get_db)):
account = crud.get_account(db, account_id)
if account is None:
raise HTTPException(status_code=404, detail="Account not found")
if account.type not in ("bank", "splitwise", "receivable", "payable"):
raise HTTPException(status_code=422, detail="Balance adjustment only valid for bank/splitwise/receivable/payable accounts")
result = db.query(
func.sum(models.Transaction.amount - models.Transaction.fee)
).filter(
models.Transaction.account_id == account_id
).scalar()
current = float(result or 0)
delta = req.target_balance - current
if abs(delta) < 0.001:
raise HTTPException(status_code=422, detail="Balance already matches target")
# Typed 'adjustment', not income/expense: a correction to the ledger is
# neither earnings nor consumption, so spending and income must not see it.
# The signed delta still moves SUM(amount - fee) onto the target.
txn = models.Transaction(
account_id=account_id,
type="adjustment",
amount=delta,
fee=0,
currency=account.currency,
description="Balance adjustment",
date=_date.today(),
)
db.add(txn)
db.commit()
db.refresh(txn)
return txn