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

1062 lines
35 KiB
Python

import math
from datetime import date as _date
from sqlalchemy import func
from sqlalchemy.orm import Session
from app import models, schemas
_MONTH_ABBR = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
# ---------------------------------------------------------------------------
# Accounts
# ---------------------------------------------------------------------------
def get_accounts(db: Session, include_inactive: bool = False) -> list[models.Account]:
q = db.query(models.Account)
if not include_inactive:
q = q.filter(models.Account.is_active.is_(True))
return q.all()
def get_account(db: Session, account_id: int) -> models.Account | None:
return db.query(models.Account).filter(models.Account.id == account_id).first()
def create_account(db: Session, account: schemas.AccountCreate) -> models.Account:
initial = account.initial_balance
data = account.model_dump(exclude={"initial_balance"})
db_account = models.Account(**data)
db.add(db_account)
db.flush() # assign the id for the opening transaction
if initial and initial > 0:
# 'adjustment', not 'income': an opening balance is money that was
# already there, not money earned this month.
opening = models.Transaction(
account_id=db_account.id,
type="adjustment",
amount=initial,
fee=0,
currency=db_account.currency,
description="Opening balance",
date=_date.today(),
)
db.add(opening)
db.commit()
db.refresh(db_account)
return db_account
def update_account(
db: Session, account_id: int, account: schemas.AccountUpdate
) -> models.Account | None:
db_account = get_account(db, account_id)
if db_account is None:
return None
for field, value in account.model_dump(exclude_unset=True).items():
setattr(db_account, field, value)
db.commit()
db.refresh(db_account)
return db_account
def deactivate_account(db: Session, account_id: int) -> models.Account | None:
db_account = get_account(db, account_id)
if db_account is None:
return None
db_account.is_active = False
db.commit()
db.refresh(db_account)
return db_account
# ---------------------------------------------------------------------------
# Categories
# ---------------------------------------------------------------------------
def get_categories(db: Session) -> list[models.Category]:
return db.query(models.Category).all()
def get_category(db: Session, category_id: int) -> models.Category | None:
return db.query(models.Category).filter(models.Category.id == category_id).first()
def create_category(db: Session, category: schemas.CategoryCreate) -> models.Category:
db_category = models.Category(**category.model_dump())
db.add(db_category)
db.commit()
db.refresh(db_category)
return db_category
def update_category(
db: Session, category_id: int, category: schemas.CategoryUpdate
) -> models.Category | None:
db_category = get_category(db, category_id)
if db_category is None:
return None
for field, value in category.model_dump(exclude_unset=True).items():
setattr(db_category, field, value)
db.commit()
db.refresh(db_category)
return db_category
def delete_category(db: Session, category_id: int) -> bool:
db_category = get_category(db, category_id)
if db_category is None:
return False
db.query(models.Transaction).filter(
models.Transaction.category_id == category_id
).update({"category_id": None})
db.delete(db_category)
db.commit()
return True
# ---------------------------------------------------------------------------
# Transactions
# ---------------------------------------------------------------------------
def get_transactions(
db: Session,
account_id: int | None = None,
category_id: int | None = None,
month: str | None = None,
limit: int | None = None,
) -> list[models.Transaction]:
q = db.query(models.Transaction)
if account_id is not None:
q = q.filter(models.Transaction.account_id == account_id)
if category_id is not None:
q = q.filter(models.Transaction.category_id == category_id)
if month is not None:
_year, _mon = int(month[:4]), int(month[5:7])
_start = _date(_year, _mon, 1)
_end = _date(_year + 1, 1, 1) if _mon == 12 else _date(_year, _mon + 1, 1)
q = q.filter(models.Transaction.date >= _start, models.Transaction.date < _end)
q = q.order_by(models.Transaction.date.desc(), models.Transaction.id.desc())
if limit is not None:
q = q.limit(limit)
return q.all()
def get_transaction(db: Session, transaction_id: int) -> models.Transaction | None:
return (
db.query(models.Transaction)
.filter(models.Transaction.id == transaction_id)
.first()
)
def find_by_client_ref(db: Session, client_ref: str | None) -> list[models.Transaction]:
"""Return the rows a previous request already wrote under this key.
Empty when no key was supplied or nothing matches. Every creator that
accepts a client_ref calls this first, so retrying an ambiguous request
replays the original result instead of double-booking.
"""
if not client_ref:
return []
return (
db.query(models.Transaction)
.filter(models.Transaction.client_ref == client_ref)
.order_by(models.Transaction.id)
.all()
)
def create_transaction(
db: Session, transaction: schemas.TransactionCreate
) -> models.Transaction:
existing = find_by_client_ref(db, transaction.client_ref)
if existing:
return existing[0]
db_transaction = models.Transaction(**transaction.model_dump())
db.add(db_transaction)
db.commit()
db.refresh(db_transaction)
return db_transaction
def create_transactions_batch(
db: Session, req: schemas.BatchTransactionRequest
) -> list[models.Transaction]:
"""Write every row of a batch in a single commit — all land or none do.
Items carry positive amounts and the sign is derived from req.type, so a
batch cannot mix money in with money out. Only single-leg types are
accepted (see BatchTransactionRequest), so this never produces the orphan
legs that invariant #1 exists to prevent.
"""
existing = find_by_client_ref(db, req.client_ref)
if existing:
return existing
sign = -1 if req.type == "expense" else 1
rows = [
models.Transaction(
date=req.date,
account_id=req.account_id,
amount=sign * item.amount,
currency=req.currency,
type=req.type,
category_id=item.category_id,
description=item.description,
fee=item.fee,
client_ref=req.client_ref,
)
for item in req.items
]
db.add_all(rows)
db.commit()
for row in rows:
db.refresh(row)
return rows
def update_transaction(
db: Session, transaction_id: int, txn: schemas.TransactionUpdate
) -> models.Transaction | None:
db_txn = get_transaction(db, transaction_id)
if db_txn is None:
return None
for field, value in txn.model_dump(exclude_unset=True).items():
setattr(db_txn, field, value)
db.commit()
db.refresh(db_txn)
return db_txn
def delete_transaction(db: Session, transaction_id: int) -> bool:
db_transaction = get_transaction(db, transaction_id)
if db_transaction is None:
return False
# Collect the whole linked group (transfer/settlement/splitwise legs, and
# the expense of a split expense) by following linked_transaction_id in
# both directions, and delete it atomically — orphaned legs corrupt
# account balances.
group: dict[int, models.Transaction] = {db_transaction.id: db_transaction}
frontier = [db_transaction]
while frontier:
txn = frontier.pop()
related: list[models.Transaction] = []
if (
txn.linked_transaction_id is not None
and txn.linked_transaction_id not in group
):
partner = get_transaction(db, txn.linked_transaction_id)
if partner is not None:
related.append(partner)
related.extend(
db.query(models.Transaction)
.filter(models.Transaction.linked_transaction_id == txn.id)
.all()
)
for r in related:
if r.id not in group:
group[r.id] = r
frontier.append(r)
if len(group) == 1 and db_transaction.linked_account_id is not None:
# Fallback for legacy rows written before linked_transaction_id existed.
# Match on date/type too, and prefer the closest id — both legs were
# inserted together, so their ids are adjacent. A bare .first() could
# grab a leg of a *different* transfer between the same two accounts.
candidates = (
db.query(models.Transaction)
.filter(
models.Transaction.account_id == db_transaction.linked_account_id,
models.Transaction.linked_account_id == db_transaction.account_id,
models.Transaction.linked_transaction_id.is_(None),
models.Transaction.date == db_transaction.date,
models.Transaction.type == db_transaction.type,
)
.all()
)
partner = min(
candidates, key=lambda t: abs(t.id - db_transaction.id), default=None
)
if partner is not None:
group[partner.id] = partner
for txn in group.values():
db.delete(txn)
db.commit()
return True
def create_transfer(
db: Session, req: schemas.TransferRequest
) -> list[models.Transaction]:
"""Write both legs of a transfer atomically."""
existing = find_by_client_ref(db, req.client_ref)
if existing:
return existing
dest_amount = req.to_amount if req.to_amount is not None else req.amount
dest_currency = req.to_currency if req.to_currency is not None else req.currency
from_leg = models.Transaction(
date=req.date,
account_id=req.from_account_id,
amount=-req.amount,
currency=req.currency,
type="transfer",
description=req.description,
linked_account_id=req.to_account_id,
fee=req.fee,
client_ref=req.client_ref,
)
to_leg = models.Transaction(
date=req.date,
account_id=req.to_account_id,
amount=dest_amount,
currency=dest_currency,
type="transfer",
description=req.description,
linked_account_id=req.from_account_id,
fee=0,
client_ref=req.client_ref,
)
db.add_all([from_leg, to_leg])
db.flush()
from_leg.linked_transaction_id = to_leg.id
to_leg.linked_transaction_id = from_leg.id
db.commit()
db.refresh(from_leg)
db.refresh(to_leg)
return [from_leg, to_leg]
def create_settlement(
db: Session, req: schemas.SettlementRequest
) -> list[models.Transaction]:
"""Write both legs of a Splitwise settlement atomically.
direction='splitwise_to_bank': she paid me — bank +amount, splitwise -amount
direction='bank_to_splitwise': i paid her — bank -amount, splitwise +amount
"""
existing = find_by_client_ref(db, req.client_ref)
if existing:
return existing
if req.direction == "splitwise_to_bank":
bank_amount = req.amount
splitwise_amount = -req.amount
else:
bank_amount = -req.amount
splitwise_amount = req.amount
bank_leg = models.Transaction(
date=req.date,
account_id=req.bank_account_id,
amount=bank_amount,
currency=req.currency,
type="settlement",
description="Splitwise settlement",
linked_account_id=req.splitwise_account_id,
fee=0,
client_ref=req.client_ref,
)
splitwise_leg = models.Transaction(
date=req.date,
account_id=req.splitwise_account_id,
amount=splitwise_amount,
currency=req.currency,
type="settlement",
description="Splitwise settlement",
linked_account_id=req.bank_account_id,
fee=0,
client_ref=req.client_ref,
)
db.add_all([bank_leg, splitwise_leg])
db.flush()
bank_leg.linked_transaction_id = splitwise_leg.id
splitwise_leg.linked_transaction_id = bank_leg.id
db.commit()
db.refresh(bank_leg)
db.refresh(splitwise_leg)
return [bank_leg, splitwise_leg]
def create_split_expense(
db: Session, req: schemas.SplitExpenseRequest
) -> list[models.Transaction]:
"""Write the user's expense plus both partner-share transfer legs atomically."""
user_share = req.total_amount - req.partner_share
expense = models.Transaction(
date=req.date,
account_id=req.bank_account_id,
amount=-user_share,
currency=req.currency,
type="expense",
category_id=req.category_id,
description=req.description,
fee=0,
)
transfer_desc = req.description or "Split expense (partner share)"
from_leg = models.Transaction(
date=req.date,
account_id=req.bank_account_id,
amount=-req.partner_share,
currency=req.currency,
type="transfer",
description=transfer_desc,
linked_account_id=req.receivable_account_id,
fee=0,
)
to_leg = models.Transaction(
date=req.date,
account_id=req.receivable_account_id,
amount=req.partner_share,
currency=req.currency,
type="transfer",
description=transfer_desc,
linked_account_id=req.bank_account_id,
fee=0,
)
db.add_all([expense, from_leg, to_leg])
db.flush()
from_leg.linked_transaction_id = to_leg.id
to_leg.linked_transaction_id = from_leg.id
# Link the expense into the group so deleting any of the three rows
# removes all of them (delete_transaction follows these links).
expense.linked_transaction_id = from_leg.id
db.commit()
db.refresh(expense)
db.refresh(from_leg)
db.refresh(to_leg)
return [expense, from_leg, to_leg]
def create_splitwise_i_paid(
db: Session, req: schemas.SplitwiseIPaidRequest
) -> list[models.Transaction]:
"""Write both legs of a 'Splitwise: I paid' atomically."""
bank_leg = models.Transaction(
date=req.date,
account_id=req.bank_account_id,
amount=-req.total_amount,
currency=req.currency,
type="splitwise_pay",
category_id=req.category_id,
description=req.description,
splitwise_partner_share=req.partner_share,
linked_account_id=req.splitwise_account_id,
fee=0,
)
splitwise_leg = models.Transaction(
date=req.date,
account_id=req.splitwise_account_id,
amount=req.partner_share,
currency=req.currency,
type="splitwise_pay",
description=req.description,
linked_account_id=req.bank_account_id,
fee=0,
)
db.add_all([bank_leg, splitwise_leg])
db.flush()
bank_leg.linked_transaction_id = splitwise_leg.id
splitwise_leg.linked_transaction_id = bank_leg.id
db.commit()
db.refresh(bank_leg)
db.refresh(splitwise_leg)
return [bank_leg, splitwise_leg]
# ---------------------------------------------------------------------------
# Holdings
# ---------------------------------------------------------------------------
def get_holdings(db: Session, account_id: int) -> list[models.Holding]:
return (
db.query(models.Holding)
.filter(models.Holding.account_id == account_id)
.all()
)
def get_holding(db: Session, account_id: int, ticker: str) -> models.Holding | None:
return (
db.query(models.Holding)
.filter(
models.Holding.account_id == account_id,
models.Holding.ticker == ticker,
)
.first()
)
# ---------------------------------------------------------------------------
# Stock Transactions
# ---------------------------------------------------------------------------
def create_stock_transaction(db: Session, txn: schemas.StockTransactionCreate) -> models.StockTransaction:
from fastapi import HTTPException
db_txn = models.StockTransaction(**txn.model_dump())
db.add(db_txn)
db.flush() # make row queryable before recompute reads it
try:
recompute_holding_from_stock_transactions(db, txn.account_id, txn.ticker)
except ValueError as exc:
db.rollback()
raise HTTPException(status_code=422, detail=str(exc)) from exc
db.commit()
db.refresh(db_txn)
return db_txn
def get_stock_transactions(
db: Session, account_id: int | None = None
) -> list[models.StockTransaction]:
q = db.query(models.StockTransaction)
if account_id is not None:
q = q.filter(models.StockTransaction.account_id == account_id)
q = q.order_by(models.StockTransaction.date.desc(), models.StockTransaction.id.desc())
return q.all()
def recompute_holding_from_stock_transactions(
db: Session, account_id: int, ticker: str
) -> models.Holding | None:
"""Rebuild holding from all remaining stock transactions. Deletes the holding if none remain."""
from datetime import datetime
txns = (
db.query(models.StockTransaction)
.filter(
models.StockTransaction.account_id == account_id,
models.StockTransaction.ticker == ticker,
)
.order_by(models.StockTransaction.date.asc(), models.StockTransaction.id.asc())
.all()
)
holding = (
db.query(models.Holding)
.filter(
models.Holding.account_id == account_id,
models.Holding.ticker == ticker,
)
.first()
)
if not txns:
if holding:
db.delete(holding)
return None
shares = 0.0
total_cost = 0.0
currency = txns[0].currency
for tx in txns:
if tx.type.lower() in ("buy", "rsu_vest"):
shares += tx.shares
total_cost += tx.shares * tx.price_per_share + tx.fee
elif tx.type.lower() == "sell":
if tx.shares > shares:
raise ValueError(
f"Cannot sell {tx.shares} shares of {tx.ticker}: only {round(shares, 6)} held at that point in history"
)
sell_shares = tx.shares
if shares > 0:
cost_per_share = total_cost / shares
total_cost -= sell_shares * cost_per_share
shares -= sell_shares
total_cost = max(0.0, total_cost)
avg_cost_basis = (total_cost / shares) if shares > 0 else 0.0
if holding is None:
holding = models.Holding(
account_id=account_id,
ticker=ticker,
shares=shares,
avg_cost_basis_per_share=avg_cost_basis,
currency=currency,
)
db.add(holding)
else:
holding.shares = shares
holding.avg_cost_basis_per_share = avg_cost_basis
holding.updated_at = datetime.utcnow()
return holding
def delete_stock_transaction(db: Session, stock_txn_id: int) -> bool:
stock_txn = (
db.query(models.StockTransaction)
.filter(models.StockTransaction.id == stock_txn_id)
.first()
)
if stock_txn is None:
return False
account_id = stock_txn.account_id
ticker = stock_txn.ticker
# Revert any grant vested through this transaction to pending, otherwise the
# FK on vested_stock_transaction_id makes the delete fail with a 500.
db.query(models.RSUGrant).filter(
models.RSUGrant.vested_stock_transaction_id == stock_txn.id
).update({"vested_at": None, "vested_stock_transaction_id": None})
db.delete(stock_txn)
db.flush()
try:
recompute_holding_from_stock_transactions(db, account_id, ticker)
except ValueError as exc:
db.rollback()
from fastapi import HTTPException
raise HTTPException(status_code=422, detail=str(exc)) from exc
db.commit()
return True
def create_funded_buy(
db: Session,
req: schemas.FundedBuyRequest,
eur_usd_rate: float,
) -> tuple[models.StockTransaction, models.Transaction]:
"""Atomically create a stock buy/sell + the matching bank cash leg.
The bank leg is typed 'transfer', not 'expense'/'income': moving cash into
or out of securities is an asset conversion and must not show up in the
spending or income metrics.
"""
funding_account = db.get(models.Account, req.funded_by_account_id)
if not funding_account:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Funding account not found")
stock_create = schemas.StockTransactionCreate(
account_id=req.account_id,
ticker=req.ticker,
type=req.type,
shares=req.shares,
price_per_share=req.price_per_share,
fee=req.fee,
total_cost=req.total_cost,
date=req.date,
currency=req.currency,
exchange_rate_used=req.exchange_rate_used or eur_usd_rate,
notes=req.notes,
)
db_stock = models.StockTransaction(**stock_create.model_dump())
db.add(db_stock)
db.flush()
try:
recompute_holding_from_stock_transactions(db, req.account_id, req.ticker)
except ValueError as exc:
db.rollback()
from fastapi import HTTPException
raise HTTPException(status_code=422, detail=str(exc)) from exc
account_currency = funding_account.currency
if req.type == "sell":
# Proceeds credited to the bank: gross minus fee
cash_native = req.shares * req.price_per_share - req.fee
else:
cash_native = -req.total_cost
used_rate = req.exchange_rate_used or eur_usd_rate
if account_currency == req.currency:
bank_amount = cash_native
else:
bank_amount = (cash_native / used_rate) if used_rate > 0 else cash_native
verb = "Sell" if req.type == "sell" else "Buy"
db_bank = models.Transaction(
date=req.date,
account_id=req.funded_by_account_id,
amount=bank_amount,
currency=account_currency,
type="transfer",
description=f"{verb} {req.shares} {req.ticker}",
linked_account_id=req.account_id,
fee=0,
)
db.add(db_bank)
db.commit()
db.refresh(db_stock)
db.refresh(db_bank)
return db_stock, db_bank
# ---------------------------------------------------------------------------
# Spending
# ---------------------------------------------------------------------------
def get_monthly_spending(
db: Session,
month: str, # "YYYY-MM"
account_id: int | None = None
) -> dict:
"""
Returns the spending summary for a given month, grouped by category.
Only includes transactions where:
- amount < 0 (expenses)
- type IN ('expense', 'splitwise_pay', 'splitwise_receive')
Returns dict with:
- month: str
- total: float (sum of all category totals, positive)
- categories: list of category summaries with totals and percentages
"""
_year, _mon = int(month[:4]), int(month[5:7])
_start = _date(_year, _mon, 1)
_end = _date(_year + 1, 1, 1) if _mon == 12 else _date(_year, _mon + 1, 1)
# Query 1: regular expenses and splitwise_receive (use raw amount)
base_query = db.query(
models.Transaction.category_id,
func.sum(models.Transaction.amount).label("total_amount")
).join(
models.Account, models.Transaction.account_id == models.Account.id
).filter(
models.Transaction.date >= _start,
models.Transaction.date < _end,
models.Transaction.type.in_(['expense', 'splitwise_receive']),
models.Transaction.amount < 0,
models.Account.type.notin_(["receivable"])
)
if account_id is not None:
base_query = base_query.filter(models.Transaction.account_id == account_id)
results = base_query.group_by(models.Transaction.category_id).all()
# Query 2: splitwise_pay — bank amount is the full paid amount, but the
# user's actual expense is only their share: amount + splitwise_partner_share
# (e.g. amount=-100, partner_share=50 → user expense = -50).
# Fetch individual rows to sum correctly per category.
sw_pay_query = db.query(models.Transaction).join(
models.Account, models.Transaction.account_id == models.Account.id
).filter(
models.Transaction.date >= _start,
models.Transaction.date < _end,
models.Transaction.type == 'splitwise_pay',
models.Transaction.amount < 0,
models.Account.type.notin_(["receivable"])
)
if account_id is not None:
sw_pay_query = sw_pay_query.filter(models.Transaction.account_id == account_id)
sw_pay_rows = sw_pay_query.all()
# Accumulate splitwise_pay per category using user's share
sw_pay_by_category: dict[int | None, float] = {}
for row in sw_pay_rows:
partner_share = row.splitwise_partner_share or 0.0
user_share = row.amount + partner_share # e.g. -100 + 50 = -50
cat_id = row.category_id
sw_pay_by_category[cat_id] = sw_pay_by_category.get(cat_id, 0.0) + user_share
# Merge both result sets by category
merged: dict[int | None, float] = {}
for category_id, total_amount in results:
merged[category_id] = merged.get(category_id, 0.0) + total_amount
for cat_id, user_share_total in sw_pay_by_category.items():
merged[cat_id] = merged.get(cat_id, 0.0) + user_share_total
# Build category summaries
all_cats = {c.id: c for c in get_categories(db)}
category_data = []
overall_total = 0.0
for category_id, total_amount in merged.items():
if total_amount >= 0:
# No net expense for this category (shouldn't normally happen)
continue
# total_amount is negative; convert to positive for reporting
positive_total = abs(total_amount)
overall_total += positive_total
# Fetch category details
category = None
if category_id is not None:
category = all_cats.get(category_id)
category_data.append({
'category_id': category_id,
'category_name': category.name if category else None,
'color': category.color if category else None,
'icon': category.icon if category else None,
'total': positive_total,
'percentage': 0.0 # Will calculate after we know the overall total
})
# Calculate percentages
for item in category_data:
if overall_total > 0:
item['percentage'] = round((item['total'] / overall_total) * 100, 1)
else:
item['percentage'] = 0.0
income_q = db.query(func.sum(models.Transaction.amount)).join(
models.Account, models.Transaction.account_id == models.Account.id
).filter(
models.Transaction.date >= _start,
models.Transaction.date < _end,
models.Transaction.type == 'income',
models.Account.type.notin_(["payable", "receivable"]),
)
if account_id is not None:
income_q = income_q.filter(models.Transaction.account_id == account_id)
income_total = income_q.scalar() or 0.0
return {
'month': month,
'total': overall_total,
'income_total': income_total,
'categories': category_data
}
def get_spending_summary(
db: Session,
mode: str,
year: int | None = None,
start: str | None = None,
end: str | None = None,
) -> dict:
"""
Aggregate spending across multiple months.
mode='yearly': requires year. Returns 12 monthly periods for that year.
mode='custom': requires start and end ('YYYY-MM-DD'). Iterates months in range.
"""
if mode == 'yearly':
if year is None:
raise ValueError("year is required for yearly mode")
months = [f"{year:04d}-{m:02d}" for m in range(1, 13)]
elif mode == 'custom':
if not start or not end:
raise ValueError("start and end are required for custom mode")
s = _date.fromisoformat(start)
e = _date.fromisoformat(end)
if s > e:
raise ValueError("start date must not be after end date")
months = []
y, m = s.year, s.month
while (y, m) <= (e.year, e.month):
months.append(f"{y:04d}-{m:02d}")
if m == 12:
y, m = y + 1, 1
else:
m += 1
else:
raise ValueError(f"unknown mode: {mode}")
periods = []
cat_agg: dict[int | None, dict] = {}
grand_total = 0.0
income_total = 0.0
for month in months:
ms = get_monthly_spending(db, month)
_y, _m = int(month[:4]), int(month[5:7])
label = f"{_MONTH_ABBR[_m - 1]} {_y}"
periods.append({
'period_label': label,
'total': ms['total'],
'income_total': ms['income_total'],
'categories': ms['categories'],
})
grand_total += ms['total']
income_total += ms['income_total']
for c in ms['categories']:
cid = c['category_id']
if cid not in cat_agg:
cat_agg[cid] = {
'category_id': cid,
'category_name': c['category_name'],
'color': c['color'],
'icon': c['icon'],
'total': 0.0,
'percentage': 0.0,
}
cat_agg[cid]['total'] += c['total']
aggregated_categories = list(cat_agg.values())
for item in aggregated_categories:
item['percentage'] = round((item['total'] / grand_total) * 100, 1) if grand_total > 0 else 0.0
aggregated_categories.sort(key=lambda x: x['total'], reverse=True)
return {
'mode': mode,
'periods': periods,
'grand_total': grand_total,
'income_total': income_total,
'net_total': income_total - grand_total,
'categories': aggregated_categories,
}
# ---------------------------------------------------------------------------
# RSU Schedule helpers
# ---------------------------------------------------------------------------
def _add_months(d: _date, months: int) -> _date:
import calendar
month = d.month - 1 + months
year = d.year + month // 12
month = month % 12 + 1
day = min(d.day, calendar.monthrange(year, month)[1])
return _date(year, month, day)
def _generate_rsu_schedule(
grant_date: _date,
total_gross_shares: float,
cliff_months: int,
total_months: int,
frequency_months: int,
withholding_pct: float,
) -> list[dict]:
"""
Returns a list of dicts: {vest_date, gross_shares, withholding_pct}
ready to pass to create_rsu_grant.
Cliff = one tranche at grant_date + cliff_months.
Periodic = remaining tranches at every frequency_months after cliff,
until grant_date + total_months.
Share allocation: floor each tranche; remainder added to last.
"""
total_gross_shares = math.floor(total_gross_shares)
cliff_date = _add_months(grant_date, cliff_months)
remaining_months = total_months - cliff_months
n_periodic = remaining_months // frequency_months
total_tranches = 1 + n_periodic
shares_per_tranche = total_gross_shares / total_tranches
floored = math.floor(shares_per_tranche) # whole shares only
tranches = []
tranches.append({
"vest_date": cliff_date,
"gross_shares": floored,
"withholding_pct": withholding_pct,
})
for i in range(1, n_periodic + 1):
vd = _add_months(cliff_date, frequency_months * i)
tranches.append({
"vest_date": vd,
"gross_shares": floored,
"withholding_pct": withholding_pct,
})
# Push remainder into last tranche so sum == total_gross_shares
allocated = floored * total_tranches
remainder = int(round(total_gross_shares)) - allocated
if remainder != 0:
tranches[-1]["gross_shares"] = floored + remainder
return tranches
# ---------------------------------------------------------------------------
# RSU Grants
# ---------------------------------------------------------------------------
def get_rsu_grants(db: Session, include_vested: bool = False) -> list[models.RSUGrant]:
q = db.query(models.RSUGrant)
if not include_vested:
q = q.filter(models.RSUGrant.vested_at.is_(None))
q = q.order_by(models.RSUGrant.vest_date.asc(), models.RSUGrant.id.asc())
return q.all()
def get_rsu_grant(db: Session, grant_id: int) -> models.RSUGrant | None:
return db.query(models.RSUGrant).filter(models.RSUGrant.id == grant_id).first()
def create_rsu_grant(db: Session, grant: schemas.RSUGrantCreate) -> models.RSUGrant:
from fastapi import HTTPException
account = db.query(models.Account).filter(models.Account.id == grant.account_id).first()
if account is None:
raise HTTPException(status_code=404, detail="Account not found")
if account.type not in ("investment", "rsu"):
raise HTTPException(status_code=422, detail="RSU grants can only be linked to investment or rsu accounts")
db_grant = models.RSUGrant(**grant.model_dump())
db_grant.ticker = db_grant.ticker.upper()
db.add(db_grant)
db.commit()
db.refresh(db_grant)
return db_grant
def delete_rsu_grant(db: Session, grant_id: int) -> bool:
db_grant = get_rsu_grant(db, grant_id)
if db_grant is None:
return False
if db_grant.vested_at is not None:
raise ValueError("Cannot delete a vested grant. Delete the linked stock transaction instead.")
db.delete(db_grant)
db.commit()
return True
def vest_rsu_grant(
db: Session, grant_id: int,
withholding_pct_override: float | None = None,
net_shares_override: int | None = None,
) -> models.RSUGrant | None:
"""Create an rsu_vest StockTransaction for the net shares and mark the grant vested."""
from datetime import datetime as _dt
from fastapi import HTTPException
db_grant = get_rsu_grant(db, grant_id)
if db_grant is None:
return None
if db_grant.vested_at is not None:
raise HTTPException(status_code=422, detail="Grant already vested")
if net_shares_override is not None:
net_shares = net_shares_override
else:
pct = withholding_pct_override if withholding_pct_override is not None else db_grant.withholding_pct
net_shares = math.floor(db_grant.gross_shares * (1 - pct))
if net_shares <= 0:
raise HTTPException(status_code=422, detail="Net shares must be at least 1")
price_row = (
db.query(models.PriceCache)
.filter(models.PriceCache.symbol == db_grant.ticker)
.first()
)
if price_row is None or price_row.price <= 0:
raise HTTPException(
status_code=422,
detail=f"No cached price for {db_grant.ticker}. Refresh prices first.",
)
price = price_row.price
currency = price_row.currency or "USD"
total_cost = net_shares * price
db_txn = models.StockTransaction(
account_id=db_grant.account_id,
ticker=db_grant.ticker,
type="rsu_vest",
shares=net_shares,
price_per_share=price,
fee=0,
total_cost=total_cost,
date=db_grant.vest_date,
currency=currency,
notes=f"RSU vest (grant #{db_grant.id}, {net_shares} net shares)",
)
db.add(db_txn)
db.flush()
try:
recompute_holding_from_stock_transactions(db, db_grant.account_id, db_grant.ticker)
except ValueError as exc:
db.rollback()
raise HTTPException(status_code=422, detail=str(exc)) from exc
db_grant.vested_at = _dt.utcnow()
db_grant.vested_stock_transaction_id = db_txn.id
db.commit()
db.refresh(db_grant)
return db_grant