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>
82 lines
2.8 KiB
Python
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
|