Two long-standing correctness gaps, both flagged in fin-research-frontier. A1 — POST /api/transactions could write half a linked pair TransactionCreate inherited the full six-value type Literal and accepted a free-standing linked_account_id, so any client could create a lone `transfer` row with no partner. That is exactly the balance corruption invariant #1 exists to prevent: the orphan counts toward SUM(amount - fee) and paired-delete can never find a partner to remove it with. TransactionCreate now narrows type to the single-leg kinds and rejects a caller-supplied linked_account_id. TransactionBase stays wide on purpose — reads must keep describing historical rows of every type. splitwise_pay deliberately stays allowed. The two-leg creator uses that type, but a standalone row carrying splitwise_partner_share is also a legitimate record, and is what `fin tx add --type splitwise_pay --partner-share` writes. Narrowing to expense/income would have broken it. B3 — no idempotency, so any retry double-booked Migration i9d0e1f2a3b4 adds a nullable client_ref to transactions. POST /api/transactions, /batch, /transfer and /settlement accept it, and crud.find_by_client_ref is checked before writing, so retrying a timed-out request replays the original rows instead of booking them again. NULL means "no idempotency requested" and never collides. Deviation from the frontier's sketch, which called for unique-when-not-null: the index is NOT unique. A transfer writes two rows and a batch writes many, all under one key, so uniqueness cannot live on the column. Suppression is a check-then-write inside the creators instead — sound here because SQLite serialises writers and the scheduler never writes transactions. Exposed on the MCP create_transaction/create_transfer/create_settlement tools, since a retrying agent is the case B3 was written about. The server instructions no longer tell agents that writes are non-idempotent. Tests: 60 passing, up from 47. Thirteen new cover retries collapsing to one write across all four endpoints, differing keys staying separate, NULL keys not colliding, and the orphan-leg guard refusing transfer/settlement/manual linkage while the four legitimate single-leg types still work. Migration verified both directions on a scratch DB seeded at the previous head: existing rows survive with NULL client_ref, index is created, and downgrade drops cleanly with rows intact. Verified live over HTTP that a transfer submitted three times with one client_ref yields one pair of legs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L3qYLuu9jgXsCRXmJ18YGY
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
|
|
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)
|