fix: stock trades and receivable/payable settles no longer inflate spending/income

Asset movements between own accounts (buy/sell cash leg, reconciling a
receivable or payable) were typed 'expense'/'income', so they showed up in
the spending and income metrics. They are now typed 'transfer'/'settlement',
which the spending queries already exclude.

Backend:
- funded-buy/sell bank leg now typed 'transfer' (linked to investment account)
  instead of 'expense' — buys no longer inflate spending
- FundedBuyRequest accepts type 'sell' in addition to 'buy'; the sell leg
  credits proceeds (shares × price − fee) to the bank account
- /reconcile legs retyped to 'settlement' — collecting a receivable no longer
  inflates income, paying off a payable no longer inflates spending
- RSU vest picks currency from PriceCache instead of hardcoding 'USD'
- create_account writes account + opening balance in one atomic commit
- create_split_expense links the expense row to the transfer legs so deleting
  any one of the three rows deletes the whole group
- delete_transaction follows linked_transaction_id in both directions to
  collect and delete the full linked group atomically
- splitwise-i-paid rejects partner_share >= total_amount (422)
- Migration h8c9d0e1f2a3 retypes historical rows the same way

Frontend:
- StockTradeFlow: sell now shows bank-account selector ('Credit proceeds…');
  selector shown for both buy and sell, hidden only for rsu_vest
- Portfolio Add Trade form: same buy/sell bank-account selector added,
  routes to createFundedBuy when an account is chosen
