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

155 lines
6.0 KiB
Python

from datetime import datetime
from sqlalchemy import (
Boolean,
Column,
DateTime,
Float,
ForeignKey,
Integer,
Text,
UniqueConstraint,
Date,
)
from sqlalchemy.orm import relationship
from app.database import Base
class Account(Base):
__tablename__ = "accounts"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(Text, nullable=False)
type = Column(Text, nullable=False) # bank | investment | rsu | splitwise | receivable | payable
currency = Column(Text, nullable=False, default="EUR")
notes = Column(Text, nullable=True)
manual_override_balance = Column(Float, nullable=True)
is_active = Column(Boolean, nullable=False, default=True)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
transactions = relationship(
"Transaction", back_populates="account", foreign_keys="Transaction.account_id"
)
linked_transactions = relationship(
"Transaction",
back_populates="linked_account",
foreign_keys="Transaction.linked_account_id",
)
holdings = relationship("Holding", back_populates="account")
stock_transactions = relationship("StockTransaction", back_populates="account")
class Category(Base):
__tablename__ = "categories"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(Text, nullable=False)
color = Column(Text, nullable=False)
icon = Column(Text, nullable=False)
type = Column(Text, nullable=False, server_default="expense") # "expense" | "income"
transactions = relationship("Transaction", back_populates="category")
class Transaction(Base):
__tablename__ = "transactions"
id = Column(Integer, primary_key=True, autoincrement=True)
date = Column(Date, nullable=False)
account_id = Column(Integer, ForeignKey("accounts.id"), nullable=False)
amount = Column(Float, nullable=False)
currency = Column(Text, nullable=False, default="EUR")
type = Column(Text, nullable=False) # expense|income|transfer|splitwise_pay|splitwise_receive|settlement|adjustment
category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
description = Column(Text, nullable=True)
fee = Column(Float, nullable=False, default=0)
exchange_rate_used = Column(Float, nullable=True)
splitwise_partner_share = Column(Float, nullable=True)
linked_account_id = Column(Integer, ForeignKey("accounts.id"), nullable=True)
linked_transaction_id = Column(Integer, nullable=True)
# Caller-supplied idempotency key. Shared by every row of one write, so a
# retried request returns the rows it already made instead of double-booking.
# Indexed but NOT unique: a two-leg or batched write puts the same value on
# several rows. Duplicate suppression is a check-then-write inside the
# creator, which is sound here because SQLite serialises writers and the
# scheduler never writes transactions.
client_ref = Column(Text, nullable=True, index=True)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
account = relationship("Account", back_populates="transactions", foreign_keys=[account_id])
linked_account = relationship(
"Account", back_populates="linked_transactions", foreign_keys=[linked_account_id]
)
category = relationship("Category", back_populates="transactions")
class Holding(Base):
__tablename__ = "holdings"
id = Column(Integer, primary_key=True, autoincrement=True)
account_id = Column(Integer, ForeignKey("accounts.id"), nullable=False)
ticker = Column(Text, nullable=False)
shares = Column(Float, nullable=False)
avg_cost_basis_per_share = Column(Float, nullable=False)
currency = Column(Text, nullable=False, default="USD")
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow)
__table_args__ = (UniqueConstraint("account_id", "ticker", name="uq_holding_account_ticker"),)
account = relationship("Account", back_populates="holdings")
class StockTransaction(Base):
__tablename__ = "stock_transactions"
id = Column(Integer, primary_key=True, autoincrement=True)
account_id = Column(Integer, ForeignKey("accounts.id"), nullable=False)
ticker = Column(Text, nullable=False)
type = Column(Text, nullable=False) # buy | sell | rsu_vest
shares = Column(Float, nullable=False)
price_per_share = Column(Float, nullable=False)
fee = Column(Float, nullable=False, default=0)
total_cost = Column(Float, nullable=False)
date = Column(Date, nullable=False)
currency = Column(Text, nullable=False, default="USD")
exchange_rate_used = Column(Float, nullable=True)
notes = Column(Text, nullable=True)
account = relationship("Account", back_populates="stock_transactions")
class RSUGrant(Base):
__tablename__ = "rsu_grants"
id = Column(Integer, primary_key=True, autoincrement=True)
account_id = Column(Integer, ForeignKey("accounts.id"), nullable=False)
ticker = Column(Text, nullable=False)
vest_date = Column(Date, nullable=False)
gross_shares = Column(Float, nullable=False)
withholding_pct = Column(Float, nullable=False, default=0.5)
notes = Column(Text, nullable=True)
vested_at = Column(DateTime, nullable=True)
vested_stock_transaction_id = Column(Integer, ForeignKey("stock_transactions.id"), nullable=True)
class PriceCache(Base):
__tablename__ = "price_cache"
id = Column(Integer, primary_key=True, autoincrement=True)
symbol = Column(Text, nullable=False, unique=True)
price = Column(Float, nullable=False)
currency = Column(Text, nullable=False)
source = Column(Text, nullable=False) # yfinance | frankfurter
fetched_at = Column(DateTime, nullable=False)
name = Column(Text, nullable=True)
class NetWorthSnapshot(Base):
__tablename__ = "net_worth_snapshots"
id = Column(Integer, primary_key=True, autoincrement=True)
snapshot_date = Column(Date, nullable=False, unique=True)
total_eur = Column(Float, nullable=False)
breakdown_json = Column(Text, nullable=False)