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>
155 lines
6.0 KiB
Python
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)
|