Aatlas labs
26 chains · 29 exchanges · liveSign inStart free
Changelog

What's new.

Every notable change across the Atlas Labs platform, newest first. Pick an app to see its release history — each entry comes straight from that app's own changelog.

Production Ledger domain now routes through the hq-fs.com login flow

Fixed
  • Production Ledger now uses ledger.hq-fs.com for login callbacks. The live Vercel deployment was rebuilt with the corrected Ledger, Auth, Home, and Docs URLs, so opening Ledger sends users to Auth with a ledger.hq-fs.com callback instead of a stale non-existent hostname.

Recon engine credits now gate paid runs and gain operator pricing controls

Added
  • Recon credit pricing is now operator-configurable. Atlas Ledger now reads reconciliation AI pricing from a dedicated runtime config instead of a hardcoded table, letting Atlas operators define the per-model token rates, shared markup, and the default portfolio credit-cap setting that future portfolio credit controls can inherit.
Changed
  • Reconciliation engine runs can now enforce AI-credit budgets when the credits rollout flag is enabled. Before a run starts, Atlas Ledger estimates the run's charged AI spend from the selected strength preset and row cap, then blocks the run if the user-level credit pool or the portfolio's spend cap would be exceeded. Successful paid runs now debit the final charged usage automatically after the manifest is written; when the flag is off, runs remain free and usage is still recorded for analytics.
  • The engine settings cost preview now shares the same run-volume assumptions as the server-side credit guard. The calls and token estimate shown on the settings page is now driven by the same preset-based run estimator used by the paid-run budget check, so the pre-run preview and the backend guard stay aligned.
  • Settled recon-credit purchases can now top up the ledger-side credit pool. Atlas Ledger exposes an internal settlement endpoint for the auth server, and purchase references are now applied idempotently so duplicate settlements do not double-credit the user balance.
  • Portfolio Settings now includes a direct recon-engine entry point, and both settings surfaces use the full working width. Operators can now reach Recon Engine from Portfolio Settings without knowing the URL, the recon page behaves like a child of Portfolio Settings instead of a detached page, and both the main Portfolio Settings screen and the Recon Engine settings screen now spread their content across the available page width instead of being capped to a narrow central column.
  • Ready-to-post buckets can now be batch-accepted without opening every row one by one. The Recon Engine surface now offers a confirmed batch-post action for the active Ready bucket, posts only the rows whose journal bodies can be derived safely from queue data, stamps engine provenance onto every posted entry, and skips any row that still needs manual FMV, fee, or account choices instead of guessing.

Reconciliation engine strength controls and spend analytics

Added
  • Reconciliation engine runs now have a strength dial and row cap. The engine settings page can choose Eco, Balanced, Thorough, Intensive, Max, or Ultra before the next run, pairing a model ceiling with an effort tier so operators can trade cost for deeper review. The same page now caps rows per run from 1 to 500 and shows an estimated calls/tokens preview before any provider is called.
  • Reconciliation AI spend is now tracked per run and visible in analytics. Every successful provider call writes a model-usage record with input/output tokens, at-cost spend, charged spend, and 40% margin. The portfolio Analytics page now shows a Recon AI spend card with calls, tokens, model breakdown, and charged-vs-cost totals.
Changed
  • AI reconciliation now respects the selected strength preset. Engine runs pass the chosen effort tier into the specialist ladder, clamp it to each model's supported maximum, and stop escalation at the preset's model ceiling. Qwen remains support-only, GPT-5.5 sits below Opus, and provider-specific effort controls are translated at the adapter boundary.

Lot groups: share or isolate a journal's cost-basis pool

Added
  • Lot groups — share or isolate a journal's cost-basis pool. Each journal (wallet, exchange, or brokerage) can now be placed in one of three cost-basis tiers from a new Lot scope control on its detail page: Portfolio (the default — shares the portfolio-wide pool with every other Portfolio-tier journal), Group (shares an isolated pool with only the other journals you put in the same group), or Individual (fully isolated — its lots are never shared). A built-in Manage groups dialog lets you create, rename, and delete groups; a group can only be deleted once no journals belong to it. Changing a journal's tier applies to future lots only — existing lots keep the scope they were tagged with when they were created.
  • Position cards now show market value where a price is available. Each per-asset balance tile on a wallet or exchange (CEX) journal now shows the position's current market value (quantity × live spot price, in your portfolio currency) beneath the quantity. The value line appears only when a real price resolves for that asset — illiquid or unpriced tokens (LP tokens, scam/airdrop tokens, and the like) show the quantity alone, never a misleading zero or a borrowed price. Brokerage instrument tiles show value too (quantity × the security's average cost, until a live securities-price feed lands).
  • Manual price override — set a price for an asset whose market value can't be auto-resolved (e.g. illiquid tokens), so its holdings value correctly. On any wallet or exchange (CEX) journal, each per-asset tile now has a small pencil control to set a manual price per unit (with an optional note). Once set, that price drives the tile's displayed value and the journal's market-value / unrealized-P&L figures, and it wins over the live price if one exists. A small amber dot marks any value that comes from a manual price so it isn't mistaken for a live quote. The override is valuation-only — it affects only displayed market value, never your cost basis, posted transactions, or realized profit/loss — and can be cleared at any time.
  • Reset cost basis & switch cost-basis method (FIFO/LIFO/HIFO/AVCO) — re-derives the whole chain. Two related controls now safely recompute realized profit/loss across your entire history. Switching the cost-basis method (in Settings → Finance) no longer locks after your first disposal: changing it re-derives every disposal under the new method — re-matching which lots each sale consumed and rewriting each disposal's realized gain/loss accordingly. Reset cost basis (Settings → Danger zone) re-runs the same full rebuild under your current method from scratch — useful after a bulk import or correction. Both are deterministic and idempotent, keep every journal entry balanced, and never cross isolated lot pools (group/individual journals stay separate) or touch staked principal. A confirmation dialog guards the reset, and a toast reports how many disposals were reprocessed.
Changed
  • Manual price overrides now value holdings at the portfolio level, not just on a journal's per-asset tile. Net worth, holdings, and the wealth/family-office rollups now mark an asset's remaining quantity to your manual price (e.g. an illiquid token like SHARK with no live quote), so the portfolio value reflects it everywhere. Override wins over any live quote; cost basis and realized P&L are untouched.
  • Cashflow view unified across all journals. The per-asset Cashflow drawer now works the same way on every journal type — wallet, exchange (CEX), bank, and brokerage. Each shows the asset's Received / Spent / Balance and a complete list of the transactions that moved it, all drawn from one consistent source so the totals and the listed rows always agree. Exchange cashflow in particular previously computed its row list and its totals from two different places, which could disagree; they now match exactly. Bank and brokerage journals gain the Cashflow view for the first time.
  • Bank cashflow is now organised by expense category. A bank account is single-currency, so the old per-currency cashflow tile said nothing useful. The bank journal dashboard now shows one tile per spending / income category (Groceries, Salary, Rent, and so on — drawn from how each entry was classified), with that category's net cash for the journal. Clicking a category opens the cashflow drawer listing every bank transaction filed under it, money-in vs money-out.
  • Brokerage cashflow is now organised by asset class. The brokerage journal dashboard now groups holdings under Equities · Funds/ETFs · Fixed Income · Derivatives · Cash, with one tile per instrument (each ticker, each settlement currency, each open derivative contract) showing units held and value. Clicking a tile opens that instrument's cashflow — shares in/out for securities, money in/out for cash, contracts opened/closed for derivative positions. (Full premium/settlement attribution for derivative contracts is a follow-up; their cash flows currently appear under the Cash tile.)
