diff --git a/CHANGELOG.md b/CHANGELOG.md index 04a83ca..cef92ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ Intermediate package/UI version bumps are grouped under the next published GitHu ### Fixed +- Investment overview uses a clearly named Gain on net invested ratio, a compact responsive summary/chart/allocation layout, and simpler selected-day gain tooltips. Remove requested explanatory copy and the duplicate privacy button (#171). +- Holding price charts display split-only-adjusted history and consistently adjusted trade dots, preventing artificial NVDA split crashes while retaining original executions and raw accounting inputs. Format normal chart prices to cents and preserve tiny-coin precision (#171). +- Apply holding popup design 2a: compact centered 600px layout, instrument header, value/gain summary, chart-first flow, six-cell position grid, readable source footer and smaller Buy/Sell actions. Remove gain bars and duplicate summary cards. Keep chart caption height stable when hovering between range returns and dates. Make the full holding row open details, retaining keyboard-accessible controls. Preserve split-adjusted trade markers, full quantity access and review history; only compatible current quotes can extend the display endpoint (#171). +- Put asset search/manual selection before category, pricing, trading currency and holding unit in new investment account setup (#171). +- Place cross-currency settlement confirmation after the full trade review in a prominent required-action card. Show read-only totals before acknowledgement while keeping saves blocked until confirmed (#171). +- Restrict Dashboard income, expense and transfer creation to cash accounts; new investment trades start on Investments. Ignore saved investment-account defaults while preserving canonical linked-trade editing from existing ledger rows (#171). + - Main/investment-epic integration preserves both existing 017 migration filenames, transaction split exports/history and direct investment trade paths. Split mutations share guarded decimal cash arithmetic so concurrent trades, pending confirmation and fractional payments retain exact balances (#169). - Investment price returns explicitly exclude dividends. Dividend events no longer block known purchase costs, gains or history or trigger misleading purchase-review warnings; split and missing-history safeguards remain (#161). diff --git a/api/src/services/investment-trade.service.ts b/api/src/services/investment-trade.service.ts index e2152f7..5a6f212 100644 --- a/api/src/services/investment-trade.service.ts +++ b/api/src/services/investment-trade.service.ts @@ -148,7 +148,9 @@ export class InvestmentTradeService { if (!trade) throw conflict() const {cash} = await this.pair(state,trade) if ((await this.repo.revision()) !== state.revision) throw conflict() - return {trade,cash_account_id:cash.account_id,source_revision:state.revision} + return {trade,cash_account_id:cash.account_id, + linked_cash:{amount:Math.abs(cash.amount),currency:state.accounts.find(a=>a.id===cash.account_id)!.currency}, + source_revision:state.revision} } private execute(input: TradeInput, clientDate: string | undefined, id: string | undefined, preview: false): Promise private execute(input: TradeInput, clientDate: string | undefined, id: string | undefined, preview: true): Promise<{account: Account; trades: InvestmentTransaction[]; trade_id:string;revision:number;cash_after:{account_id:string;currency:string;balance:number}}> diff --git a/client/src/components/common/modal.tsx b/client/src/components/common/modal.tsx index 9e70631..3309bde 100644 --- a/client/src/components/common/modal.tsx +++ b/client/src/components/common/modal.tsx @@ -6,8 +6,9 @@ interface ModalProps { isOpen: boolean onClose: () => void title?: string - subtitle?: string + subtitle?: React.ReactNode subtitleClassName?: string + headerLeading?: React.ReactNode initialFocus?: string headerClassName?: string contentClassName?: string @@ -16,7 +17,7 @@ interface ModalProps { placement?: 'center' | 'drawer' | 'centered' } -export function Modal({ isOpen, onClose, title, subtitle, subtitleClassName, initialFocus, headerClassName, contentClassName, children, className, placement = 'center' }: ModalProps) { +export function Modal({ isOpen, onClose, title, subtitle, subtitleClassName, headerLeading, initialFocus, headerClassName, contentClassName, children, className, placement = 'center' }: ModalProps) { React.useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === 'Escape' && isOpen) { @@ -85,7 +86,8 @@ export function Modal({ isOpen, onClose, title, subtitle, subtitleClassName, ini {/* Header */} {title && (
-
+ {headerLeading} +

{title}

