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

346 lines
10 KiB
Python

from datetime import date, datetime
from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field, model_validator
# ---------------------------------------------------------------------------
# Account
# ---------------------------------------------------------------------------
class AccountBase(BaseModel):
name: str = Field(max_length=100)
type: Literal["bank", "investment", "rsu", "splitwise", "receivable", "payable"]
currency: str = "EUR"
notes: Optional[str] = None
manual_override_balance: Optional[float] = None
is_active: bool = True
class AccountCreate(AccountBase):
initial_balance: Optional[float] = Field(default=None, description="If set, creates an opening-balance income transaction")
class AccountUpdate(BaseModel):
name: Optional[str] = None
type: Optional[Literal["bank", "investment", "rsu", "splitwise", "receivable", "payable"]] = None
currency: Optional[str] = None
notes: Optional[str] = None
manual_override_balance: Optional[float] = None
is_active: Optional[bool] = None
class AccountRead(AccountBase):
model_config = ConfigDict(from_attributes=True)
id: int
created_at: datetime
# ---------------------------------------------------------------------------
# Category
# ---------------------------------------------------------------------------
class CategoryBase(BaseModel):
name: str = Field(max_length=100)
color: str = Field(max_length=20)
icon: str = Field(max_length=20)
type: Literal["expense", "income"] = "expense"
class CategoryCreate(CategoryBase):
pass
class CategoryUpdate(BaseModel):
name: Optional[str] = None
color: Optional[str] = None
icon: Optional[str] = None
type: Optional[Literal["expense", "income"]] = None
class CategoryRead(CategoryBase):
model_config = ConfigDict(from_attributes=True)
id: int
# ---------------------------------------------------------------------------
# Transaction
# ---------------------------------------------------------------------------
class TransactionBase(BaseModel):
date: date
account_id: int
amount: float
currency: str = "EUR"
type: Literal["expense", "income", "transfer", "splitwise_pay", "splitwise_receive", "settlement"]
category_id: Optional[int] = None
description: Optional[str] = Field(default=None, max_length=500)
fee: float = 0
exchange_rate_used: Optional[float] = None
splitwise_partner_share: Optional[float] = None
linked_account_id: Optional[int] = None
client_ref: Optional[str] = Field(default=None, max_length=100)
class TransactionCreate(TransactionBase):
"""A single, standalone transaction.
Narrower than TransactionBase, which must keep describing every historical
row for reads. `transfer` and `settlement` always come in linked pairs, so
one written here would be an orphan leg — the balance corruption invariant
#1 exists to prevent. Both have dedicated atomic endpoints. `splitwise_pay`
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.
"""
type: Literal["expense", "income", "splitwise_pay", "splitwise_receive"]
@model_validator(mode="after")
def _reject_manual_linkage(self):
if self.linked_account_id is not None:
raise ValueError(
"linked_account_id cannot be set on a standalone transaction — "
"linked pairs are written by /transfer, /settlement, "
"/split-expense and /splitwise-i-paid, which set both sides atomically"
)
return self
class TransactionUpdate(BaseModel):
amount: Optional[float] = None
description: Optional[str] = Field(default=None, max_length=500)
category_id: Optional[int] = None
date: Optional[date] = None
class TransferRequest(BaseModel):
client_ref: Optional[str] = Field(default=None, max_length=100)
from_account_id: int
to_account_id: int
amount: float
currency: str = "EUR"
fee: float = 0
date: date
description: str = "Transfer"
to_amount: Optional[float] = None
to_currency: Optional[str] = None
class SettlementRequest(BaseModel):
client_ref: Optional[str] = Field(default=None, max_length=100)
bank_account_id: int
splitwise_account_id: int
amount: float
currency: str = "EUR"
date: date
direction: Literal["bank_to_splitwise", "splitwise_to_bank"]
class SplitExpenseRequest(BaseModel):
bank_account_id: int
receivable_account_id: int
total_amount: float = Field(gt=0)
partner_share: float = Field(gt=0)
currency: str = "EUR"
date: date
category_id: Optional[int] = None
description: Optional[str] = Field(default=None, max_length=500)
class SplitwiseIPaidRequest(BaseModel):
bank_account_id: int
splitwise_account_id: int
total_amount: float = Field(gt=0)
partner_share: float = Field(gt=0)
currency: str = "EUR"
date: date
category_id: Optional[int] = None
description: str = "Splitwise: I paid"
class BatchTransactionItem(BaseModel):
"""One row of a batch. Amount is positive; the sign comes from the batch type."""
amount: float = Field(gt=0)
category_id: Optional[int] = None
description: Optional[str] = Field(default=None, max_length=500)
fee: float = 0
class BatchTransactionRequest(BaseModel):
"""Several single-leg transactions sharing an account, date and currency.
`type` is restricted to the single-leg kinds. `transfer` and `settlement`
always need two linked legs, and emitting them here would create orphans
that corrupt balances (CLAUDE.md invariant #1) — those go through
/transfer and /settlement instead.
"""
account_id: int
date: date
currency: str = "EUR"
type: Literal["expense", "income"] = "expense"
client_ref: Optional[str] = Field(default=None, max_length=100)
items: list[BatchTransactionItem] = Field(min_length=1, max_length=100)
class TransactionRead(TransactionBase):
model_config = ConfigDict(from_attributes=True)
id: int
created_at: datetime
# ---------------------------------------------------------------------------
# Holding
# ---------------------------------------------------------------------------
class HoldingRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
account_id: int
ticker: str
shares: float
avg_cost_basis_per_share: float
currency: str
updated_at: datetime
class HoldingWithPnL(HoldingRead):
live_price_usd: float
current_value_eur: float
cost_basis_eur: float
pnl_eur: float
pnl_pct: float
ticker_name: Optional[str] = None
# ---------------------------------------------------------------------------
# StockTransaction
# ---------------------------------------------------------------------------
class StockTransactionBase(BaseModel):
account_id: int
ticker: str = Field(pattern=r'^[A-Za-z0-9.\-]{1,12}$')
type: Literal["buy", "sell", "rsu_vest"]
shares: float = Field(gt=0)
price_per_share: float = Field(gt=0)
fee: float = 0
total_cost: float = Field(ge=0)
date: date
currency: str = "USD"
exchange_rate_used: Optional[float] = None
notes: Optional[str] = Field(default=None, max_length=500)
class StockTransactionCreate(StockTransactionBase):
pass
class StockTransactionRead(StockTransactionBase):
model_config = ConfigDict(from_attributes=True)
id: int
class FundedBuyRequest(BaseModel):
account_id: int
ticker: str = Field(pattern=r'^[A-Za-z0-9.\-]{1,12}$')
type: Literal["buy", "sell"] = "buy"
shares: float = Field(gt=0)
price_per_share: float = Field(gt=0)
fee: float = 0
total_cost: float = Field(ge=0)
date: date
currency: str = "USD"
exchange_rate_used: Optional[float] = None
notes: Optional[str] = Field(default=None, max_length=500)
funded_by_account_id: int
class FundedBuyResponse(BaseModel):
stock_transaction: StockTransactionRead
bank_transaction: TransactionRead
# ---------------------------------------------------------------------------
# PriceCache
# ---------------------------------------------------------------------------
class PriceCacheRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
symbol: str
price: float
currency: str
source: str
fetched_at: datetime
# ---------------------------------------------------------------------------
# NetWorthSnapshot
# ---------------------------------------------------------------------------
class NetWorthSnapshotRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
snapshot_date: date
total_eur: float
breakdown_json: str
# ---------------------------------------------------------------------------
# RSU Grant
# ---------------------------------------------------------------------------
class RSUGrantBase(BaseModel):
account_id: int
ticker: str = Field(pattern=r'^[A-Za-z0-9.\-]{1,12}$')
vest_date: date
gross_shares: float = Field(gt=0)
withholding_pct: float = Field(default=0.5, ge=0, le=1)
notes: Optional[str] = Field(default=None, max_length=500)
class RSUGrantCreate(RSUGrantBase):
pass
class RSUGrantRead(RSUGrantBase):
model_config = ConfigDict(from_attributes=True)
id: int
vested_at: Optional[datetime] = None
vested_stock_transaction_id: Optional[int] = None
current_price: Optional[float] = None
current_price_currency: Optional[str] = None
class VestRequest(BaseModel):
withholding_pct_override: Optional[float] = Field(default=None, ge=0, le=1)
net_shares: Optional[int] = Field(default=None, ge=1)
class RSUScheduleRequest(BaseModel):
account_id: int
ticker: str = Field(pattern=r'^[A-Za-z0-9.\-]{1,12}$')
grant_date: date
total_gross_shares: float = Field(gt=0)
cliff_months: int = Field(default=12, ge=1, le=60)
total_months: int = Field(default=36, ge=12, le=120)
frequency_months: int = Field(default=3, ge=1, le=12)
withholding_pct: float = Field(default=0.5, ge=0, le=1)
notes: Optional[str] = Field(default=None, max_length=500)
class RSUSchedulePreviewItem(BaseModel):
vest_date: date
gross_shares: float
withholding_pct: float