Files
fin/api/app/models.py
T
EislM0203andClaude Opus 5 c9a1375fb9 fix: close the orphan-leg hole and add idempotency keys (frontier A1 + B3)
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
2026-08-22 22:08:54 +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
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)