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>
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", "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
|