{subtitle &&

{subtitle}

}
diff --git a/client/src/components/dashboard-module/AddAccountForm.tsx b/client/src/components/dashboard-module/AddAccountForm.tsx index 75ce3c9..316840d 100644 --- a/client/src/components/dashboard-module/AddAccountForm.tsx +++ b/client/src/components/dashboard-module/AddAccountForm.tsx @@ -60,7 +60,6 @@ export function AddAccountForm({ value, onChange, onTypeChange, onSubmit, onCanc })}
- {investment && } {investment &&

Asset

@@ -78,6 +77,7 @@ export function AddAccountForm({ value, onChange, onTypeChange, onSubmit, onCanc
}
} + {investment && }
diff --git a/client/src/components/dashboard-module/TransactionList.test.tsx b/client/src/components/dashboard-module/TransactionList.test.tsx index f5f75b4..5d1257a 100644 --- a/client/src/components/dashboard-module/TransactionList.test.tsx +++ b/client/src/components/dashboard-module/TransactionList.test.tsx @@ -119,103 +119,7 @@ function mockRates(rate = 0.2) { vi.mocked(apiFetch).mockImplementation(async () => ({ ok: true, json: async () => ({ rate }) }) as Response) } -it.each(['buy','sell'] as const)('keeps a same-fiat manual %s as 1 ITEM plus separate cash, including zero disposal proceeds',async type=>{ - mockRates();localStorage.clear() - const user=userEvent.setup() - const added=renderTransfers({composerOnly:true,openRequest:1,transactions:[],accounts:[...transferAccounts, - {id:'manual',name:'Whole manual asset',balance:type==='buy'?0:500,currency:'HUF',quote_currency:'HUF',type:'investment',asset_type:'manual'}]}) - await user.click(screen.getByRole('button',{name:'Transfer'})) - const dialog=screen.getByRole('dialog') - await user.selectOptions(within(dialog).getByRole('combobox',{name:'From account'}),type==='buy'?'source':'manual') - await user.selectOptions(within(dialog).getByRole('combobox',{name:'To account'}),type==='buy'?'manual':'source') - await user.type(within(dialog).getByLabelText('Whole asset (1 ITEM)'),'1') - await user.type(within(dialog).getByLabelText(type==='buy'?'Amount to Send (HUF)':'Amount to Receive (HUF)'),type==='buy'?'100':'0') - await user.type(within(dialog).getByLabelText('Executed total asset price'),type==='buy'?'100':'0') - await user.type(within(dialog).getByLabelText('Fee (HUF)'),'0') - await user.click(within(dialog).getByRole('button',{name:type==='buy'?'Record purchase':'Record sale'})) - await waitFor(()=>expect(added).toHaveBeenCalledOnce()) - const save=vi.mocked(apiFetch).mock.calls.find(([url,o])=>url==='/api/investment-trades'&&o?.method==='POST') - expect(JSON.parse(String(save?.[1]?.body))).toMatchObject({type,quantity:1,cash_amount:type==='buy'?100:0,price:type==='buy'?100:0,execution_currency:'HUF'}) -}) - -it.each([0,180])('edits a whole-asset disposal with %s proceeds using its unit direction rather than the cash sign',async proceeds=>{ - mockRates() - const user=userEvent.setup() - const added=renderTransfers({accounts:[...transferAccounts,{id:'manual',name:'Whole manual asset',balance:0,currency:'HUF',quote_currency:'HUF',type:'investment',asset_type:'manual'}],transactions:[ - {id:'cash-sale',account_id:'source',amount:proceeds,linked_transaction_id:'sale',date:'2020-01-01'}, - {id:'sale',account_id:'manual',amount:-proceeds,quantity:-1,price:proceeds,asset_event:'disposal',execution_currency:'HUF',fee_status:'known',fee_amount:0,trade_version:1,linked_transaction_id:'cash-sale',date:'2020-01-01'}, - ]}) - expect(screen.getByText('1 ITEM')).toBeInTheDocument() - await user.click(screen.getAllByRole('button',{name:'Edit transfer'})[0]) - const dialog=screen.getByRole('dialog') - expect(within(dialog).getByLabelText('Whole asset (1 ITEM)')).toHaveValue('1') - expect(within(dialog).getByLabelText('Amount to Receive (HUF)')).toHaveValue(String(proceeds)) - await user.type(within(dialog).getByLabelText('Note (optional)'),'Corrected note') - await user.click(within(dialog).getByRole('button',{name:'Save changes'})) - await waitFor(()=>expect(added).toHaveBeenCalledOnce()) - const save=vi.mocked(apiFetch).mock.calls.find(([url,o])=>url==='/api/investment-trades/sale'&&o?.method==='PUT') - expect(JSON.parse(String(save?.[1]?.body))).toMatchObject({type:'sell',quantity:1,cash_amount:proceeds,price:proceeds,expected_version:1}) -}) - describe('TransactionList redesigned transfers', () => { - it('records a sale with exact proceeds and preserves the draft and request key after a failed save', async () => { - mockRates() - localStorage.clear() - let attempts=0 - vi.mocked(apiFetch).mockImplementation(async (url,options) => { - if (url==='/api/investment-trades' && options?.method==='POST' && attempts++===0) throw new Error('Temporary failure') - return {ok:true,json:async()=>({})} as Response - }) - const user=userEvent.setup() - const added=renderTransfers({composerOnly:true,openRequest:1,transactions:[],accounts:[...transferAccounts, - {id:'holding',name:'Sale holding',balance:1.5,currency:'SHARE',quote_currency:'EUR',type:'investment'}]}) - await user.click(screen.getByRole('button',{name:'Transfer'})) - const dialog=screen.getByRole('dialog') - await user.selectOptions(within(dialog).getByRole('combobox',{name:'From account'}),'holding') - await user.selectOptions(within(dialog).getByRole('combobox',{name:'To account'}),'source') - await user.type(within(dialog).getByLabelText('Units to Sell'),'0.25') - await user.type(within(dialog).getByLabelText('Amount to Receive (HUF)'),'100') - await user.type(within(dialog).getByLabelText('Executed unit price'),'1') - await user.type(within(dialog).getByLabelText('Fee (HUF)'),'0') - await user.click(within(dialog).getByRole('button',{name:'Record sale'})) - expect(await within(dialog).findByText('Temporary failure')).toBeInTheDocument() - expect(within(dialog).getByLabelText('Units to Sell')).toHaveValue('0.25') - expect(within(dialog).getByLabelText('Amount to Receive (HUF)')).toHaveValue('100') - await user.click(within(dialog).getByRole('button',{name:'Record sale'})) - await waitFor(()=>expect(added).toHaveBeenCalledOnce()) - const saves=vi.mocked(apiFetch).mock.calls.filter(([url,o])=>url==='/api/investment-trades'&&o?.method==='POST').map(([,o])=>JSON.parse(String(o?.body))) - expect(saves).toHaveLength(2) - expect(saves[0]).toEqual(saves[1]) - expect(saves[0]).toMatchObject({type:'sell',quantity:0.25,cash_amount:100,execution_currency:'EUR',fee_amount:0}) - expect(vi.mocked(apiFetch).mock.calls.some(([url])=>String(url).includes('/market/'))).toBe(false) - }) - it('groups a reciprocal investment pair once and edits it through the cash transaction', async () => { - mockRates() - const user = userEvent.setup() - const added = renderTransfers({ - accounts: [...transferAccounts, { id: 'holding', name: 'Test holding', balance: 2.125, currency: 'SHARE', quote_currency: 'EUR', type: 'investment' }], - transactions: [ - { id: 'buy', account_id: 'holding', amount: 50, quantity: 0.125, price: 400, - linked_transaction_id: 'purchase-cash', date: '2020-01-01', description: 'Transfer from Savings' }, - { id: 'purchase-cash', account_id: 'source', amount: -100, linked_transaction_id: 'buy', - date: '2020-01-01', description: 'Transfer to Test holding' }, - ], - }) - expect(screen.getByText('1 posted')).toBeInTheDocument() - await user.click(screen.getAllByRole('button', { name: 'Edit transfer' })[0]) - const dialog = screen.getByRole('dialog') - expect(within(dialog).getByLabelText('Amount to Send (HUF)')).toHaveValue('100') - const quantity = within(dialog).getByLabelText('Units to Buy') - expect(quantity).toHaveValue('0.125') - await user.clear(quantity) - await user.type(quantity, '0.25') - await user.click(within(dialog).getByRole('button', { name: 'Save changes' })) - await waitFor(() => expect(added).toHaveBeenCalledOnce()) - const save = vi.mocked(apiFetch).mock.calls.find(([url, options]) => url === '/api/investment-trades/purchase-cash' && options?.method === 'PUT') - expect(save).toBeDefined() - expect(JSON.parse(String(save?.[1]?.body))).toMatchObject({ cash_amount:100,quantity:0.25,price:400,execution_currency:'EUR',fee_status:'unknown' }) - }) - it('previews posted edit deltas, preserves both native amounts when swapping, and reverts the full edit', async () => { mockRates() const user = userEvent.setup() @@ -302,29 +206,7 @@ describe('TransactionList redesigned transfers', () => { expect(JSON.parse(String(save?.[1]?.body))).toMatchObject({ from_account_id: 'source', to_account_id: 'other', amount_from: 12.5, amount_to: 12.5 }) }) - it('keeps investment quantities precise and includes the manual price in the save', async () => { - mockRates(0.001) - localStorage.clear() - const user = userEvent.setup() - const added = renderTransfers({ composerOnly: true, openRequest: 1, transactions: [], accounts: [ - ...transferAccounts, - { id: 'holding', name: 'Example holding', balance: 1.5, currency: 'UNIT', quote_currency: 'EUR', type: 'investment' }, - ] }) - await user.click(screen.getByRole('button', { name: 'Transfer' })) - const dialog = screen.getByRole('dialog') - await user.selectOptions(within(dialog).getByRole('combobox', { name: 'From account' }), 'source') - await user.selectOptions(within(dialog).getByRole('combobox', { name: 'To account' }), 'holding') - await user.type(within(dialog).getByLabelText('Amount to Send (HUF)'), '100') - const quantity = within(dialog).getByLabelText('Units to Buy') - await user.clear(quantity) - await user.type(quantity, '0.123456789123') - await user.type(within(dialog).getByLabelText('Executed unit price'), '123.45') - await user.type(within(dialog).getByLabelText('Fee (HUF)'), '0') - await user.click(within(dialog).getByRole('button', { name:'Record purchase' })) - await waitFor(() => expect(added).toHaveBeenCalledOnce()) - const save = vi.mocked(apiFetch).mock.calls.find(([url, options]) => url === '/api/investment-trades' && options?.method === 'POST') - expect(JSON.parse(String(save?.[1]?.body))).toMatchObject({ quantity:0.123456789123,price:123.45,execution_currency:'EUR',fee_status:'known',fee_amount:0,cash_amount:100 }) - }) + }) it('hides balances in cards, account options, projected balances, and insufficient-balance warnings', async () => { diff --git a/client/src/components/dashboard-module/TransactionList.trade-handoff.test.tsx b/client/src/components/dashboard-module/TransactionList.trade-handoff.test.tsx new file mode 100644 index 0000000..3254c34 --- /dev/null +++ b/client/src/components/dashboard-module/TransactionList.trade-handoff.test.tsx @@ -0,0 +1,80 @@ +import {cleanup,render,screen,waitFor,within} from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import {afterEach,beforeEach,expect,it,vi} from 'vitest' +import type {ComponentProps} from 'react' +import type {TradeEditor} from '../investments-module/TradeEditor' +import {TransactionList} from './TransactionList' +import {apiFetch} from '../../config' +const editor=vi.hoisted(()=>vi.fn()) +vi.mock('../investments-module/TradeEditor',()=>({TradeEditor:(props:ComponentProps)=>{ + editor(props) + return
+}})) +vi.mock('../../config',()=>({API_BASE_URL:'/api',apiFetch:vi.fn()})) +vi.mock('../../context/PrivacyContext',()=>({usePrivacy:()=>({privacyMode:'visible',shouldHideInvestment:()=>false})})) +vi.mock('../../context/AlertContext',()=>({useAlert:()=>({confirm:vi.fn(),showAlert:vi.fn()})})) +const accounts=[{id:'cash',name:'Cash',type:'cash' as const,balance:1000,currency:'HUF'}, + {id:'holding',name:'Coin',type:'investment' as const,balance:2,currency:'COIN',quote_currency:'EUR'}, + {id:'manual',name:'Gold',type:'investment' as const,asset_type:'manual' as const,balance:0,currency:'HUF'}] +function show(options:Partial>={}){ + const saved=vi.fn() + render( ) + return saved +} +beforeEach(()=>{localStorage.clear();editor.mockReset();vi.mocked(apiFetch).mockReset().mockResolvedValue(Response.json({rate:1}))}) +afterEach(cleanup) +it('only offers cash accounts on both sides of a new Dashboard transfer',async()=>{ + const user=userEvent.setup();show() + await user.click(screen.getByRole('button',{name:'Transfer',exact:true})) + for(const name of ['From account','To account']){ + const select=screen.getByRole('combobox',{name}) + expect(within(select).queryByRole('option',{name:/Coin|Gold/})).not.toBeInTheDocument() + expect(within(select).getByRole('option',{name:/Cash/})).toBeInTheDocument() + } + expect(editor).not.toHaveBeenCalled() + expect(vi.mocked(apiFetch).mock.calls.some(([,o])=>o?.method==='POST')).toBe(false) +}) +it.each(['finance_last_transfer_from','finance_last_transfer_to'])('ignores saved investment default %s without opening a trade editor',async key=>{ + localStorage.setItem('finance_last_transfer_from','cash');localStorage.setItem('finance_last_transfer_to','cash') + localStorage.setItem(key,'holding') + const user=userEvent.setup();show();await user.click(screen.getByRole('button',{name:'Transfer',exact:true})) + expect(screen.getByRole('combobox',{name:key.endsWith('from')?'From account':'To account'})).toHaveValue('') + expect(screen.queryByRole('dialog',{name:'Shared Buy/Sell'})).not.toBeInTheDocument() + expect(editor).not.toHaveBeenCalled() +}) +it('opens the canonical versioned editor for an existing investment transfer through either linked leg',async()=>{ + const user=userEvent.setup() + const edit={trade:{id:'buy',account_id:'holding',type:'buy',quantity:1,price:10,date:'2026-01-01',linked_transaction_id:'paid',trade_version:0},cash_account_id:'cash',source_revision:7} + vi.mocked(apiFetch).mockResolvedValue(Response.json(edit)) + show({composerOnly:false,openRequest:0,transactions:[{id:'paid',account_id:'cash',amount:-100,date:'2026-01-01',linked_transaction_id:'buy'}, + {id:'buy',account_id:'holding',amount:10,quantity:1,date:'2026-01-01',linked_transaction_id:'paid',trade_version:0}]}) + await user.click(screen.getAllByRole('button',{name:'Edit transfer'})[0]) + await screen.findByRole('dialog',{name:'Shared Buy/Sell'}) + expect(editor.mock.lastCall?.[0]).toMatchObject({edit,sourceRevision:7}) + expect(vi.mocked(apiFetch).mock.calls.some(([url])=>String(url).startsWith('/api/investment-trades/'))).toBe(true) +}) +it('keeps ordinary cash-transfer editing in the cash form with investment accounts excluded',async()=>{ + const user=userEvent.setup();show({composerOnly:false,openRequest:0,accounts:[...accounts,{id:'cash2',name:'Savings',balance:100,currency:'HUF',type:'cash'}],transactions:[ + {id:'out',account_id:'cash',amount:-10,date:'2026-01-01',linked_transaction_id:'in'}, + {id:'in',account_id:'cash2',amount:10,date:'2026-01-01',linked_transaction_id:'out'}]}) + await user.click(screen.getAllByRole('button',{name:'Edit transfer'})[0]) + const dialog=screen.getByRole('dialog') + expect(within(dialog).getByRole('heading',{name:'Edit Transfer'})).toBeVisible() + expect(within(dialog).queryByRole('option',{name:/Coin|Gold/})).not.toBeInTheDocument() + expect(editor).not.toHaveBeenCalled() +}) + +it('preserves the ledger view and never opens an editor when linked-trade validation fails',async()=>{ + const user=userEvent.setup() + vi.mocked(apiFetch).mockRejectedValue(new Error('The linked pair is inconsistent')) + show({composerOnly:false,openRequest:0,transactions:[ + {id:'paid',account_id:'cash',amount:-100,date:'2026-01-01',linked_transaction_id:'buy'}, + {id:'buy',account_id:'holding',amount:10,quantity:1,date:'2026-01-01',linked_transaction_id:'paid'}]}) + await user.click(screen.getAllByRole('button',{name:'Edit transfer'})[0]) + await waitFor(()=>expect(screen.queryByRole('status')).not.toBeInTheDocument()) + expect(editor).not.toHaveBeenCalled() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(screen.getByText('1 posted')).toBeVisible() +}) diff --git a/client/src/components/dashboard-module/TransactionList.tsx b/client/src/components/dashboard-module/TransactionList.tsx index 5295d22..b2f21bc 100644 --- a/client/src/components/dashboard-module/TransactionList.tsx +++ b/client/src/components/dashboard-module/TransactionList.tsx @@ -1,3 +1,5 @@ +import { TradeEditor } from '../investments-module/TradeEditor' +import type { TradeEdit } from '../investments-module/trade-editor' import { LegacyTradeDetails } from '../investments-module/LegacyTradeDetails' import type { TradeFacts } from '../../../../shared/investment-trade' import type { HoldingFields } from '../../../../shared/holding' @@ -174,6 +176,10 @@ export function TransactionList({ const [activeTxId, setActiveTxId] = useState(null) const [pendingDetails, setPendingDetails] = useState(null) const [legacyTradeId,setLegacyTradeId]=useState(null) + const [tradeEditor,setTradeEditor]=useState<{edit:TradeEdit}|null>(null) + const [loadingTrade,setLoadingTrade]=useState(false) + const tradeRead=useRef(null) + useEffect(()=>()=>tradeRead.current?.abort(),[]) const [formData, setFormData] = useState({ account_id: '', to_account_id: '', @@ -186,10 +192,6 @@ export function TransactionList({ manual_price: '', // For investment accounts - manual price override exclude_from_estimate: false }) - const [tradeDraft, setTradeDraft] = useState({ execution_currency:'', fee_status:'known' as 'known'|'unknown', fee_amount:'' }) - const originalTradeVersion = useRef(undefined) - const originalTradeDraft = useRef(null) - const tradeRequest = useRef<{ signature:string; key:string } | null>(null) const originalTransferRef = useRef<{ data: typeof formData; rate: number | null; rateDraft: string } | null>(null) const [transferSaveError, setTransferSaveError] = useState(null) const [exchangeRate, setExchangeRate] = useState(null) @@ -410,7 +412,7 @@ export function TransactionList({ // Load saved defaults when opening the form const loadSavedDefaults = (type: 'expense' | 'income' | 'transfer') => { - const availableAccount = (id: string) => accounts.some(account => account.id === id && !isLocked(id)) ? id : '' + const availableAccount = (id: string) => accounts.some(account => account.id === id && account.type === 'cash' && account.archived_at == null && !isLocked(id)) ? id : '' if (type === 'expense') { const savedAccount = localStorage.getItem(STORAGE_KEYS.expenseAccount) || '' const savedCategory = localStorage.getItem(STORAGE_KEYS.expenseCategory) || '' @@ -500,9 +502,9 @@ export function TransactionList({ useEffect(() => { if (openRequest > lastOpenRequest.current) { lastOpenRequest.current = openRequest - if (!isAdding) handleOpenForm() + if (!isAdding && !tradeEditor && !loadingTrade) handleOpenForm() } - }, [openRequest, isAdding, handleOpenForm]) + }, [openRequest, isAdding, tradeEditor, loadingTrade, handleOpenForm]) // Handle type change with saved defaults const handleTypeChange = (newType: 'expense' | 'income' | 'transfer') => { @@ -524,8 +526,10 @@ export function TransactionList({ field: 'account_id' | 'to_account_id', accountId: string, ) => { + if(accountId&&!accounts.some(account=>account.id===accountId&&account.type==='cash'&&account.archived_at==null&&!isLocked(account.id)))return resetTransferRateState(false) - setFormData(prev => ({ ...prev, [field]: accountId, amount_to: '', manual_price: '' })) + manuallyEditedTransferFieldsRef.current.amount_to=false + setFormData(prev => ({ ...prev, [field]: accountId, amount_to:'', manual_price: '' })) } // Which submit button was used: only expense and income entries can stay open for the next payment. @@ -652,26 +656,8 @@ export function TransactionList({ price = parsedPrice } - const holding = sourceAccount.type === 'investment' ? sourceAccount : toAccount.type === 'investment' ? toAccount : null - if (holding) { - if (sourceAccount.type === 'investment' && toAccount.type === 'investment') throw new Error('Choose one cash account and one holding') - const executedPrice = parseAmount(formData.manual_price) - const fee = tradeDraft.fee_status === 'known' ? parseAmount(tradeDraft.fee_amount) : null - if (executedPrice === null || executedPrice < 0 || (executedPrice === 0 && !(holding.asset_type === 'manual' && sourceAccount.type === 'investment'))) throw new Error('Enter the executed unit price') - if (tradeDraft.fee_status === 'known' && fee === null) throw new Error('Enter the included fee, including zero') - const body = { ...(editingId ? { expected_version:originalTradeVersion.current } : {}),holding_account_id:holding.id, cash_account_id:sourceAccount.type === 'investment' ? toAccount.id : sourceAccount.id, - type:sourceAccount.type === 'investment' ? 'sell' : 'buy', quantity:sourceAccount.type === 'investment' ? amount : amountTo, - cash_amount:sourceAccount.type === 'investment' ? amountTo : amount, price:executedPrice, - execution_currency:tradeDraft.execution_currency, fee_status:tradeDraft.fee_status, fee_amount:fee, - date:formData.date, notes:formData.description } - const signature = JSON.stringify([editingId,body]) - if (tradeRequest.current?.signature !== signature) tradeRequest.current = { signature,key:crypto.randomUUID() } - await apiFetch(`${API_BASE_URL}/investment-trades${editingId ? `/${editingId}` : ''}`, { - method:editingId ? 'PUT' : 'POST', headers:{ 'Content-Type':'application/json' }, - body:JSON.stringify({ ...body,idempotency_key:tradeRequest.current.key }) }) - tradeRequest.current = null - setEditingId(null) - } else if (editingId) { + if(sourceAccount.type==='investment'||toAccount.type==='investment')throw new Error('Use the Buy / Sell editor for investment trades.') + if (editingId) { await apiFetch(`${API_BASE_URL}/transactions/${editingId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, @@ -835,7 +821,7 @@ export function TransactionList({ } } - const handleEdit = (tx: Transaction) => { + const handleEdit = async (tx: Transaction) => { // Prevent editing if the account is locked if (isLocked(tx.account_id)) { return @@ -862,6 +848,18 @@ export function TransactionList({ const relatedTx = (tx as Transaction & { relatedTx?: Transaction }).relatedTx || (tx.linked_transaction_id ? allKnownTransactions.find(t => t.id === tx.linked_transaction_id) : undefined) + if(tx.linked_transaction_id && (accounts.some(a=>a.type==='investment'&&(a.id===tx.account_id||a.id===relatedTx?.account_id)))) { + tradeRead.current?.abort(); const controller=new AbortController();tradeRead.current=controller;setLoadingTrade(true) + try { + const response=await apiFetch(`${API_BASE_URL}/investment-trades/${encodeURIComponent(tx.id)}`,{throwOnError:true,signal:controller.signal}) + const edit=await response.json() as TradeEdit + const shownTrade=accounts.some(a=>a.id===tx.account_id&&a.type==='investment')?tx:relatedTx + if(!edit.trade || (edit.trade.id!==tx.id&&edit.trade.linked_transaction_id!==tx.id) || (shownTrade?.trade_version!=null&&shownTrade.trade_version!==edit.trade.trade_version) || !Number.isSafeInteger(edit.trade.trade_version) || !accounts.some(a=>a.id===edit.trade.account_id&&a.type==='investment') || !accounts.some(a=>a.id===edit.cash_account_id&&a.type==='cash'))throw new Error('Investment records changed. Refresh and reopen the trade.') + if(!controller.signal.aborted){setIsAdding(false);setTradeEditor({edit})} + }catch(error){if(!controller.signal.aborted)showAlert({type:'error',message:error instanceof Error?error.message:'Could not read the linked trade'})} + finally{if(!controller.signal.aborted)setLoadingTrade(false)} + return + } resetTransferRateState() if (tx.linked_transaction_id && relatedTx) { @@ -875,15 +873,6 @@ export function TransactionList({ const incomingValue = incomingAccount?.type === 'investment' && incoming.quantity !== undefined ? Math.abs(incoming.quantity) : Math.abs(incoming.amount) - const investmentRow = outgoingAccount?.type === 'investment' ? outgoing : incoming - const investmentPrice = investmentRow.price !== undefined - ? formatNumber(investmentRow.price) - : '' - const tradeRow = outgoingAccount?.type === 'investment' ? outgoing : incomingAccount?.type === 'investment' ? incoming : undefined - originalTradeVersion.current = tradeRow?.trade_version ?? 0 - const draft = { execution_currency:tradeRow?.execution_currency || (outgoingAccount?.type === 'investment' ? outgoingAccount : incomingAccount)?.quote_currency || '', - fee_status:tradeRow?.fee_status || 'unknown' as 'known'|'unknown', fee_amount:tradeRow?.fee_amount == null ? '' : formatAmount(tradeRow.fee_amount) } - setTradeDraft(draft); originalTradeDraft.current = draft; tradeRequest.current = null const existingRate = Math.abs(outgoing.amount) > 0 ? incomingValue / Math.abs(outgoing.amount) : null @@ -897,7 +886,7 @@ export function TransactionList({ description: transferNote, date: outgoing.date, type: 'transfer' as const, - manual_price: investmentPrice, + manual_price: '', exclude_from_estimate: false } setFormData(transferData) @@ -1137,19 +1126,7 @@ export function TransactionList({ return acc ? acc.currency : 'HUF' } - const selectedHoldingId = formData.type === 'transfer' ? accounts.find(a => a.type === 'investment' && (a.id === formData.account_id || a.id === formData.to_account_id))?.id : undefined - const selectedHoldingQuote = accounts.find(a => a.id === selectedHoldingId)?.quote_currency || '' - useEffect(() => { - if (!editingId) setTradeDraft({ execution_currency:selectedHoldingQuote,fee_status:'known',fee_amount:'' }) - },[selectedHoldingId,selectedHoldingQuote,editingId]) - const handleTransferPatch = (patch: Partial) => { - const { execution_currency,fee_status,fee_amount,...regular } = patch - setFormData(prev => ({ ...prev,...regular })) - setTradeDraft(prev => ({ ...prev, - ...(execution_currency !== undefined ? { execution_currency } : {}), - ...(fee_status !== undefined ? { fee_status } : {}), - ...(fee_amount !== undefined ? { fee_amount } : {}) })) - } + const handleTransferPatch = (patch: Partial) => setFormData(prev=>({...prev,...patch})) // For transfer preview const fromAccount = accounts.find(a => a.id === formData.account_id) @@ -1163,7 +1140,6 @@ export function TransactionList({ (['account_id', 'to_account_id', 'date', 'description'] as const).some(key => formData[key] !== originalTransfer.data[key]) || (['amount', 'amount_to', 'manual_price'] as const).some(key => parseAmount(formData[key]) !== parseAmount(originalTransfer.data[key])) || parseAmount(exchangeRateDraft) !== parseAmount(originalTransfer.rateDraft) - || !!originalTradeDraft.current && JSON.stringify(tradeDraft) !== JSON.stringify(originalTradeDraft.current) ) // Posted balances already include the old legs. Undo them before previewing // the replacement, including when either selected account has changed. @@ -1237,7 +1213,6 @@ export function TransactionList({ manuallyEditedTransferFieldsRef.current = { amount_to: true, manual_price: !!originalTransfer.data.manual_price } editedTransferPairRef.current = getTransferPairKey(originalTransfer.data.account_id, originalTransfer.data.to_account_id) setFormData({ ...originalTransfer.data }) - if (originalTradeDraft.current) setTradeDraft({ ...originalTradeDraft.current }) setExchangeRate(originalTransfer.rate) setExchangeRateDraft(originalTransfer.rateDraft) setIsLoadingRate(false) @@ -1537,7 +1512,7 @@ export function TransactionList({ } a.type==='cash')} + data={formData} editing={!!editingId} reviewDraft={editingReviewTransfer} dirty={transferDirty} @@ -1573,10 +1546,6 @@ export function TransactionList({ onUseMarket={() => suggestedRate && handleTransferRateChange(formatCalculatedAmount(suggestedRate, { maximumFractionDigits: 12 }))} onSwap={handleSwapTransfer} onChange={handleTransferPatch} - onPriceChange={manualPrice => { - manuallyEditedTransferFieldsRef.current.manual_price = true - setFormData(prev => ({ ...prev, manual_price: manualPrice })) - }} onRevert={handleRevertTransfer} onCancel={handleCancel} /> @@ -1803,6 +1772,11 @@ export function TransactionList({
} } + {loadingTrade&&

Loading linked trade…

} + {tradeEditor&&({...a,updated_at:0}))} {...tradeEditor} + quotes={{}} rates={{}} hidden={privacyMode==='hidden'||shouldHideInvestment()} currency={masterCurrency} + sourceRevision={tradeEditor.edit?.source_revision} onClose={()=>{setTradeEditor(null);resetForm()}} + onSaved={async()=>{await onTransactionAdded()}}/>} {legacyTradeId&&
-

Return shows unrealized gain and its percentage of remaining cost. Filters affect holdings only.

@@ -43,7 +42,7 @@ export function HoldingsList(props:Props) { const timestamp=market?q?.as_of:a.manual_valuation?.as_of??a.manual_price_date const freshness=quotePresentation(q),sourceTime=holdingSourceTime(timestamp),priceLabel=market?(freshness.label==='Price as of'?'As of':freshness.label):'Valued' const gain=p.metrics.unrealized_gain,returnPercent=p.metrics.unrealized_gain_percent - return + return {if(!(event.target as Element).closest('button'))onOpenDetail(a)}} className="grid cursor-pointer grid-cols-[minmax(0,1fr)_minmax(0,1fr)_2.75rem] items-center gap-x-3 gap-y-2 px-4 py-4 transition-colors hover:bg-secondary/20 sm:px-5 @min-[900px]:table-row @min-[900px]:p-0"> - ))} +
+
+

Price

+

{close!=null?money(close):date?'Daily close unavailable':'Price unavailable'}

+ {/* Keep both captions in the same grid cell so wrapped range text reserves + its height while the selected date is visible. */} +
+

{holdingSourceTime(date)?.short??date}

+

0?'text-success':'text-destructive'}`}> + {change!=null&&percent!=null&&<>{change>0?'+':''}{money(change)} ({percent>0?'+':''}{percent.toLocaleString('hu-HU',{minimumFractionDigits:2,maximumFractionDigits:2})}%) · {{'1d':'past day','5d':'past 5 days','1mo':'past month','6mo':'past 6 months','1y':'past year','5y':'past 5 years','max':'past 10 years'}[range]}} +

+
+
+
+ {RANGES.map(([value,label])=>)}
-
-

- {close != null ? money(close) : date ? 'Daily close unavailable' : 'Price unavailable'} - {selected?.key === key && ( - {date} - )} -

- {change !== null && percent !== null && ( -

- {change >= 0 ? '+' : ''} - {money(change)} ({percent.toFixed(2)}%) · selected range -

- )} - {splitInRange && ( -

Range change unavailable across split events.

- )} -
- {history && ( -

- Yahoo Finance · daily unadjusted close · excludes dividends · {history.currency} ·{' '} - {history.status === 'stale' || error ? 'Saved history; refresh unavailable' : 'Retrieved'}{' '} - {history.retrieved_at} - {last?.provisional ? ' · current session provisional' : ''} -

- )} - {history?.events.some(event => event.type === 'split') && ( -

- Stock splits occur in this range. Price history alone does not establish complete portfolio - returns. -

- )} - {error && (

{error}{' '} @@ -213,7 +204,7 @@ export function InvestmentChart({ {history ? 'Refreshing price history…' : 'Loading price history…'}

)} -
+
{!points.length ? (

{loading ? 'Loading daily observations…' : 'No known daily closes in this range.'} @@ -243,33 +234,39 @@ export function InvestmentChart({ setSelected({ key, date: new Date(time).toISOString().slice(0, 10) }) }} onMouseLeave={() => setSelected(null)} - margin={{ left: 0, right: 6, top: 12, bottom: 0 }} + margin={{ left: 6, right: 6, top: 8, bottom: 0 }} > - new Date(Number(t)).toISOString().slice(5, 10)} - minTickGap={38} + tickFormatter={t=>new Intl.DateTimeFormat(undefined,{month:'short',...(history&&Date.parse(history.to)-Date.parse(history.from)>200*86400000?{year:'numeric' as const}:{day:'numeric' as const}),timeZone:'UTC'}).format(new Date(Number(t)))} + tickLine={false} + axisLine={false} + tickCount={4} + minTickGap={50} tick={{ fontSize: 10 }} /> Number(n).toLocaleString(undefined, { maximumFractionDigits: 6 })} + tickFormatter={(n) => Number(n).toLocaleString(undefined, { maximumFractionDigits: Math.abs(Number(n))>=1?2:12 })} /> { const p = (active ? payload?.[0]?.payload : undefined) as - { date?: string; close?: number; time?: number } | undefined + { date?: string; close?: number; time?: number; dateOnly?: boolean; markerPrice?:number } | undefined const d = p?.date ?? (p?.time ? new Date(p.time).toISOString().slice(0, 10) : null) return d ? (

{d}

- {p?.close != null &&

Daily close: {money(p.close)}

} + {p?.dateOnly &&

Date only · execution price unavailable in chart currency

} + {p?.close != null &&

Split-adjusted close: {money(p.close)}

} + {p?.markerPrice!=null&&!p.dateOnly&&

Split-adjusted execution: {money(p.markerPrice)}

} {trades .filter((t) => t.date === d) .map((t) => ( @@ -294,18 +291,22 @@ export function InvestmentChart({ } + onClick={entry => setSelected({key, date: entry.date})} /> )} {sells.length > 0 && ( } + onClick={entry => setSelected({key, date: entry.date})} /> )} {currentCost !== null && ( @@ -320,19 +321,14 @@ export function InvestmentChart({ )}
-

- Buy / Sell markers use recorded execution prices in {history?.currency ?? 'quote currency'}. - Unverified or other-currency executions remain in the dated list below. -

-

- Current average remaining cost:{' '} - {currentCost === null - ? 'Unavailable in this chart currency' - : costPrice(currentCost, history?.currency ?? '')} - . Dashed reference is today's remaining basis per unit, not a historical cost series. -

+ {(trades.length>0||currentCost!=null)&&
+ {buys.length>0&&Your buys · {buys.length}} + {sells.length>0&&Your sells · {sells.length}} + {currentCost!=null&&Average cost {money(currentCost)}} + {markers.some(t => t.dateOnly) && ○ Date only} +
} {dates.length > 0 && ( -