Files
fin/api/app/schemas.py
T
EislM0203andClaude Opus 5.5 ec21078bf8 fix: balance adjustments counted as spending and income — add 'adjustment' type
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>
2026-09-30 09:10:45 +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", "adjustment"]
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