Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
ffdd531
Show gain on net invested in portfolio summary
31b4 Oct 19, 2026
c850eae
Remove investment explanatory copy requested in review
31b4 Oct 19, 2026
904fb35
Move allocation below portfolio chart and summary
31b4 Oct 19, 2026
ecf8b30
Emphasize total gain in portfolio history tooltip
31b4 Oct 19, 2026
819f92c
Compact investment overview into balanced responsive sections
31b4 Oct 19, 2026
2e5d422
Remove duplicate investment privacy button
31b4 Oct 19, 2026
426dcdc
Center holding details and show all dated trade markers
31b4 Oct 19, 2026
193d754
Expose historical review for versioned linked legacy trades
31b4 Oct 19, 2026
0e19d6b
Prefill legacy trade settlement from verified linked cash
31b4 Oct 19, 2026
5812242
Use the shared buy sell editor for investment transfers
31b4 Oct 19, 2026
ce27ae8
Give holding details a clear value gain and position hierarchy
31b4 Oct 20, 2026
790e219
Display split-adjusted holding charts and aligned trade markers
31b4 Oct 20, 2026
d7ac6ed
Show gain breakdown as visible zero centered comparison cards
31b4 Oct 20, 2026
b534893
Implement holding detail design 2a with compact chart first layout
31b4 Oct 20, 2026
99825de
Keep holding chart caption height stable on hover
31b4 Oct 20, 2026
a7a280d
Revert "Keep holding chart caption height stable on hover"
31b4 Oct 20, 2026
be0a485
Reapply "Keep holding chart caption height stable on hover"
31b4 Oct 20, 2026
46bf542
Open holding details from the whole row
31b4 Oct 20, 2026
ba6b323
Restrict Dashboard transfer creation to cash accounts
31b4 Oct 20, 2026
ac00fd9
Place settlement confirmation after the trade review
31b4 Oct 20, 2026
8d27a0d
Show asset selection before investment identity fields
31b4 Oct 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
4 changes: 3 additions & 1 deletion api/src/services/investment-trade.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TradeResult>
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}}>
Expand Down
8 changes: 5 additions & 3 deletions client/src/components/common/modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -85,7 +86,8 @@ export function Modal({ isOpen, onClose, title, subtitle, subtitleClassName, ini
{/* Header */}
{title && (
<div className={cn("flex shrink-0 items-center justify-between gap-3 p-4 sm:p-6 border-b border-border/50", subtitle && "px-5 py-3 sm:px-6 sm:py-3", headerClassName)}>
<div className="min-w-0">
{headerLeading}
<div className="min-w-0 flex-1">
<h2 id={titleId} className={subtitle ? "text-xs text-muted-foreground" : "text-lg sm:text-xl font-semibold text-foreground"}>{title}</h2>
{subtitle && <p className={cn("mt-0.5 truncate text-[17px] font-semibold tracking-tight", subtitleClassName)}>{subtitle}</p>}
</div>
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/dashboard-module/AddAccountForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,6 @@ export function AddAccountForm({ value, onChange, onTypeChange, onSubmit, onCanc
})}
</div>
</fieldset>
{investment && <HoldingAccountFields value={value} onChange={onChange} section="identity" />}
{investment && <section aria-label="Investment asset" className="space-y-2">
<div className="flex items-baseline justify-between gap-3">
<h3 className="text-[13px] font-medium">Asset</h3>
Expand All @@ -78,6 +77,7 @@ export function AddAccountForm({ value, onChange, onTypeChange, onSubmit, onCanc
<button type="button" onClick={onManualAsset} className="text-xs text-primary hover:underline disabled:opacity-50">Enter manually</button>
</div>}
</section>}
{investment && <HoldingAccountFields value={value} onChange={onChange} section="identity" />}
<div className="space-y-2">
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
<Label htmlFor="create-account-balance" className="text-[13px]">{marketAsset ? 'Starting quantity' : 'Starting balance'}</Label>
Expand Down
120 changes: 1 addition & 119 deletions client/src/components/dashboard-module/TransactionList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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 () => {
Expand Down
Loading
Loading