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
346 lines
10 KiB
Python
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
|