- Portfolio price-fetch handlers guard against stale ticker closures

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
EislM0203
2026-07-02 14:27:39 +00:00
co-authored by Claude Sonnet 4.6
parent bc256f6c6e
commit e36d719cbe
7 changed files with 239 additions and 94 deletions
@@ -0,0 +1,56 @@
"""retype internal cash flows so they stop counting as income/spending
Funded stock buys wrote the bank debit as type='expense' and
receivable/payable settlements wrote their legs as 'income'/'expense'.
Both are asset movements between own accounts, not consumption or
earnings, so they inflated the spending and income metrics.
New rows are written as 'transfer' / 'settlement'; this migration
retypes the existing rows the same way.
Revision ID: h8c9d0e1f2a3
Revises: g7b8c9d0e1f2
Create Date: 2026-07-02
"""
import re
from alembic import op
import sqlalchemy as sa
revision = 'h8c9d0e1f2a3'
down_revision = 'g7b8c9d0e1f2'
branch_labels = None
depends_on = None
# Matches only the auto-generated funded-buy description "Buy <shares> <TICKER>",
# not user-entered expenses like "Buy groceries".
_FUNDED_BUY_DESC = re.compile(r'^Buy \d+(\.\d+)? [A-Za-z0-9.\-]{1,12}$')
def upgrade() -> None:
conn = op.get_bind()
# Reconcile (receivable/payable settle) legs are the only income/expense
# rows that carry linked_account_id — retype the pair to 'settlement'.
conn.execute(sa.text(
"UPDATE transactions SET type = 'settlement' "
"WHERE linked_account_id IS NOT NULL AND type IN ('income', 'expense')"
))
# Funded-buy bank debits: expense rows with the generated description.
rows = conn.execute(sa.text(
"SELECT id, description FROM transactions "
"WHERE type = 'expense' AND description LIKE 'Buy %'"
)).fetchall()
buy_ids = [row.id for row in rows if _FUNDED_BUY_DESC.match(row.description or '')]
if buy_ids:
conn.execute(
sa.text("UPDATE transactions SET type = 'transfer' WHERE id IN :ids")
.bindparams(sa.bindparam('ids', expanding=True)),
{'ids': buy_ids},
)
def downgrade() -> None:
# Data migration — the original income/expense typing is not restored.
pass
+87 -46
View File
@@ -29,8 +29,7 @@ def create_account(db: Session, account: schemas.AccountCreate) -> models.Accoun
data = account.model_dump(exclude={"initial_balance"})
db_account = models.Account(**data)
db.add(db_account)
db.commit()
db.refresh(db_account)
db.flush() # assign the id for the opening transaction
if initial and initial > 0:
opening = models.Transaction(
account_id=db_account.id,
@@ -42,7 +41,8 @@ def create_account(db: Session, account: schemas.AccountCreate) -> models.Accoun
date=_date.today(),
)
db.add(opening)
db.commit()
db.commit()
db.refresh(db_account)
return db_account
@@ -176,37 +176,56 @@ def delete_transaction(db: Session, transaction_id: int) -> bool:
db_transaction = get_transaction(db, transaction_id)
if db_transaction is None:
return False
# If this transaction has a paired leg (transfer/settlement/splitwise_pay),
# delete both atomically to avoid orphaned legs that corrupt account balances.
if db_transaction.linked_account_id is not None:
if db_transaction.linked_transaction_id is not None:
partner = (
db.query(models.Transaction)
.filter(models.Transaction.id == db_transaction.linked_transaction_id)
.first()
)
else:
# 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
# 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:
db.delete(partner)
db.delete(db_transaction)
group[partner.id] = partner
for txn in group.values():
db.delete(txn)
db.commit()
return True
@@ -332,6 +351,9 @@ def create_split_expense(
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)
@@ -529,7 +551,17 @@ def create_funded_buy(
req: schemas.FundedBuyRequest,
eur_usd_rate: float,
) -> tuple[models.StockTransaction, models.Transaction]:
"""Atomically create a stock BUY + bank debit 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,
@@ -553,25 +585,29 @@ def create_funded_buy(
from fastapi import HTTPException
raise HTTPException(status_code=422, detail=str(exc)) from exc
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")
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_debit = -req.total_cost
bank_amount = cash_native
else:
bank_debit = -(req.total_cost / used_rate) if used_rate > 0 else -req.total_cost
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_debit,
amount=bank_amount,
currency=account_currency,
type="expense",
description=f"Buy {req.shares} {req.ticker}",
type="transfer",
description=f"{verb} {req.shares} {req.ticker}",
linked_account_id=req.account_id,
fee=0,
)
db.add(db_bank)
@@ -902,7 +938,6 @@ def vest_rsu_grant(
"""Create an rsu_vest StockTransaction for the net shares and mark the grant vested."""
from datetime import datetime as _dt
from fastapi import HTTPException
from app.services.net_worth import _get_ticker_price
db_grant = get_rsu_grant(db, grant_id)
if db_grant is None:
@@ -918,12 +953,18 @@ def vest_rsu_grant(
if net_shares <= 0:
raise HTTPException(status_code=422, detail="Net shares must be at least 1")
price = _get_ticker_price(db, db_grant.ticker)
if price <= 0:
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(
@@ -935,7 +976,7 @@ def vest_rsu_grant(
fee=0,
total_cost=total_cost,
date=db_grant.vest_date,
currency="USD",
currency=currency,
notes=f"RSU vest (grant #{db_grant.id}, {net_shares} net shares)",
)
db.add(db_txn)
+1 -1
View File
@@ -21,7 +21,7 @@ class Account(Base):
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(Text, nullable=False)
type = Column(Text, nullable=False) # bank | investment | rsu | splitwise | receivable
type = Column(Text, nullable=False) # bank | investment | rsu | splitwise | receivable | payable
currency = Column(Text, nullable=False, default="EUR")
notes = Column(Text, nullable=True)
manual_override_balance = Column(Float, nullable=True)
+21 -26
View File
@@ -83,6 +83,8 @@ def create_split_expense(req: schemas.SplitExpenseRequest, db: Session = Depends
def create_splitwise_i_paid(req: schemas.SplitwiseIPaidRequest, db: Session = Depends(get_db)):
if req.bank_account_id == req.splitwise_account_id:
raise HTTPException(status_code=422, detail="Bank and Splitwise accounts must differ")
if req.partner_share >= req.total_amount:
raise HTTPException(status_code=422, detail="Partner's share must be less than the total amount")
return crud.create_splitwise_i_paid(db, req)
@@ -98,8 +100,12 @@ class ReconcileRequest(BaseModel):
def reconcile_accounts(req: ReconcileRequest, db: Session = Depends(get_db)):
"""
Settle a payable or receivable against a bank account.
For receivable (someone pays you back): bank +amount, other -amount (expense)
For payable (you pay someone): bank -amount (expense), other +amount
For receivable (someone pays you back): bank +amount, other -amount
For payable (you pay someone): bank -amount, other +amount
Both legs are typed 'settlement' — repaying a debt or collecting a
receivable moves cash between own accounts and must not count as
income or spending (the consumption was already booked when the
receivable/payable was created).
Both legs linked via linked_account_id.
"""
from app import models
@@ -121,30 +127,19 @@ def reconcile_accounts(req: ReconcileRequest, db: Session = Depends(get_db)):
desc = req.description or ("Received payment" if other.type == "receivable" else "Payment made")
if other.type == "receivable":
# Someone paid you back: bank gets income (+), receivable gets expense (-)
bank_leg = models.Transaction(
account_id=bank.id, type="income",
amount=req.amount, fee=0, currency=bank.currency,
description=desc, date=txn_date,
)
other_leg = models.Transaction(
account_id=other.id, type="expense",
amount=-req.amount, fee=0, currency=other.currency,
description=desc, date=txn_date,
)
else: # payable
# You paid someone: bank gets expense (-), payable gets income (+, reduces debt)
bank_leg = models.Transaction(
account_id=bank.id, type="expense",
amount=-req.amount, fee=0, currency=bank.currency,
description=desc, date=txn_date,
)
other_leg = models.Transaction(
account_id=other.id, type="income",
amount=req.amount, fee=0, currency=other.currency,
description=desc, date=txn_date,
)
# receivable: someone paid you back → bank +, receivable
# payable: you paid someone → bank , payable + (reduces debt)
sign = 1 if other.type == "receivable" else -1
bank_leg = models.Transaction(
account_id=bank.id, type="settlement",
amount=sign * req.amount, fee=0, currency=bank.currency,
description=desc, date=txn_date,
)
other_leg = models.Transaction(
account_id=other.id, type="settlement",
amount=-sign * req.amount, fee=0, currency=other.currency,
description=desc, date=txn_date,
)
db.add_all([bank_leg, other_leg])
db.flush()
+1 -1
View File
@@ -200,7 +200,7 @@ class StockTransactionRead(StockTransactionBase):
class FundedBuyRequest(BaseModel):
account_id: int
ticker: str = Field(pattern=r'^[A-Za-z0-9.\-]{1,12}$')
type: Literal["buy"] = "buy"
type: Literal["buy", "sell"] = "buy"
shares: float = Field(gt=0)
price_per_share: float = Field(gt=0)
fee: float = 0
@@ -57,7 +57,7 @@ export default function StockTradeFlow({ accounts, onSuccess, onBack, initialAcc
try {
const d = new Date()
const today = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
if (tradeType === 'buy' && fundedByAccountId !== null) {
if (tradeType !== 'rsu_vest' && fundedByAccountId !== null) {
await api.createFundedBuy({
account_id: selectedAccountId,
ticker: ticker.trim().toUpperCase(),
@@ -131,7 +131,7 @@ export default function StockTradeFlow({ accounts, onSuccess, onBack, initialAcc
{(['buy', 'sell', 'rsu_vest'] as TradeType[]).map(t => (
<button
key={t}
onClick={() => { setTradeType(t); if (t !== 'buy') setFundedByAccountId(null) }}
onClick={() => { setTradeType(t); if (t === 'rsu_vest') setFundedByAccountId(null) }}
className={`flex-1 py-2 rounded-lg border text-sm ${
tradeType === t
? 'border-primary bg-primary/10 font-medium'
@@ -225,10 +225,12 @@ export default function StockTradeFlow({ accounts, onSuccess, onBack, initialAcc
/>
</div>
{tradeType === 'buy' && bankAccounts.length > 0 && (
{tradeType !== 'rsu_vest' && bankAccounts.length > 0 && (
<div>
<label className="text-xs text-muted-foreground mb-1 block">
Fund from bank account (optional)
{tradeType === 'buy'
? 'Fund from bank account (optional)'
: 'Credit proceeds to bank account (optional)'}
</label>
<select
className="w-full border rounded-md px-2 py-2 text-sm bg-background"
+67 -16
View File
@@ -53,6 +53,7 @@ export default function Portfolio() {
price_per_share: '',
fee: '',
})
const [fundedByAccountId, setFundedByAccountId] = useState<number | null>(null)
const investmentAccounts = accounts.filter(
a => ['investment', 'rsu'].includes(a.type) && a.is_active
@@ -146,17 +147,33 @@ export default function Portfolio() {
setSubmitting(true)
try {
const fee = formData.fee ? Number(formData.fee) : 0
await api.createStockTransaction({
account_id: Number(formData.account_id),
ticker: formData.ticker.toUpperCase(),
type: formData.type,
shares,
price_per_share: pricePerShare,
fee,
total_cost: shares * pricePerShare + fee,
date: todayLocalISO(),
currency: 'USD',
})
const totalCost = shares * pricePerShare + fee
if (formData.type !== 'rsu_vest' && fundedByAccountId !== null) {
await api.createFundedBuy({
account_id: Number(formData.account_id),
ticker: formData.ticker.toUpperCase(),
type: formData.type as 'buy' | 'sell',
shares,
price_per_share: pricePerShare,
fee,
total_cost: totalCost,
date: todayLocalISO(),
currency: 'USD',
funded_by_account_id: fundedByAccountId,
})
} else {
await api.createStockTransaction({
account_id: Number(formData.account_id),
ticker: formData.ticker.toUpperCase(),
type: formData.type,
shares,
price_per_share: pricePerShare,
fee,
total_cost: totalCost,
date: todayLocalISO(),
currency: 'USD',
})
}
setShowAddForm(false)
setFormData({
account_id: '',
@@ -166,6 +183,7 @@ export default function Portfolio() {
price_per_share: '',
fee: '',
})
setFundedByAccountId(null)
setRefreshKey(prev => prev + 1)
} catch (error) {
console.error('Failed to create trade:', error)
@@ -177,10 +195,16 @@ export default function Portfolio() {
const handleFetchPrice = async () => {
if (!formData.ticker) return
const requestedTicker = formData.ticker.toUpperCase()
setFetchingPrice(true)
try {
const result = await api.fetchTickerPrice(formData.ticker.toUpperCase())
setFormData(f => ({ ...f, price_per_share: result.price.toFixed(2) }))
const result = await api.fetchTickerPrice(requestedTicker)
// Ignore the result if the ticker changed while the fetch was in flight
setFormData(f =>
f.ticker.toUpperCase() === requestedTicker
? { ...f, price_per_share: result.price.toFixed(2) }
: f
)
toast(`Price: ${result.price.toFixed(2)} ${result.currency}`)
} catch (e) {
toast(e instanceof Error ? e.message : 'Failed to fetch price', 'error')
@@ -279,10 +303,13 @@ export default function Portfolio() {
const handleFetchVestPrice = async () => {
if (!vestForm.ticker) return
const requestedTicker = vestForm.ticker.toUpperCase()
setVestFetchingPrice(true)
try {
const result = await api.fetchTickerPrice(vestForm.ticker.toUpperCase())
setVestForm(f => ({ ...f, price: String(result.price) }))
const result = await api.fetchTickerPrice(requestedTicker)
setVestForm(f =>
f.ticker.toUpperCase() === requestedTicker ? { ...f, price: String(result.price) } : f
)
} catch {
toast('Could not fetch price', 'error')
} finally {
@@ -431,7 +458,7 @@ export default function Portfolio() {
{!loading && investmentAccounts.length > 0 && (
<div className="p-4 border-b">
<Button
onClick={() => setShowAddForm(!showAddForm)}
onClick={() => { setShowAddForm(v => !v); setFundedByAccountId(null) }}
variant="default"
className="w-full"
>
@@ -533,6 +560,30 @@ export default function Portfolio() {
/>
</div>
{formData.type !== 'rsu_vest' && (
<div>
<label className="text-sm font-medium text-foreground">
{formData.type === 'buy'
? 'Fund from bank account (optional)'
: 'Credit proceeds to bank account (optional)'}
</label>
<Select
value={fundedByAccountId !== null ? String(fundedByAccountId) : ''}
onValueChange={v => setFundedByAccountId(v ? Number(v) : null)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="None — record trade only" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">None record trade only</SelectItem>
{accounts.filter(a => a.type === 'bank' && a.is_active).map(acc => (
<SelectItem key={acc.id} value={String(acc.id)}>{acc.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{addTradeError && (
<p className="text-sm text-destructive">{addTradeError}</p>
)}