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:
co-authored by
Claude Sonnet 4.6
parent
bc256f6c6e
commit
e36d719cbe
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user