Fixed
  • The account-report drill-in no longer crashes on a crypto account. Opening the Reports drill-in for a crypto-asset account (e.g. a SHARK or BNB ledger account) threw a "Invalid currency code" error, because the account's Total DR / Total CR / Balance were being formatted as if the token symbol were a fiat currency. Crypto-denominated amounts now render as a token quantity (with the symbol) and only true fiat amounts use currency formatting, so the drill-in opens for every account type.
  • Reopening a trade with a tax now remembers whether you entered it as Net or Gross and restores the original amounts. A taxed crypto trade now records the Net (tax on top) / Gross (tax inside the amount) choice you made, along with which side the tax sat on. When you reopen the trade to edit it, the form comes back with that exact toggle and the original figure you typed — a Gross trade shows the full gross amount you entered, a Net trade shows the swap amount with the tax added on top — so re-saving with no other change reproduces the same total disposed and the same balance either way. (Previously every taxed trade reopened as "Net" regardless of how it was entered, which could show a different amount than you originally typed.)
  • Editing a trade that has a tax now keeps the tax — reopening restores the tax amount, asset and unit price instead of silently dropping it on save. Previously, opening a crypto trade that carried a manual treasury/token tax brought the edit form back with the tax toggle switched off and the Net/Gross choice reset to its default, so saving the trade again quietly discarded the tax (or mis-applied it). The form now reloads the tax exactly as posted — toggle on, with the correct tax amount, asset, and per-unit price — and restores it as "Net (tax on top)" so re-saving with no other change reproduces the same total disposed and the same balance. (The send amount is restored as the swap itself, not the swap-plus-tax combined figure, so the tax is added back on top rather than double-counted.)
  • Adding a tax to a trade with "Net (tax already taken)" now correctly adds the tax on top of the trade amount instead of carving it out — the total disposed and balance now reflect the tax. On a crypto trade, the posting preview ignored the Net/Gross choice: with "Net" selected it should show the entered swap amount with the tax added on top (so the total disposed = swap + tax), and with "Gross" it should show the swap carved out of the entered amount (total disposed = entered). The preview now mirrors the posting exactly for both modes — the send/receive legs, the separate tax disposal, and a new "Total disposed" / "Kept (net of tax)" summary line all reflect the true balance impact before you save.
  • The wallet journal card balance now honours your manual price override. The journals-list card valued holdings at the live market quote only, so an asset you'd manually re-priced (e.g. an illiquid token) showed its live value on the card while the journal detail page showed your mark — the two disagreed. The card now applies the override (wins over the live quote), matching the detail page and your intended valuation.
  • Cost-basis rebuild now recomputes a sale's gain/loss even when an edit flips it between profit and loss. When switching cost-basis method or resetting cost basis caused a sale that was originally a profit to become a loss (or the reverse), the rebuild previously couldn't rewrite that entry — it left the figure unchanged and flagged the entry for review. It now creates the correctly-signed realized gain or loss line for that sale automatically (using your portfolio's existing Realized Gain / Realized Loss accounts for that asset), keeps the entry balanced, and reports the corrected amount — no manual review needed. A rebuild now also offers a preview (dry-run) that reports exactly how each sale's cost basis and realized gain/loss would change, without altering any of your data.
  • Prices now accept full precision — micro-cap unit prices (e.g. tokens worth a fraction of a cent) are no longer rounded to zero. Manual price overrides and transaction prices keep all entered decimals. Previously a per-unit price smaller than one hundred-millionth (0.00000001) — common for high-supply micro-cap tokens like a meme coin trading at 0.0000000005689 — was rounded to zero, which failed the "must be a positive number" check and silently reverted the field to the live quote. Per-unit price, rate, and fair-value fields throughout the app now store the value exactly as typed, and the prices auto-derived from a swap's two legs keep their full precision too.
  • Editing a transaction now recomputes downstream cost basis automatically instead of refusing. Previously, editing a trade or other entry whose received assets had already been spent in a later transaction was blocked — you had to void and recreate it, or unwind the later transactions first. Now a financial edit (amount, price, asset) of such a transaction simply works: it applies your change and automatically re-derives the whole downstream chain, re-matching which lots each later sale consumed and updating their realized gain/loss. Metadata edits (description, date, reference, transaction hash, source) still apply in place with no recalculation. In the rare case where a downstream sale's result flips between a gain and a loss in a way that can't be auto-balanced, that single entry is flagged for review rather than silently changed.
  • The cashflow view now has a clear Edit action, and the whole row opens the editor. Clicking anywhere on a transaction row in a journal's Cashflow drawer now opens that transaction in the edit modal (previously only the small date/type area was clickable, so most clicks did nothing), and each row has an explicit edit (pencil) button. Ctrl/Cmd-click and middle-click still open the transaction's detail page in a new tab.
  • Editing an entry from the cashflow view now works for any transaction, including older ones. Clicking a row in an asset's Cashflow drawer now opens that transaction for editing regardless of how old it is or how far down the list it sits — previously only entries on the first loaded page opened for editing, and older ones simply navigated away. This now works across wallet, exchange, and bank journals. (Ctrl/Cmd-click or middle-click still opens the transaction in a new tab.)
  • Token-approval entries now prefill the network coin's price. When reconciling an ERC-20 approval (a gas-only on-chain action), the Unit FMV field arrived empty because the form was looking at the approved token's price rather than the chain-native coin actually spent on gas. It now pre-fills the captured native-coin price (e.g. BNB), so approvals price correctly without manual entry — including older blocks the free price service can't look up.
  • Per-asset cashflow now lists every transaction it counts. On a wallet journal, opening an asset's Cashflow drawer (e.g. "Cake — Cashflow") could show Received/Spent totals that didn't match the listed rows: older movements — such as an airdrop beyond the first page of entries — were counted in the totals but missing from the list. The drawer's rows and its Received/Spent/Balance cards now come from a single complete dataset for that asset, so anything in the totals appears as a row. Each row links to its transaction (Ctrl/Cmd-click opens in a new tab); a plain click opens it for editing.

Staking imports reconstruct principal + reward · gas-only price prefill

Added
  • Staking imports now reconstruct the staked principal and harvested reward from the full transaction receipt. When you stake into a single-asset farm that pays its rewards in the same token (e.g. Seedify SFUND), the import only captured the small reward leg and showed it as the "Quantity staked" — the much larger principal you actually staked was lost. The reconcile form now reads the transaction's on-chain staking event and fills in both legs: the full principal as the staked amount, and the reward in the harvest companion panel, so the entry arrives complete and correctly split.
Changed
  • Deleting a posted journal entry now voids it instead of erasing it. Across every journal type — wallet, exchange (CEX), bank, and brokerage — deleting a posted entry now performs a void: the entry and its ledger postings are kept on record (the audit trail accountants and auditors need), marked void, and excluded from balances. Previously exchange deletes erased the entry outright and bank deletes erased the bank-side mirror; both now preserve the row. Genuine hard delete remains available only for unposted items in the reconciliation queue. Delete buttons and confirmations on posted entries now read Void and explain that the entry stays on record.
  • Voided entries moved to a dedicated "Voided" view. Voided entries no longer clutter the main entries list. Each journal now has a separate, read-only Voided tab/toggle that lists only voided entries. The main list shows live entries only. On wallet journals this replaces the old "Show voided" checkbox that interleaved them inline.
Fixed
  • Gas-only transactions (approvals, failed swaps) prefill their native-token price when edited. Token-approval and fee entries paid in the chain's native coin (BNB, ETH, etc.) now carry the price the importer captured, so re-opening one to edit shows its unit price already filled in instead of a blank "price required" field — including for older blocks the free price service can't look up.
  • Network fees on wallet transfers are recorded in the chain's native coin, not the token sent. Reconciling an on-chain token transfer from a wallet (e.g. sending CAKE to an exchange on BNB Chain) showed the gas fee labelled in the token (CAKE) when the fee was actually paid in the chain's native coin (BNB). Wallet transfers now always record the network fee in the native coin. Transfers sent from an exchange still record their fee in the asset sent, as exchanges charge it.
  • The transfer form now shows the network fee in the correct coin. The Network fee field on a wallet transfer previously labelled the fee with the token being sent (e.g. "Cake") even though the gas is paid in the chain's native coin; it now reads the native coin (e.g. "BNB"). The amount field's "(incl. fee)" note now appears only when the fee is in the same asset as the amount (a native-coin send or an exchange transfer) — for token sends, where the fee is a separate coin, it now reads simply "Quantity sent".
  • Editing a wallet→exchange transfer no longer drops its network fee. Re-saving a reconciled wallet transfer rebuilt the entry without its gas-fee line, silently erasing the fee. The edit now carries the network fee through (in the chain's native coin), so the gas cost survives an edit.

Editing a transfer can never delete it again

Changed
  • Editing only text/reference fields no longer rebuilds the transaction. When you change just non-computed fields — description, notes, reference/tx hash, the source/destination exchange, token address, chain — the entry is now updated in place with no churn to its accounting lines or cost-basis lots. Only edits that change a computed input (amount, price, asset) recompute the FIFO lots, as they must. The exchange counterparty you pick is now saved on edit too.
Fixed
  • Editing a posted transaction can no longer make it disappear. When you edited a wallet entry, the app rebuilt its ledger lines by deleting them first and re-posting; if the re-post failed (e.g. a CEX transfer whose exchange wasn't carried through), the entry was left stripped of all lines and vanished from the posted list. The edit flow now snapshots the entry first and restores it intact if anything goes wrong — a failed edit is now a true no-op, for every transaction type, not one. A 10 BUSD CEX withdrawal lost this way was recovered.
  • Editing a CEX transfer now saves. Re-saving a CEX deposit/withdrawal failed with a "requires an internalTransfer counterparty" error because the edit didn't carry the exchange through; it now does, and warns clearly if no exchange is selected.

Token-to-token swaps auto-fill the sent asset

Added
  • Reconciling a token-to-token swap now fills in the asset you sent. For a swap like BUSD → SFUND, the import only captures the token you received — the token you sent lives in the transaction's on-chain logs. The entry form now decodes the transaction on open and pre-fills the send asset and amount automatically. If the sent asset is a stablecoin it's pinned to $1 and the received token's unit price is derived from the trade, so a BUSD → SFUND swap arrives fully priced and balanced with nothing to type.

Token-to-token swaps price right · wallet balance matches everywhere

Changed
  • A wallet's balance is now the live market value of its crypto, everywhere. The wallet card and the wallet detail page disagreed (e.g. $5,033 vs $7,663) because the card summed historical book value across every account type (including staked/in-transit balances) while the detail showed live market value of spendable crypto. The card now matches the detail: live spot price × your current crypto holdings, so the same wallet reads the same number on both screens.
Fixed
  • Swap unit prices no longer break on comma-formatted amounts. Entering a trade where an amount showed a thousands separator (e.g. 7,166.59) made the auto-calculated unit price wrong by ~1000× (the comma truncated the amount to 7, so BUSD computed at $1,000 instead of $1). Amounts are now parsed ignoring commas/spaces, and the input strips separators so the stored value stays clean.
  • A wallet's card balance now exactly matches the detail page for stablecoins. The card valued stablecoins (e.g. BUSD) at the raw market price (~$1.001) while the detail pinned them to their $1 peg — a ~$7 gap on a large BUSD position. The card now pins same-currency stablecoins to 1.0 too.
  • Token-to-token swap prefill in the reconciliation queue no longer fabricates a native counter-leg (same root as the modal fix), so the receive price isn't pre-seeded wrong.
  • Token-to-token swaps (e.g. BUSD → SFUND) no longer pre-fill a phantom price. When reconciling a swap with no native coin on either side, the form was treating the trade's internal WBNB routing hop as the "sent" leg and pricing it as BNB — so the received token got a wildly wrong unit price (e.g. SFUND at ~$0.0026 instead of ~$2.64, understating the trade ~1000×). These swaps now leave the send leg blank for you to enter the real asset, and the receive price is derived correctly from your figures. (Native-coin swaps are unaffected.)
  • Sending a gift from your wallet now records the network gas fee. A wallet-initiated Gift out (e.g. transferring SFUND out, paying BNB gas) was posting only the gift disposal — the gas the wallet paid was silently dropped. The gas leg is now booked alongside the gift, matching how transfers and liquidity actions already handle it.
  • Editing a CEX transfer reloads the exchange you picked. A crypto deposit/withdrawal between your wallet and an exchange wasn't storing which exchange (e.g. Binance) was the source/destination, so re-opening the entry showed an empty exchange picker. The exchange is now saved with the entry and reloaded on edit. (Applies to entries created from here on; entries posted before this still need re-selecting once.)

Stablecoin & token deposits stop showing a wildly wrong fair value

Fixed
  • A non-native token deposit no longer inherits the chain's coin price. When reconciling an imported deposit of a token (e.g. BUSD on BNB Chain), the entry form was pre-filling the unit price with the chain's native gas-coin price — so 7,635 BUSD showed an indicative fair value of ~$2.6M (BUSD priced as if it were BNB at ~$343). The form now pins stablecoins to $1, uses the native price only when the deposited asset actually is the native coin, and otherwise leaves the price blank for the normal CoinGecko/manual lookup. (The posting itself was unaffected for internal transfers — basis is preserved — but the preview figure was alarming and any fee leg could mis-price.)

Staked balances can no longer be spent by a trade

Fixed
  • A trade, sale, fee, or withdrawal can no longer silently consume staked (or otherwise locked) crypto. Cost-basis lots that have been moved into a staking sub-account were being pooled together with your spendable wallet balance, so a disposal could draw down staked principal you hadn't unstaked — and because staked lots are usually the oldest, FIFO even picked them first. Disposals now only ever consume spendable holdings; to sell staked crypto you must unstake it first (a sale that exceeds your spendable balance now correctly errors instead of quietly eating staked principal). Unstaking and slashing are unaffected — they use their own dedicated paths.

Chain discovery finds Ethereum reliably

Fixed
  • Wallet chain discovery no longer silently drops Ethereum. Discovery probed Ethereum through a public, keyless RPC that frequently times out; when it did, Ethereum was quietly skipped — not shown as active, errored, or anywhere — even though the wallet clearly had Ethereum activity. Discovery now falls back to the keyed block-explorer lookup whenever the fast RPC probe fails for any chain that has an explorer (Ethereum and the other major chains), so a flaky public node no longer hides a chain you actually use.

Staking overview splits token staking from LP farming

Added
  • Each staking headline now breaks out token staking vs LP farming. Under Total staked, Rewards, and Weighted APR you now see two sub-values — one for single-asset token staking, one for LP farming (liquidity-pool tokens like Cake-LP / SLP). Crucially, the weighted APR is computed separately per category, so the much higher LP-farm rate is no longer blended with (and diluted by) lower-risk token staking, and vice versa. No new columns were added — just the two readouts on the existing cards.
Changed
  • Staking Amount column lines up its numbers and tickers. The quantity now right-aligns in a fixed slot (so decimals stack straight down the column) and the asset symbol trails left-aligned in muted text — matching the reconciliation queue, instead of the whole number+ticker block floating against the right edge.

Reverted transactions can be booked as gas-only fees

Fixed
  • Reconciling a reverted (failed) transaction as a fee no longer errors. A reverted swap never moves any tokens — only the gas is spent — so it's correctly suggested as a gas-only Fee. But posting it from a wallet failed silently (a 400 in the background): the fee's asset and amount were sent under the wrong field names, so the server saw them as missing. Wallet fee posts now send the right fields and pass through the unit price you enter, so old transactions (before CoinGecko's free price history window) price off your figure instead of a failed lookup. Only the gas fee is booked — nothing else.

Cleaner amount fields in the transaction form

Changed
  • Send / Receive amount fields show up to 6 decimals and give the token ticker more room. Long on-chain amounts (e.g. 22.87650232120) were crowding the asset chip so a 5-letter ticker like SFUND showed as SFU. Amounts now display rounded to 6 decimals at rest, and the asset/amount split was widened so the full ticker fits. The rounding is display only — the exact on-chain amount is still what posts; click into the field to see and edit the full-precision value.

Unstaking a single-asset farm shows the amount you unstaked

Added
  • Copy button on a transaction's raw data. The transaction-detail drawer's Raw section gained a Copy all JSON button that grabs every leg's raw data in one click (a single object for one leg, or an array when there are several), so you can pull the on-chain detail straight into a spreadsheet or analysis tool.
Fixed
  • Unstaking from a single-asset farm now shows the amount you unstaked, not just the reward. When a farm pays its reward in the same token you staked (e.g. Seedify SFUND staking, where both the returned principal and the harvest are SFUND), the reconciliation queue was showing only the small reward amount (e.g. 1.14 SFUND) and hiding the much larger unstaked principal (e.g. 60.59 SFUND). The queue now reads the contract's own withdrawal event to split the two: the Amount column shows the principal you unstaked, and the entry form pre-fills the unstake with that principal plus the reward as its harvest companion — so both are captured when you reconcile.

Unstakes from your own staking contracts no longer look like deposits

Fixed
  • An inbound from a staking contract you've whitelisted is now suggested as an unstake (or reward), not a deposit. Withdrawing your stake (e.g. a withdrawStake call returning SFUND from your Seedify staking contract) was landing in the reconciliation queue as a plain Deposit, because the coarse "money in" classification ran before the staking-contract check and the contract wasn't in the built-in list. Now the queue recognises any counterparty you've added to your staking whitelist: money out to it → Stake, money in from it → Unstake when the method is a withdrawal, otherwise a reward claim. The withdrawStake method signature is also recognised directly.

Record a tax on a transaction

Added
  • Optional tax on any crypto transaction. Some swaps (and other token moves) charge a transfer/treasury tax on top of the transaction — sometimes on the token, sometimes in the native coin via newer hook contracts. Every crypto entry form (trades, liquidity, staking, deposits, gifts, transfers, income, card buys) gained an Add tax toggle: enter the tax amount and asset — one-click Send / Receive chips set the asset and fill its unit price straight from that leg's price (or type your own; auto-priced from CoinGecko if left blank). The tax currency tells the ledger which side it sits on (your input token, your output token, or a separate native coin), so there's nothing extra to pick there. On a trade there's one more choice — whether your Send/Receive figures are Net (tax already taken) or Gross (tax inside the amount) — so the right amount swaps and the right amount is taxed; other transaction types just take the tax as an added cost. The tax books to a dedicated Trading Tax expense — so it lowers your realised margin and shows as its own cost line — kept separate from gas fees. A tax paid in a coin you already hold realises its own gain/loss on those units (like a crypto gas fee); a tax skimmed from the swap's own output (a token that burns part of what it pays you, e.g. FlashX) is booked straight to the expense and your received lot is the net you actually kept — no phantom "you don't hold this yet" error. The live posting preview shows the tax lines as you fill them. Detection stays manual: transactions are still auto-classified normally, and you add the tax when you know it applies.

Sortable columns across the app's tables

Added
  • Click any column header to sort. Sorting is content-aware: number columns (amounts, values, quantities, %, counts) order numerically low→high / high→low — reading the real value behind the formatting, so currency symbols, %, thousands separators and negative signs are all handled (−$836.35 sorts below $1.54) — and date columns order chronologically; text columns (names, tickers, status, etc.) order A→Z / Z→A. First click on a number/date column shows highest/newest first, a text column A→Z; click again to reverse. Empty cells () always sort to the bottom in both directions, and an arrow marks the active column. One shared, consistent control powers every table.
  • Rolled out across the app's list tables, including Analytics → Staking and Liquidity, Transactions (and the dashboard preview cards), Securities, Expenses, Invoices, Recurring, Payslips, Loans, Equity grants, Corporate actions, Reports, Accounts (chart of accounts), Business (VAT periods), Family office, Relationships, and the Advisor portfolio breakdown. (Tables that are key-value detail panels, drag-to-reorder lists, or ≤2-row summaries are intentionally left unsorted.)
Changed
  • Staking Amount column now separates the quantity from the asset symbol (e.g. the number right-aligned with the symbol trailing in muted text), matching the reconciliation queue so decimals line up down the column.
  • Trade & liquidity entry: amount fields are roomier and no longer repeat the ticker. In the Send (from) / Receive (to) (and liquidity pair-token) inputs the asset ticker now lives only in the asset selector beside the field — it's no longer crammed in as a suffix inside the number box. The number gets the wider share of the row, so 4–5 decimals stay visible even for long tickers like SFUND.

BNB liquidity removals recognised + clearer row actions

Changed
  • Row action menus replaced the "…" with clear icon buttons. On the Staking and Liquidity lists, each row's trailing "…" is now a # button that copies the protocol / pool id, and Staking rows gain a pencil button to rename the contract. App-wide, the # icon is now the consistent "copy id" affordance — the copy/duplicate icon stays reserved for duplicating a transaction.
Fixed
  • "BNB"-named liquidity removals are no longer mistaken for deposits. Some routers name the native leg "BNB" rather than "ETH" (e.g. removeLiquidityBNBWithPermit), which uses a different method signature than the "ETH"-named variants the ledger already knew. Such a removal — where the pool returns your two pooled tokens to your wallet — was being offered as a plain deposit in the reconciliation queue. Every BNB-named add/remove-liquidity method is now recognised as a liquidity action.
  • A fully-unstaked staking protocol now opens its position history. On Analytics → Staking, a protocol you've completely unstaked shows as Inactive; clicking it now opens that position's detail page (the stake / unstake / reward timeline) instead of the contract-management panel, and the row shows its real lifetime rewards and APR. Contracts you whitelisted but never staked still open the management panel (there's no position to show).
  • Hundreds of wallet transactions reclassified from their on-chain method. Many imported wallet transactions were stuck on a generic "transfer" label (and so were offered as a CEX deposit/withdrawal in the reconciliation queue) even though their on-chain method clearly identified them — a swap, stake, unstake, reward claim, wrap/unwrap, or liquidity add/remove. A sweep re-derived the type from each transaction's method for every unreconciled wallet row across all wallets and corrected 647 of them (479 swaps, 96 stakes, 44 unstakes, 16 liquidity removals, plus claims, wraps and an add). Reverted transactions and ambiguous harvest-vs-unstake calls were left untouched for manual review. Only unreconciled rows' classification was touched — nothing posted was changed.

Manage staking contracts + clearer Liquidity & Staking surfaces

Added
  • Whitelisted staking contracts with nothing staked now show up in the list. Previously the Analytics → Staking page only listed contracts you'd actually staked into — a contract you'd whitelisted in advance (or one whose positions you'd fully unstaked) simply vanished. Such contracts now appear as an Inactive row so you can see, edit, and stake into them. A Hide inactive toggle (with a count) collapses them when you only want live positions.
  • Manage / edit your whitelisted staking contracts from the staking page. A new Manage contracts button opens a panel listing every contract with its lock type, reward policy, custody and on-chain address, where you can rename a contract, archive/unarchive it, or delete one that has no positions. Editing is intentionally limited to the display name — the address, chain, lock type and reward policy are immutable audit facts. The Edit details link shown when you pick a contract while recording a stake now deep-links straight to that contract's editor (and opens in a new tab on Ctrl/Cmd-click).
Changed
  • The Liquidity Protocol column now shows the LP token (e.g. Cake-LP, SLP) instead of a generic pair label. Each pool is identified by the LP token you actually hold, which matches how the position reads on-chain and on the block explorer.
  • The Liquidity headline value reflects your active positions, not lifetime. Once every position in a pool is closed, the cost-basis figure reads zero for the live total (the lifetime figure is still available as a sublabel) — so adding new positions over time no longer leaves a stale, ever-growing number that ignores what you've already removed.
Fixed
  • Adding LP tokens to a farm is no longer misread as an unstake. An LP-token mint (tokens created from the zero address when you add liquidity) was being labelled UNSTAKE in some imports. LP mints now classify as Add liquidity and LP burns as Remove liquidity, with farm-direction transfers still read as stake / unstake.

Unreconcile any posted entry from the journal

Added
  • Unreconcile button on every journal entry. Each posted entry in a wallet journal now has an undo button at the end of its row. Unreconciling reverses the posting completely — it removes the ledger entry, undoes its FIFO lot movements and recalculates cost basis, restores any liquidity or staking position the entry had closed, and (for entries that came from an import) sends the transaction back to the reconciliation queue so you can re-reconcile it cleanly. It is not a void: nothing is left behind as a dead audit stub, so the re-posted entry is a fresh start. A confirmation dialog explains exactly what will happen before anything changes. If an entry can't be cleanly reversed (e.g. it opened a position that has since had rewards or partial removals), you're told to unreconcile the dependent entries first rather than risk corrupting the position history.

Impermanent loss + live fair-market value on Liquidity

Added
  • Impermanent loss is now tracked on every liquidity position. Each time you add liquidity the deposit is recorded as a lot (the tokens you put in + the LP tokens you received). When you remove liquidity, the ledger walks those lots in your portfolio's cost-basis order (FIFO / LIFO / HIFO / average) — spanning as many adds as the removal covers — and books the realized impermanent loss for the part you took out: what you actually withdrew, valued at removal-time prices, versus what those same deposited tokens would be worth if you'd simply held them. Whatever stays in the pool carries a live unrealized IL. IL is a performance figure shown alongside your books — it never changes the journal entries themselves (your accounting realized gain/loss is unchanged).
  • Live fair-market value (FMV) for LP positions. The Liquidity overview and each pool's detail page now show both cost basis and a live FMV — your current claim on the pool's reserves valued at today's prices — with FMV falling back to cost basis when no live price is available. Prices are derived from the pool's own reserves anchored to a known token (BNB/ETH/stablecoin), so even pairs whose tokens aren't listed on CoinGecko (e.g. SFUND) get a real value.
  • New KPIs + columns: the pool detail page gains Value (FMV), Unrealized IL, Realized IL tiles and per-position Value and IL columns, plus an Impermanent loss · booked out (token qty) breakdown showing the AMM's implicit rebalance per token. The Liquidity overview gains Value (FMV) and Impermanent loss KPIs and columns.
Fixed
  • "Tokens still in pool" no longer shows a phantom balance on closed positions. The detail page previously computed amounts in − amounts out and labelled it "still in pool" — but because the AMM rebalances your tokens, that residual is actually the impermanent loss, not a holding (a fully-withdrawn position would wrongly show leftover tokens). It now shows your live claim on the pool (zero once fully withdrawn), and the residual is surfaced separately as the IL quantity booked out.
  • Removing liquidity now records cost basis for the tokens you get back, and consumes the LP token's cost-basis lot. Previously LP removals posted the journal lines but didn't deplete the LP token's lot or create lots for the received pair tokens — so the received tokens had no cost basis for future disposals. Both are now handled (the long-deferred "LP_REMOVE mirror"), and a removal that spans several deposits is consumed across all of them in your chosen cost-basis order instead of only the earliest one.

Stake-row auto-fills the contract picker + accurate staking overview totals

Added
  • Event-signature registry for on-chain classification. A new registry (src/lib/onchain/event-signature-registry.ts) maps emitted-event signatures (Seedify's StakeWithdrawn, StakeDeposited, with more to come) to a decoder + classifier function. The reconciliation queue reads each stake-related tx's receipt logs and lets the registry decide whether the on-chain truth is stake, unstake, reward_claim, stake_with_harvest, or unstake_with_harvest — using the contract's own emitted fields rather than the previous coarse methodId + defiLabel heuristic. New contracts plug in by appending a single object to the registry; the heuristic / decodedStakeLeg demote path stays as a fallback. Verdicts are cached on rawJson.eventClassification so subsequent queue loads skip the RPC.
Fixed
  • Opening a plain stake row from the reconciliation queue now auto-selects the matching whitelisted contract. Before, the Staking contract / platform picker only filled itself when the row was a composite stake-with-harvest (a stake call that ALSO paid out a reward in the same tx). For plain stake rows — common when you stake into a farm that doesn't pay on deposit, or when the reward leg is decoded as null — the picker stayed on "Pick a staking contract…" even when the counterparty address was one of your whitelisted Seedify / PancakeSwap / Lido contracts. Now every row whose DeFi label is STAKE, UNSTAKE, or CLAIM_REWARD seeds the picker's address field, so the form lands with the correct contract already selected when a whitelist match exists.
  • Harvest-only withdraw(pid, 0) calls no longer open as an UNSTAKE. Some farms (Seedify, PancakeSwap MasterChef, …) reuse the same withdraw selector for both real unstakes and "harvest accumulated rewards without touching principal" — you just pass amount=0. The queue was upgrading every CLAIM_REWARD row with a withdraw/deposit selector to UNSTAKING/STAKING + harvest, even when the on-chain call moved no LP. The form opened on the UNSTAKING tab with the harvest companion enabled, even though nothing was actually being unstaked. The new event registry reads Seedify's StakeWithdrawn(value, _globalYieldPerToken) event directly — value=0 is unambiguously a harvest-only call — and routes the modal back to the Staking reward form. A fallback demote path covers any other contract not yet in the registry: if the on-chain LP-leg decode finds no LP transfer, the classification still drops to STAKING_REWARD.
  • PancakeSwap removeLiquidity permit + fee-on-transfer router variants now classify correctly. Six selectors (removeLiquidity, removeLiquidityETH, removeLiquidityWithPermit, removeLiquidityETHWithPermit, removeLiquidityETHSupportingFeeOnTransferTokens, removeLiquidityETHWithPermitSupportingFeeOnTransferTokens) all represent the same logical action; only the first two were previously registered. A Pancake removeLiquidityETHWithPermit tx (0xad52c0… SFUND/BNB LP burn) was imported with defiLabel=TOKEN_TRANSFER and the queue defaulted it to a BUY. With all six selectors now registered, the queue heuristic correctly identifies it as LP_REMOVE and any fresh import populates the structured pair-token detail the modal needs for prefill.
  • Staking analytics overview now shows the lifetime totals across all sibling sub-positions, including drained ones. A LOGICAL POSITION is one (staking contract, asset, custody) tuple — under the hood it can have many StakingPosition rows (one per stake action). Once all but one had been fully unstaked, the overview was hiding the closed siblings entirely, so the group's cost basis read $35 instead of the actual $2,236 lifetime basis, and rewards read $17 instead of $245. The overview now reads every sibling and shows the lifetime aggregate. A new Closed filter chip surfaces fully-drained groups; the default "All" view keeps showing live + unbonding positions only, but the historical data is back.
  • APR is stable under unstake. The formula already used cost basis (frozen at stake time) as the principal denominator — so APR was structurally stable — but with the previous bug only the live sibling's tiny stake / reward pair was feeding the calculation. With the full sibling history now in play, the displayed APR is the position's true lifetime rate and won't shift when you unstake portions of a logical position. The same applies under LP-removal: the staking analytics path is independent of LP-token lot depletion, so removing the underlying LP doesn't move the APR.
  • Closed staking positions no longer mislabel as "Slashed". Previously every status=CLOSED position rendered as "Slashed" on the overview + the detail page, regardless of how it closed. We now look at the closing journal entry: only an actual STAKING_SLASH-closed position reads as Slashed; a normal full UNSTAKING-close reads as Closed.

Staking position detail page

Added
  • Per-position staking detail page at /[slug]/analytics/staking/[positionId]. Click any row on the Analytics → Staking list and you land on a dedicated page for that position. Shows the full event timeline — the opening Stake event, every Reward / Reward claim that landed against the position (with the reward asset's own symbol, so a Cake-LP stake earning SFUND rewards reads the SFUND rewards correctly), plus any Unstake or Slash events that closed it — along with running totals (current FMV, lifetime rewards, derived APR, days earning), the position metadata strip (stake date, unbond/close date, quantities in/out/remaining, cost basis, stored contract APR), and a chain badge + copyable contract address. The list defaults to oldest-first so you can read the position's history top-down; a toggle flips it to newest-first. Mirrors the Liquidity Pool detail page added earlier on this PR.
  • Parent list rows are now clickable. Every row on the staking grid is a real <a href> per the project's Ctrl/Cmd-click rule, so middle-click and Open in new tab work the same as they do everywhere else in the app.

LP-token cost basis: backfill + LP_ADD lot creation

Fixed
  • Adding liquidity to a pool now records the LP token's cost basis correctly. Previously the LIQUIDITY → Add Liquidity path posted the journal lines but never recorded the cost basis of the LP token you received (Cake-LP, UNI-V2-LP, etc.). Every LP position therefore read $0 cost basis downstream — the Analytics → Staking page showed Value = $0 and APR = "—" whenever the staked asset was an LP token, cost-basis reports showed no lots for the LP token, and the "fallback to cost basis when live spot is unavailable" path returned zero because the cost basis itself was missing. New LP_ADD entries from here on capture the LP token's basis as the sum of the deposited pair tokens' fiat values, which is what you actually paid for those LP units.
  • Existing LP positions backfilled. A one-shot backfill recovers the missing CostBasisLot row for every LiquidityPosition that posted before this fix, and recomputes costBasisFiat on every StakingPosition whose staked asset is an LP token (those captured cost basis = 0 at stake time because no lot existed yet). The user's WBNB/SFUND Cake-LP stake now reads $158.55 cost basis on the analytics page instead of $0 — Value falls back to that figure while live spot for Cake-LP is unavailable on CoinGecko, and APR is computed against it.

Staking analytics: per-position rewards + live-FMV-based APR

Changed
  • Staking analytics now attributes rewards to the right position, even when the reward asset differs from the staked asset. Previously the Analytics → Staking page grouped rewards by ticker symbol — so a Cake-LP stake earning SFUND rewards showed no rewards on the Cake-LP row (the SFUND rewards ended up under a non-existent "SFUND position" bucket and were silently dropped). Every staking-related journal entry now carries a direct reference back to the position it was posted against, so a Cake-LP position correctly shows the realized SFUND reward and a stake denominated in one asset can earn rewards in any other asset without losing track of where the income came from.
  • "Value" column on the Staking grid now uses live spot price instead of cost basis. A position is valued today at current price × remaining quantity rather than what it cost when you opened it. The headline Total staked KPI matches — sublabel reads "live FMV". When CoinGecko has no current price for the symbol the cell dims and a tooltip reads "Live price unavailable — showing cost basis" so you can still see something useful.
  • APR is now derived from realized rewards for VARIABLE-policy positions. Previously the APR column showed "—" on every position whose policy was not FIXED_APR. It now shows (rewards in fiat ÷ current FMV) × (365 ÷ days staked) × 100 — i.e. the actual yield you've realized, annualised. FIXED_APR positions keep their stored APR (authoritative from the contract). Hover the cell for the formula. Closed / slashed positions cap the time horizon at their close date so a stale closed row doesn't dilute over time.
Fixed
  • Cross-asset staking rewards no longer disappear from the analytics page. Direct consequence of the reward-attribution fix above — the BNB/SFUND Cake-LP test position now shows its $12.15 SFUND reward against the Cake-LP row.

Staking position picker on Unstake / Reward / Slash entries

Changed
  • Unstake, Staking reward, and Staking slash now show a dropdown of your real staking positions instead of a free-text contract field. Previously the Staking contract / platform input on these three variants was a plain text field; the modal then handed whatever you typed (a contract address, a tx hash, the platform name) to the server as the stakingPositionId — so unless you happened to paste a real position cuid the post 404'd with "Staking position not found". The field is now a Radix-styled dropdown that lists your active staking positions (closed positions too on Slash, for retroactive recording), one per line, showing the asset symbol, remaining quantity, contract / platform label, custody type, chain, and stake date. Pick the position once and the form mirrors its asset symbol; submit goes through cleanly.
  • Empty-state link to the staking analytics page. When the dropdown has nothing to show (you've never staked the asset you've selected), an inline hint appears with a real <a> link to your portfolio's Analytics → Staking page so Ctrl/Cmd-click opens it in a new tab.
Fixed
  • Posting a staking reward against an existing position no longer errors with Staking position not found. This was the user-facing symptom of the same bug — once the picker is wired the cuid is always correct.

Stake transactions prefill from a tx hash

Added
  • Look up a staking transaction on-chain to prefill the form. The Staking → Stake entry now has a Tx hash field at the top, with a Look up on-chain button next to it. Paste the transaction hash, pick the chain (BSC, Ethereum, Polygon, Base, Arbitrum, Optimism, Avalanche — every EVM chain the rest of the ledger already supports), click the button, and the modal fills in the staking-contract reference, the token contract address, the asset symbol, the staked quantity, and the entry date from the on-chain receipt. Known staking contracts (Lido, Rocket Pool, BNB Beacon, Pancake Syrup Pool, …) come back with a friendly label; everything else comes back as Contract 0xae7…84. The whitelist check then fires automatically since the contract address + chain are now populated, so you see the green Whitelisted as &lt;name&gt; indicator (or the First stake on this contract register-now form) without typing anything else. Read-only — nothing is committed until you hit Post.

Staking auto-whitelist on transaction submit

Added
  • Non-custodial stakes whitelist themselves in the same modal. Posting a Staking → Stake entry no longer requires a side-trip to the staking page to register the contract first. When you paste a token contract address + pick a chain in the entry modal, the form looks up your existing whitelist and shows one of three states: a green "Whitelisted as &lt;name&gt;" indicator when a match is found (the stake silently links to it), an indigo "First stake on this contract — registering it now" disclosure when no match exists (you fill name, lock type, reward policy, plus fixed-duration or APR-bps when applicable, all in the same submit), or a muted "Fill in contract address + chain to check whitelist status" hint when those identity fields are still empty. Client-side validation mirrors the server's 400 cases so you see a specific toast ("LOCKED_FIXED requires fixedDurationSeconds") instead of a generic API error.
Changed
  • "Manage whitelist" button on the Staking page renamed to "Manage existing whitelist entries". The button used to be the only path to add a new contract to the whitelist; with the auto-whitelist flow in the transaction modal, this modal is now edit-only (rename / archive existing rows). The new label removes the ambiguity.

Liquidity Pool detail page + grouped pool list

Added
  • Per-pool detail page on the Liquidity surface. Clicking a row in Analytics → Liquidity now opens a dedicated page for that pool at /[portfolio]/analytics/liquidity/[poolId]. The detail page shows pool metadata (kind, chain, fee tier, custody, copyable address), a KPI strip with total liquidity, active vs closed counts, net token quantities still in pool, and a Fees · YTD placeholder, followed by a per-position table (deposit date, status, LP-token remaining, amounts in / out, cost basis, link to the originating journal entry). Multiple LP_ADD transactions against the same pool now consolidate to one parent-list row and roll up on the detail page — no data-model change required; this was always how LiquidityPool keyed by (userId, chainId, poolAddress) worked, the UI just hadn't surfaced it yet.
Changed
  • Liquidity list now groups by pool instead of one row per position. The parent list at Analytics → Liquidity used to render a row for every individual LiquidityPosition, so two LP_ADDs against the same pool showed twice. Rows now roll up by pool, the row's Liquidity number is the sum of cost basis across all the pool's positions, and a small "N positions" chip appears when more than one position consolidates into a row. Rows are sorted by total liquidity descending.
  • Liquidity rows are real <Link> anchors. Every cell on a Liquidity row is now a click-through link to the pool detail page — Ctrl/Cmd+click opens in a new tab, middle-click and right-click → Open in new tab work natively. Wrapped-native pool tokens (WBNB, WETH, WMATIC, …) unwrap to their human form (BNB, ETH, MATIC) on the detail page header while the canonical symbol stays in the schema.

CoinGecko ids backfilled across every posted crypto entry

Fixed
  • Historical price lookups now resolve to the right coin, every time. Most posted entries that touched a crypto asset were missing the coingeckoId tag on the journal entry, so price lookups fell back to ticker-symbol resolution — which can collide for tickers reused by multiple projects. Every posted entry that touches a CRYPTO_ASSET (or staked-crypto) account now has its coingeckoId set to the canonical CoinGecko id for the primary asset; crypto-to-crypto trades also carry coingeckoIdSend for the send leg. 214 entries were updated in place — no voids, no other columns touched. The backfill ran via scripts/backfill-coingecko-id-2026-05-26.ts; default mode is dry-run, --apply writes the changes inside per-entry transactions and the script is safely idempotent. Three entries on ambiguous tickers (DATA, FRONT, GO) were skipped and need a manual pick via the currency picker.

Editing transactions no longer voids them

Changed
  • Editing a transaction now updates the same row in place — it never voids and re-creates it. Previously the wallet and CEX edit modals used a "void the old entry, post a fresh one" pattern: every save voided your original JournalEntry and inserted a brand-new one with a new id. When validation on the new entry failed (e.g. a price lookup error, a missing field), the original stayed voided with no replacement — silently disappearing from your books. From this release, edits write a true UPDATE on the same id: the entry's id is stable, the FIFO state is untouched, and a validation failure rolls back cleanly so nothing is lost.
  • Edits are now restricted to scalar metadata fields. Description, reference / external id, entry date, CoinGecko id, line memos (wallet); note, externalRef, bank ref, payer / invoice / service-description, CoinGecko ids (CEX). Anything that would change the transaction's line shape, amounts, accounts, or FIFO impact — the transaction type, asset codes, quantities, fee, internal-transfer flag, wallet / counterparty / card link — is treated as structural and rejected with a clear "Delete and recreate the entry instead" message (STRUCTURAL_EDIT_REJECTED). The delete action remains the only place where a void is intentional. This avoids the silent-data-loss class of bug while we wire per-variant in-place editors for amount-level changes.
Fixed
  • Voided legacy entries no longer clutter the live ledger view. Wallet, CEX, and bank journal-detail entry lists now filter out VOID / void rows by default. They were left behind by the old edit pattern (and by some early imports) and were showing up alongside the live entries — confusing balances at a glance. Pass ?status=ALL (wallet entries) or ?status=all (bank entries) to surface them for admin investigation.

Internal-transfer bookkeeping fixed (Opening Balance is now opt-in)

Changed
  • Opening Balance is now opt-in. The ledger used to silently credit [3000] Opening Balance on every reconciled crypto deposit — including internal transfers from one of your own wallets / CEX journals. That's no longer how it works. A DEPOSIT is now one of two explicit kinds: (a) an internal transfer from an owned counterparty (wallet, CEX journal, bank journal, or one of your other portfolios) — the deposit's cost basis is taken from the counterparty's lots, with the original acquisition date and per-unit cost preserved, and the entry credits the per-currency [8000+] Internal Transfer Clearing account so the two legs net to zero in asset units; or (b) an opening balance — only when you explicitly classify the entry as your starting position. External-source acquisitions (income, airdrops, mining, gifts received) must be classified as INCOME (taxable). The same rule applies to WITHDRAWAL: internal transfer (basis follows the asset) or opening-balance adjustment — external disposals must be GIFT_OUT.
Fixed
  • Cost basis now follows the asset across internal transfers. Previously, a wallet → CEX (or CEX → wallet) move could create a fresh lot at market price on the destination and leave a phantom Opening Balance lot on the books — losing the original acquisition date (and your HODL clock) and inflating realised gain on the next sale. The new relocateLots helper FIFO-walks the source's lots, preserves their costPerUnit and acquisitionDate, and writes paired audit rows on both sides.
  • [8000+] Internal Transfer Clearing aggregates no longer show nonsense USD-shaped balances on coin-denominated accounts. The CEX-side internal-transfer post used to store the USD fiat value in the clearing account's amount column instead of the asset quantity (so the Internal Transfer Clearing — BNB account would sum to a number like "-1077 BNB" because every leg was actually a USD amount). The clearing line now stores the asset quantity, with fiatAmount carrying the USD value separately.
  • Remediation script for existing wrong entries. scripts/remediate-internal-transfers-2026-05-26.ts finds entries previously booked to Opening Balance for what was really an internal transfer, plus entries with the units bug, and rewrites them in place — no void / no delete. Default mode is dry-run; pass --apply after reviewing the output.

Liquidity transactions now populate the Liquidity page

Added
  • LIQUIDITY transactions now populate the dedicated Liquidity page. When you post an LP_ADD entry, the ledger now upserts a LiquidityPool row (auto-inferring the DEX kind from the chain — BSC → PancakeSwap, Ethereum → Uniswap v2, otherwise Other) and opens a new LiquidityPosition linked to the journal entry, populated with token symbols, deposited amounts, LP tokens, cost basis, and the deposit date. When you post the matching LP_REMOVE, the ledger finds the corresponding open position (FIFO by deposit date — matched first by pool, falling back to token symbols + chain), records the withdrawn amounts, decrements the remaining LP tokens, and marks the position closed once every LP token is withdrawn. The Liquidity page (/analytics/liquidity) reflects this immediately — previously it always rendered empty because nothing created position rows. Mirrors the existing STAKING wiring behaviour.

Liquidity entry posting hardened (silent 500 + FIFO leak)

Added
  • "Fee unit price" field in the transaction entry modal. When you add a liquidity (LP_ADD / LP_REMOVE) entry with a crypto fee — or any entry where the fee leg needs a historical price — you can now type a per-unit price for the fee asset directly in the modal. The field auto-flags as required with a red label when the entry date is older than CoinGecko's free-tier 365-day window so you know to fill it in before posting. Leave it blank for any recent date and the server still auto-prices the fee from CoinGecko.
Fixed
  • LP_ADD / LP_REMOVE with a crypto fee no longer return a silent 500 when the fee asset's date is outside the CoinGecko 12-month window. The post used to fail with a generic "Internal server error" and no actionable message; you now get a clear 422 toast — "Could not fetch historical price for fee asset BNB on 2021-04-01. Enter a fee unit price manually in the modal, or pick a date within the last 12 months." — and can resolve it without leaving the entry.
  • Liquidity entries with a crypto fee now correctly deplete the fee asset's FIFO lots. Previously the fee's general-ledger lines posted but the lot inventory was never decremented and no LotMovement audit row was written — so cost-basis reports showed phantom fee-asset balances and a future disposal could fail with INSUFFICIENT_LOTS on a quantity the books said was already gone. Now matches the behaviour of every other transaction type (BUY, SELL, CRYPTO_TRADE, STAKING, etc.).
  • Pair-token / asset price-lookup failures across every transaction type now return an actionable 422. Any post that hits CoinGecko's 365-day historical-window limit (or any other transient lookup failure) on a missing unit-price override now surfaces a clear error code (PRICE_LOOKUP_FAILED) and message, instead of bubbling up as an opaque 500.

All 12 portfolio types now live (stable, USER-role default)

Added
  • All 12 portfolio types are now live and default-enabled for every user. Creating a portfolio in the onboarding wizard offers the full grid with no STUB badges: Crypto Investor, Crypto Trader, TradFi Investor (equities, bonds, ETFs, funds, REITs), TradFi Cash (bank auto-feeds via Plaid + manual entry), TradFi Work / Salary (payslips, equity grants, RSU vesting, withholding), Freelance / Side-Hustle (invoices, expenses, estimated tax), Small Business / LLC (VAT for UK / Ireland / Germany), Property / Real Estate (per-property P&L, mortgage amortisation, depreciation), Retirement (401(k), Roth/Traditional IRA, SIPP, RRSP, Super, ISA, generic), Debt / Loans (personal, student, BNPL, credit lines with payoff progress), Hybrid Founder (cross-portfolio composition with inter-portfolio elimination), Family Office (member registry + ownership-weighted consolidation). Each portfolio gets its own dedicated page, journal entry types, reports, and bookkeeping builders. The 10 master feature flags (tradfi-investor, tradfi-cash, tradfi-work, tradfi-freelance, tradfi-business, tradfi-property, tradfi-retirement, tradfi-debt, hybrid-founder, family-office) all promoted directly from pipeline to stable — they're on by default for every USER-role account, no opt-in required. The per-region bank-provider sub-flags (tradfi-cash-truelayer, tradfi-cash-tink, tradfi-cash-nordigen) stay at pipeline for staged regional rollout; users in those regions see the branded "manual entry only" panel and can still record bank transactions by hand.
  • New /[slug]/family page (Family Office). Member registry (Head, Spouse, Child, Dependant, Trustee, Other) and composition section that lets you link any of your other portfolios as a child with an ownership percentage. The consolidated aggregator scales each linked child's net worth by its ownership share and rolls it up into the family office total; the W4.3 inter-portfolio elimination pass automatically zeros out transfers between linked portfolios so they don't double-count. Ending a composition link preserves the historical row for audit.
  • Loan payoff progress visualisation. The Loans page now shows a per-loan payoff card above the table with a progress bar (principal paid down ÷ original principal) and YTD principal + interest splits.

Umbrella scaffolding for 12-of-12 portfolio-type end-to-end UI

Added
  • Tracking checklist for the 9 remaining portfolio types. docs/plans/portfolio-types-e2e-tracking.md lists every stub workstream (W1.2 cash, W2.1 work, W2.2 freelance, W3.1 business, W3.2 property, W4.1 retirement, W4.2 debt, W4.3 hybrid founder, W5.1 family office) with its master feature-flag key, acceptance criteria, and a per-sub-PR checklist. The 9 master flags + the 12 per-jurisdiction tax-pack flags + the 4 tradfi-cash provider sub-flags were already declared at pipeline status — the tracking doc gives each one a per-sub-PR home.
  • Per-portfolio-type report-catalog extension hook. The base REPORT_CATALOG in src/lib/v2-reports-data.ts keeps the 6 portfolio-agnostic reports (balance sheet, trial balance, cash flow, holdings at cost, realized P&L, general ledger). A new registry at src/lib/portfolio-report-registry.ts lets per-workstream sub-PRs register specialist reports (rent roll, VAT period, vesting calendar, loan amortisation, etc.) without touching the base catalog. The registry is empty today, so report rows surface identically for every existing portfolio — the hook is strictly additive.
  • Reusable <ManualOnlyFallback> panel for connect-account surfaces. New component at src/components/integrations/ManualOnlyFallback.tsx, branded to the dark-glass system, surfaces a "manual entry only — provider X coming via {flag-key}" message on a portfolio's import page while the real provider backend is still behind its pipeline flag. Used by the per-workstream sub-PRs to avoid empty-state holes during the rollout.

Portfolio onboarding v3 (asset & motivation-driven wizard, opt-in beta)

Added
  • New "Create your first portfolio" wizard. Instead of picking one of 12 portfolio types up front, the new flow asks two questions: what do you want to manage? (14 asset categories — crypto wallets, exchanges, NFTs, stocks & ETFs, bonds & funds, derivatives, cash & banking, salary & payroll, freelance income, small business, property, mortgages & loans, retirement, family office) and why are you tracking it? (8 motivations — tax filing, net worth, performance, retirement planning, business bookkeeping, multi-entity consolidation, goals, audit readiness). The system infers the best-fit portfolio type from your answers; the Review step shows the inferred type and lets you override to any of the 12 if needed. Coexists in parallel with the legacy wizard behind the Portfolio onboarding wizard v3 beta flag for A/B testing.
  • Smart jurisdiction & primary-fiat suggestions on the Identity step. When you create a portfolio, the wizard pre-fills your country and primary fiat from (in priority order) any previous portfolio you created, then a transient geo-IP lookup using the self-hosted MaxMind GeoLite2 database. An inline pill reads "Suggested from your location: Germany · EUR — change"; clicking it applies the suggestion, and you can override either field before continuing. No IP address is ever logged or stored against your account.
  • AI-powered tile suggestions on Assets and Motivations (opt-in beta). With the Portfolio onboarding AI suggestions flag enabled, Claude Haiku 4.5 surfaces small "Suggested" pills on the tiles it thinks would complement your selections (e.g. picking Stocks & ETFs surfaces Bonds & funds and Retirement as suggestions). Suggestions are never auto-applied — you click to accept. The AI never receives your name, email, IP, address, or any free-text content.
  • Mobile-ready wizard shell. The create-portfolio wizard is now usable on phones. Below the 640px breakpoint it renders as a full-screen sheet, the stepper collapses to compact dots, footer buttons stack and meet 44px touch targets, and the right-hand live preview moves to a bottom drawer behind a Preview button.
Changed
  • Portfolio creation accepts onboarding telemetry. Newly-created portfolios now optionally store the wizard variant (v2 / v3), the asset and motivation choices made, and whether the geo-IP suggestion was kept — used to compare conversion and quality between the two onboarding flows. Existing portfolios are not modified.

Transaction modal deep audit, round 2 (engine + UI + routes)

Fixed
  • UNSTAKING and STAKING_SLASH posts no longer rejected for missing cryptoCurrencyCode. Both variants left the field off the POST body so the route's Zod schema rejected every attempt. The receive/stake siblings (STAKING, STAKING_REWARD) include it correctly; the gap on the close-out variants meant users couldn't actually unwind a position from the modal.
  • CEX entry edit no longer creates duplicate GL entries. The PATCH path used to wrap the "void the old ledger entry" call in a try/catch that swallowed the failure and continued straight to re-posting — so any void error (locked entry, already-voided, network blip) produced a second journal entry against the same trade with the old one still live. The void now aborts the entire PATCH on failure and returns 422 / 500 with the engine's own message; no silent re-post.
  • CEX entry edit now validates foreign keys before update. The PATCH handler previously took walletId, counterpartyCexJournalId, counterpartyCexAccountId, bankAccountId, and paymentCardId straight from the body and handed them to Prisma — any stale or cross-user value tripped a P2003 foreign-key violation and surfaced as an opaque 500. PATCH now runs the same FK-guard block the POST handler does (with the same WalletJournal.id → ConnectedWallet.id fallback so the v2 wallet picker keeps working).
  • LIQUIDITY recv-leg no longer ships a fiat total as a per-unit spot rate. The recv token's spotRate was being set to formData.fiatAmount (the total fiat value of the pair, not a per-unit price), silently corrupting cost basis on every LP add / remove. The recv leg now omits spotRate so the server falls back to CoinGecko historical for the asset; users who want to override can edit the posted entry.
  • Balance-non-negative guard now always runs. The check was wrapped in if (isEnabled("lot-scope-portfolio-wide")) — turning that flag off silently disabled the only enforcement of the per-journal currency rule, allowing any disposal to drive an account negative. The guard runs unconditionally now; the flag still controls FIFO lot scoping but no longer gates the invariant.
  • Account-resolution helpers in the CEX bridge now throw BookkeepingError instead of generic Error. getSystemAccount and getAssetClearingAccount previously threw new Error(...) for missing-account conditions; the route catch mapped that to a generic 500 the user could not act on. Both now throw BookkeepingError("…", "ACCOUNT_NOT_FOUND") so the user sees a 422 with a "re-seed your portfolio" message they can resolve themselves.
  • Crypto fee lines no longer silently dropped on a CoinGecko miss. buildCexFeeLines used to return { lines: [], extraDepletion: null } whenever the fee asset's historical price was unavailable — the main entry posted without the fee, the lot inventory was never depleted for the fee disposal, and lot state permanently drifted from the ledger with no error surfaced. It now throws BookkeepingError("Could not find a historical price for fee asset …") so the user sees a 422 and can either add a unit FMV or post the fee separately.
  • Realized gain/loss lines no longer conditionally omitted. When the chart of accounts was missing a REALIZED_GAIN / REALIZED_LOSS row, the fee-disposal branch silently skipped the gain/loss line — producing a structurally imbalanced entry that bypassed validateBalance (the check ran before the line was due to be appended). It now throws BookkeepingError("Realized gain account not provisioned …", "ACCOUNT_NOT_FOUND") so the post fails loudly and the user re-seeds the chart.
  • Fiat fees in any of the 15 supported currencies now post correctly. appendFeeLines in /api/portfolios/[id]/transactions only checked fee.currencyCode === "USD" || "EUR"; every other fiat (CHF, JPY, GBP, ZAR, IDR, …) fell through into the crypto-fee path, hit "no CRYPTO_ASSET account for CHF", and silently dropped the fee entirely with no error. The check is now isTrueFiat(fee.currencyCode) (covers all ISO fiat in asset-classification.ts) and the missing fee-expense account throws BookkeepingError(ACCOUNT_NOT_FOUND) instead of a silent return.
  • Counterparty FK probes now verify ownership. The CEX entries POST handler's counterpartyCexJournalId and counterpartyCexAccountId lookups used findUnique without an ownership filter — any authenticated user could reference any other user's journal or exchange-account id as a counterparty link. Metadata-only (no data leaked) but a cross-user link that shouldn't exist. All FK probes (wallet / wallet-journal / counterparty CEX journal / counterparty CEX account / bank / payment card) now require portfolio: { userId: session.user.id } or equivalent ownership.
  • CEX batch import: skipped GL posts surface as errors. The batch endpoint used to silently ignore the { skipped: true } return shape from postCexEntryToLedger — the CexJournalEntry stayed with ledgerEntryId: null and the batch report read failed: 0 even though the row's P&L impact was zero. The skip is now reported in the errors array with code: "POST_SKIPPED" and the reason. Same endpoint also FK-guards bankAccountId so a stale id doesn't crash a single row.

Transaction modal deep audit (P0 + P1 fixes)

Fixed
  • CEX-routed posts now leave their reconciliation queue row. The CEX entries route used to return { entry } without echoing the GL journal entry id. The entry-modal's reconciliation auto-link reads data.journalEntryId to flip the originating row to RECONCILED; without it, every CEX-routed post (CRYPTO_TRADE, CEX_TRANSFER, FEE on a CEX journal) left the queue row stranded as TO_RECONCILE even though the GL post succeeded. Route now echoes journalEntryId alongside the entry, and the modal accepts both the canonical field and the legacy nested entry.ledgerEntryId shape.
  • CEX deposit no longer records a "wallet withdrawal" outcome. The variant taxonomy had both CEX_DEPOSIT and CEX_WITHDRAWAL sub-keys mapped to txType: "WITHDRAWAL". Every deposit was reported to the suggestion-confidence model as a withdrawal, so the model was learning the wrong signal for a whole family of entries. Deposit now maps to DEPOSIT.
  • Auto-link covers every transaction type the modal can post. directionForTransactionType previously knew about only 6 of ~20 transaction types — every other variant relied entirely on the side-channel link-and-confirm call, which silently skipped when the prefill carried no _importedTxId. The map now covers WORK_INCOME, STAKING, STAKING_REWARD, STAKING_REWARD_CLAIM, UNSTAKING, STAKING_SLASH, GIFT_OUT, FEE, TOKEN_APPROVAL, SCAM_FAKE_ASSET, SCAM_REAL_ASSET, AIRDROP_RECEIVED, DIVIDEND_RECEIVED, FUND_DISTRIBUTION, EXPENSE, LOAN_DRAW, LOAN_REPAY, EQUITY_BUY (and exits null for multi-leg types like CRYPTO_TRADE / LIQUIDITY / WALLET_TRANSFER, which are still handled by the by-id link-and-confirm path).
  • Multi-leg hashes no longer leave sibling rows stuck in the queue. After the one-row-per-flow refactor a single tx hash imports as N rows (one per token-leg + direction). Reconciling the SFUND-OUT leg of a SFUND→BNB swap used to leave the BNB-IN leg in TO_RECONCILE until the user dismissed it manually. link-and-confirm now sweeps every other TO_RECONCILE row sharing the same bare hash on the same portfolio and links them to the same journal entry.
  • Failed reconciliation links are visible. The modal's link-and-confirm call used to swallow any network or server failure; the queue's optimistic remove would hide the row even though the server still considered it TO_RECONCILE, and a refresh would reveal the row again. The modal now inspects the response, surfaces a toast.warning with the reason on failure, and signals linkOk: false back to its caller so the queue skips the optimistic remove and leaves the row visible.
  • 22 transaction-posting routes now return 422 instead of 500 on validation failure. postJournalEntry and postCexEntryToLedger throw BookkeepingError for every user-correctable problem (insufficient lots, balance violation, account not provisioned, IFRS rule violation, etc.). 14 specialised routes (payslips, properties, expenses, equity-grants, loans, invoices, retirement, securities, corporate-actions, VAT periods, CEX batch + edit endpoints, …) used to fall through to a generic 500 — every IFRS / balance / lot violation showed up as "Internal server error" with no detail. They now share a bookkeepingErrorResponse helper that returns 422 with the engine's own message and code, or 404 for ACCOUNT_NOT_FOUND / INVALID_ACCOUNT.

Swap counter-leg surfaced; suggestion cascade respects defiLabel; full BSC audit

Added
  • Reconciliation queue rows now show the native counter-leg of a swap. A SFUND→BNB trade used to read as a one-sided "121955.3666 CYT" send even after the classifier knew it was a swap; the BNB the wallet received via internal CALL trace was recorded in rawJson.internalTxs but never displayed. The row now reads as "121955.3666 CYT → 0.075101 BNB" — the counter-leg amount is summed from rawJson.internalTxs and rendered with the chain's native symbol. Same treatment for token-IN swaps (shown with a left arrow) and for pure-native rows whose only value is in the internal trace (Binance-style internal receives now display the actual BNB amount instead of 0).
Changed
  • The "New journal entry" modal feels less overwhelming on dense variants. The chain + token-contract + on-chain-LP-fallback block (Auto-FMV identity) is now a single collapsed disclosure on every variant that uses it — it auto-opens when prefilled data is present, but starts closed for casual entries so the form reads as 3–4 chunks instead of 7+ stacked rows. The Token approval variant in particular is unrecognisably tighter: the amber "ERC-20 approve()" banner and the method-id chip are merged into one compact strip, and the approved-token / spender / tx-hash fields share a single 3-column row. The Direction sub-toggle (Buy/Sell, In/Out, Add/Remove, etc.) moves out of the form body into a 40-px band immediately under the modal header, freeing about 60 px of vertical budget. Notes textarea is now h-12 instead of h-16, and the "Post immediately / Save as draft / Auto-add gas fee" checkboxes sit directly below it instead of in a separate band. Net result: every variant (Token approval, Liquidity add/remove, Work income, Staking family) fits inside the landscape modal envelope (1080 × min(820 px, 92vh)) without scrollbars on either axis. Portrait viewports continue to allow vertical scroll.
Fixed
  • Token-approval entries now show the correct spender contract AND the approved token's symbol. For an approve(address spender, uint256 amount) tx the wallet's external to is the token contract — not the spender — and because no Transfer event fires the importer also left the row's tokenSymbol empty. The modal's "Spender contract" field was therefore pre-filled from to (showing the token rather than the actual spender, e.g. PancakeSwap V2 router 0x05fF…608C7F) and the "Token approved (symbol)" field was empty. Two fixes shipped together: (1) the importer + tx-lookup decode the spender from calldata bytes 4–36 and store it at rawJson.spender; (2) the Alchemy import path now also does one eth_call(symbol()) (and decimals()) against the token contract for every approve() row, so tokenAddress / tokenSymbol / tokenDecimals are populated. The modal prefill picks up both. A single backfill script (scripts/backfill-approval-spender.ts) re-fetches calldata + token metadata via free public RPCs for existing approval rows (no debug_* needed) and patches the row in place — covering 887 of 940 historical approvals for the spender field and 394 for token symbol/address.
  • New journal entry — Token approval now has a real posting preview. The right-hand POSTING PREVIEW panel used to read "Variant: Not implemented" with a red "Fill in the form to balance both sides" box whenever the user landed on Other → Token approval. Token approval has always posted at the GL level (DR Fees & expenses · token approval, CR the gas asset — same shape as a Fee entry), so the preview now mirrors those lines, including a sub-line showing approved-token symbol, spender contract (short address), and gas fiat value when Unit FMV is set. The "Variant: Not implemented" placeholder has been retired across the entire modal; the only remaining variants that don't show real numbers are the Derivatives & margin group (Options / Futures / Perps / CFD / Short / Margin loan), which now render their anticipated debit/credit lines plus a calm violet "Preview only — this variant is gated to a future release" notice instead of a red error.
  • New journal entry modal is now the same size on every variant, adapts to viewport orientation, and no longer scrolls on landscape. The dialog has one shared className across every variant — no per-variant overrides. Landscape viewports (most laptops + monitors) render a landscape rectangle sized to fit even the densest variant (LIQUIDITY pair-tokens, Token approval with the on-chain LP fallback) without scrollbars on either axis — capped at 1080 × min(96vh, 1100px). Portrait viewports stack the type-rail, form, and posting-preview into a single column; on portrait, vertical scrolling on the modal body is allowed because the stacked layout intentionally exceeds viewport height — horizontal scroll is still banned. The variant-sub strip on Options/Perps used to scroll horizontally through its 5–7 pills; it now wraps onto a second row instead.
  • Reconciliation suggestion now respects DeFi classification. Both the portfolio-wide reconciliation queue and the per-wallet-journal reconciliation drawer had a shortcut that mapped entryType directly to a suggested transaction type and skipped the rest of the cascade — including defiLabel. After a successful BSC internal-tx backfill flipped a SFUND→BNB row's defiLabel to SWAP, the row still suggested WITHDRAWAL because the shortcut never saw it. Both pages now run the full suggestTransactionType cascade, which considers defiLabel at step 2 (above entryType at step 3). Knock-on effect: clicking such a row also opens the entry modal on the Trading → Crypto-trade tab instead of Transfers → Wallet transfer.
  • BSC internal-tx trace now retries on transient DRPC errors. The first backfill run hit 582 trace failures of 1371 hashes against DRPC's public BSC endpoint; my code returned null on any error so those rows were silently left without internal data — even December 2025 txs well inside the archive window. The trace helper (fetchBscInternalsForHash) now retries up to 3 times with exponential backoff on 429 / 503 / "not found" / network errors; the backfill script also moved to concurrency 4 and uses the shared helper. The follow-up audit run across all 25 non-empty BSC wallet journals picked up every additional swap row left behind by transient errors.

Wallet journal detail: add-by-tx-hash returns, voided entries hidden by default, internal BNB transfers no longer dropped

Added
  • "Add by tx hash" button on every wallet-journal detail page. Sits next to "Add entry" in the page header and opens a small dialog where you paste a transaction hash and pick its chain. The chain picker is a row of clickable chain chips drawn from the journal's discovered chains — no dropdown, no chain that this wallet hasn't actually used. The hash is fetched from on-chain, attached to the journal as a new HASH import batch, and lands in the reconciliation queue ready to classify. If the wallet has never been through chain discovery yet, the dialog points you to the Discover chains action on the Dashboard tab. Duplicate hashes are detected and reported instead of silently re-imported.
Changed
  • Voided entries are hidden by default on the Entries tab of a wallet journal. Previously they rendered struck-through next to live entries, which made a re-imported wallet look like a wall of duplicates. A "Show voided" checkbox above the entries table — with a count of how many are hidden — toggles them back into view. The Realized P&L / Fees / dashboard tiles already excluded voided rows; only the table now matches.
Fixed
  • DEX swaps that pay out native BNB are no longer mis-labelled as plain transfers. The DeFi labeler used to look only at ERC-20 Transfer logs to spot a swap; a SFUND→BNB trade where the wallet sends 10 SFUND and receives BNB back via an internal CALL frame had no Transfer log for the BNB leg, so it landed as TOKEN_TRANSFER instead of SWAP. The labeler now reads rawJson.internalTxs and detects the counter-flow (token OUT + same-wallet internal IN, or token IN + same-wallet internal OUT) as a high-confidence swap signal even when the immediate counterparty isn't a router we recognise. Existing rows on touched BSC wallets have been backfilled in place — defiLabel / defiProtocol / defiConfidence are recomputed once internal data is patched on.
  • No single wallet or exchange journal can be driven negative by an outflow. Before today's fix, the balance-non-negative invariant summed posted lines across the entire portfolio on the affected ledger account, so a wallet holding 0 BNB could still record a 6 BNB withdrawal as long as another wallet (or exchange) elsewhere in the portfolio held BNB on the same shared CRYPTO_ASSET:BNB account. The check is now scoped per-journal: every entry posted into a wallet journal or a CEX journal is validated against that journal's own running balance and rejected with a NEGATIVE_BALANCE error if the proposed outflow would push that journal into the red. FIFO / cost-basis depletion deliberately stays portfolio-wide — cost basis is a tax-property of the portfolio, not of any one custodian — so the two checks are now properly separated.
  • BSC wallet imports now capture internal BNB transfers. DEX swaps where the wallet sends an ERC-20 token (e.g. SFUND) and receives BNB back as an internal CALL trace were imported as a one-sided token send; the BNB leg was invisible to the labeler and the row landed as TOKEN_TRANSFER. Alchemy doesn't expose its internal asset-transfer category on BSC at all (only ETH and MATIC), so every BSC scan now follows up with a per-tx debug_traceTransaction against DRPC's free public BSC RPC and merges any wallet-involved internal CALL frames into the row's rawJson.internalTxs. The "Add by tx hash" dialog feeds the same data through too, so manually pasted hashes get the trace data on first import. Pure-internal-only receives (e.g. Binance hot-wallet withdrawals where the wallet never appears in an external or ERC-20 transfer event) can now be added via "Add by tx hash" — the per-journal import endpoint detects the wallet inside the internal trace, sets direction and valueWei from the internal flow, and stops rejecting with WALLET_NOT_INVOLVED. Discovery of those withdrawals at scan time still requires a paid Etherscan V2 or BscScan key — no free BSC API offers an address-scoped internal-tx scan today.

Reconciliation queue sort no longer breaks on Exchange / Suggested

Fixed
  • Clicking the Exchange or Suggested header in the reconciliation queue no longer breaks the page. The Exchange column (CEX tab) was throwing a server-side Prisma error because the orderBy clause used the wrong shape for a nested relation field — the page would silently fail to load rows. The Suggested column ignored the click direction entirely because it has no direct database column to sort by; it now flips the underlying date sort so the click still feels responsive. Date / Chain / Direction / Amount / Counterparty / Wallet / Batch / Tx hash / Reference were unaffected and continue to work.

Reports list rows now actually open the report

Added
  • The six pre-canned reports on Analytics → Reports each open into a dedicated drill-in page — Balance Sheet, Trial Balance, Cash Flow, Holdings at Cost, Realized P&L, and General Ledger. Until now, every report row linked back to the same Reports page (the ?tab=... parameter wasn't read by anything), so clicking a row appeared to do nothing. Each report now lives at its own URL (/[portfolio]/analytics/reports/[reportSlug]) and renders directly from the live ledger: - Balance Sheet — Assets / Liabilities / Equity sections grouped by account type, with a Net Income synthetic line and an A = L + E check on the totals row. - Trial Balance — every active account with its total DR and CR; footer asserts the books balance. - Cash Flow — IAS 7 Operating / Investing / Financing split, computed as the signed change of CASH and BANK_CASH accounts on each posted entry, grouped by transaction type. - Holdings at Cost — open cost-basis lots grouped by currency, valued at acquisition cost (IAS 38.74), with average cost per unit. - Realized P&L — FIFO disposals from LotMovement aggregated by currency, with proceeds / cost basis / realised gain. - General Ledger — every posted debit and credit across all accounts, server-paginated 100 per page, with a per-account quick-filter rail and prev/next pagination. Filterable via ?account=<id>&page=N on the URL.

Multi-currency fiat entry, auto-converted to portfolio currency

Added
  • The entry modal now accepts every fiat currency in the picker (USD, EUR, GBP, IDR, SGD, AUD, CAD, JPY) and posts succeed for all of them — not just USD and EUR. If you have a multi-currency setup (e.g. a GBP credit card funding a portfolio that reports in USD), you can finally record the entry in the currency you actually paid in.
Changed
  • Every posted fiat amount is automatically converted to the portfolio's reporting currency before it lands in the ledger, using the historical ECB rate for the entry date (via frankfurter.dev). Your reports stay coherent — every total is in your portfolio's primary fiat — while the original amount and the exchange rate used are appended to the entry's description for audit ([orig GBP @ 1.2734]). Buy / Sell / Card buy / Income / Work income / Gift out / Liquidity / Fee — every variant that surfaces a fiat amount benefits. If the FX feed is unreachable, the post is refused with an explanatory message rather than silently substituting 1:1 (which would corrupt cost basis).

Column sorting back on the reconciliation queue

Added
  • Click any column header on the reconciliation queue to sort by that column. Every header on every type-tab (All, Wallets, CEX) is now clickable — Date, Chain, Direction, Amount, Counterparty, Wallet, Batch, Tx hash, Exchange, Reference, Suggested, all sortable. Each header shows an icon: ⇅ when not sorted, ▲ when sorted ascending, ▼ when sorted descending. Clicking the active sort column toggles direction; clicking a different column sets ascending. Default is Date descending (matches the server's existing ordering). Amount sorting handles token amounts (scaled by tokenDecimals) and native amounts (18 decimals) so big tokens don't dwarf native ETH when comparing across rows. Sorting works client-side over the current page — pagination is unchanged; to sort the entire queue, server-side support would be a follow-up.

Drag-and-drop now also reorders pinned items

Fixed
  • Dragging two pinned cards past each other now sticks. The list orderBy is pinnedAt DESC NULLS LAST, sortOrder ASC, createdAt DESC — for pinned rows, pinnedAt (the pin-time timestamp) determines the order, not sortOrder. The reorder endpoints used to write only sortOrder, so dragging pinned-A above pinned-B would render correctly in the optimistic state but snap back on refresh (because pinnedAt DESC re-asserted itself). Every reorder endpoint (wallet, CEX, bank, payment cards, brokerage, retirement) now writes sortOrder AND rewrites pinnedAt for currently-pinned rows to a strictly-decreasing timestamp matching the new array order. Unpinned rows keep pinnedAt = NULL (the CASE clause in the SQL preserves it), so the pin cap accounting stays unaffected. Net effect: pin order = drag order = display order, all consistent.

DnD + pin rolled out to every list (CEX, bank, cards, brokerage, retirement)

Added
  • Every journal / account list now supports drag-and-drop reorder + pin-to-top, not just wallet journals. The roll-out covers: CEX journals (V2), Bank journals, Payment cards (on the Connections page), Brokerage / Investment accounts (on the Connections page), and Retirement accounts (table view). Each surface follows the same pattern that's been on wallet journals: drag the whole card / row to reorder, click the amber pin button to float a row to the top. Re-clicking pin on an already-pinned row bumps it back to the top of the pinned group.
  • The pin VIP cap is now one shared budget across every pin-able entity in a portfolio: wallet journals + CEX + bank + payment cards + brokerage + retirement, counted together. VIP 0 cannot pin at all (the button surfaces the upgrade prompt); VIP N gets N pins total per portfolio across all entity types; VIP 10 (founder) is unlimited. This means a user with VIP 3 can spread their three pins across whichever mix matters most — e.g. one wallet journal, one brokerage account, one payment card.
Changed
  • DnD click suppression is now built into every list. The shared suppressNextClick() helper installed in the previous batch is now called from onDragEnd / onDragCancel on every list, so dropping a card / row never opens the underlying item. Bank, cards, brokerage, retirement — all inherit the wallet-journals fix automatically.
Fixed
  • Three dropdown surfaces that were broken in light mode are now theme-aware. The reconciliation queue's journal multi-select popover, the wallet-journal detail page's sort menu, and the entry modal's native journal-selector <option> all used a hard-coded bg-[#14121f] (dark purple) without a light-mode variant — they rendered unreadable dark-on-dark in light mode. All three now use the theme-aware bg-popover + text-popover-foreground tokens.

Drop-after-drag truly stops opening the journal now

Changed
  • The suppressNextClick() helper is the shared canonical pattern for every list with whole-card DnD. Wallet-journals (V2) uses it today. CEX V2 has no DnD yet; the legacy sections-aware CEX client (kept in sync for future re-activation) now also uses it. When bank journals / payment cards / investment accounts gain DnD they should call suppressNextClick() from their onDragEnd + onDragCancel handlers and inherit the fix for free.
Fixed
  • Dragging a wallet journal and dropping it onto a new slot no longer opens the journal you just released. The previous attempt (commit 4b09a5b) stamped a timestamp at the dragged element's parent and checked it inside the link's onClick handler, but dnd-kit's onDragEnd does not consistently fire before the browser's synthetic click event in the same tick — when click fires first the timestamp is still stale and the suppressor does nothing. The fix is now a single suppressNextClick() helper at src/components/dnd/suppressNextClick.ts that installs a capture-phase document-level click listener on drag-end; capture-phase listeners run before any element listener regardless of which fired first, so the click is killed before Next.js Link ever sees it. The listener self-removes after the first click (or after 200 ms if no click followed) so unrelated clicks elsewhere on the page are never affected.

Journal pinning (VIP-gated)

Added
  • Pin journals to the top of the list. Both wallet journals and CEX journals now have a pin button in the top-right of each card (always visible when pinned, hover-revealed when not). Pinned journals float to the top of the list, ordered most-recently-pinned-first within the pinned group; the rest follow in the existing sort order. Pinned cards get an amber-tinted border so the visual cue is visible even at a glance. Re-clicking pin on an already-pinned journal bumps it back to the top of the pinned group (useful for "this is the one I'm working on right now"). Unpin sends the journal back into the unpinned list at its previous sort position.
  • VIP-gated. Pinning is a paid feature with a per-portfolio cap that scales with VIP tier — VIP 0 cannot pin at all (the button surfaces an upgrade prompt), VIP 1 gets 1 pinned journal per portfolio, VIP 2 gets 2, etc., VIP 10 (founder) is unlimited. The cap counts WALLET + CEX pins together (one shared budget across both kinds), so users on tight tiers can spread their pins across whichever journal types matter most to them. Attempting to exceed the cap shows a toast with the next-tier upgrade hint, and the optimistic pin is rolled back. Unpinning is always free (no cap check) and re-pinning an already-pinned journal is also free (no count increase). New src/lib/journal-pin-vip.ts holds the cap formula; tune the ladder there.
Changed
  • Wallet journal and CEX journal list pages now sort with pinnedAt DESC NULLS LAST, sortOrder ASC, createdAt DESC — pinned first, then existing user-curated drag-and-drop order, then newest. The drag-and-drop reorder still writes sortOrder exactly as before (no cross-group drag yet), so the user's existing layout is preserved within each pin group.

Main dashboard "To reconcile" banner now shows the real count

Fixed
  • The amber "X imported transactions need reconciling" banner on the main portfolio dashboard now reads the actual import queue. Until now it counted JournalEntry.status = PENDING (user-created journal-entry drafts awaiting post-approval) and labelled the result as "imported transactions". On the production portfolio that meant the banner read "0 imported transactions need reconciling" while the reconciliation queue actually had ~13,560 rows waiting — the banner had been misreading its source since the V2 dashboard cutover (commit 721702f, 2026-04-30). The label now matches the data: ImportedTransaction.status = TO_RECONCILE, deletedAt IS NULL — same filter the briefing API and the queue page use. Same fix applied to the dashboard subtitle ("13,560 imported transactions await reconciliation") and the Reconcile tile in the command rail (subtext shows "13,560 pending" instead of "0 pending"). Numbers render with locale-formatted commas so a five-digit queue doesn't look like a four-digit one. The Review CTA on the banner now links directly to /<slug>/reconciliation instead of taking the user via the wallet-journals list page — one click to triage instead of two.

Retrospective scam scan applied to existing imports

Fixed
  • 75 of the 13,635 pending wallet-journal transactions in the production portfolio have been auto-dismissed by the new scanner, applied retrospectively to rows that landed before the scanner shipped. Breakdown: 7 fake-symbol-airdrop tokens (4 unique contracts: "GOOGLE" on BSC, "BABYSHARK" on BSC, "币安梦"/Binance-Dream on BSC, "世界杯"/World-Cup on BSC — classic scam Chinese-character meme tokens, plus AVAX-symbol impersonation on Avalanche, "ETHG" + "AICC" on Ethereum) added to the system-wide scam-currency registry so any future import of the same contract is auto-flagged; 68 address-poisoning transactions (lookalike sender + zero or dust amount, mostly USDT/USDC poisoning on BSC). The pending To-Reconcile count drops from 13,635 → 13,560. Every auto-dismissed row carries the scanner's verdict in its rawJson and is one click away in the Dismissed tab → Restore if the user disagrees with the verdict.
  • A new CLI scripts/scan-existing-imports.ts was added for any follow-up retrospective scans (new portfolios, new scam patterns added to the heuristic set). Dry-run by default — reports the per-kind verdict counts + the tokens that would be registered before any write. --apply commits. Uses the exact same classifier as the live commit-pipeline scanner — no duplicated heuristic logic to keep in sync.

Scan & import: automatic scam detection

Added
  • Every scan & import now runs a real-time scam scanner against the rows it just imported, and auto-dismisses anything it classifies as scam. The To-Reconcile tab only shows transactions you actually need to review — the noise is filtered out before it gets to you. The scanner separates three distinct outcomes: - Scam token — the token contract itself is fraudulent (honeypot, blacklist function, owner can mint/seize, hidden owner). Detected via the GoPlus Security token-security API (free, no key, supports 14 EVM chains: Ethereum, Optimism, BSC, Polygon, Arbitrum, Avalanche, Base, Linea, Cronos, Gnosis, HECO, Fantom, KCC, zkSync Era), plus the existing scam-token registry (any token any user has already flagged via "Mark as scam" auto-matches future imports). Rows are dismissed AND the (chain, contract) is upserted into the global scam-currency registry so every future import of the same token is auto-flagged. - Address poisoning (scam tx with a real token) — the attacker sent you 0 or dust of a real token (USDC, USDT, WETH, etc.) from a wallet address whose first 4 + last 4 hex chars match one of your verified addresses. Classic copy-paste-the-wrong-address trap. The row is dismissed but the token (USDC/USDT/etc.) is never registered as scam — we maintain a real-asset allowlist per chain so a real token used in a scam pattern doesn't accidentally hide every legitimate stablecoin tx that follows. - Scam tx with unknown token — lookalike-sender pattern but the token isn't on the real-asset allowlist. Row dismissed AND token registered, on the principle that an unknown contract used in a poisoning attempt is overwhelmingly likely to be a fake-symbol airdrop (the "USDT" sender that isn't actually Tether's contract).
  • Each auto-dismissed row carries the scanner's verdict in its rawJson: kind (SCAM_TOKEN / SCAM_TX_REAL_TOKEN / SCAM_TX), confidence (high / medium), the reasons list (e.g. "Sender 0xabcd…ef12 mimics one of your verified addresses by prefix+suffix", "Token transfer amount is zero or dust — classic poisoning seed", "GoPlus is_honeypot=1"). A new amber/red Scam token / Poisoning / Scam tx badge on the Dismissed tab surfaces this — hover for the full reasons list. If the scanner ever gets it wrong, the row is one click away in Dismissed → Restore.
Changed
  • The scanner runs fire-and-forget alongside the existing DeFi classifierexecuteCommit kicks off both as parallel background jobs. The commit response is unblocked; the scanner takes a few seconds to finish in the background and the To-Reconcile tab is up to date by the time the user opens it. External API calls (GoPlus) are concurrency-capped at 4 with a 3-second per-call abort, and cached in-process for 24 hours per (chain, contract).

Posting preview reflects what you typed

Fixed
  • The posting preview on the right of the entry modal now reads from the form. Until now it rendered a hardcoded sample (e.g. "+0.482 BTC @ $71,400" for any Fiat trade Buy) regardless of what the user typed — the green "Balanced" pill was effectively a lie, since it confirmed numbers the user hadn't entered. Every variant (Fiat trade, Crypto trade, Card buy, Liquidity add/remove, the four Staking variants, the two Income variants, CEX transfer, Wallet→wallet, Inter-portfolio, Deposit, Gift out, Fee, both Scam variants) now derives its DR/CR lines from the live formData. Empty fields render as "—" instead of fabricated numbers. The Balanced pill flips to red until both sides have real values — so the preview is a true sanity check before posting.

Wallet imports: tx hash visible, BNB dates corrected

Fixed
  • BNB Chain imports now carry the real block timestamp, not the time of import. The Alchemy adapter returns metadata: null for transfers on BSC and Base, and the previous code fell back to new Date() so every BSC import was stamped with its IMPORT time. The adapter now batches eth_getBlockByNumber(blockHex, false) (headers only, 10-way concurrent, results cached by block) to resolve the real timestamp for any transfer Alchemy returns without metadata. Base was already getting correct timestamps via a different path; BSC is the chain you would have noticed this on.
  • Existing BNB imports have been retroactively corrected. A backfill script (scripts/backfill-alchemy-block-timestamps.ts) walked every BSC ImportedTransaction whose timestamp landed within 5 minutes of its createdAt (the broken-import signature), resolved each row's actual block timestamp via Alchemy, and updated the timestamp column. 11,850 of 11,850 candidate rows updated successfully (no losses). The script is idempotent — a re-run finds nothing because corrected rows no longer match the broken-signature window.
  • Tx hash is displayed and copy-able on the Wallets reconciliation tab. The on-chain hash was always stored — externalId for native rows IS the hash, and the full hash is also in rawJson.hash — but after the recent one-row-per-flow refactor, token-sourced rows carry a composite externalId of shape ${hash}-${tokenAddress}-${dir}. The Wallets tab now extracts and shows the bare 66-char hash (truncated middle, full hash on hover, click the copy icon to lift it to the clipboard). The duplicate-resolution dialog and the modal's Transaction hash field also use the bare hash so users can paste straight into a block explorer.

Wallet-journal drop no longer opens the journal you just moved

Fixed
  • Dragging a wallet-journal card to a new spot no longer opens it on release. The whole card is both the drag surface AND a link to the journal's detail page, so when the pointer was released after a drop, the browser still fired a click on the underlying link — dropping a card into a new slot bounced you straight into that journal. The card now swallows clicks that fire within 200 ms of a drag end, so the drop just reorders. Plain clicks (no drag movement) and modifier-key clicks (Ctrl/Cmd/Shift/middle-click → new tab/window) are unaffected — both the navigation and the new-tab behaviour still work. Same fix applied to the table view.

Manual modal: 9 more transaction types now post end-to-end

Added
  • Post entry now persists for nine additional transaction variants. The earlier rebuild wired four common variants (Fiat trade / Crypto trade / CEX transfer / Fee) end-to-end and the rest were still "use the legacy form" placeholders. This release wires the rest: Card buy (debit/credit-card crypto purchase), Liquidity (provide + withdraw), the four Staking variants (stake / unstake / claim rewards / lock), Income — passive (yield + airdrop), Income — work (salary / contract / consulting), Wallet → wallet transfer (between two verified wallets in the same portfolio), Deposit (opening balance + top-up), Gift out, and both Scam variants (scam-in / scam-out). Each variant submits through the right endpoint for the selected journal — the wallet-journal-scoped /entries POST for wallet journals, the CEX /entries POST for CEX journals — and refreshes the parent on success. Inter-portfolio transfer remains a redirect to its dedicated page (its two-portfolio payload doesn't fit the single-journal form).

Swaps now show both legs in reconciliation

Fixed
  • A USDC → ETH swap no longer collapses to a single row. Etherscan-sourced transactions where the same hash carried multiple token flows (a disposal + an acquisition, classic swap shape) used to import as one ImportedTransaction row representing only the larger leg — the counter-flow was discarded. The DeFi classifier then saw a one-sided withdrawal and mislabelled the tx; the reconciliation queue had nothing to pair the disposal against. The Etherscan importer now emits one row per (hash, token contract, direction) flow, so swaps land as a SEND row and a RECEIVE row that can be classified and reconciled independently. Gas is stamped on exactly one row per hash so fee-sum-per-tx queries don't double-count. The accurate accounting view of a swap is two ledger lines anyway, so this is what should have happened from day one.

Reconciliation queue: per-tab column models

Changed
  • The reconciliation queue now shows different columns depending on which tab you're on. The previous build used one universal column set (Date / Source / Direction / Amount / Counterparty / Suggested) regardless of tab — the Wallets tab hid the tx hash, the CEX tab wasted space on a Chain column that's always blank. Per-tab models now: Wallets drops Source, emphasises Chain as a badge, adds a copy-able Tx hash column. CEX drops Chain, adds Exchange (resolved from the journal) and Reference (falls back to externalId for CSV-imported rows so most "—" placeholders go away). All keeps the universal set. Banks / Cards continue to show the Coming-soon placeholder. The "no rows" empty-state copy is also tab-aware now.

Sub-second chain discovery + wallet-journal cards show every active chain

Changed
  • Chain discovery is ~30× faster. Discovery no longer scans Etherscan's txlist + tokentx endpoints per chain. Instead, every chain we have a viem RPC config for is probed with two cheap O(1) JSON-RPC calls — eth_getTransactionCount (nonce > 0 = address has sent) and eth_getBalance (balance > 0 = address holds native). Either signal is enough to mark the chain active. Different chains hit different RPC origins so the work is truly parallel — total wall time for a typical reachable set is under 1 second (was ~28 s). 36 chains have viem RPCs today; the few that don't (rare-explorer chains) keep the previous Etherscan-list fallback path. The trade-off: a wallet that ONLY received ERC-20s with zero native balance and never sent anything won't be picked up — uncommon in practice (any ERC-20 receipt is usually accompanied by some native dust for gas) and the full-import path catches it when the user runs a real scan.
  • RPC-probe failures now classified as skipped, not errored. A failing public RPC on an obscure chain isn't something the user can action — bucketing it as skipped keeps the warning toast quiet. The toast only triggers on truly unknown failures now.
Fixed
  • Wallet-journal LIST cards now show every active chain, not just the verified one. The cards in the journals list view rendered a single chip derived from wallet.chainId (the SIWE-verification chain) — so a wallet with activity on Ethereum + BNB + Base + Avalanche showed only "Ethereum". The list page now collects every chain from WalletJournal.discoveredChainIds, every ImportedTransaction.chainId, and every posted JournalEntry.chainId, unions them, and renders the full set as chips in both the grid and list views (capped at 3 visible + "+N" overflow). The detail page header had been correct since the discovery feature shipped; this closes the gap on the list.

Reconciliation queue: one-click "Mark token as scam"

Added
  • "Mark token as scam" quick action on every row in the To-Reconcile tab's actions menu. Clicking it registers the token as a system-wide scam (POST /api/reconciliation/classify-scam with the row's chainId + tokenSymbol + tokenAddress) AND auto-dismisses the queue row in one click, with a clear audit-trail note attached. Saves the user from the full SCAM_FAKE_ASSET entry-modal flow when they only want to flag the token and have it stop polluting the queue — no journal entry, no cost basis, just "this is junk, get it out". Subsequent rows that carry the same token will automatically be tagged isKnownScam: true so the next discovery picks them up without re-flagging.

Discovery toast no longer flags Etherscan rate-limit as user-facing error

Fixed
  • "20 chains errored — re-run discovery to retry" on a working discovery. The Etherscan V2 token bucket was set to 5 RPS — Etherscan's nominal free-tier limit, but in practice bursts of 5 trigger Max calls per sec rate limit reached. The per-call retry path then fires Rate-limited, retrying in 1000ms / 2000ms / 4000ms and ~20 chains exhausted their 3 retries → RATE_LIMIT ExplorerError. Two changes: the V2 bucket is now 3 RPS (gives Etherscan enough headroom that the retry path fires only for genuine throttle conditions; runtime grows from ~17 s to ~28 s but the storm goes away), and RATE_LIMIT is now classified the same as NETWORK_ERROR (silent skipped) instead of errored. Only genuinely UNKNOWN failures end up in the user-facing errored count now.
  • Alchemy "network not enabled" 403s are surfaced with the dashboard URL. When the user's Alchemy app doesn't have a network enabled (e.g. OPT_MAINNET is not enabled for this app), the 403 body includes a direct dashboard URL: https://dashboard.alchemy.com/apps/<id>/networks. The previous handler bucketed the 403 as a generic NETWORK_ERROR; it now throws CHAIN_NOT_SUPPORTED with Alchemy's exact body — including the dashboard URL — preserved in the error so the diagnosis is one click away.

Discovery faster: skip dead chain explorers + per-probe wall clock

Fixed
  • Discovery took ~70 seconds even with the concurrency cap. The probe was firing against ~20 chains whose explorers are either slow, dead, or just don't accept our API key (Posichain, Ether-1, Harmony's own explorer, the un-keyed Routescan chains — Zora, Palm, Boba, Mode, etc.) and each stalled for the full 10-second TCP-connect default. Two changes: chains using a chain-native explorer with no dedicated key are now filtered out of the probe set up front (no request fires for them — they land directly in skipped); and every probe that does fire is wrapped in a 5-second AbortController so a single slow endpoint can't hold up its worker for the full 10 s. AbortError is classified as skipped, not errored, so the warning toast stays quiet for these expected outcomes. Discovery now completes in ~10-15 s for a typical wallet across the chains we have keys for.

Manual-transaction modal pre-fills from the imported transaction

Fixed
  • Opening the manual-transaction modal from a queue row no longer shows empty placeholders. When the user clicked an imported transaction in the reconciliation queue (e.g. a 188 世界杯 BNB token deposit) to classify it as scam / income / trade / etc., the modal opened with empty asset / quantity / hash fields — the placeholder text "1000000" looked like a real value but wasn't. The user had to retype every number that the row already knew. The reconciliation queue now passes a prefill payload built from the row (asset symbol, decimal-string-precise quantity, tx hash, counterparty address, fee asset/amount, timestamp formatted for the local-tz datetime picker), and the modal seeds formData from it on open. The variant tile (Scam / Income / Trade / Deposit / etc.) is still picked by the user — only the data shifts. Quantity uses big-decimal math instead of parseFloat so 18-decimal token amounts survive (the same precision rule the engine audit fixed for imports/hash).
  • Modal prefill is plumbed as a generic prefill?: Partial<FormData> prop on EntryModalV2, not bolted to the reconciliation queue specifically. Any future surface that wants to open the modal pre-populated (per-row "Reconcile" actions on the Entries tab, AI auto-classify, bulk paste) can pass the same shape via the new importedTxToPrefill() helper.

Chain discovery now finds activity on BSC and Base

Fixed
  • "Re-discover" was reporting no activity on BSC and Base for wallets that clearly had funds on those chains. Three stacked issues, fixed in order: 1. Next.js App Router's fetch caching layer was wrapping the Alchemy adapter's POST RPC calls and producing intermittent TypeError: fetch failed under concurrent load — direct curl to the same URLs returned HTTP 200 in <200 ms, so the fault was in the patcher, not the network. cache: "no-store" is now set on every Alchemy + explorer fetch so the patcher leaves them alone (live RPC responses should never be cached anyway). 2. Discovery fired 84+ simultaneous fetches against api.etherscan.io (42 V2 chains × 2 sub-requests). The shared 5 RPS TokenBucket throttled rate-of-acquisition but did not bound concurrency, so Etherscan's edge silently dropped SYN packets above its per-IP burst threshold and every Etherscan V2 call timed out with ConnectTimeoutError. The discovery probe now runs through a worker pool with a hard concurrency cap of 5 — the rate cap still applies via the bucket, but at most 5 requests are in-flight at any moment so undici can reuse keepalive connections. Total runtime is unchanged. 3. The Alchemy adapter discarded every transfer on BSC and Base because alchemy_getAssetTransfers returns those transfers with metadata: null even when withMetadata: true is requested (Ethereum populates the field; BSC and Base don't). The strict if (!t.metadata) continue filter was the final reason discovery reported zero activity. The filter now falls back to a placeholder timestamp when metadata.blockTimestamp is missing — discovery works correctly. Note: if you full-import a BSC or Base wallet through this path, the imported entries will currently carry an approximate now() timestamp; use the canonical Etherscan path for accurate timestamps until the follow-up batched-block-lookup lands. Tracked in ledger/known-bugs.md #7.
  • Wallet-journals list card chips lagged the detail page after running discovery — the detail page header would correctly show every active chain, but the journal card on the list view stayed on the original chain until a hard reload. The discovery route now calls revalidatePath("/[slug]/wallet-journals") so the list page rebuilds on next render. Tracked in known-bugs.md #8 (closed).
  • Provider error messages now include the underlying cause. [Alchemy] Network error / [Etherscan] Network error log lines previously logged only String(err) which swallowed the .cause chain — every failure looked like a generic "fetch failed" with no actionable signal. Both paths now expose cause.name, cause.message, and cause.code (with API-key segments masked from the URL). The diagnosis turnaround for the original issue was ~30 minutes once the cause was visible, after a dead-end hour of guessing.

Chain discovery no longer treats "no API key" as an error

Fixed
  • "93 chains errored — re-run discovery to retry" toast on a successful discovery run. The discovery probe was firing against every chain in SUPPORTED_CHAINS (~97), but most have no provider key configured in the typical environment — Routescan-hosted chains need ROUTESCAN_API_KEY, Blockscout-hosted chains have their own endpoints, and the shared Etherscan V2 key doesn't always cover every L2 on every plan. Provider rejections (INVALID_KEY, CHAIN_NOT_SUPPORTED, NETWORK_ERROR) were all bucketed as "errored", producing the noisy warning even when discovery worked correctly. Two changes: discovery now pre-filters to chains whose env var is set (or whose provider runs keyless, like BSC via Ankr) so we never even fire the request for a chain we know we can't reach; and provider-side rejections are reported as skipped, not errored. Only genuine rate-limits and unknown failures count toward the warning toast now — and the toast only appears when there's something the user can actually retry.

Manual-transaction modal: every field editable + four variants posting end-to-end

Fixed
  • Every field in the v2 manual-transaction modal is now editable. The previous build was a non-functional mockup — every "Input" was a <div> displaying a hardcoded string, every "Select" was a no-op button, the Notes <textarea> had no onChange, the date display was a hardcoded "now" snapshot, and both the "Post entry" and "Save draft" buttons had no click handlers at all. The modal now uses real controlled <input> / <select> elements throughout, lifts one shared formData dictionary into the modal so every variant binds to the same source of truth, threads it through to EntryFormFields for all 14 transaction-type variants (FIAT_TRADE, CRYPTO_TRADE, CARD_BUY, LIQUIDITY, the four STAKING variants, the two INCOME variants, CEX_TRANSFER, WALLET_TRANSFER, INTER_PORTFOLIO_TRANSFER, DEPOSIT-as-opening-balance, GIFT_OUT, and the two SCAM variants), and binds the Notes textarea + datetime-local picker to state.
  • "Post entry" actually posts. Wired end-to-end for the four most common variants — FIAT_TRADE (BUY/SELL), CRYPTO_TRADE, CEX_TRANSFER (deposit/withdrawal), and FEE. The payload is assembled from formData and POSTed against the right endpoint depending on whether the selected journal is a wallet journal (the new /api/portfolios/[id]/wallet-journals/[journalId]/entries POST) or a CEX journal (/api/cex-journals/[id]/entries). Validation failures surface as a one-sentence toast; success closes the modal and refreshes the parent. Other variants still surface a "use the legacy form" message until their builders are wired through _postJournalEntry — their fields are still editable so they're ready for the wiring sweep.
  • CEX deposit / withdrawal counter-party now pre-fills from the journal context. Opening the modal from a CEX-journal page with CEX_DEPOSIT selected pre-fills the destination CEX account with that journal; CEX_WITHDRAWAL pre-fills the source. Opening from a wallet-journal page pre-fills the wallet leg with that journal. Previously both selectors showed hardcoded mock strings the user couldn't change.
  • The footer hint is honest now. ⌘+Enter (or Ctrl+Enter) actually posts the entry, matching the "⌘ + Enter to post" hint in the footer. "Save draft" persists the current formData snapshot to localStorage so a tab refresh doesn't lose typed work.

Engine audit B-tier sweep: chain coverage, CSRF, atomicity, error visibility

Added
  • CSRF gate on bank-journal manual entries. The bank-journal POST endpoint previously relied on the edge proxy alone for CSRF, while every other mutating endpoint on the platform is also gated in-route. Now belt-and-braces with assertCsrf.
Changed
  • Add-transaction-by-hash now works on ~30 more chains. The hash-import flow's chain map only covered Ethereum, BSC, Polygon, Arbitrum, Base, and Avalanche — every other chain the wizard accepts (Optimism, Linea, Scroll, Blast, Mantle, Polygon zkEVM, Arbitrum Nova, Celo, Gnosis, Fantom, Cronos, Moonbeam, Moonriver, Sonic, Berachain, World Chain, Soneium, Unichain, Katana, Sei, Ronin, Filecoin, opBNB, Metis, Fraxtal, Boba, Mode, Zora, Kaia, VeChain) silently rejected the hash with "Chain N not available". The map is now derived from viem's first-party chain registry, so any chain we register in SUPPORTED_CHAINS that has a public RPC endpoint is reachable.
  • Bulk CSV CEX import rejects unknown entry types instead of silently coercing them to TRADE. The previous schema mapped any unrecognised entryType string to "TRADE", which corrupted imports whose column mappings were off-by-one and made it impossible to ever introduce a new entry type without manually updating every active CSV preset. The schema now enforces the strict 8-value enum (TRADE, FIAT_DEPOSIT, FIAT_WITHDRAWAL, CRYPTO_DEPOSIT, CRYPTO_WITHDRAWAL, FEE, INCOME, WORK_INCOME) and surfaces the bad row in the standard Zod validation response so the user can fix the column mapping.
  • Bulk CSV CEX import is now all-or-nothing. Every row create runs inside one $transaction, so a mid-loop failure no longer leaves a half-imported batch with no clean retry path. Rows whose timestamps fail to parse are surfaced in the response as skippedBadTimestamp instead of being silently dropped.
Fixed
  • Bank-journal entries reject currency mismatches. A USD bank journal will no longer silently accept an EUR entry; the route now compares the incoming currency to the journal's bank account and rejects mismatches with a clear CURRENCY_MISMATCH instead of storing the wrong code (which would have been invisible until the next reconciliation drifted the cash balance).
  • Rate-limit retries inside the Etherscan/explorer client now honour AbortSignal. Previously the recursive call dropped the signal, so a user disconnecting during a long backoff kept the server slogging through every retry. The wait now races against the signal and the recursive attempt forwards it.
  • scanExecutor cache-read failures are no longer silent. The cache-table-doesn't-exist-yet try { ... } catch {} swallow at the top of every scan also caught real DB errors (connection drop, schema drift) — every subsequent scan fell back to a full-history fetch and silently burned the user's provider quota. We now log loudly with [scanExecutor] and a "full-scan fallback engaged" breadcrumb so the on-call query knows where to look.

Manual wallet-journal entries can now actually be saved

Added
  • POST endpoint for per-journal manual entries. The wallet-journal detail page's "Add entry" action previously had nowhere to send a payload — /api/portfolios/[id]/wallet-journals/[journalId]/entries was a GET-only route. POSTing there now succeeds with the same payload shape as /api/portfolios/[id]/transactions, and the resulting JournalEntry is auto-linked to the journal so it appears in its Entries tab without any cross-join. The route is a thin wrapper around the canonical transactions endpoint, so every supported transaction type (BUY, SELL, CRYPTO_TRADE, DEPOSIT, WITHDRAWAL, FEE, INCOME, WORK_INCOME, WALLET_TRANSFER, GIFT_OUT, the SCAM variants, LIQUIDITY, and the STAKING family) works end-to-end with full lot management and IFRS validation — no duplicate logic, no second source of truth.
Changed
  • walletJournalId is now a first-class field on the transactions API. Both the canonical /api/portfolios/[id]/transactions route and the underlying postJournalEntry posting helper accept an optional walletJournalId; when set, the created JournalEntry is linked to that journal and the route validates the journal belongs to the same portfolio first. The per-journal POST wrapper above is the first caller, but any future surface that creates entries from a journal context (the planned manual-transaction modal, an AI-driven auto-poster, a bulk-paste flow) can pin the link with one field instead of running a follow-up update.

Engine audit A-tier sweep: compromised-wallet guards, UTC+7 quotas, soft-delete filters

Fixed
  • Compromised wallets no longer auto-import on any path. The platform rule says wallets the user has flagged as compromised must never auto-pull new on-chain rows — the previous behaviour honoured this only on the global /api/import/scan endpoint, so the per-journal scan / commit / discover / import-by-hash routes plus the cross-portfolio "add transaction by hash" all kept pulling. Every auto-import path now goes through one shared assertWalletNotCompromised() helper that also blocks manual-only wallets, so the rule is enforced consistently and any future import route inherits the guard by importing one function.
  • Daily import quota now resets at local midnight (UTC+7), not at 07:00 local. getUsedImportsToday was anchored to UTC midnight, which meant every Atlas Labs user in UTC+7 saw their daily import allowance reset at 07:00 local time. The day-start computation now shifts the wall clock by +7h before flooring to the day boundary, so the quota window matches the timezone the platform actually operates in.
  • Soft-deleted imported transactions no longer pollute the queue. Eight routes that act on or surface ImportedTransaction rows — duplicate-resolution reconcile, duplicate-resolution resolve, duplicates listing, AI-suggest, import preview, the transactions create endpoint's gas-autofill, the per-journal import-by-hash dedupe, and the cross-portfolio add-by-hash dedupe — now filter out rows with deletedAt set. Two visible consequences: re-adding a deliberately-deleted row by hash now succeeds instead of being blocked with a phantom "DUPLICATE", and the duplicates / AI-suggest views no longer dangle soft-deleted rows that the user can't act on.
  • Multi-chain import batches now tag ImportJob.chainId with the dominant chain. A scan-and-commit run that covered five chains used to record the ImportJob row with chainId: chainIds[0] — the first requested chain, irrespective of which chain actually produced the rows. Analytics and audit trails read that single id as the source of the entire batch. The job now records the chain that contributed the most rows in the batch, which is always right when there's a clear majority and the only honest answer until the schema accepts a multi-chain set.

Engine audit S-tier sweep: data-integrity, atomicity, precision

Fixed
  • Cross-tenant write bypass closed on account-ownerships. The "Edit ownership" save endpoint verified the portfolio belonged to the caller, but never confirmed that the wallet / CEX account / bank account / etc. being edited was actually inside that portfolio. A forged request could have rewritten the ownership rows of any account in any portfolio whose id was known or guessed. The route now resolves the subject to the right Prisma model (one of eight kinds — wallets, CEX, banks, brokerages, retirement, payment cards, properties, business entities) and rejects with 404 when the id doesn't belong to the caller. The ownership-row id generator was also hardened from a Date.now()_random6 scheme to randomUUID() so concurrent inserts can't collide.
  • Duplicate-resolution "void journal entry" now reverses cost basis. When you resolved a duplicate in the reconciliation queue and chose Void journal entry, the previous implementation flipped the entry's status to VOID directly — but never reversed the FIFO lot consumption the entry had performed. Every void via this path produced cumulative cost-basis drift: consumed lots stayed consumed against the VOIDed entry. The route now goes through the canonical voidJournalEntry helper, which rebuilds FIFO state for every currency the entry touched. Reverting an old duplicate-resolution today does not retroactively heal the existing drift — please re-run cost basis recalculation if you've used this flow before.
  • Manual CEX entry save is now atomic. Previously the route did three independent writes — cexJournalEntry.create, then postCexEntryToLedger, then update(ledgerEntryId) — and quietly caught any ledger-posting failure, returning 201 to the client while leaving a CexJournalEntry row with ledgerEntryId: null (a "saved-but-unposted" zombie that only the repairUnlinkedCexEntries job could rescue). The route now applies the compensating-action pattern: if posting throws, the just-created entry is deleted and the client sees a 500 with a clear message ("your entry was rolled back — try again"). Expected skipped: true outcomes (e.g. internal transfers that intentionally defer posting) keep the entry unlinked, as before.
  • Multi-chain commits now surface per-chain failures instead of silently returning "0 rows imported". When every per-chain provider call rejected — bad API key, rate-limit, network — the import-commit route used to swallow every error via Promise.allSettled, return 201 with imported: 0, and burn the user's daily quota. The commit result now carries a chainErrors array; the global import endpoint returns 502 with the list when all chains failed, and 201 with the partial-failure list when some succeeded. Daily quota is still consumed on partial success (the work was real) but a total failure no longer looks like a successful empty import.
  • Import-by-hash precision restored + silent direction-default removed. The per-journal "import by hash" route used to convert ETH amounts to wei via parseFloat(ethValue) * 1e18 → Math.round → BigInt, which silently lost precision on any 18-decimal value past 15 significant digits — i.e. almost every real transaction's wei tail. Conversion now happens in string space, so an 18-decimal amount survives intact. Separately, when neither the sender nor the receiver of the supplied hash matched the journal's wallet, the route used to default direction to OUT and push the disambiguation onto the reconciler invisibly; it now rejects the request with WALLET_NOT_INVOLVED and a clear message naming the actual from / to so the user can fix the input.
  • Token-swap counter-flow no longer silently dropped at import. The Etherscan importer aggregates Transfer events per (hash, contract, direction), but the row it wrote to the database only carried the dominant flow — the larger leg of a swap. The smaller leg (e.g. the USDC out side of a USDC→ETH swap) was discarded entirely. The full set of aggregated flows is now persisted in rawJson.flows so the DeFi classifier, the reconciliation UI, and the AI suggestion can all see both sides of every swap. A full one-row-per-flow refactor is the proper fix and is queued.

Wallet-journal multi-chain discovery + chain picker + Dashboard activity row

Added
  • "Discover chains" on every wallet journal. A new action button on the Dashboard tab probes every supported EVM chain at the wallet's address and persists the list of chains that returned at least one transaction. Subsequent visits show every active chain as a chip on the wallet card and detail header (Base/BNB/Avalanche/Polygon/etc. — not just the chain the wallet was first SIWE-verified on) without re-probing. Re-running the action only adds newly active chains; it never removes a chain you've already seen, so a transient explorer rate-limit doesn't make a chip disappear.
  • Chain picker on "Scan & import". Clicking the primary scan button opens a popover listing every discovered chain with a checkbox, a "Select all / Deselect all" toggle, and a one-click confirm that scans only the chains you ticked. Default selection is every discovered chain. The button label updates to show the active selection, e.g. "Scan & import (4/6)". Non-EVM wallets continue to scan their single chain.
  • "To Reconcile" card on the wallet-journal Dashboard tab. When a wallet journal has imported but unclassified transactions, an amber card now appears at the top of the Dashboard tab showing the pending count with a one-click jump to the Entries tab to resolve them. Previously this signal only existed on the Entries tab itself — so a user opening the journal from the wallet list saw a clean Dashboard even when work was waiting.
Changed
  • "Scan & import" moved from the Import history tab to the Dashboard tab. The Import history tab is for looking back at what already happened; the scan action is a weekly-or-more-frequent activity that belongs on the surface the user lands on. The history tab now reads as a pure audit trail.
  • Reconciliation page Journal dropdown re-themed to the canonical dark-glass surface. The cross-portfolio Reconciliation queue's "Journal:" filter previously rendered with a frosty-white background on the dark page because the upstream Select primitive's light-mode rule was winning over its arbitrary-HSL dark override. The dropdown now uses the same border/background/blur the rest of the platform's modals and popovers use, follows the app's theme correctly, caps at ten rows with a built-in scrollbar, and uses the live wallet label instead of the snapshot title — so a wallet that was renamed via "Name your wallet" now shows the chosen name in the picker instead of Wallet · 0x9bE93EB7bE1ffdb7ede3753D7390b69F16A95737.

Shell relayout: top bar now spans the viewport above the sidebar

Changed
  • The Ledger app chrome has been re-stacked into the canonical top-bar-over-sidebar layout. The Atlas Labs logo and breadcrumb now sit at the absolute top-left of the viewport, the top bar spans the full screen width, and the live price ticker runs full-width directly beneath it — including under what used to be the sidebar's column. The sidebar starts below that stack instead of running full-height from the top of the viewport. Workspace switcher, layout toggles, navigation, and the VIP card retain their previous positions inside the sidebar. The change brings the implementation in line with the design reference for every Atlas Labs app.
Fixed
  • Founder tier (VIP 10) no longer hits a "3 imports/day" wall. The import-limits table only defined caps for VIP 0, 1 and 2; every higher tier — including the founder tier that is explicitly allowed to import — fell back to VIP 0's cap of three imports per day and a hundred transactions per import, surfacing as the toast "Daily import limit reached (3/day for VIP level 10)". VIP 10 now has no daily-import cap (∞) and an effectively unbounded per-import transaction count. The daily-usage banner and side-panel counter switch their label to when the active tier is uncapped, and the amber "limit reached" banner is suppressed for those tiers.
  • Wallet scans now fan out across every supported chain — not just a hand-curated subset. Previously, the scan executor filtered the wallet address against an internal list of ~32 chains (the seventeen Etherscan V2 chains plus a handful of independent-provider chains), so any EVM wallet with funds on one of the ~67 chains added through the top-250 expansion (Unichain, Sonic, Berachain, Soneium, ApeChain, World Chain, Linea-family L2s, Filecoin EVM, ZetaChain, IoTeX, Conflux eSpace, Astar, Flare, WEMIX, and many more) had those chains silently dropped at scan time even though they appeared in the wizard's chain picker. Both the scan and commit pipelines now derive the chain groups directly from the registry, so every EVM chain in SUPPORTED_CHAINS is actually queried. Non-EVM wallets continue to dispatch to their dedicated single-chain scanner based on the wallet type chosen in the connections menu (or the chain selected during manual address entry) — Solana wallets only scan Solana, Bitcoin wallets only scan Bitcoin, and so on. The per-request chain-id cap was also raised from 50 to 500 so the "Scan all chains" button no longer hits a validation wall.
  • Edit ownership now warns clearly when Tenants-in-Common shares don't add up to 100%. Previously, opening Edit ownership on any account, choosing Tenants in Common, and entering percentages that didn't sum to 100 % (e.g. 70 % + 20 %) let the user click Save ownership — at which point a black system toast appeared with raw validation jargon ("Invalid option: expected one of PRIMARY|CUSTODIAN|BENEFICIARY|TRUSTEE; Invalid input: expected number, received string…"). The dialog now disables the Save button until the selection is valid and shows an amber banner above the action row spelling out exactly what's wrong, e.g. "Tenants-in-Common shares must add up to 100 % — currently 90.00 %." The same friendly sentence is also returned from the server when any other client hits the endpoint with bad data, so accountants, API integrators, and CLI users all see the rule in the same words.
  • "Name your wallet" popup now actually saves the name and the owners. After connecting a new crypto wallet, the Name Your Wallet step asks for a label and lets the user pick the household owner(s). Previously, clicking Save & continue would advance the popup to "done" without persisting the label or the owners on a partial failure — the user only saw the new wallet's label reappear after a full page refresh, and only if both saves had actually succeeded silently. Both pieces are now persisted via the canonical ownership endpoint, the popup stays open with a clear toast if either save fails, the new label shows up immediately on the wallet card without waiting for a refresh, and Save & continue / Skip / Add-watch-only all disable with an inline reason when the owner picker isn't valid.

Design-system uniqueness: shell + account dropdown now sourced from one place

Changed
  • The Ledger's top bar, sidebar, account dropdown, app shell, mesh background, email-verification banner, and font applier are now sourced from a single shared package (@atlas-labs/ui) instead of per-app copies. The user experience is unchanged. The change matters because future tweaks to any of these surfaces — a new option in the account menu, a polish to the sidebar's collapse animation, an addition to the brand-font pipeline — now propagate to every Atlas Labs app at once instead of needing seven separate edits that drift out of sync. Three monorepo-level guard scripts (npm run check:canonical, check:tokens, check:select) now run in CI to catch any future re-introduction of per-app duplicates.

Connections-Journals invariant tightened; To-Reconcile banner on wallet entries; Account dashboard font hardened

Added
  • To Reconcile banner on every wallet journal Entries tab. When a wallet has imported transactions still awaiting classification, an amber banner now appears at the top of the Entries tab showing the pending count and a one-click "Open reconciliation" button. Previously a wallet with imported but unreconciled rows showed nothing on the Entries tab — users had to navigate to the cross-portfolio reconciliation queue to discover their pending work. The banner only renders when there is at least one pending row, so an empty journal stays clean.
  • Auto-create journals for connected accounts. Every connected wallet (verified via SIWE on EVM, MultiversX, or Solana, and watch-only addresses added manually) and every API-connected exchange account tied to a portfolio now gets a paired journal created automatically at connection time. This brings on-chain and CEX connections in line with the existing bank and brokerage flows, where the journal has always been minted alongside the account. Existing accounts that pre-date this rule receive their journals on the next visit to the wallet-journals or cex-journals page (idempotent backfill — never overwrites or duplicates).
Fixed
  • Account dashboard font no longer renders italic / wrong-shape glyphs (platform-wide incident, 2026-05-08). Every Atlas Labs app uses Instrument Serif for headlines and the "Welcome back, …" greeting on the account dashboard. The font appeared italic-leaning to users — the lowercase 'a' rendered with a single-story bowl, descenders curled, and the overall heading felt slanted instead of upright. Three independent root causes contributed: - adjustFontFallback defaulted to "Arial". The next/font/local declaration for Instrument Serif in admin, ledger, docs, and the new auth-UI did not set adjustFontFallback. Next.js's default is "Arial" — a sans-serif font. While the WOFF2 swapped in (or any time the WOFF2 was slow / blocked / failed), the browser rendered Arial scaled to Instrument Serif's metrics, producing visibly wrong-shape glyphs at heading sizes. All four declarations now set adjustFontFallback: "Times New Roman" so the metric-adjusted auto-fallback is a serif and the swap is visually compatible. - Runtime Google Fonts injection raced the local font. The branding providers in admin (BrandingProvider, BrandingTab, BrandingTypographySection), ledger (FontApplier, DisplayFontProvider), docs (PlatformFontProvider, DisplayFontProvider), and home (PlatformFontProvider) maintained a stale "skip CDN" set listing only Geist and Inter. Every other font — including Instrument Serif, DM Sans, and DM Mono, all of which are bundled locally — fell through and triggered a runtime <link> to fonts.googleapis.com. The CDN @font-face declarations then competed with the local @font-face, and when Google Fonts returned italic styles in the response (its default behaviour for fonts with both axes), the italic file occasionally won the cascade. Every skip set now includes the full bundled list (Geist, Geist Mono, Inter, DM Sans, DM Mono, Instrument Serif); every Google Fonts URL pins ital@0 so italic styles are never returned even when an operator picks a non-bundled tenant font. - Legacy auth Fastify pages loaded DM Sans from the CDN at runtime. The login, register, and account-shell pages each emitted a <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Sans..."> in the head. On any network round-trip the page rendered with the metric-adjusted fallback first and swapped in DM Sans afterwards (the "stretched serif" symptom the platform-wide font policy in ~/.claude/CLAUDE.md was written to prevent). Both the login/register GEIST_FONT_LINK constant and the account-shell V2_PAGE_HEAD constant have been cleaned: body text falls back to the system font stack (Segoe UI / system-ui / -apple-system) when DM Sans isn't installed locally, which is readable on every platform with zero network cost. Instrument Serif is unchanged; it was already served from the auth app's own origin via @fastify/static. Net effect: every account dashboard, every admin panel page, every docs reader page, and every home marketing page now renders Instrument Serif from the app's own origin with no CDN race and a serif metric-fallback. The new auth-UI Next.js application gained the same setup in the same pass: it now loads Instrument Serif via next/font/local from a self-hosted WOFF2 set in auth-ui/src/fonts/, replacing the previous Fraunces dependency on next/font/google.

Coordinated security disclosure surface

Added
  • A /.well-known/security.txt file declaring the canonical security contact (security@hq-fs.com), the 90-day coordinated-disclosure window, the PGP key location, and a link to the full disclosure policy hosted on the home app. Published at https://ledger.hq-fs.com/.well-known/security.txt per RFC 9116.

Chart of Accounts is now extensible: 47 admin-managed templates, user-defined accounts with safety rails

Added
  • Default Accounts catalogue (47 built-in templates). Every newly created portfolio is now seeded with a chart of accounts that fits its type. Household / cash portfolios receive Bank Checking, Bank Savings, Credit Card, Salary Income, Rent, Mortgage Interest, Electricity, Water, Gas, Internet & Mobile, House Maintenance, Groceries, Restaurants, Transport, Insurance (general + health), Subscriptions, Education, Childcare, Personal Care, and Misc. Investment portfolios get Brokerage Cash, Securities Held, Dividend Income, Bond Interest, Realised Gain, Realised Loss, and Brokerage Fees. Property portfolios get Real Estate Cost, Accumulated Depreciation, Mortgage Liability, Rent Income, Property Tax, Property Insurance, Property Maintenance, and Depreciation Expense. Freelance portfolios get Accounts Receivable, Estimated Tax Paid, Service Revenue, Cost of Service, and Operating Expenses. Five core accounts (Cash, Opening Balance, Owner's Equity, Retained Earnings, Misc Expense) seed in every portfolio regardless of type.
  • Chart of Accounts page (Settings → Bookkeeping → Chart of accounts). Lists every ledger account in the portfolio grouped by Asset / Liability / Equity / Revenue / Expense, with code, name, category, currency, and active status. System-managed accounts (opening balance, realised gain/loss, inter-portfolio clearing, crypto inventory) are flagged with a System badge and remain read-only.
  • Add Account dialog. Users can now mint custom Asset, Liability, Revenue, or Expense accounts on demand — with a 4-digit unique code, free-text name, IFRS-aligned category, and ISO currency. Six server-side safety rails guard double-entry integrity: code regex, code uniqueness in the portfolio, no clash with built-in templates, no system-managed categories (opening balance, realised gain/loss, inter-portfolio clearing, crypto asset variants, transfer-out), no user-minted equity, and category-aligns-with-type validation.
Changed
  • Portfolio creation now seeds from the template registry. The chart-of-accounts seeder consults the new AccountTemplate table and inserts every active row whose isCore=true or whose portfolioTypes contains the new portfolio's type. Previously the seeder created only five hard-coded accounts.

LIFO, HIFO, and AVCO cost-basis methods; selectable per portfolio at creation

Added
  • Cost-basis method is now selectable per portfolio. When creating a portfolio, users choose from four industry-standard lot-matching methods: FIFO (First In, First Out, default), LIFO (Last In, First Out), HIFO (Highest In, First Out), and AVCO (Weighted Average Cost). The selection is presented as a four-tile grid in the creation wizard — each tile shows the method name, the one-line description, and the tax/compliance note ("IFRS compliant · EU / UK / AU", "Tax-minimising · crypto focus", etc.).
  • Cost-basis method can be changed in Portfolio Settings (Finance section) until the first disposal is recorded. The existing segmented control in Settings is now live — previously locked to FIFO, it now reflects and saves the portfolio's actual method.
  • LIFO depletion engine. Newest-cost lots are consumed first. Uses the same locking and scope-resolution logic as FIFO; only the ordering differs. Legal under US GAAP; not permitted under IFRS.
  • HIFO depletion engine. Highest-cost lots are consumed first, minimising realised gains at each disposal. Secondary sort is FIFO for deterministic tie-breaking when two lots carry the same cost per unit. Widely used for crypto tax optimisation.
  • AVCO (Weighted Average Cost) depletion engine. At each disposal, the weighted average cost per unit is computed across all open lots. The realised gain uses this average rather than each individual lot's cost, per the IAS 2 / IFRS 9 AVCO treatment for fungible assets. Physical lots are still depleted in FIFO order so the cost-basis-lot table remains coherent.
  • Securities always use FIFO regardless of portfolio setting. Equity sells, bond maturities, and any disposal that carries a securityId are always processed by the FIFO engine — IFRS 9 and US GAAP both mandate FIFO or Specific Identification for non-fungible instruments. The depleteLots() router enforces this automatically.

Non-EVM multichain expansion: Solana, Bitcoin, XRP, NEAR, Aptos, Sui, Cosmos, TRON, TON, Stellar, Polkadot, and 20+ more chains

Added
  • Solana wallet import is now wired end to end. Users can connect a Phantom or Solflare wallet, verify ownership via nacl ed25519 signature (POST /api/wallets/verify-solana), and trigger a transaction scan backed by the Helius enriched-transaction endpoint. The scanner decodes the top instruction kinds automatically — native SOL transfers, SPL token transfers, Jupiter/Raydium/Orca swaps, Marinade/Jito staking — and produces a FIFO-ready NormalizedSolanaTx for every confirmed signature. Signatures outside the RPC's retention window are recorded in SolanaScanGap rows and surfaced to the user as a "partial history" warning. A SOLANA_ARCHIVAL_RPC_URL env override routes those gaps through a full-archive node. Gated behind the solana feature flag (pipeline / STAFF).
  • Bitcoin, Litecoin, Dogecoin, Bitcoin Cash, Dash, Zcash, and Kaspa wallets are now importable via the UTXO scanner. All seven chains share a unified REST scanner backed by the Blockchair unified API. Wallet connection records balance changes against each UTXO input and output; the scanner determines "IN" versus "OUT" direction per wallet and the commit step writes ImportedTransaction rows with the full input/output list. Kaspa uses a dedicated REST endpoint. Gated behind non-evm-multichain.
  • XRP Ledger, Stellar, TRON, and TON wallet import is supported. The XRP scanner uses xrpl.js against the public xrplcluster.com WebSocket cluster (full history since genesis); Stellar uses the Horizon REST API; TRON uses TronGrid REST for both native TRX and TRC-20 transfers; TON uses TonCenter REST. All four produce normalised transaction rows and are gated behind non-evm-multichain.
  • NEAR Protocol, Aptos, and Sui wallet import is supported. NEAR uses the NearBlocks REST API; Aptos uses the official Aptos Labs REST node; Sui uses the @mysten/sui/jsonRpc client against the public fullnode. Each chain's scanner handles its native decimal precision and encodes token transfers with the chain-specific address format. Gated behind non-evm-multichain.
  • Cosmos ecosystem import covers ATOM, OSMO, INJ, SEI, TIA, KAVA, DYDX, SCRT, and STARS. A unified Cosmos scanner uses the standard /cosmos/tx/v1beta1/txs REST endpoint against each chain's public node — both sent and received transactions are fetched. IBC token transfers are recorded with their denom as tokenAddress. Gated behind non-evm-multichain.
  • Polkadot and Kusama wallet import is supported via Subscan. The Polkadot scanner calls Subscan's transfer API for both DOT and KSM, converts planck-denominated amounts, and maps extrinsic-index references to block/position coordinates. Gated behind non-evm-multichain.
  • Six new EVM chains are now part of the import registry. Ethereum Classic (chainId 61, Blockscout), Kaia / Klaytn (8217, Kaiascan), Core Chain (1116, CoreScan), Harmony ONE (1666600000, Harmony Explorer), Sei EVM (1329, Seitrace), and VeChain (100009) are added to SUPPORTED_CHAINS and the scanner's independent-chain group. These chains use existing EVM scanner infrastructure with their own Etherscan-compatible explorer endpoints.
  • Chain badges for all 35+ new chains are shipped. Every chain added to the import registry — EVM and non-EVM — has an entry in ChainBadge.tsx with a brand colour and minimal SVG logo. Chains without a formal badge fall back to "Chain ID" text.
  • Token registries for all new chains are populated. CHAIN_TOKENS now includes canonical stablecoins, governance tokens, and liquid-staking tokens for Solana, NEAR, Aptos, Sui, TRON, TON, Cosmos hub, Osmosis, Injective, Polkadot, Algorand, Hedera, Cardano, Tezos, Bitcoin, Litecoin, and Dogecoin. The trade UI token picker respects the connected wallet's chain family and shows only chain-appropriate tokens.
  • Three new Solana database tables are live. SolanaTxDetail (per-transaction slot, feePayer, parsedKind), SolanaInstructionDetail (per-instruction breakdown), and SolanaScanGap (retention-gap tracker) are created. All are purely additive — no existing rows are touched.
  • solana and non-evm-multichain feature flags are registered. Both flags ship at pipeline / STAFF stage and are visible in the admin feature-flag panel. Turning them on per-user or globally requires an admin action — they do not affect any existing user flows until enabled.

Brand colours are now fully tokenised and operator-configurable

Changed
  • Primary brand colour, accent colour, chart palette, and sidebar highlights are now resolved from CSS variables, not hardcoded values. The --primary, --ring, --chart-1, --chart-2, --brand-end, --sidebar-primary, and --sidebar-ring CSS properties in both light and dark themes now read from --brand-primary-hsl and --brand-accent-hsl fallback chains. When a BrandingProvider overrides those primitives at runtime, every affected surface — focus rings, primary buttons, chart bars, sidebar active state, and the gradient hero elements — updates without a deploy.
  • The body background mesh gradient now uses color-mix against brand variables. The light-mode lavender and dark-mode indigo body background gradients are constructed from var(--brand-primary) and var(--brand-accent) via color-mix(in oklch, …) rather than literal RGBA stops. The visual appearance is unchanged for Atlas Labs deployments; whitelabel tenants that supply different primary and accent values will see their brand colour blend into the ambient background.
  • The .brand-grad, .ring-tier, and .featured utility classes pull from brand variables. The gradient background used in brand chips, the VIP card CTA button, and the tier-ring box shadows are all derived from --brand-primary-hsl / --brand-accent-hsl. A new .bg-brand-gradient utility is available as an alias of .brand-grad for Tailwind-style class authoring.
  • The V2 top bar brand chip and sidebar workspace chip resolve the brand name and initial from the BRAND constant. The "AL" initial and "Atlas Labs" label now come from BRAND.logo / BRAND.name (set via NEXT_PUBLIC_BRAND_* env vars) rather than being hardcoded strings. The page title generated by generateMetadata in the root layout likewise reads BRAND.name, so whitelabel deployments emit their operator name in the browser tab.

Display font is now configurable from the admin panel

Changed
  • The hero and heading font (h1 / page titles) is now configurable from the admin branding panel. Administrators can set a custom display font — any Google Fonts family — in Admin → Branding → Display Font. The ledger app reads the configured value on page load and swaps the CSS font stack automatically, so all serif headings such as "Good afternoon, PlayQodeX" and portfolio page titles render in the chosen typeface. Instrument Serif remains the default; the change is purely additive and requires no user action.

Admin CMS coverage: operator-editable jurisdiction, VIP, chain, exchange, and scam-token registries

Added
  • Scam token blocklist is now admin-editable. The global scam-currency registry — previously managed only through ad-hoc scripts — is now surfaced in the admin panel under General → Scam Token Blocklist. Operators can paste a contract address, chain ID, and symbol and press "Block token"; they can also remove individual rows. Imported transactions matching blocked addresses continue to receive the SCAM_FAKE_ASSET recommendation flag automatically.
  • VIP capability ladder is now admin-editable without a deploy. A new VIP Capabilities editor in the admin panel replaces the five hardcoded TypeScript files (vip-allowance.ts, import-limits.ts, news-limits.ts, watchlist-vip.ts, journal-sections-vip.ts). Operators can override any (VIP level, capability) pair — monthly scan/CSV/hash budgets, transactions-per-import, watchlist counts, journal section limits, news holdings caps, and refresh intervals — through a grid view. The runtime reads from the database first and falls back to the code constants, so existing behaviour is preserved until a row is explicitly overridden.
  • Jurisdiction metadata is now admin-editable. A new Jurisdictions editor in the admin panel surfaces the estimated effective tax rate, rate-basis description, accountant-review flag, and active/inactive toggle for each of the twelve supported jurisdictions (US, GB, IE, DE, FR, NL, CH, SG, AU, CA, ZA, ID). Adjusting a jurisdiction's estimated rate after a budget announcement no longer requires an engineering deploy.
  • EVM chain, token, exchange, and bank-provider registries are now admin-editable. Three new database tables — Chain, ChainToken, SupportedExchange, BankProvider — are seeded from the existing code constants (26 chains, 78 tokens, 29 exchanges, 5 bank providers) and exposed through a Chain & Exchange Registry editor in the admin panel. Operators can enable or disable a chain, add a new token to any chain, toggle an exchange active or inactive, and add new bank connection providers — all without a deploy. The runtime falls back to the TypeScript constants while the tables are empty.

Account dropdown reshape, VIP Status and Billing rebuilt from V2 design

Added
  • VIP Status now opens a native ledger page rebuilt from the V2 design. Clicking VIP Status from the account dropdown in the ledger app now opens /account/vip rendered inside the standard ledger V2 shell — same sidebar, same top bar, same violet mesh — with the V2 layout the design source has always specified. The page leads with a gradient hero card showing the current tier on the left (large "VIP X" headline, the tier label, the maintenance threshold in plain language, and the perks list as a checked grid) and a next-tier card on the right (target tier, window-spend progress bar with percent + amount-to-go, the unlocks list, and a Top up CTA that routes to /account/billing). Below the hero, a Spend Timeline section presents three stat tiles (six-month window, lifetime, window roll-off in the next 30 days) and a 12-month bar chart that buckets every qualifying purchase by calendar month with refunds netted out. The full eleven-tier ladder (Visitor through Founder) renders as a table with status pills — Reached, Ramp-up, Maintaining, or At risk — and a Path to next tier panel and Recent activity panel sit in the right rail.
  • Billing now opens a native ledger page with the full V2 catalogue. /account/billing opens inside the same V2 shell. The status ribbon at the top shows the user's tier and the spend progress toward the next unlock; below it sits a side rail (catalogue anchors, payment-method card showing the user's VISA from Connections when one is on file, and a Pay-with-crypto card) and the full catalogue grid: three VIP unlocks (Tier maintenance, the gap-to-next top-up flagged "Most popular", and a two-tier-leap), three API call packs (Starter, Standard "Most popular", Pro), three Premium imports (Concierge single, Concierge full archive, Custom integration), and three Tax & export products (Tax pack, Audit packet, Custom report) — all rendering at their design prices with the design's bullet lists, "Counts toward VIP" footers, and tier-gating badges. Buy buttons drive checkout through ledger's same-origin /api/billing/checkout proxy, which forwards the user's session cookie to auth's /api/billing/checkout. The Purchase history table sits at the bottom.
Changed
  • The split is explicit. Account-bearing data — Purchase rows, vipLevel, spend windows, payment provider config — continues to live in the auth application's database; the user-facing UI for VIP and Billing now lives in the ledger application and reads from auth's /api/billing/* endpoints server-side. Other applications that need billing programmatically still talk to auth's API directly with the user's session cookie, no detour through the ledger app.
  • The Tweaks panel has been removed from the v2 shell. The settings-icon button in the top bar that opened a small popover with sidebar-collapse and nav-layout toggles is gone, along with the popover itself. The same controls remain available where they belong — the sidebar's own collapse chevron at the bottom and the layout toggle in the workspace header — so no functionality is lost; the chrome is just less crowded.
  • The dark/light theme toggle in the v2 top bar is now functional. The moon/sun icon next to the bell now flips the application theme via next-themes (class="dark" on <html>) instead of being inert. The V2 design content remains a dark-themed surface; light-theme styling for the new VIP and Billing pages is a follow-up.
  • The account dropdown in the top-right corner surfaces account-owner items, not the admin panel link. The menu under the user's name and VIP badge now reads Account Settings, VIP Status, and Billing. The role-gated Admin Panel link has been retired from the dropdown — admins continue to reach the admin panel from their account hub at /account and from any bookmark they have. The change is platform-wide: every Atlas Labs app that renders the shared dropdown picks up the new menu set. In the ledger app, VIP Status and Billing route to the new ledger-native pages above; in other applications they continue to route to the auth-hosted equivalents at /account/vip and /account/billing.
Fixed
  • The Household "Date of birth" field no longer shows the browser's native dark-mode calendar. When adding or editing a household member at Settings → Household, the date-of-birth picker was dropping to the browser's built-in <input type="date"> popup — unbranded, inconsistent across operating systems, and visibly off-key against the violet-mesh design language of the rest of the app. The field now uses the same date picker every other date entry in Ledger uses (transaction dates, fee timestamps, tax-pack window selectors), so the calendar that opens matches the surrounding modal in colour, typography, and behaviour. Existing dates of birth on already-recorded household members are unchanged.

Household redesign + full Phase 4–7 rollout

Added
  • Household page redesign. Settings → Household has been rebuilt as a wealth-management hub. The page now opens with a hero that names the portfolio plus a prominent "+ Add person" CTA, four summary tiles (household size, household value at live mark-to-market, accounts managed across the household, tax jurisdictions covered), and a card grid where each Person renders with a gradient avatar coloured by relationship, the name (preferred + legal), age derived from date of birth, the ISO tax-residence flag emoji, a live net-worth strip with a "Cost basis" comparison, account-by-type chiclets with counts (bank, card, wallet, exchange, brokerage, retirement, fiat, staking, LP, property, entity), and status pills (TIN on file / Archived / Deceased). Clicking a card opens a slide-in detail dialog with four tabs — Identity, Holdings, Tax, Care notes — including a PIN-gated Tax ID reveal and an inline edit form grouped into Identity / Tax identity / Care notes sections. The page renders inside the V2 shell (sidebar + breadcrumb chrome) so it feels like a first-class surface, not a settings sub-page.
  • OwnerPicker redesign. The chip-select now carries gradient avatars next to each name plus a relationship label. The "Add owner" search popover groups available persons with avatar thumbnails, the joint-structure picker renders as a branded tile group with a one-line legal note per shape, and the Tenants-in-Common percentage editor adds a "Distribute evenly" shortcut and a coloured running-total badge that flips green at 100%.
  • OwnerPicker on more create surfaces. Adding a wallet (after SIWE verification, on the "Name your wallet" step), a payment card, or a business entity now surfaces the same Owner(s) chip-select that already lived on the Investment Account, Exchange, and Bank Account flows. Business-entity creation labels the field "Beneficial owner(s)" and threads the picker through to the FATF Recommendations 10 & 24 ultimate-beneficial-owner chain.
  • HolderBadge on every Connections list. Bank, exchange, wallet, payment-card, and brokerage rows on the Connections page now render a small initials chip per holder when ownership has been recorded (and stay clean for SELF-only accounts). The Connections page batches the holder lookups across all five subject kinds in one server-side parallel pass.
  • On-behalf-of picker on transaction entry. The manual TransactionEntryForm and the CexJournalEntryDialog now carry an optional collapsible "On behalf of" picker. Default is "follow account holder" — expand it to attribute a specific entry to a different household person (e.g. operator pays for spouse's gym out of operator's account). The chosen personId is sent as onBehalfOfPersonId on the journal-entry POST.
  • Holder column in CSV exports. The transaction CSV export at GET /api/portfolios/{id}/exports?type=transactions now carries a "Holder" column derived from the journal entry's onBehalfOfPersonId (preferred name when set, falling back to legal name). Empty when no override is recorded.
  • Tradfi mark-to-market on per-person net worth. The per-person net-worth API now also sums book values for brokerage accounts (LedgerLine projection through the BrokerageJournal), bank accounts (BankJournalEntry direction × amount), fiat accounts (LedgerLine projection through FiatAccount-linked entries), retirement accounts (the account's own balance column), and properties (currentValuation falling back to acquisitionCost). Crypto subjects continue to flow through the existing aggregator's spot-price feed; the Property test portfolio's SELF Person now snapshots a real value.
  • Tax pack filtered to specific persons — line level. When personIds is passed to the tax-pack endpoint the underlying line builders (crypto capital gains, equity capital gains, interest + dividends) now restrict their queries to journal entries whose onBehalfOfPersonId matches the named persons. The PDF cover page renders a "Filtered to N persons" block listing each person's preferred + legal name, relationship, tax residence, and a masked Tax ID.
  • Ultimate Beneficial Owner walker. A new endpoint GET /api/portfolios/{id}/business-entities/{entityId}/ubo walks AccountOwnership rows on a BusinessEntity down to every natural Person who ultimately owns or controls the entity, aggregating ownership share across paths and recursing through entity-of-entity layers up to depth 8. Required for FATF Recommendations 10 & 24, the FinCEN CDD Rule, and AMLD5.
  • Collaboration UI now lives on real data. The Collaboration card in Settings has been re-wired from a static demo to the actual /api/portfolios/{id}/collaborators list. Each collaborator row carries a chip-select for scopedToPersonIds — picking one or more household persons restricts that collaborator to only see accounts whose active ownership rows intersect the named persons. "Clear scope" returns to legacy "see everything in the portfolio" behaviour.
  • Custodial-flip click surface. Investment Account rows that carry an active CUSTODIAN ownership row now render a "Convert from custodial" button. Clicking opens a beneficiary picker that prefers CHILD / WARD / DEPENDENT relationships, then a confirmation dialog; on confirm, the existing convert-custodial endpoint atomically closes the custodian row and opens a new PRIMARY row pointing at the beneficiary, preserving the historical record.
Changed
  • /api/portfolios/{id}/accounts and /ledger honour ownership scope. When a scoped collaborator (PortfolioCollaborator.scopedToPersonIds non-empty) reads accounts or ledger lines, the response now filters to subjects whose active ownership intersects the scope — pooled accounts (no source) are always visible, source-scoped accounts (WALLET / CEX_ACCOUNT) only show when the underlying subject is in the scope, and the ledger walks journal entries via onBehalfOfPersonId plus the four wallet-side counterparty FKs. Owner sessions and unscoped collaborators see no change.

Household: OwnerPicker rollout, mark-to-market, custodial flip

Added
  • OwnerPicker on Investment Accounts, Exchanges, and Bank Accounts. Adding a brokerage / exchange / bank account from the Connections page now surfaces an Owner(s) chip-select. Defaults to "you" — but if the account is jointly held you can pick a co-owner from the household, choose the joint-ownership structure (JTWROS, Tenants in Common, Tenants by the Entirety, or Community Property), and on Tenants in Common enter a percentage per chip with a running total that has to equal 100. Other create surfaces (wallets, retirement accounts, payment cards, properties, business entities) gain the same picker in a follow-up.
  • Holder badge on Investment Accounts. Each row in the Investment Accounts list now shows a small initials chip per holder when ownership has been recorded, plus a label for the joint structure. SELF-only accounts stay clean. The Connections page resolves holders for every visible account in one batched server-side pass; the same chip will roll into the bank, exchange, wallet, and card lists in a follow-up.
  • Per-person net worth at market value. The per-person net-worth endpoint (GET /api/portfolios/{id}/people/{personId}/net-worth) now walks open cost-basis lots through the existing aggregator's spot-price feed and returns mark-to-market values for crypto subjects (wallets and exchange accounts), with cost-basis fallback on unpriced symbols. Tradfi subjects (brokerages, retirement accounts, banks, cards) still report zero today and pick up market valuation when the aggregator's tradfi reader is per-person-aware.
  • Nightly per-person snapshot. A new cron at scripts/cron/snapshot-person.ts writes one PersonNetWorthSnapshot + PersonAllocationSnapshot per active Person per day, idempotent on (personId, date, currency). Schedule daily at 02:30 local — after the user-level aggregator snapshot lands.
  • Tax pack filtered to a person. The tax-pack endpoint (GET /api/portfolios/{id}/tax-pack) now accepts a personIds query parameter — a comma-separated list of household persons. The response includes a cover block listing each named person's legal name, preferred name, relationship, tax residence, and a masked Tax ID for the eventual PDF cover page. Line-level filtering inside the plan generator follows in a later session; the cover-block surface ships first so the UI can be wired in parallel.
  • UGMA / UTMA custodial flip. A new endpoint (POST /api/portfolios/{id}/account-ownerships/{ownershipId}/convert-custodial) atomically closes a CUSTODIAN ownership row and opens a new PRIMARY row on the same subject pointing at the minor child Person — the audit-trail-correct way to record the moment a UGMA / UTMA account converts to the minor's own ownership at the age of majority. Two audit-log rows (close + create) are written; the historical CUSTODIAN row is preserved with its effectiveTo set to the transition date.
  • Collaborator scoping body field. Updating a portfolio collaborator (PATCH /api/portfolios/{id}/collaborators/{collaboratorId}) now accepts a scopedToPersonIds array. Setting it to a non-empty list scopes the collaborator to only see accounts where one of the named persons is an owner — the canonical "audit access to my accounts only" pattern. Persons must belong to the same portfolio (422 otherwise). The read-path filter that consumes this scope is the next session's work; the data path is in place today.
Changed
  • Family-members endpoint surfaces persons too. When the household feature flag is on, GET /api/portfolios/{id}/family-members also returns a persons[] array carrying the equivalent Person rows for the same portfolio, so legacy callers can migrate from members[].name + .role to persons[].legalName + .relationship at their own pace. The legacy FamilyMember table is preserved one release cycle for safety; the drop is a separately-confirmed step.

Household & personal ownership (Phase 1–6 + 7 backfill)

Added
  • Household & personal ownership (behind the household flag). The ledger now has a first-class registry of the human persons who actually own the financial accounts you track in a portfolio — your spouse, children, parents, dependants, or anyone else whose finances you manage on their behalf. Open it at Settings → Household or directly at /{slug}/settings/household. Each Person can record a legal name, optional preferred name, date of birth, ISO tax-residence country code, an envelope-encrypted Tax Identifier (SSN / SIN / ITIN / equivalent) that is masked everywhere except a PIN-gated reveal, a relationship label (Spouse / Child / Parent / Sibling / Dependent / Ward / Trustee / Executor / Estate / Trust beneficiary / Other), an optional link to an Atlas Labs login when the person has one, and free-text caretaker notes. Every personal portfolio gets one auto-created SELF Person (you) on first visit, which can never be archived. STAFF and beta-enrolled users only at this release; the OwnerPicker rolls into every account-creation form in a follow-up.
  • Account-ownership join — who actually owns each account. Behind the same flag, each bank account, credit / debit card, exchange connection, on-chain wallet, brokerage account, retirement account, payment card, hybrid fiat account, staking contract, liquidity pool, property, or business entity can now record one or more ownership rows: PRIMARY (sole legal holder), JOINT (with a JTWROS / Tenants in Common / Tenants by the Entirety / Community Property structure), BENEFICIAL_OWNER (the natural person behind a trust or LLC, per FATF beneficial-ownership rules), SIGNATORY (Power-of-Attorney transact-but-not-own), BENEFICIARY (TOD / POD designation), CUSTODIAN (UGMA / UTMA on behalf of a minor child), or TRUSTEE. Every row is effective-dated, so marriage, divorce, death, adding or removing a signatory, and the UGMA / UTMA flip at age of majority are recorded as old-row-closes / new-row-opens transitions — the historical record is preserved.
  • Per-person net worth. A new GET /api/portfolios/{id}/people/{personId}/net-worth returns the per-person book-value rollup across every active ownership row, with the ownership share scaled per joint structure (Tenants-in-Common pulls the explicit percentage; JTWROS / TBE / Community Property splits equally among co-owners). Live spot pricing for crypto and securities follows in a later session; cash and crypto book balances ship today.
  • Holder badge. A small avatar-style badge that surfaces the active holder(s) of an account is now available as a shared component for use in every account list, dashboard, and reconciliation queue. It stacks initials (up to three) with a "+N" overflow chip, hides itself on SELF-only accounts when the host opts in, and surfaces the joint structure as a label when more than one holder is recorded.
  • Owner picker. A reusable multi-select chip control for the "Owner(s)" field that every account-creation form will adopt. Defaults to a single chip representing SELF; selecting a second person reveals the joint-structure picker (JTWROS / Tenants in Common / Tenants by the Entirety / Community Property); choosing Tenants in Common reveals a percentage field per chip with a running total that must equal 100%. Stateless, pre-loaded persons passed in to avoid a round-trip on every form mount.
  • Per-account-ownership audit log. Every ownership write (create / close / change / archive) lands in a dedicated insert-only OwnershipAuditLog table — separate from the journal-entry audit feed, so the ownership history can be replayed independently of the books.
Changed
  • Journal entries now carry attribution. Two new optional fields are recorded on every journal entry: who entered it (the auth user who hit save, distinct from the portfolio owner) and an optional "on behalf of" Person when ownership of a single line differs from ownership of the account (e.g. operator pays for spouse's gym membership out of operator's account — the spouse is the user of the service, the operator is the holder). Existing entries are unaffected; the columns are nullable and stay null on every historical row, which is the correct semantics ("we don't know" is more honest than "guessed").
  • Collaborator scoping (preview). Portfolio collaborators can now optionally be scoped to a subset of persons, so an operator can grant their CPA "audit access to my accounts only" without splitting the portfolio in two. Default behaviour is unchanged — when no scope is set, collaborators continue to see every account in the portfolio.

Live price banner now visible on every v2 surface

Fixed
  • The live price banner is now part of the application chrome, not just the Wealth Dashboard. The marquee strip at the top of the page — BTC, ETH, SOL, BNB, XRP and the rest of the major-cap names with their live prices and 24h percentage change — was previously only rendered on the cross-portfolio Wealth Dashboard. Every other surface (per-portfolio dashboards, Transactions, Connections, Tax Pack, Reports, every settings sub-page) reserved a slot for the banner but populated it with nothing, so the strip was silently absent everywhere except the aggregator. The banner is now mounted by the v2 shell itself, so it follows you across every page and pauses on hover for reading. The Wealth Dashboard continues to lead the marquee with the user's own top-by-cost-basis currencies; on every other page the banner falls back to a curated list of twenty top-cap symbols, polled at the user's VIP-tier interval.

VIP status alignment in v2 sidebar and top bar

Fixed
  • Workspace switcher no longer triggers a hydration warning. The portfolio switcher in the v2 sidebar was emitting a console hydration mismatch on every page load — the trigger button's aria-controls attribute pointed at one popover id on the server and a different one on the client, so the trigger and the popover content briefly disagreed about which element they were wired to. The switcher now defers wiring up its popover until after the page has hydrated, which keeps the server and the client rendering identical markup on first paint and removes the warning. Visual appearance is unchanged; the dropdown opens normally as soon as the page is interactive.
  • VIP tier now matches across the top bar and the sidebar. The "Manage VIP" card at the bottom of the v2 sidebar previously displayed a hardcoded "VIP 5" regardless of the signed-in user's actual tier, while the top-right account dropdown read from the live session. On any surface where the v2 client did not forward the user's VIP through, the dropdown silently fell back to "VIP 0", so two parts of the same screen could disagree. Both surfaces now share a single value resolved from auth (the source of truth for VIP status), so the tier displayed on the sidebar card, the badge next to the account name, and the conditional "Upgrade to VIP" link in the dropdown stay consistent on every page. The sidebar card's CTA also routes to the auth account page rather than a non-existent ledger-side VIP route.

Connections page redesign: inline brokerage flow, module gating, URL rename

Added
  • Add an investment account directly from the Connections page. The Investment Accounts tile previously redirected to the Securities page to add a brokerage. It now mounts an inline modal that creates the brokerage account and its paired journal in a single atomic transaction, matching the "all connection events happen on the connection page — no redirects" rule. Manual entry only in this release; SnapTrade / Plaid auto-feed providers connect through their own OAuth flows in a follow-up.
  • Archive a brokerage from the Connections page. Each Investment Accounts row now has an archive button. Archiving flips the connection's liveLink flag to false and stamps archivedAt — the paired journal and every entry posted against it are preserved (Phase 1 lifecycle invariant). The confirmation dialog states the entry count up front so the user sees what's being preserved.
Changed
  • The Connections page now lives at /{slug}/connections instead of /{slug}/wallets. The old URL was a holdover from the pre-tradfi era when blockchain wallets were the only thing this page held. The new URL matches the page title, the sidebar label, and the canonical connections nav key — and lines up with industry convention (Mint, Empower, YNAB, Plaid all use "Linked accounts" or "Connections"). Bookmarks and external links to /{slug}/wallets permanently (308) redirect to /{slug}/connections.
  • Connection sections are now gated by the portfolio's enabled modules. A CRYPTO_INVESTOR portfolio shows only Crypto Sources; a TRADFI_CASH portfolio shows only Cash & Cards; a TRADFI_INVESTOR portfolio shows Cash & Cards + Investment Accounts. The gating respects per-portfolio overrides set in Portfolio settings → Modules: turning a module off there hides its connection section here, and the underlying server query is skipped so we don't fetch data the user can't see. When every connection module is disabled the page renders a clear notice pointing the user back to settings.

Connections & Journals Unification (Phase 0–4 + carve-outs)

Added
  • Investment Accounts now have a sub-ledger. Brokerage accounts (Hargreaves Lansdown, Schwab, Interactive Brokers, etc.) now produce a per-broker journal — the same pattern that already exists on the crypto and bank sides. Every brokerage row is paired 1:1 with its journal: removing or rotating a connection no longer wipes the entries booked against it. The Securities page continues to be the entry point for adding a brokerage; the new "Investment Accounts" section on the Connections page (behind the v2 layout flag) summarises every connected broker, the entries booked, and a small "Margin" badge for accounts that will eventually trade derivatives.
  • Connections page v2 layout (behind flag). A new three-section layout — Crypto Sources (wallets + exchanges combined), Cash & Cards (bank accounts + cards), Investment Accounts (brokerages) — replaces the legacy four-section grid for users with the connections-v2-layout flag enrolled. The "Link from CEX Journal" picker is dropped: the underlying schema now guarantees that exchange connections and their journals are always paired, so the orphan-pairing the picker existed to fix can no longer happen. Available to STAFF and via Beta Enrollments while the wider rollout is sequenced.
  • Unified fiat source model (schema-only, behind flag). A new FiatAccount row replaces BankAccount + PaymentCard at the data layer, with a kind discriminator covering bank cash, debit card, credit card, and hybrid wallets such as Wise / Revolut / Payoneer. The legacy tables are kept in place this release; the user-visible cutover lands when the Phase 4 v2 layout flag flips for everyone and the Phase 5 cleanup drops the deprecated columns.
  • Derivatives carve-out (schema-only, no engine yet). New transaction types (option / future / perp / CFD / short / margin-loan), a Position table, a Security.derivativeKind flag, and a "margin enabled" indicator on exchanges and brokerages. Nothing renders or posts yet — the columns are reserved so the next release can ship the engine without re-migrating the same tables.
  • Derivatives tile group inside the manual transaction modal (behind flag). When transactions-modal-v2 is on, the manual transaction entry modal grows a "Derivatives & margin" tile group with twelve placeholder tiles (option / future / perp / short / margin-loan). Every tile renders disabled with a "Coming in derivatives release" tooltip. The submit handler additionally rejects derivative tx-types as defence in depth so a stale form value cannot post a leg before the engine ships. STAFF can preview the surface today via Beta Enrollments.
  • Derivatives surface visible inside the v2 dashboard's New Transaction modal. The same disabled tile group now renders inside the V2 dashboard's EntryModalV2, with each variant (Options, Futures, Perpetuals, CFDs, Short Equity, Margin Loan) carrying its own disabled sub-toggle row (Buy-to-Open / Sell-to-Open / Close, Open / Close / Settle, etc.). Hovering any tile surfaces the "Coming in derivatives release" tooltip; clicks are inert. Phase 6 wires the engine.
Changed
  • Historical exchange journals renamed. The six pre-API exchange journals — Bitforex, Coinbase, Binance, Atomic Wallet, Bybit, BlockFi — are now suffixed (Historical) instead of 2021+. The new label communicates the role of these journals clearly: each is a manual record of trades made before an API key was on file, paired with a synthetic exchange connection that is preserved in the Connections list as an archived entry. Every existing journal entry (Binance: 182, Coinbase: 35, BlockFi: 4, Atomic Wallet: 3, Bitforex: 1) is untouched; only the display labels are updated. The paired exchange-connection labels were renamed atomically alongside the journals so the UI stays in sync.
  • Archived exchange connections render as "Archived" in the Connections list. Synthetic exchange-connection rows that have no API key on file (the six historical journals just renamed, plus any future archived row) now show an "Archived" badge instead of the misleading amber "Unverified" state. The Verify and Sync buttons are hidden on these rows — there is no key to test or sync. The KPI rail's Crypto Sources tile splits the count: <wallets> wallets · <live exchanges> live · <archived exchanges> archived.
  • Phase 5 flag promotion. Both connections-v2-layout and transactions-modal-v2 are now defaultEnabled: true. Both stay at minRole: STAFF while the wider reader cutover continues; USER-role rollout follows via Beta Enrollments once the remaining paymentCardId → fiatAccountId reader switches land.
  • CEX connection lifecycle. Removing the API key for a connected exchange no longer deletes the connection record. The encrypted credentials are null-zeroed and the row is marked as archived (liveLink = false); the journal and every entry posted against it always survive. Re-adding the key flips the marker back. This removes the "I had to re-pair my Binance journal after rotating my API key" failure that the manual link picker existed to work around. Same pattern is now in place for brokerage and bank connections so the lifecycle is uniform across every source type.
  • Two new feature flags in the admin panel. connections-v2-layout (the Connections page redesign) and transactions-modal-v2 (the upcoming unified entry modal) ship as STAFF / experimental with Beta Enrollments enabled. Both surface in the Feature Flags table and in the Beta Enrollments picker.
Fixed
  • CEX journals can no longer drift apart from their exchange connection. A subtle bug in earlier releases let a connection record and its journal record describe the same Binance / Bybit / Kraken account through string-match alone. Renaming the journal, capitalising the label differently, or re-adding keys with a slightly different label silently broke the pairing — the manual "Link from CEX Journal" picker was the workaround. The schema now binds the two records via a real foreign key (1:1, with @unique on each side), so the failure mode is gone for new pairs and the Phase 2 backfill resolves any historical drift.

Portfolio dashboard recon banner always-on, "Back to portfolio" pill restored, Preview badge removed

Changed
  • Reconciliation banner on the portfolio dashboard now always renders. The amber "X imported transactions need reconciling" strip used to disappear whenever the queue was empty, leaving the layout slot blank between the KPI rail and the action tiles. It now stays put: when there is no backlog it reads "0 imported transactions need reconciling — Nothing waiting in the queue." so the dashboard composition is consistent regardless of state.
  • "Preview" badge removed from the ledger. The amber Preview pill in the top bar of every ledger surface, and the "Rows are illustrative" footer disclaimer at the bottom of list pages, have both been retired. The redesign is now in production for every role; the preview signaling no longer applies.
Fixed
  • "Back to <portfolio>" pill on the Wealth dashboard is rendering again when the user navigates from inside a portfolio. The sidebar's link to the Wealth dashboard had stopped passing the originating portfolio slug after the V1 retirement collapsed /v2/<slug>/... to /<slug>/..., so the dashboard never knew which portfolio to send the user back to and the pill silently disappeared. The link now carries the slug again and the pill reappears in the breadcrumb area as soon as the user is in a portfolio context.

Dashboard cells: no scroll bars, capped rows, Realized P&L cleanup

Changed
  • Dashboard cells no longer scroll their inner content. The Holdings Allocation, V2 Allocation, 24h Movers, Action Queue, News Pulse, Recent Activity, Scam Alerts, Tax Horizon, Balance Integrity, and Unreconciled Clearing cells used to fall back to a vertical scroll bar when their contents didn't fit. They now cap the displayed row count instead — Holdings Allocation and V2 Allocation show the top 7 holdings plus an aggregated "Other" entry; the multi-row smart cells show 5 rows. The cell-registry minimum-size policy already keeps each cell tall enough to render the cap without truncation, so a scroll bar is never the right answer.
  • Realized P&L bar chart now drops dust entries (any asset whose net P&L is below $1 absolute), keeps the 10 most-significant assets, and forces every Y-axis label to render. The previous version sorted by signed net only and let the recharts default tick interval skip the top labels — the highest-P&L bars displayed without their currency label, and dust currencies (CELO, MITH, DEXE, NU) crowded the bottom of the axis with invisible bars. The Y-axis is now 56px wide so longer symbols (LINK, AVAX, MATIC) don't truncate.

Dashboard cells: minimum-size policy + readable resize

Changed
  • Dashboard cells now have a content-aware floor size. Charts and lists can no longer be shrunk below the size their content needs to stay readable. Composition charts (Holdings Allocation, Cost vs Market, Cumulative P&L, Drawdown, Chain Distribution, Top Assets, Allocation Over Time, the V2 Allocation card), performance charts (Portfolio Value, Realized P&L by Asset, Income Over Time), and multi-row smart cells (Morning Briefing, 24h Movers, News Pulse, Recent Activity, Action Queue, Tax Horizon, Balance Integrity) all hold a 4×5 minimum on the 12-column grid (≈ 400px × 312px). KPI tiles stay at 2×2; single-stat cells (Reconciliation Focus, Scam Alerts, Pending Classification, Quick Actions, Portfolio Pulse) stay at 3×4.
  • Saved layouts inherit the new floors. Every dashboard surface (V1 home, V1 analytics, V2 hub) now reads the latest registry minimums on load and clamps any saved width / height up to those values. Users who previously shrunk a chart below the new floor will see it pulled back up automatically; their x / y positions are preserved.
  • Holdings Allocation legend now shows the dollar value alongside the percentage. Each legend row reads SYMBOL · pct · $value, matching the layout used elsewhere in the app, and the legend reserves at least five rows of vertical space before any scrolling kicks in. The donut is capped at 240px so a tall cell can no longer stretch it past the legend's available width.
  • V2 Allocation card legend reserves the same five-row floor; the donut is fixed at 172px and the registry's 4×5 minimum keeps the cell wide enough to render donut + legend without overflow.

Mock content fully removed from the V2 redesign

Changed
  • Workbenches index now reads from a typed in-file catalogue (TA + FA + Reconciliation Studio + Lab) with one entry marked live and three marked roadmap, instead of mapping over an empty preview array. The "Headline numbers are illustrative" footnote no longer appears.

V1 → V2 transition foundations and pending-shell closures

Added
  • Ledger V2 redesign (preview) is now a real lifecycle flag — STAFF and ADMIN preview the redesign at /v2/... while everyone else stays on the production V1 surface. Status is pipeline; the next promotion (to experimental) will open the redesign to VIP 5+ Beta Enrollments.
  • Try the redesign banner appears at the top of every V1 portfolio page for STAFF — one click jumps to the V2 preview for the same portfolio. Disappears for users without the redesign flag.
  • V2 transaction detail page is live at /v2/[slug]/transactions/[txId]. Shows the same double-entry ledger lines, FIFO lot movements, and posting metadata as the V1 page, but inside the V2 shell with the violet glass surface, KPI strip (date / debits / credits / balance), and serif headlines.
  • V2 admin shortcut at /v2/[slug]/admin redirects ADMIN users to the standalone admin panel — same destination as the V1 link, now reachable from the V2 chrome.
  • V2 profile shortcut at /v2/[slug]/profile redirects to the auth server's account settings, mirroring V1.
  • V2 on-chain import page at /v2/[slug]/import mounts the existing import wizard inside the V2 shell, with the verified-wallet count and daily-import quota in the eyebrow strip. Email-verification gate kept identical to V1.
  • V2 VIP upgrade flow at /v2/[slug]/vip-upgrade, /v2/[slug]/vip-upgrade/donate, and /v2/[slug]/vip-upgrade/ledger-wallet mirrors the V1 journey inside the V2 shell. The ledger-wallet form is now a shared component used by both designs, so the encryption flow has one source of truth.
Changed
  • V2 page guards are now driven through the unified access map — the previous mix of hardcoded role checks and per-feature flag checks has been normalised. Every V2 page resolves the redesign flag through the same path as Vault, Trade, Watchlist, and Reconciliation, in series with the per-feature gate where one applies.
  • Security headers are now injected by edge middleware on every page and API route — strict-transport-security (HTTPS only), X-Content-Type-Options, X-Frame-Options, Referrer-Policy, and a permissions policy that blocks geolocation / microphone / camera / payment APIs the app does not use.

Mock content removed; v2 surfaces serve real data only

Changed
  • v2 portfolio dashboard footer no longer carries the "TEST-Design V2 renders illustrative preview data" disclaimer; every portfolio now shows real cost-basis numbers or the empty-state callout.

v2 journal detail pages, Connections, dashboard polish, reconciliation cleanup

Added
  • v2 Wallet, CEX, and Bank journal detail pages — every per-journal detail surface (entries, import history, dashboards, reconciliation drawer, posting preview, bulk edit, CSV import) now renders inside the v2 shell. Full feature parity with the legacy detail pages.
  • Asset balance tiles on the wallet journal dashboard — the dashboard tab now renders one square tile per outstanding on-chain asset position, colour-coded green / red / muted by balance, mirroring the existing CEX journal dashboard pattern.
  • v2 Connections page — single-surface 2×2 grid of Blockchain Wallets, Exchange APIs, Bank Accounts, and Payment Cards inside the v2 violet shell. KPI strip up top (counts + verified counts). Each card delegates to the existing v1 list components so add / verify / archive flows are unchanged.
Changed
  • Tax pack button on the v2 portfolio dashboard opens the canonical 5-step Tax Pack wizard (Jurisdiction → Scope → Preflight → Format → Review) instead of a one-step export modal.
  • v2 Reconciliation page — Balance Summary tab removed (the metric was unreliable; the surface will be redesigned from scratch). Import CSV and "Add by hash" buttons are no longer rendered on the v2 surface — ingestion lives in the dedicated import flows. The page is now resolve-only.
  • Reconciliation new-transaction modal on v2 routes through the v2 entry modal (taxonomy + violet shell) instead of the legacy transaction form. The legacy v1 reconciliation page continues to use the v1 form so its auto-fill / mark-RECONCILED linkage is preserved.

v2 dashboard: clickable journal cards, surface ports, action wiring

Added
  • v2 Trade page — full quote-aggregator surface (chain selector, pair selector, amount, "get best quote" submit, side-rail results) replaces the placeholder shell. Reuses the existing 1inch + 0x quote endpoint.
  • v2 Vault page — encrypted hot-wallet flow now lives in the v2 sidebar with the create / dashboard / send / receive screens wrapped in the canonical violet shell.
  • v2 Reconciliation page — single cross-journal queue at /v2/[slug]/reconciliation aggregates every unreconciled imported transaction across all wallet, CEX, and bank journals, plus the balance-summary tab.
  • v2 News page — live SWR feed against CryptoCompare with density toggle, image/text card layouts, settings drawer, qualified-symbol filter chips, lock state, and per-article relevance badging — full feature parity with the v1 surface.
  • v2 Watchlist — multi-list nav, per-list categories, add-token / create-list / category dialogs, tier-aware limit banner, and the full token table now ship in the v2 surface.
  • v2 Reports — per-account drill-in — a chart-of-accounts dropdown surfaces account metadata (code, name, type, category, currency) with a deep-link into the legacy ledger drill view.
  • Tax-pack export modal — CommandRail's "Tax pack" tile opens a modal with jurisdiction / tax year / format selectors that downloads the generated pack from the existing endpoint.
Changed
  • Wallet, CEX, and Bank journal cards on v2 — every card and table row in the v2 journal surfaces is now clickable; tapping opens that journal's detail page instead of doing nothing.
  • CommandRail destinations on v2 — every quick-action tile now points at the correct v2 route. "New entry" opens the v2 entry modal in place; "Tax pack" opens the new export dialog; "Trade" and "Reconcile" navigate to the new v2 pages.
  • Onboarding wizard — Step 1 portfolio types and Step 4 modules grid — Step 4 modules render as a denser tile grid with checkboxes, large centered icons, recommended-for-type modules sorted to the front, and a corner dot to mark them as recommended.
  • Onboarding wizard — Step 2 selectors — primary fiat, cost-basis method, country, and FY-end dropdowns are now real selects with options and onChange (previously inert presentational stubs).
  • Onboarding wizard — Financial year end — auto-derives from the chosen jurisdiction (UK 5 April, AU 30 June, ZA 28 February, etc.) so non-US users no longer have to correct the default.
  • Onboarding wizard — Step 3 — the "Upload CSV" tile has been dropped; CSV import lives on the per-journal screens after onboarding completes.
  • Onboarding wizard — Live preview rail — Sources and Modules counts now show the resolved display names on hover.
  • Onboarding wizard — header — flow title now renders at heading scale instead of body-text size.
  • v2 Workbenches — only the Technical Analysis card is clickable; Fundamental Analysis, Reconciliation Studio, and the Lab render as muted "Coming soon" placeholders so users no longer click through to dead routes.
Fixed
  • The "New entry" tile on the v2 dashboard initially mounted the v1 transaction form by mistake; it now opens the v2 entry modal as designed.

Round 20: Advisor portal client drill-down

Added
  • Per-client advisor view (/account/advisor/clients/[clientUserId]) — advisors granted access by a client can now open a consolidated read-only view of that client's net worth directly from the advisor portal. The page shows an "Acting as: [client name]" frame so the impersonation context is unambiguous, the client's total net worth in the advisor's home currency, a per-portfolio breakdown, and three allocation cards (by class, by currency, by custodian) with progress bars and percentages. A market/cost toggle lets the advisor switch valuation modes without leaving the page.
  • "View as advisor" link on each row of the "Clients who have granted me access" card on the advisor portal — opens the new drill-down page.
Changed
  • WRITE-scope grants show an extra "WRITE pending impersonation" pill on the drill-down — the actual write actions are intentionally absent from the UI in v1 because the auth-server impersonation work that would let an advisor post journal entries on behalf of a client has not landed yet. The READ view works the same for both READ_ONLY and WRITE grants.

Round 19: Family Office consolidated net worth; advisor read view

Added
  • Family Office consolidated net worth — every FAMILY_OFFICE-typed portfolio now exposes a consolidated net-worth endpoint that walks the membership graph, scales each child portfolio's contribution by the registered ownership percentage, and runs the inter-portfolio elimination so a parent-to-subsidiary loan inside the family-office composition does not double-count. The response carries the same allocation buckets (by class, by currency, by custodian) as the user-level dashboard, plus per-portfolio rows that show pre-scaling and post-scaling values side-by-side so a head can read "Spouse's brokerage — 50% of $400 000 = $200 000" directly.
  • Advisor read view — advisors granted access via the Advisor Portal can now query a client's consolidated net worth through a dedicated read-only endpoint without the client having to share their session. The response includes the client's display details, the active grant row, and the same net-worth shape the client sees on their own dashboard. Self-view (advisor reading their own portfolios via this path) is rejected — use the regular dashboard. Write actions (advisor posting journal entries on behalf of the client) still require auth-server impersonation work and remain deferred.

Round 18: Specialist marketplace apply page + nav links

Added
  • Specialist marketplace apply / edit page (/account/specialist) — users with the marketplace flag enabled can now apply to be listed as a vetted specialist directly from the UI. The page has two states: applicants without a profile see the application form; existing applicants see an edit form with a status banner showing "Pending review", "Live" (vetted), or "Self-paused". Editable fields cover display name, bio, jurisdictions covered (ISO-3166-1 alpha-2 chips), specialism tags, hourly rate, currency, and profile image URL. A self-pause toggle on the status banner lets a vetted specialist remove their listings from the marketplace without involving an admin (e.g. on holiday) — the admin's vetting decision is preserved when paused.
  • Specialist marketplace entry in the account dropdown — opens the apply page when the marketplace flag is enabled.
  • Advisor access entry in the account dropdown — opens the existing advisor portal page (which previously had no nav link).

Round 17: Specialist profile read/edit; admin vetting queue

Added
  • Read your own specialist profile — applicants can now fetch their own profile (vetted or unvetted) via the new "specialist me" endpoint to preview how their listing will appear once vetted.
  • Edit your own specialist profile — applicants can update display name, bio, jurisdictions, specialism tags, hourly rate, currency, profile image URL, and a self-pause toggle. The self-pause toggle lets a specialist remove their listings without involving an admin (e.g. on holiday) — distinct from admin vetting. Re-vetting is not triggered automatically on edit; the admin's vetting decision stands until they explicitly revoke it.
  • Admin specialist queue — admins can list every specialist application with optional filters: pending only, vetted only, active only, and a row cap. Each row includes the applicant's email, name, role, and VIP level so the admin can assess the application without a second round-trip; the response also returns total / pending / vetted counts for the queue header.

Round 16: Specialist application + admin vetting; Family Office CRUD

Added
  • Specialist marketplace application — registered users with the marketplace flag enabled can now apply to be listed as a specialist via the new application endpoint. The application captures display name, bio, jurisdictions covered, specialism tags, hourly rate, currency, and an optional profile image URL. One profile per user — a repeat application returns the existing profile id so the client can route the user to their existing profile instead of silently overwriting. Listings stay invisible to other users until an admin vets the profile.
  • Admin specialist vetting — admins can mark a specialist as vetted (or revoke vetting) via the new admin vetting endpoint. Vetting stamps the admin's user id and the timestamp on the row; revoking immediately removes the listings from browse / engagement on the next fetch. The endpoint deliberately bypasses the marketplace flag check on the admin side so a mis-vetted specialist can always be revoked.
  • Family member CRUD — every FAMILY_OFFICE-typed portfolio now exposes endpoints to register, list, edit, and remove family members (HEAD / SPOUSE / CHILD / DEPENDANT / TRUSTEE / OTHER). Trying to add a member to any other portfolio type returns a structured error so the UI can prompt the user to register a FAMILY_OFFICE portfolio first.
  • Family-office membership CRUD — endpoints to enrol child portfolios in a FAMILY_OFFICE with an ownership percentage, list memberships (including the linked child portfolio details), update ownership or role on an active membership, and end a membership. Membership end is insert-only on revoke — the row stays with endedAt set so consolidated reports can still walk historical compositions. Self-enrolment, nested family offices, and duplicate active memberships are rejected with structured codes.

Round 15: Specialist Marketplace + Family Office foundations, retirement contribution limits, loan payoff progress

Added
  • Specialist Marketplace foundation — schema laid down for the W3.3 marketplace where users can find vetted advisors, accountants, and tax specialists. Five new data shapes: specialist profiles, service listings, engagements with status tracking, in-thread messages, and Stripe Connect onboarding state. Listings stay invisible until an admin marks the specialist vetted. Platform fee captured per engagement (default 17.5%) so a later platform-fee change does not retroactively re-price open work. Two flags shipped — a master flag for browse / engagement / messaging and a separate Stripe-payouts flag so the payout rail can be killed independently of the marketplace itself. UI, vetting flow, Stripe onboarding, and messaging surfaces ship in subsequent tranches.
  • Family Office composition foundation — schema laid down for the W5.1 family-office surface. Two data shapes: family members (with HEAD / SPOUSE / CHILD / DEPENDANT / TRUSTEE / OTHER roles) and family-office membership links between a parent FAMILY_OFFICE portfolio and each member's child portfolio. Each membership carries an ownershipPct so the consolidated aggregator (shipping in a later tranche) can scale the child's net worth before consolidation, and the W4.3 inter-portfolio elimination prevents double-counting when both the family office and the child portfolio are in scope. Membership is insert-only on revoke — historical ended rows coexist with the active row.
  • Per-loan payoff progress widget on the Loans page — each active loan shows a horizontal progress bar (principal paid down as a percentage of original principal), current balance, principal paid this calendar year, and interest paid this calendar year. Renders nothing for users without active loans.
  • Retirement contribution-limit warnings on the contribution endpoint — every recorded contribution now returns a soft warning when the year-to-date total exceeds the jurisdictional cap. 2026 limits seeded for nine retirement-account types (US 401(k) / 403(b) USD 23 000 + 7 500 catch-up; US IRA combined USD 7 000 + 1 000 catch-up; UK SIPP / Personal Pension GBP 60 000; CA RRSP CAD 32 490; CA TFSA CAD 7 000; AU Super AUD 30 000 concessional; DE Riester EUR 2 100). Multi-currency portfolios FX-convert the YTD total into the limit currency before the comparison. The warning is advisory and never blocks the post — catch-up eligibility, cross-account allocation across multiple IRAs, employer-side caps, and income-based phase-outs are not modelled in this first pass and the warning's basis line names what's not covered.
Changed
  • Net-worth dashboard custodian breakdown now reflects long-term holdings. Previously, mortgages, loans, retirement balances, and real estate all collapsed into the generic "Manual / unsourced" bin in the byCustodian breakdown — visibly dominating it for any user with a house or a retirement account. Each long-term row now uses the underlying account's source (a connected wallet or exchange when set) or a synthetic per-bucket bin ("Real estate (off-platform)", "Mortgage (off-platform)", "Loan (off-platform)", "Retirement custodian") otherwise, so the breakdown is no longer dominated by a single default.

Round 14: RMD scheduling + estimated-tax engine

Added
  • Required Minimum Distribution scheduling — once a year, the platform now stamps an RMD reminder against every retirement account whose holder has reached the jurisdiction's RMD age (US 73 since SECURE 2.0, AU 60 for Super, CA 71 for RRSP, UK SIPP and Roth IRA and TFSA carry no RMD). The reminder lists the tax year, the statutory deadline, and the placeholder amount the user fills in once their statement balance is known. Account types with no RMD obligation get a one-time "not required" stamp so the cron leaves them alone thereafter. Cron lives at scripts/cron/rmd-schedule.ts.
  • Quarterly estimated-tax engine for freelancers — every TRADFI_FREELANCE portfolio with a configured jurisdiction now gets per-period estimated-tax rows generated automatically. The engine sums paid invoices minus expenses for each period (US 1040-ES four-quarter rhythm by default; UK self-assessment payments-on-account override) and applies a per-jurisdiction effective rate (12 day-one jurisdictions, 15–35% blends). Every estimate is flagged "requires accountant review" — the calculated amount is a planning figure, not a filing figure. Already-paid rows are not overwritten on re-runs. Cron lives at scripts/cron/estimated-tax.ts.
Changed
  • Retirement accounts now carry an account-holder date of birth (nullable) — required by the RMD cron to compute age. Existing accounts default to NULL and the cron skips them with a structured warning until DOB is filled in.

Round 13: Advisor Portal foundation (W4.4)

Added
  • Advisor Portal (/account/advisor) — grant a financial advisor or accountant access to all of your portfolios with a single email-based grant. Two cards on the page: advisors you've given access to (with Grant + per-row Revoke) and clients who have granted you access. Each grant carries a permission level (READ_ONLY by default; WRITE optional) and a free-text note ("tax season 2026"). Revoke is insert-only — the row stays on the audit trail with revokedAt + the user id of who revoked it.
Changed
  • Per-portfolio collaborator vs cross-portfolio advisor — the existing per-portfolio Collaboration card (Settings → Collaboration) keeps its narrow scope (one accountant on one portfolio with role-aware permissions). Advisor Portal is the user-level grant that covers every portfolio you own at once. Two surfaces for two genuinely different patterns.

Round 12: Property + loan polish + Compensation YTD

Added
  • Compensation YTD card on the Payslips page — lead-in card shows the calendar-year totals across every payslip (gross, income tax withheld, payroll tax withheld, net pay) plus the FMV of every equity vest event that completed this year. Multi-currency portfolios surface a per-currency line so a US salary + UK consulting income show side by side instead of being silently rolled together.
  • Dispose property action on each property row — opens a dialog where you enter the sale price (and accumulated depreciation + mortgage payoff if applicable). The dialog previews the gain or loss against net book value live before posting. The endpoint posts a PROPERTY_DISPOSAL journal entry that closes the asset, settles the mortgage payoff, recognises the gain or loss, marks the property inactive, and zeros the mortgage balance.
  • Amortisation schedule modal on each loan row — opens a dialog that renders the level-payment schedule month-by-month with monthly payment, total interest, and total paid summarised at the top.
Changed
  • Property depreciation now posts itself. A new monthly cron walks every active property with depreciationMethod: STRAIGHT_LINE and posts a DEPRECIATION journal entry for each missing month between the acquisition date and the previous month-end. Idempotent — re-running the cron is a no-op for already-posted months. Run via npx tsx scripts/cron/depreciation.ts.

Round 11: Retirement, real estate, and debt on the dashboard

Changed
  • Net worth now reflects retirement balances, real estate, mortgages, and unsecured loans. Previously the dashboard was blind to these account categories — a portfolio with a $400k house, a $300k mortgage, a $50k retirement balance, and a $20k personal loan reported zero net worth from this surface, even though the journal entries existed. The aggregator now reads RETIREMENT_ASSET, REAL_ESTATE_ASSET (net of accumulated depreciation), MORTGAGE_LIABILITY, and LOAN_LIABILITY balances per portfolio, translates each to your home currency, and adds them to the relevant byClass bucket (Retirement / Real estate / Mortgage debt / Loans). Asset balances add to net worth, liability balances subtract.

Round 10: Bank Journals + Settings polish

Added
  • Bank Journals — every connected bank account now has its own per-account journal alongside Wallet Journals and CEX Journals. Provider syncs (Plaid today; TrueLayer / Tink / Nordigen when their stubs go live) write transactions directly into the matching journal. Twelve connected accounts produce twelve journals; sandbox imports of 300+ transactions route to the right journal automatically. The journal tile is created the moment a bank is connected, so it's visible even before the first sync.
  • Sync now button on each Bank Journal tile and on the per-journal detail page — runs the same sync the cron uses without waiting for the next tick.
  • Add entry dialog on each Bank Journal — record cash transactions the provider feed missed, or for accounts without a provider, in the same shape as a synced row.
  • Bank Journals sidebar entry under Transactions in the unified-nav restructure.
Changed
  • Bank syncs no longer drop into the legacy Reconciliation queue. The unified-nav UI does not surface a top-level Reconciliation page, and double-entry bookkeeping is the working surface — bank rows now land in their journal as the standard pattern.
  • Settings page width raised from 768px to 1280px, centred — uses meaningful space on a 27" screen instead of ~30%.
  • Settings → Navigation restructured into seven categorised rows (General, Journals, Crypto markets, Crypto staking & DeFi, TradFi & employment, Self-employment & business, Property/retirement/wealth). Each row has at least four cells and stays on one row; tiles are uniform size; enabled tiles sort to the left; drag reordering is back, scoped per category and per show/hide lane; Dashboard is pinned to the first item slot since it can't be turned off.
Fixed
  • Add property failed on USD-primary TRADFI_PROPERTY portfolios with "TRADFI_PROPERTY chart accounts (1010, 1700) not seeded" — the property endpoint now lazy-seeds the chart on first use and resolves accounts by category + currency rather than a hard-coded code lookup.

Round 9: Inter-portfolio transfers (HYBRID Founder)

Added
  • Inter-portfolio transfer — a new tile in the Transactions page's Transfers section lets a HYBRID Founder move money between two portfolios that have a registered relationship. One click posts the transfer atomically on both sides: the source records an Inter-Portfolio Receivable + the cash decrease, and the target records the cash increase + an Inter-Portfolio Payable. The pair is linked at the database level so a duplicate post is rejected and consolidated reports can detect it.
  • Consolidated elimination on the dashboard — when net worth is computed across every portfolio you own (the standard dashboard read), matched inter-portfolio pairs net out automatically so a parent-to-subsidiary loan does not double-count. Single-portfolio reads still show the receivable as an asset and the payable as a liability against the counterparty portfolio. Two new allocation buckets ("Inter-portfolio receivable" and "Inter-portfolio payable") surface in the byClass breakdown when the underlying balance is non-zero on the active read.
Changed
  • HYBRID Founder workstream complete (W4.3) — the W4.3.3 transfer flow + W4.3.4 aggregator elimination ship together. Future tranches will add the inter-portfolio reports surface and the optional same-currency conversion for cross-currency transfers.

Round 8: Five portfolio types shipped end-to-end + sidebar layout toggle

Added
  • Business sidebar entry — register a sole-trader / Ltd company entity (with VAT scheme + country), open a VAT period for the relevant jurisdiction (UK / IE / DE supported via the per-jurisdiction VAT engine), then close the period when reporting time comes. Closing posts the closure journal entry and stamps the computed totals on the period row. Atlas Labs exports the figures only — the user files the return through HMRC / Revenue / ELSTER themselves.
  • Properties sidebar entry — register a property (residential / commercial / holiday let / owner-occupied / mixed) with optional mortgage financing on acquisition. Each property carries a per-row Record tx dialog that handles seven transaction kinds: rent received, mortgage payment (auto-splits principal vs interest), property tax, insurance, maintenance, CAPEX (capitalises into property cost), depreciation. Mortgage balance decrements automatically as principal is paid down.
  • Retirement sidebar entry — register an account at any of 11 jurisdiction-typed retirement vehicles (US 401k / 403b / IRA Trad / IRA Roth, UK SIPP / Personal Pension, CA RRSP / TFSA, AU Super, DE Riester, Other). Per-account contribution recording captures employee + optional employer match; the match recognises as Equity Comp Income per IAS 19; the account balance updates atomically.
  • Loans sidebar entry — register an unsecured loan (student / personal / auto / BNPL / credit line) with principal + rate + term. Per-loan payment recording auto-splits the payment into principal + interest portions via the W3.2 amortisation engine and decrements the loan's outstanding balance. Mortgages still live on the Properties page since they're property-secured.
  • Relationships sidebar entry — register a cross-portfolio relationship between portfolios you own. Three kinds: OWNS (founder owns company), CO_OWNS (joint ownership with ownership%), GUARANTEES (one portfolio guarantees another's debt). The inter-portfolio transfer flow + aggregator elimination logic land in subsequent tranches.
  • Sidebar layout toggle — a small toolbar above the navigation lets you flip the entire sidebar between list view (the historical row layout) and a 3-column grid of square tiles with the icon stacked above the label. The chosen layout persists across sessions via localStorage. Items with sub-pages (Transactions, Analytics) reveal their child icons in a hover popover that floats above page content while the sidebar stays a fixed width.
  • Per-portfolio navigation preferences (Settings → Navigation) — every sidebar item the user has feature access to is listed in a new Settings card with an on/off switch. Items canonically recommended for the portfolio's type are listed first ("Recommended for this portfolio type"); the remaining accessible items appear under "Optional — enable if you need it". A user override on a portfolio always wins over the admin persona default and the canonical recommendation; Reset clears the override.
  • Admin: Persona Nav Defaults (Atlas Labs admin → Ledger → Persona Nav Defaults) — the company owner can override the canonical nav recommendation per portfolio type. Each persona's matrix shows the canonical default (ON / OFF), any active admin override, and a switch + reset action. Per-portfolio user overrides still take precedence; this layer changes what new portfolios of each type see by default.
Changed
  • Per-jurisdiction VAT engine landed — the W3.1 period-close flow now routes through src/lib/vat/{gb,ie,de}.ts which carries the standard rate, reduced rates, registration thresholds, period cadence, and filing form names (UK VAT100, IE VAT3, DE Umsatzsteuer-Voranmeldung).

Round 7: Five-portfolio-type foundation (W3.1, W3.2, W4.1, W4.2, W4.3)

Added
  • TRADFI Business / VAT (W3.1) — foundation. New BusinessEntity (per-portfolio sole-trader / Ltd company with VAT scheme) and VatPeriod (OPEN → CLOSED → FILED) models. Three pure builders: VAT payment to tax authority, VAT refund from tax authority, and VAT period closure (transfers accumulated VAT_OUTPUT minus VAT_INPUT into VAT_PAYABLE). Master tradfi-business flag. Six new chart-of-accounts seed when a portfolio is typed TRADFI_BUSINESS. The per-jurisdiction VAT engine (UK VAT100, IE VAT3, DE Umsatzsteuer-Voranmeldung) and the period-close API + UI follow.
  • TRADFI Property (W3.2) — foundation. New Property, Mortgage, Tenant models. Mortgage amortisation engine at src/lib/finance/amortisation.ts — pure function returning the full level-payment schedule with principal + interest splits per row, plus a splitPayment helper for one-off entries. Seven pure builders: rent received, mortgage payment (split principal + interest), property expense (PROPERTY_TAX / INSURANCE / MAINTENANCE), CAPEX (capitalises into property cost per IAS 16), depreciation, property acquisition (cash + mortgage), property disposal (closes asset + accumulated depreciation, recognises gain/loss). Master tradfi-property flag. Seven new chart accounts seed when a portfolio is typed TRADFI_PROPERTY. The CRUD endpoints, depreciation cron, and /[slug]/properties page follow.
  • TRADFI Retirement (W4.1) — foundation. New RetirementAccount (US 401k / IRA / UK SIPP / CA RRSP / AU Super / etc.) and Contribution models. Two builders: contribution (with employer-match recognised as Equity Comp Income per IAS 19) and distribution (drawdown — per-jurisdiction income classification on Roth vs Traditional follows in the W1.3 tax pack). Master tradfi-retirement flag. Three new chart accounts.
  • TRADFI Debt (W4.2) — foundation. New Loan and LoanPayment models for personal loans / student loans / BNPL / credit lines. Loan-payment builder splits principal vs interest using the W3.2 amortisation engine. Master tradfi-debt flag. Two new chart accounts.
  • HYBRID Founder (W4.3) — foundation. New PortfolioRelationship model with three kinds (OWNS = founder owns company; CO_OWNS = couple's joint + individual; GUARANTEES = parent guarantees child's debt). Master hybrid-founder flag. Inter-portfolio transfer flow + aggregator elimination logic land in W4.3.3 / W4.3.4.
  • Compensation YTD endpoint (W2.1.9). New GET /api/portfolios/[id]/compensation?year=YYYY sums credits to the four compensation accounts (Salary 4100 + Bonus 4110 + Equity Comp 4120 + Other Compensation 4130) in the requested calendar year and returns by-category totals. Used by the dashboard Compensation YTD tile (UI tile lands in a follow-up).

Round 6: TRADFI Freelance end-to-end + vesting cron auto-post

Added
  • Invoices page — new Invoices entry in the sidebar (gated on the tradfi-freelance flag). Add a client (name + email + country + payment terms), then create an invoice with line items. Each invoice transitions DRAFT → SENT (mints HMAC-signed pay token + posts INVOICE_ISSUED journal entry: DR Accounts Receivable, CR Service Revenue per IFRS 15.31) → PAID (posts INVOICE_PAID: DR Cash, CR Accounts Receivable). Per-portfolio invoice numbers auto-generate as INV-{YYYY}-{NNNN} if you don't supply one.
  • Expenses page — new Expenses entry in the sidebar. Record an expense with vendor + date + gross + category. Eight categories: SOFTWARE, HARDWARE, TRAVEL, OFFICE, PROFESSIONAL_SERVICES, MARKETING, SUBCONTRACTOR, OTHER. SUBCONTRACTOR routes to Cost of Service (5300); everything else routes to Operating Expenses (5400). Cash basis — expense recognises on payment. Optional tax-deductible amount and receipt-attachment URL captured for the W1.3 tax pack.
  • Five new TRADFI Freelance API endpoints — Client CRUD, invoice CRUD with status-transition PATCH, expense entry. All gated on the tradfi-freelance flag, atomic via prisma.$transaction, idempotent on invalid state transitions (returns 409 INVOICE_INVALID_STATE).
  • TRADFI Freelance chart of accounts seeder — five new accounts auto-provision when a portfolio is typed TRADFI_FREELANCE: 1200 Accounts Receivable, 1250 Estimated Tax Paid, 4200 Service Revenue, 5300 Cost of Service, 5400 Operating Expenses.
  • Equity vesting cron — auto-post enabled — the W2.1.4 cron now resolves FMV automatically via the new spot-price proxy (Yahoo Finance v8 chart endpoint) and posts the vest journal entry + opens a CostBasisLot scoped to the security. RSU and NSO/ISO vests auto-post; ESPP still requires manual entry (per-event purchase price isn't tracked on the EquityVestingEvent row). Skips with a clear log line for: pre-IPO grants (no Security), unknown tickers, missing TRADFI_WORK chart, missing strike price on options.

Round 5: TRADFI Work end-to-end (W2.1.3 → W2.1.5 + chart seeder)

Added
  • Payslips page — new Payslips entry in the sidebar (gated on the tradfi-work flag). Add an employer (legal name + country + tax id + pay currency), then record a payslip against it: pay date, period start/end, gross, optional bonus, income tax withheld, payroll tax withheld. Each payslip posts a balanced SALARY_PAID journal entry — gross + bonus credit Salary / Bonus Income; withholdings debit Income Tax / Payroll Tax liabilities; net pay debits the brokerage cash account. IAS 19 employee-benefit recognition.
  • Equity Grants page — new Equity Grants entry in the sidebar (also tradfi-work gated). Register an RSU / ESPP / ISO / NSO grant with a vesting schedule (cliff-then-monthly is the default; monthly / quarterly / annual also supported). The grant materialises into individual EquityVestingEvent rows on creation. Click a row to expand the vesting timeline, then Vest now on any scheduled event to enter the FMV at vest and post the journal entry. RSU vests recognise full FMV as ordinary income; ESPP recognises the discount; NSO/ISO recognises the strike-spread. All variants open a CostBasisLot scoped to (portfolio, security) when the grant has an attached Security.
  • Three new TRADFI Work API endpointsPOST /api/portfolios/[id]/employers, POST /api/portfolios/[id]/payslips, POST /api/portfolios/[id]/equity-grants, plus the per-event POST .../equity-grants/[grantId]/vest/[eventId]. Each gated on the tradfi-work flag, atomic via prisma.$transaction, idempotent on already-vested events (returns 409).
  • TRADFI Work chart of accounts seeder — eight new accounts auto-provision when a portfolio is typed TRADFI_WORK (or TRADFI_INVESTOR, since salaried users often hold both): 1110 Net Pay Receivable, 1120 Equity Comp Receivable, 4100 Salary Income, 4110 Bonus Income, 4120 Equity Comp Income, 4130 Other Compensation, 5100 Income Tax Withheld, 5110 Payroll Tax Withheld.
Changed
  • Nothing user-facing for existing TRADFI_INVESTOR portfolios — the new chart accounts are additive and only seed on portfolio creation. Existing portfolios get the new accounts on next accountSeeder run (typically when a TRADFI_WORK transaction is first attempted).

Round 4: TRADFI Work foundation + W3 scoping (partial)

Added
  • TRADFI Work schema (W2.1.1) — additive Prisma migration 20260428000000_add_tradfi_work adds the data shapes for employee compensation tracking: Employer (per-portfolio), Payslip (gross / withholding / deductions / net), EquityGrant (RSU / ESPP / ISO / NSO with vesting schedule), EquityVestingEvent (SCHEDULED → VESTED → FORFEITED). Eight new chart-account categories (Salary, Bonus, Equity Comp, Other Compensation, Income Tax Withheld, Payroll Tax Withheld, plus two receivable accounts). Seven new transaction types (SALARY_PAID, RSU_VEST, ESPP_PURCHASE, OPTION_EXERCISE, EMPLOYER_MATCH_CONTRIB, BENEFIT_TAXABLE, WITHHOLDING_TAX). Migration applied via prisma migrate deploy (bug #26 workaround for shadow-DB replay failures).
  • TRADFI Work bookkeeping builders (W2.1.2) — seven pure builders in src/lib/bookkeeping/builders/tradfi-work.ts produce balanced ledger lines for each of the new transaction types. Routing follows IAS 19 (employee benefits) for salary / bonus / employer-match and IFRS 2 (share-based payment) for RSU / ESPP / option-exercise. Withholding handled as a liability to the tax authority — the cash leg nets it out, but the gross-income side captures the full amount so payroll-tax filings get accurate gross figures.
Changed
  • Nothing user-visible yet. The schema + builders are foundation; the endpoints, vesting cron, and UI tier (W2.1.3 → W2.1.6) land in subsequent tranches once prisma generate regenerates the client (currently blocked by the running dev server holding the query-engine DLL).

Round 3: tax packs for 12 jurisdictions + PDF, bank account picker, W2.1 scoping

Added
  • Tax Pack — full 12-jurisdiction coverage — every supported jurisdiction (US, UK, IE, DE, FR, NL, CH, SG, AU, CA, ZA, ID) now produces real cryptoCapitalGainsLines, equityCapitalGainsLines, and interestAndDividendsLines from posted journal entries. The line-shaping logic moved to a single shared helper module so per-jurisdiction files supply just the holding-period thresholds + wash-sale flags. US (12-month + 30-day wash sale), UK (Section 104 pooling + 30-day bed-and-breakfasting), Germany (365-day crypto exemption), Australia (12-month CGT discount), Canada (50% inclusion + 30-day superficial-loss), South Africa (36-month threshold + 40% inclusion), Ireland (4-week bed-and-breakfast), France (PFU flat 30%), Netherlands (Box 3 deemed yield), Switzerland (private capital gains exempt), Singapore (no CGT), Indonesia (0.1% final). Every PDF carries a "draft -- not for filing" watermark until a chartered accountant signs off the country.
  • Tax Pack page — new Tax Pack entry in the sidebar (visible whenever any tax-pack-{cc} flag is enabled). Pick jurisdiction + tax year, click Compute lines for an inline summary, click Download PDF for a watermarked report. Per-jurisdiction picker filtered to the flags the user has access to.
  • Bank connect — account picker — when an institution returns multiple accounts (Plaid sandbox returns 12 fake First Platypus Bank accounts, real banks usually return 5–15), the connect flow now stops at a picker step. A checkbox list with Select all / Clear controls lets you import only the accounts you actually want. Direct response to the 27-04 sandbox-on-production incident — the silent default that auto-attached every account is gone.
  • W2 scopingwealth-migration.md § W2.1 (TRADFI Work) and § W2.2 (TRADFI Freelance) gain detailed sub-phase breakdowns covering schema deltas, bookkeeping builders, endpoints, vesting cron, UI tier, aggregator integration, and closure for each. The tradfi-work master flag is now registered (status planned); the actual schema migration is queued for the next session when the user is at the keyboard.
Changed
  • Tax-pack registry no longer ships any notImplementedYetTaxPack stubs — every entry resolves to a real per-jurisdiction contributor backed by the shared helpers in src/lib/tax-pack/helpers.ts.

Round 2: 6 TRADFI types, corporate-actions queue, cash aggregator, US tax pack

Added
  • All six remaining TRADFI investor entry types — DIVIDEND_RECEIVED, INTEREST_RECEIVED, BOND_COUPON, BOND_MATURITY, FUND_DISTRIBUTION, MARGIN_INTEREST. Two new surfaces on the Securities page: a top-level Record income / expense dropdown that handles dividends, interest, bond coupons, fund distributions, and margin-interest charges; and a per-row Mature button on bond holdings that posts BOND_MATURITY (face-value redemption with premium-amortisation / discount-accretion routed to Realised Gains/Losses). The route's discriminated-union schema now covers all 8 TRADFI tx types end-to-end.
  • Corporate actions queue — new Corporate Actions entry in the sidebar (visible with the tradfi-investor flag). Lists pending and processed corporate actions per portfolio. Click Add to record a SPLIT, REVERSE_SPLIT, STOCK_DIVIDEND, CASH_DIVIDEND, MERGER, SPINOFF, RETURN_OF_CAPITAL, CAPITAL_DISTRIBUTION, or RIGHTS_ISSUE; click Apply on a pending row to run the action through the existing close-lot / open-lot / income-recognition flow atomically. Historical lots are never overwritten; closed lots transition to status DEPLETED with a journal-entry anchor; new lots inherit the predecessor's acquisition date for tax-holding-period continuity.
  • US crypto + equity capital-gains lines (W1.3) — the US tax-pack contributor now produces real cryptoCapitalGainsLines, equityCapitalGainsLines, and interestAndDividendsLines from the user's posted journal entries in the tax-year window. Each disposal is one line, classified long-term vs short-term per the US 12-month threshold. Wash-sale handling deferred to a follow-up. The "draft — not for filing" watermark stays in place until a chartered accountant signs off.
  • EQUITY_SELL on the Securities page — every open holding on the Securities page now has a Sell button. Click it, enter quantity + total proceeds + trade date, and the disposal posts end-to-end: FIFO cost basis matched server-side scoped strictly to the security, realised gain or loss recognised against Realised Gains (4500) or Realised Losses (5500), proceeds settled to Brokerage Cash (1010). The toast surfaces the gain/loss outcome ("gain 3,250.00 USD") so the result is visible without leaving the page. First of seven deferred TRADFI investor entry types; the other six (DIVIDEND, INTEREST, BOND_COUPON, BOND_MATURITY, FUND_DISTRIBUTION, MARGIN_INTEREST) follow.
  • Tax-pack scaffolding (W1.3) — the framework for per-jurisdiction tax-pack rendering is in place. Every supported jurisdiction (US, UK, IE, DE, FR, NL, CH, SG, AU, CA, ZA, ID) is registered and answers to the same three line buckets: crypto capital gains, equity capital gains, interest + dividends. All jurisdictions return empty arrays today — the per-country line logic lands in subsequent tranches. Every plan ships with a draft — not for filing watermark until a chartered accountant signs off the country.
  • Plaid is now a real provider — the Plaid (sandbox) tile on the Connections page is no longer a stub. With PLAID_CLIENT_ID, PLAID_SECRET, and PLAID_ENV=sandbox configured, the Connect button runs end-to-end against Plaid's sandbox: it asks for a short-lived link_token, obtains a public_token for the First Platypus Bank sandbox institution without needing the browser-side Plaid Link SDK, exchanges it for an access token, fetches the linked account, and lands the new bank in the existing sync + reconciliation pipeline. Production-mode Link wiring follows in a later tranche; the sandbox path covers all developer flows today.
  • Read-only OAuth scope — the Plaid client requests only the transactions product. Payments-initiation, identity, and other write-capable scopes are explicitly not requested, which keeps the master tradfi-cash flag's read-only invariant intact at the provider layer too.
Changed
  • Wealth aggregator now buckets bank-cash balances under CashnetWorthService.byClass walks BANK_CASH LedgerAccount rows and sums their posted LedgerLine totals, then routes the per-account balance into the Cash allocation bucket alongside crypto and equities. Bank balances also flow into byCurrency (per the account's currency code), byCustodian (per the bank source), and perPortfolio. Zero-balance accounts are skipped.
  • Bank connect — sandbox-vs-production guard — connecting a sandbox provider (Mock, or Plaid when PLAID_ENV=sandbox) to a portfolio that already holds reconciled transactions or posted ledger lines now requires explicit confirmation. The Connect button surfaces a confirmation dialog ("Connect a sandbox provider to a production portfolio?") rather than silently attaching twelve fake institutions. Direct response to today's incident; doesn't prevent deliberate use.
  • listConfiguredBankProviders advertises Plaid when its env vars are set. Without env vars only the Mock provider appears in the connect tile list; with env vars configured Plaid joins it. The three EU providers (TrueLayer, Tink, GoCardless) stay marked Coming soon until their real implementations land.

Bank auto-feed UI (W1.2.7 + W1.2.8)

Added
  • Connect a bank — the existing Connections page now has a working Connect button on the Mock (sandbox) provider tile. Click it, pick a country, and the connect flow runs end-to-end: a Bank Account is created with encrypted credentials and the recurring detection has data to find. The four real provider tiles (Plaid, TrueLayer, Tink, GoCardless) stay marked Coming soon until their API keys land.
  • Per-account Sync button — every bank-feed-connected account now shows a refresh icon next to its delete button. Click to trigger a one-shot sync, which lands new transactions in the existing reconciliation queue and tells you the count via toast.
  • Recurring spend page — a new Recurring entry in the sidebar lists every detected recurring pattern from the last 90 days of bank transactions: merchant, amount, occurrences, first / last seen. Each row has Subscription / Bill / Clear buttons that apply the chosen classification to every contributing transaction.
  • Reconciliation queue rendering for bank rows — bank transactions (which are off-chain) now display a Bank badge instead of a chain icon, the merchant name as-is rather than an address shortener, and "Bank" instead of a wallet label.
Changed
  • JournalEntryType gained three new values (RECURRING_BILL, SUBSCRIPTION, REFUND) so the recurring panel can tag transactions with a typed classification rather than free-text. Schema additions land in migration 20260427020000_add_journal_entry_type_recurring — purely additive, zero rows mutated.

Bank auto-feed plumbing (W1.2 service tier)

Added
  • Bank auto-feed plumbing — the engine that imports transactions from a connected bank account into the existing reconciliation queue. A scheduled job (scripts/cron/bank-sync.ts) iterates every linked bank, fetches new transactions since the last sync, and lands them at To reconcile so the user reviews and posts them just like crypto imports. The cron is idempotent — a re-run after a transient failure never produces duplicates.
  • Recurring transaction detection — given the last 90 days of outflows on a bank account, the detection service groups by merchant, clusters by amount within a 5% tolerance (with a $1 floor so utility-bill drift doesn't split one bill into multiple buckets), and flags any merchant-amount pair with at least three matches as recurring. The service tags subscriptions (Netflix, Spotify, Apple, the major SaaS providers) as such automatically; everything else defaults to "bill". Inflows like paychecks and refunds are excluded by construction so a regular salary deposit can't be mis-classified.
  • Mock bank provider for development — a fully-wired sandbox provider returns deterministic fixtures (monthly Spotify, weekly Trader Joe's, paychecks, refunds, one-off purchases) so the connect flow, sync cron, and recurring detection can be exercised end-to-end without hitting Plaid / TrueLayer / Tink / Nordigen sandboxes. Picked by setting BankAccount.provider = "mock" in development; never enabled in production.
Changed
  • Nothing user-visible — the W1.2 work is service-layer only in this tranche. The connect flow UI, the per-portfolio recurring panel, and the real Plaid / TrueLayer / Tink / Nordigen provider clients all land in subsequent tranches once API keys are configured and the UI design pass is complete.

TRADFI investor portfolios (W1.1)

Added
  • TRADFI investor portfolio type — Atlas Labs portfolios can now hold listed equities, bonds, ETFs, funds, and REITs alongside crypto. Choose TRADFI Investor when creating a portfolio and the seven-account default chart is seeded automatically (Brokerage Cash, Securities Held, Dividend Income, Interest Income, Realised Gains, Realised Losses, Brokerage Fees).
  • Securities page at Securities in the sidebar (visible to staff while the workstream is in pipeline). Search the global ticker registry, create a new security on the fly when nothing matches, and record the first equity buy through a guided two-step dialog. The buy posts a balanced journal entry and opens a FIFO cost-basis lot tagged with the security so subsequent sells deplete the right inventory.
  • Cost basis the way each market expects it — the lot service now matches by security when set, so a sale of one ticker never consumes lots of another even when both quote in the same currency, and the same security held across multiple brokerages of one portfolio fungates correctly across them.
  • Corporate-actions service — the engine that translates splits, reverse splits, stock dividends, mergers, spinoffs, return-of-capital, capital distributions, and rights issues into the right close-old-lot / open-new-lot / income-recognition operations. Cash dividends recognise income against Dividend Income; stock dividends open a zero-cost successor lot; splits preserve cost basis verbatim while adjusting quantity; rights issues are correctly recognised as not-a-tax-event under IFRS. Every operation goes through the existing close-lot flow — historical lot rows are never overwritten.
  • Cross-portfolio Wealth dashboard now buckets by asset class — equities, bonds, ETFs, funds, and REITs each get their own slice on the allocation donut alongside Crypto and Cash.
  • Per-jurisdiction equity tax-treatment registry — each of the twelve day-one jurisdictions (US, UK, Ireland, Germany, France, Netherlands, Switzerland, Singapore, Australia, Canada, South Africa, Indonesia) ships with the equity-specific lot method, annual exempt amount, holding-period thresholds, wash-sale window, ETF distribution treatment, and dividend-rate handling that the future tax pack will need. Every entry is currently flagged for chartered-accountant review — when the tax pack ships, it will carry a "draft, not for filing" watermark for any jurisdiction that has not yet been signed off.
Changed
  • Manual Entry (Full) dialog gains an Equities tile category and four new income tiles (Dividend, Interest, Bond Coupon, Fund Distribution) when the TRADFI workstream is active for the session. The full per-type entry blocks land in a follow-up — for now the dedicated Securities page is where transactions are recorded.

Friction-day prep + working-tree hygiene

Added
  • Soft journal status mode — every CEX and wallet journal now carries an Active / Inactive / Archived status. Set it from the small pill on each tile on the homepage. Inactive tiles render dimmer and slide to the bottom; archived tiles are hidden by default behind a "Show archived" toggle. Status never blocks transaction entry — it's purely a visual signal so dust-only or wound-down accounts stop crowding the active grid.
  • Auto-computed sync state on every journal tile — a second pill, sitting next to the lifecycle status, surfaces one of three states automatically: Up to date (green) when there's nothing waiting in reconciliation and the last on-chain scan is recent, To reconcile (rose) when imported transactions or unposted CEX entries are pending, or Check for updates (sky blue) when the last on-chain scan for an active wallet journal is more than a week old. The sync pill is suppressed on archived journals unless they have a To reconcile condition — archived journals don't nag, but unresolved items still surface.
  • Public changelog — this file is now mirrored to a /changelog page in the Atlas Labs docs workspace and to ledger/changelog.pdf. New CLAUDE.md rule means it stays in sync automatically: every notable change appended to the markdown triggers a regenerated PDF + a docs-app upload before the conversation ends.
  • Pre-flight friction doc at docs/testing/2026-04-26-data-entry-friction.md — a four-section template (known-broken / UX papercuts / missing tx types / blockers) plus a checklist of every supported TransactionType so the daily data-entry session stays focused.
Changed
  • Repository line endings normalised to LF via a new .gitattributes. Future commits from CRLF working copies stop churning blame on every file edit.
Fixed
  • Sidebar hydration mismatch — the VIP-5+ sortable sidebar no longer logs an aria-describedby mismatch error on first paint. The drag-context now mounts after hydration completes.
  • Drag-and-drop "click without moving" bug — clicking a tile or sidebar entry without dragging no longer silently moves it to the end of the list. Both the journal grids and the sidebar now require ≥ 6 px of pointer travel to count as a real drag.
  • 30+ pre-existing TypeScript errors swept up across lotService tests, the BSC method-id duplicate (0x2e1a7d4d), the dashboard-layout JSON cast, the TransactionEntryForm dead setFeeEnabled reference, and a null-safety hole in the disposal-line backfill script. Final tsc --noEmit exits clean.

Solana support + watchlist v1 + analytics workbenches

Added
  • Solana support across the on-chain pricing layer — Solana wallets, watchlist tokens, dashboard liquidity-pool widgets, CoinGecko lookups, and ChainBadge rendering all now treat Solana as a first-class chain.
  • Cryptocurrency watchlist (v1 + multi-list) — track tokens you don't yet hold. VIP-gated number of watchlists per portfolio + items per watchlist. Auto-creates a default "My Watchlist" on first visit. Pool pricing for LP tokens. Drag-and-drop reorder. Behind the experimental watchlist flag (opt in via Beta Enrollments).
  • Analytics → Workbenches → Technical Analysis — candles, drawings, indicators, AI companion, rule-based backtester. First of a planned trio (Fundamental Analysis + custom workbenches to follow).
  • Liquidity pools — track LP positions with paired cost basis, fees, APR, and lock periods across DEX pools.
  • Shared drag-and-drop design system — every sortable surface (journals, sidebar, watchlist) now uses the same activation distance, collision detection, and "whole-row drag handle" behaviour.
  • VIP-5+ sidebar reorder — drag the top-level sidebar entries into your preferred order. Saved per-browser; Dashboard always pinned at the top.
  • Feature-flag lifecycle — every flag carries one of seven statuses (Live / Planned / Pipeline / Experimental / Shelved / Withdrawn / Rejected). Two-scope kill switches (ALL or USERS_ONLY). VIP-10 admins get a 4-week review banner on Experimental flags. Killed flags auto-drop from the Beta Enrollments picker.
  • Smart dashboard — context-aware widget layout that adapts to the data the portfolio actually has.
  • Per-source lot isolation — cost-basis lots live on source-scoped accounts (one per wallet/exchange) instead of a single pooled account per currency. Disposals match against the right source first; cross-source moves are explicit and traceable.
Changed
  • Cross-cutting polish across feature flags, transaction teleport, reconciliation, CEX import, dashboard cells, the in-app API reference, and developer docs.
Fixed
  • Solana integration gaps in the watchlist + CoinGecko + ChainBadge stack — now consistent with the other 26 chains.
  • Dashboard pool widget's Solana parser, encoder, chain-name resolution, and GeckoTerminal link-out.

Feature-flag lifecycle + dashboard intelligence

No detailed notes for this release.

International banking + journal Phase 8 + Connections grid

Added
  • International bank accounts — bank-account records carry IBAN/BIC, country, currency, and last-4. The CEX FIAT deposit / withdrawal flow lets you pick a bank account explicitly so the non-exchange leg books to that bank's BANK_CASH sub-account.
  • Sliding-window session — your session silently extends as long as you're active. No more mid-task logouts.
  • Connections page redesign — resizable-grid layout (drag, resize, save per cell), 2-column on extra-wide screens. Layout persists per-user. Newly registered cells auto-merge into saved layouts.
  • Wallet journal description editor — inline edit on the detail page; saves via PATCH.
  • /transactions read-only summary hub — repurposed as a portfolio-wide overview page; per-journal entry creation lives on each journal's detail page.
  • Drag-and-drop reorder for both wallet- and CEX-journal grids on the homepage.
  • Collapse Connections sections to 5 + per-chain gradients on wallet cards.
  • Unified Journals (Phases 0–8) — physical move of CEX journals + wallet journals into transactions/cex-journals and transactions/wallet-journals. Per-journal scan, commit, hash-lookup, and CSV import flows. Live unrealized P&L on the wallet-journal Dashboard. Batch detail drill-down dialog. CSV import parser + materializer for chain-explorer exports. Reconciliation drawer scoped per journal. Phase 8 redirects from legacy paths.
  • Sidebar nested children for Transactions and Analytics — collapsible parent sections.
  • API reference long tail — portfolio-ops, charts, CEX-ops, auth, cron, admin tail, tx-lookup, trade, currency-search, news, health endpoints all documented in the in-app /admin → APIs tab.
Changed
  • Wallet-journal route shape changed from [portfolioId] to [id] for consistency.
  • Wallet- + CEX-journal card UI unified — same size, same gradient surface, same hover state.
Fixed
  • CEX FIAT deposit/withdrawal allows a cross-currency bank account so EUR fiat can be received into a USD bank without a manual FX leg.
  • Saving a CEX entry now closes the calculator companion automatically.

Card buys, fee accuracy, currency disambiguation

Added
  • CARD_BUY transaction type — direct fiat → crypto purchases funded by a credit/debit card. Cost basis = total charged − fee, so disposals use the exchange price; the fee books to Card Fee Expense. Single shared CARD_PAYABLE per card.
  • Currency symbol disambiguation — when a symbol is shared by many projects (e.g. multiple "AERO" tokens), CoinGecko ID picker resolves the ambiguity at entry time. Preserved across send / receive / fee / crypto / staking fields.
Changed
  • All time-sorted lists default to newest-first.
  • CARD_BUY uses "inclusive minus fee" convention — documented in finstandards.md.
  • CEX entry fees rule unified — gas fees, CCXT-fetched fees, fee-only entries, and cross-wallet transfers all now flow through the same posting helper.
Fixed
  • CARD_BUY entries save correctly + every field carries a tooltip.
  • Stopped silently dropping fees on CEX entries — now audited end-to-end.
  • Tooltip prose shortened across the app + structured-tooltip variant for multi-line context.
  • CARD_BUY portfolio-fiat amounts converted via the right FX rate.

Staking redesign

Added
  • IFRS-compliant staking system — track staked positions, rewards, and slashing across non-custodial contracts and custodial platforms. Full UX overhaul of the staking detail page.
  • Tooltip audit — every interactive control with non-obvious behaviour now has a tooltip.
  • Dashboard cells improvements + modal layout fixes.

Cost-basis foundations

Added
  • Reusable UI primitives library — draggable dialog, calculator companion, <Num> decimal renderer, form helpers shared across every entry surface.
  • CEX journal companion features — bulk edit modal, calculator opened from any amount field, draggable entry dialog, CSV review flow.
  • Crypto transfer clearing — internal transfers between owned wallets / CEX accounts post a clearing leg with full counterparty traceability.
  • Option A cost-basis model — stablecoin peg anchoring + FROM-asset unit price for crypto-to-crypto trades, behind a shared unitFiatValue helper.
  • SIWE wallet sign-in, session improvements, reports, reconciliation, pincode, MVX (multi-vault export).
Fixed
  • FIFO orphan cleanup — disposed lots whose acquisition was later voided are reclassified ORPHANED instead of polluting the FIFO queue.
  • Temporal void filters — voided journal entries are excluded from FIFO replay.
  • validateLots scoped to asset accounts only (was previously over-scoping to non-asset accounts).
  • Cost-basis back-fix script + FIFO recalc + transfer-clearing verification.

Operations + Docker removal

Changed
  • Docker support removed — local-only dev simplifies the stack. CEX API sync, journal batches, IFRS-compliant reports, and the date-time picker landed in the same release.

Whitelabel branding + profile

Added
  • Dynamic branding config — tenant logo, name, tagline, and colour palette injected as CSS variables at request time.
  • Profile page + session updates.

CEX journals + reconciliation

Added
  • CEX journals (initial) — manual sub-ledgers for exchanges with no API access. Trades, deposits, withdrawals, fees, income.
  • Exchange API connections — Binance + Bybit + Kraken + 26 others, encrypted at rest.
  • Auth DB integration — User table moved to the auth app, ledger queries via cross-app DB user.
  • Reconciliation queue — imported on-chain transactions land in a queue for triage.

Streaming reconciliation + bookkeeping overhaul

Added
  • Streaming reconciliation — server-sent NDJSON progress for long-running on-chain scans.
  • Wallet signature auth — SIWE-style signed challenges to bind a wallet to a portfolio.
  • Bookkeeping overhaul — double-entry posting model finalised; every transaction type produces a balanced JournalEntry.
  • Etherscan provider hardening — retries, rate-limit handling, structured error surfacing.

Slug URLs + 26 chains + admin split

Added
  • Slug-based portfolio URLs/{slug} instead of /{cuid}. Existing portfolios auto-slugged.
  • Vault wallet — built-in hot wallet with browser-encrypted key storage.
  • DeFi labelling — auto-classify on-chain interactions (DEX_SWAP, LP_ADD, STAKING, etc.).
  • 26-chain import support across the Etherscan V2, Routescan, Alchemy, and Chainstack provider groups.
  • Whitelabel admin panel splitadmin/ repo as a separate app, branded per-tenant.

Token-driven theming

Changed
  • AppTour replaces hardcoded colours with design tokens.
  • Auth URL constant centralised; JWT public-key cache; logout hook extracted.

Auth migration

Changed
  • Auth migrated to Fastify JWT (from the previous in-app Next.js auth). Vault fix + admin panel removal happened in the same release.

CEX encryption + admin polish

Changed
  • CEX API key encryption hardened.
  • Admin panel refresh + TopNav and auth UI updates.

CEX accounts + LP recon

Added
  • CEX accounts as a first-class entity (linked, encrypted credentials, scope-limited).
  • LP reconciliation for liquidity pool deposits/withdrawals.
  • Method discovery — auto-classify unknown method-IDs from on-chain calldata.
  • Performance monitor + general hardening.

Balance reconciliation + analytics

Added
  • Balance reconciliation — on-chain wallet balance vs. book balance check on the Reconciliation page.
  • Card-ordering improvements on the dashboard.
  • User analytics & feedback widgets.

VIP system + 2FA

Added
  • VIP system (levels 0–10) — drives feature gating, per-day import limits, and admin reorder rights.
  • Achievements — gamified onboarding milestones.
  • 2FA for high-risk actions.
  • Duplicate transaction checker.
  • DeFi wallet modal — stake/unstake/claim flows for known protocols.

App-wide tooltips

Added
  • Tooltips throughout the app on every non-obvious control.

Admin debug + chain providers

Added
  • Admin debug tab for staff diagnostics.
  • BNB and Base moved off Etherscan onto chain-native providers (Chainstack JSON-RPC for BNB, Alchemy Enhanced API for Base).

TX hash lookup + glassmorphism + IFRS reports

Added
  • TX hash lookup — paste a tx hash and the entry form pre-fills every field it can resolve from on-chain data.
  • Glassmorphism UI redesign across every surface.
  • Live dashboard.
  • CSV exports for transactions, lots, and reports.
  • DEX trade checker — compare quotes across aggregators before trading.
  • IAS 1/7 reports — Balance Sheet + Cash Flow.
  • Integrated fees model — fees post inline with their parent transaction.
Changed
  • Code-quality cleanup: dedup utils, tab-gate queries, parallelize DB calls, unify DEX fetchers.

Foundations

Added
  • Initial commit — crypto portfolio manager MVP.
  • IFRS accounting — double-entry posting model, IAS 1 / IAS 7 ground floor.
  • News feed, settings, price caching, baseline UX.
  • RBAC (User / Staff / Admin), A/B testing infrastructure, admin panel, feature-flag toggles, charts.
  • Portfolio collaboration — invite accountants and auditors via email.