diff --git a/docs/BillingHistoryPreview.md b/docs/BillingHistoryPreview.md index 96f47f7..b86853d 100644 --- a/docs/BillingHistoryPreview.md +++ b/docs/BillingHistoryPreview.md @@ -141,14 +141,14 @@ const data: PreviewCardData = { ## API — BillingHistory page -`BillingHistory` is a default-exported React component at `src/pages/BillingHistory.tsx`. It requires no props — all data comes from the internal `MOCK_TRANSACTIONS` constant. +`BillingHistory` is a default-exported React component at `src/pages/BillingHistory.tsx`. It requires no props. Deposit rows come from the vault contract's `deposit` events for the connected wallet, read with Soroban RPC `getEvents` (`src/services/vaultEvents.ts`, via `src/hooks/useDepositHistory.ts`). Each row shows the real transaction hash. The page shows loading, no-wallet, error with Retry, and empty states, and a note when history falls outside the RPC retention window. Older deposits load with **Load older deposits**. ### Exports | Name | Kind | Description | |------|------|-------------| | `BillingHistory` | named + default export | Page component | -| `MOCK_TRANSACTIONS` | named export | Array of `BillingTransaction` for use in tests and Storybook | +| `MOCK_TRANSACTIONS` | fixture (`src/data/billingFixtures.ts`) | Demo and test rows, not used by the page | | `BillingTransaction` | type | Shape of a single transaction record | | `TxType` | type | `'Deposit' \| 'API Call' \| 'Refund' \| 'Settlement' \| 'Fee'` | | `TxDirection` | type | `'credit' \| 'debit'` | diff --git a/src/data/billingFixtures.ts b/src/data/billingFixtures.ts new file mode 100644 index 0000000..335aec7 --- /dev/null +++ b/src/data/billingFixtures.ts @@ -0,0 +1,105 @@ +/** + * Demo and test fixtures for the Billing History page. Not used by the + * production page, which reads deposits from vault events. Hashes are + * 64-character hex, derived from each row's id. + */ +import type { BillingTransaction } from '../pages/BillingHistory'; + +export const MOCK_TRANSACTIONS: BillingTransaction[] = [ + { + id: 'tx-001', + description: 'USDC vault deposit', + type: 'Deposit', + direction: 'credit', + amount: 100.00, + status: 'success', + timestamp: '2026-07-25T14:32:00Z', + txHash: 'cb23007c9881e61d89fc4ce18aafd4b6347d159d500bf848a36c4fda7a03fa41', + network: 'Stellar Testnet', + confirmations: 120, + }, + { + id: 'tx-002', + description: 'WeatherSim API · 3 calls', + type: 'API Call', + direction: 'debit', + amount: 0.24, + status: 'success', + timestamp: '2026-07-25T15:10:22Z', + txHash: 'e11acea283811fac885409d80da17754501fccdd85a3de55b1bbbb8e9e6e55fb', + network: 'Stellar Testnet', + confirmations: 98, + }, + { + id: 'tx-003', + description: 'Stellar settlement · Epoch 1047', + type: 'Settlement', + direction: 'credit', + amount: 12.80, + status: 'success', + timestamp: '2026-07-26T00:00:00Z', + txHash: '65c17fb5c236df1ce3793ebe1316e8195cc7844a30e1c8b65ebcc4b526c5bb14', + network: 'Stellar Testnet', + confirmations: 60, + }, + { + id: 'tx-004', + description: 'Refund · CreditScore API', + type: 'Refund', + direction: 'credit', + amount: 0.05, + status: 'success', + timestamp: '2026-07-26T09:17:44Z', + txHash: '5e9e2b34ce4c6598b3a52c3f7848a9a00110f0fd49cb4d79e569dd02618a2224', + network: 'Stellar Testnet', + confirmations: 45, + }, + { + id: 'tx-005', + description: 'Network fee · tx D6I2…', + type: 'Fee', + direction: 'debit', + amount: 0.001, + status: 'success', + timestamp: '2026-07-26T09:17:44Z', + txHash: 'e6df263d1f2dcbcdfc1b188f64326a342e14839f937cbb019f3980dd391b1bee', + network: 'Stellar Testnet', + confirmations: 45, + }, + { + id: 'tx-006', + description: 'USDC vault deposit', + type: 'Deposit', + direction: 'credit', + amount: 50.00, + status: 'pending', + timestamp: '2026-07-27T11:04:00Z', + txHash: '3ed78887c6352d23346167a4f0e74cad6dd2e8b85d7cfed5b58b87017b990871', + network: 'Stellar Testnet', + confirmations: 2, + }, + { + id: 'tx-007', + description: 'ChainRisk API · 1 call', + type: 'API Call', + direction: 'debit', + amount: 0.08, + status: 'error', + timestamp: '2026-07-27T13:22:10Z', + txHash: '5b9add6af41c5b2e0de227650bbdebb1cd669937f95402b3729e0456f928afb7', + network: 'Stellar Testnet', + confirmations: 0, + }, + { + id: 'tx-008', + description: 'MarketFeed API · 10 calls', + type: 'API Call', + direction: 'debit', + amount: 0.80, + status: 'success', + timestamp: '2026-07-28T08:55:33Z', + txHash: '3eb279b05065c07591de370b50368c7f7d808790161c2ac62f1bcef3b9d27fb5', + network: 'Stellar Testnet', + confirmations: 88, + }, +]; diff --git a/src/hooks/useDepositHistory.ts b/src/hooks/useDepositHistory.ts new file mode 100644 index 0000000..9a041be --- /dev/null +++ b/src/hooks/useDepositHistory.ts @@ -0,0 +1,123 @@ +import { useCallback, useEffect, useState } from "react"; +import { useWalletStore } from "../state/walletStore"; +import { fetchDepositEvents } from "../services/vaultEvents"; +import type { BillingTransaction } from "../pages/BillingHistory"; + +export type DepositHistoryStatus = "no-wallet" | "loading" | "error" | "empty" | "ready"; + +export type DepositHistory = { + status: DepositHistoryStatus; + transactions: BillingTransaction[]; + retentionTruncated: boolean; + error: string | null; + hasMore: boolean; + loadingMore: boolean; + loadMoreError: string | null; + retry: () => void; + loadOlder: () => void; +}; + +type Loaded = { + address: string; + transactions: BillingTransaction[]; + nextCursor: string | null; + retentionTruncated: boolean; + error: string | null; +}; + +const GENERIC_ERROR = "Could not load deposit history. Try again."; + +function errorMessage(error: unknown): string { + return error instanceof Error && error.message ? error.message : GENERIC_ERROR; +} + +function mergeById(existing: BillingTransaction[], incoming: BillingTransaction[]): BillingTransaction[] { + const seen = new Set(existing.map((tx) => tx.id)); + return [...existing, ...incoming.filter((tx) => !seen.has(tx.id))]; +} + +/** + * Deposit history for the connected wallet, read from the vault's deposit + * events. Results are tagged with the address they were fetched for, so a + * response that arrives after the wallet changed is discarded and never + * mixes rows from two addresses. + */ +export function useDepositHistory(): DepositHistory { + const { address } = useWalletStore(); + const [loaded, setLoaded] = useState(null); + const [reloadKey, setReloadKey] = useState(0); + const [loadingMore, setLoadingMore] = useState(false); + const [loadMoreError, setLoadMoreError] = useState(null); + + useEffect(() => { + if (!address) return; + const controller = new AbortController(); + setLoaded(null); + setLoadingMore(false); + setLoadMoreError(null); + fetchDepositEvents({ address, signal: controller.signal }) + .then((page) => { + if (controller.signal.aborted) return; + setLoaded({ + address, + transactions: page.transactions, + nextCursor: page.nextCursor, + retentionTruncated: page.retentionTruncated, + error: null, + }); + }) + .catch((error: unknown) => { + if (controller.signal.aborted) return; + setLoaded({ address, transactions: [], nextCursor: null, retentionTruncated: false, error: errorMessage(error) }); + }); + return () => controller.abort(); + }, [address, reloadKey]); + + const current = loaded && loaded.address === address ? loaded : null; + + const loadOlder = useCallback(() => { + if (!address || !current?.nextCursor || loadingMore) return; + const cursor = current.nextCursor; + setLoadingMore(true); + setLoadMoreError(null); + fetchDepositEvents({ address, cursor }) + .then((page) => { + setLoaded((prev) => { + if (!prev || prev.address !== address) return prev; + return { + ...prev, + transactions: mergeById(prev.transactions, page.transactions), + nextCursor: page.nextCursor, + retentionTruncated: prev.retentionTruncated || page.retentionTruncated, + }; + }); + }) + .catch((error: unknown) => { + setLoadMoreError(errorMessage(error)); + }) + .finally(() => { + setLoadingMore(false); + }); + }, [address, current, loadingMore]); + + const retry = useCallback(() => setReloadKey((key) => key + 1), []); + + let status: DepositHistoryStatus; + if (!address) status = "no-wallet"; + else if (!current) status = "loading"; + else if (current.error) status = "error"; + else if (current.transactions.length === 0) status = "empty"; + else status = "ready"; + + return { + status, + transactions: current?.transactions ?? [], + retentionTruncated: current?.retentionTruncated ?? false, + error: current?.error ?? null, + hasMore: current?.nextCursor != null, + loadingMore, + loadMoreError, + retry, + loadOlder, + }; +} diff --git a/src/pages/BillingHistory.test.tsx b/src/pages/BillingHistory.test.tsx index ce93b44..9ca97a9 100644 --- a/src/pages/BillingHistory.test.tsx +++ b/src/pages/BillingHistory.test.tsx @@ -20,7 +20,26 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { AccountProvider } from '../hooks/useAccountContext'; import { addAccount, switchAccount, _reset } from '../state/accountStore'; import { MemoryRouter, useLocation } from 'react-router-dom'; -import { BillingHistory, MOCK_TRANSACTIONS } from './BillingHistory'; +import { BillingHistory } from './BillingHistory'; +import { MOCK_TRANSACTIONS } from '../data/billingFixtures'; +import { fetchDepositEvents } from '../services/vaultEvents'; + +const walletState = vi.hoisted(() => ({ + address: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF' as string | null, +})); + +// Deposit history comes from vault events; the page is tested against the +// fixture rows, served as if the connected wallet had made those deposits. +vi.mock('../services/vaultEvents', async () => { + const { MOCK_TRANSACTIONS: rows } = await import('../data/billingFixtures'); + return { + fetchDepositEvents: vi.fn(async () => ({ transactions: rows, nextCursor: null, retentionTruncated: false })), + }; +}); +vi.mock('../state/walletStore', async (importOriginal) => ({ + ...await importOriginal(), + useWalletStore: () => ({ address: walletState.address, status: 'connected', networkPassphrase: null, isNetworkMismatch: false }), +})); // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -28,12 +47,15 @@ import { BillingHistory, MOCK_TRANSACTIONS } from './BillingHistory'; * Render BillingHistory inside a MemoryRouter. * Pass `initialEntries` to pre-seed URL search params, e.g. `['?sort=date&dir=asc']`. */ -function renderBH(initialEntries: string[] = ['/']) { - return render( +async function renderBH(initialEntries: string[] = ['/']) { + const result = render( , ); + // Rows arrive from the deposit history fetch; wait until loading finishes. + await waitFor(() => expect(screen.queryByText(/Loading deposit history/)).toBeNull()); + return result; } /** Get the row element for a given transaction id. */ @@ -51,20 +73,20 @@ function getTriggerInRow(row: HTMLElement) { describe('BillingHistory — page structure', () => { afterEach(() => { cleanup(); _reset(); }); - it('renders the page heading', () => { - renderBH(); + it('renders the page heading', async () => { + await renderBH(); expect( screen.getByRole('heading', { name: /billing history/i, level: 1 }), ).toBeTruthy(); }); - it('renders all mock transactions by default', () => { - renderBH(); + it('renders all mock transactions by default', async () => { + await renderBH(); expect(screen.getByText(`${MOCK_TRANSACTIONS.length} transactions`)).toBeTruthy(); }); - it('renders a table with an accessible aria-label', () => { - renderBH(); + it('renders a table with an accessible aria-label', async () => { + await renderBH(); expect( screen.getByRole('table', { name: /billing transaction history/i }), ).toBeTruthy(); @@ -88,8 +110,8 @@ describe('BillingHistory — page structure', () => { expect(screen.getByText('Jul 25, 10:32 AM')).toBeTruthy(); }); - it('renders column headers with scope="col"', () => { - renderBH(); + it('renders column headers with scope="col"', async () => { + await renderBH(); const headerCells = screen .getAllByRole('columnheader') .filter((th) => th.getAttribute('scope') === 'col'); @@ -97,27 +119,27 @@ describe('BillingHistory — page structure', () => { expect(headerCells.length).toBeGreaterThanOrEqual(6); }); - it('renders a status live region for filter announcements', () => { - renderBH(); + it('renders a status live region for filter announcements', async () => { + await renderBH(); const liveRegion = document.querySelector('[role="status"][aria-live="polite"]'); expect(liveRegion).toBeTruthy(); }); - it('renders a keyboard tip containing "Esc"', () => { - renderBH(); + it('renders a keyboard tip containing "Esc"', async () => { + await renderBH(); // The bottom keyboard tip paragraph expect(screen.getAllByText(/Esc/i).length).toBeGreaterThan(0); }); - it('renders StatusBadge for each transaction', () => { - renderBH(); + it('renders StatusBadge for each transaction', async () => { + await renderBH(); // StatusBadge uses role="img"; there should be at least one per row. const badges = screen.getAllByRole('img'); expect(badges.length).toBeGreaterThanOrEqual(MOCK_TRANSACTIONS.length); }); - it('shows the net balance summary line', () => { - renderBH(); + it('shows the net balance summary line', async () => { + await renderBH(); // Net: label should be visible expect(screen.getByText('Net:')).toBeTruthy(); }); @@ -128,8 +150,8 @@ describe('BillingHistory — page structure', () => { describe('BillingHistory — PreviewCard hover/focus integration', () => { afterEach(cleanup); - it('each transaction row has a PreviewCard trigger button', () => { - renderBH(); + it('each transaction row has a PreviewCard trigger button', async () => { + await renderBH(); // Each row must have exactly one trigger per PreviewCard MOCK_TRANSACTIONS.forEach((tx) => { const row = getRow(tx.id); @@ -137,8 +159,8 @@ describe('BillingHistory — PreviewCard hover/focus integration', () => { }); }); - it('hovering a row description opens the preview tooltip', () => { - renderBH(); + it('hovering a row description opens the preview tooltip', async () => { + await renderBH(); const row = getRow(MOCK_TRANSACTIONS[0].id); const wrapper = row.querySelector('.preview-card__wrapper') as HTMLElement; @@ -150,8 +172,8 @@ describe('BillingHistory — PreviewCard hover/focus integration', () => { expect(screen.queryByRole('tooltip')).toBeNull(); }); - it('focusing a row trigger opens the preview tooltip', () => { - renderBH(); + it('focusing a row trigger opens the preview tooltip', async () => { + await renderBH(); const row = getRow(MOCK_TRANSACTIONS[0].id); const trigger = getTriggerInRow(row); @@ -159,8 +181,8 @@ describe('BillingHistory — PreviewCard hover/focus integration', () => { expect(screen.getByRole('tooltip')).toBeTruthy(); }); - it('preview panel shows the transaction description', () => { - renderBH(); + it('preview panel shows the transaction description', async () => { + await renderBH(); const tx = MOCK_TRANSACTIONS[0]; // "USDC vault deposit" const row = getRow(tx.id); const trigger = getTriggerInRow(row); @@ -169,8 +191,8 @@ describe('BillingHistory — PreviewCard hover/focus integration', () => { expect(screen.getByRole('tooltip')).toHaveTextContent(tx.description); }); - it('preview panel shows the tx hash (truncated)', () => { - renderBH(); + it('preview panel shows the tx hash (truncated)', async () => { + await renderBH(); const tx = MOCK_TRANSACTIONS[0]; const row = getRow(tx.id); const trigger = getTriggerInRow(row); @@ -181,32 +203,32 @@ describe('BillingHistory — PreviewCard hover/focus integration', () => { expect(panel).toHaveTextContent(tx.txHash.slice(0, 8)); }); - it('preview panel shows network name', () => { - renderBH(); + it('preview panel shows network name', async () => { + await renderBH(); const tx = MOCK_TRANSACTIONS[0]; const row = getRow(tx.id); fireEvent.focus(getTriggerInRow(row)); - expect(screen.getByRole('tooltip')).toHaveTextContent('Stellar Mainnet'); + expect(screen.getByRole('tooltip')).toHaveTextContent('Stellar Testnet'); }); - it('preview panel shows confirmation count', () => { - renderBH(); + it('preview panel shows confirmation count', async () => { + await renderBH(); const tx = MOCK_TRANSACTIONS[0]; // 120 confirmations const row = getRow(tx.id); fireEvent.focus(getTriggerInRow(row)); expect(screen.getByRole('tooltip')).toHaveTextContent('120'); }); - it('preview panel shows formatted USDC amount', () => { - renderBH(); + it('preview panel shows formatted USDC amount', async () => { + await renderBH(); const tx = MOCK_TRANSACTIONS[0]; // 100.00 USDC const row = getRow(tx.id); fireEvent.focus(getTriggerInRow(row)); expect(screen.getByRole('tooltip')).toHaveTextContent('100.00 USDC'); }); - it('Escape closes the preview and removes aria-describedby', () => { - renderBH(); + it('Escape closes the preview and removes aria-describedby', async () => { + await renderBH(); const row = getRow(MOCK_TRANSACTIONS[0].id); const trigger = getTriggerInRow(row); @@ -218,8 +240,8 @@ describe('BillingHistory — PreviewCard hover/focus integration', () => { expect(trigger.getAttribute('aria-describedby')).toBeNull(); }); - it('trigger aria-label includes the transaction description', () => { - renderBH(); + it('trigger aria-label includes the transaction description', async () => { + await renderBH(); const tx = MOCK_TRANSACTIONS[0]; const row = getRow(tx.id); const trigger = getTriggerInRow(row); @@ -229,8 +251,8 @@ describe('BillingHistory — PreviewCard hover/focus integration', () => { ); }); - it('panel has pointer-events: none to avoid mouse trapping', () => { - renderBH(); + it('panel has pointer-events: none to avoid mouse trapping', async () => { + await renderBH(); const row = getRow(MOCK_TRANSACTIONS[0].id); fireEvent.focus(getTriggerInRow(row)); expect(screen.getByRole('tooltip').style.pointerEvents).toBe('none'); @@ -242,8 +264,8 @@ describe('BillingHistory — PreviewCard hover/focus integration', () => { describe('BillingHistory — filters', () => { afterEach(cleanup); - it('filter by type "Deposit" shows only deposit rows', () => { - renderBH(); + it('filter by type "Deposit" shows only deposit rows', async () => { + await renderBH(); const typeSelect = screen.getByRole('combobox', { name: /filter by transaction type/i }); fireEvent.change(typeSelect, { target: { value: 'Deposit' } }); @@ -258,8 +280,8 @@ describe('BillingHistory — filters', () => { }); }); - it('filter by status "pending" shows only pending rows', () => { - renderBH(); + it('filter by status "pending" shows only pending rows', async () => { + await renderBH(); const statusSelect = screen.getByRole('combobox', { name: /filter by transaction status/i }); fireEvent.change(statusSelect, { target: { value: 'pending' } }); @@ -268,8 +290,8 @@ describe('BillingHistory — filters', () => { expect(screen.getByText(`${pendingCount} transaction${pendingCount !== 1 ? 's' : ''}`)).toBeTruthy(); }); - it('filter by direction "credit" shows only credit rows', () => { - renderBH(); + it('filter by direction "credit" shows only credit rows', async () => { + await renderBH(); const dirSelect = screen.getByRole('combobox', { name: /filter by transaction direction/i }); fireEvent.change(dirSelect, { target: { value: 'credit' } }); @@ -283,8 +305,8 @@ describe('BillingHistory — filters', () => { }); }); - it('search by description filters rows', () => { - renderBH(); + it('search by description filters rows', async () => { + await renderBH(); const searchInput = screen.getByRole('searchbox', { name: /search transactions/i }); fireEvent.change(searchInput, { target: { value: 'WeatherSim' } }); @@ -295,8 +317,8 @@ describe('BillingHistory — filters', () => { expect(screen.getByText(`${matchCount} transaction${matchCount !== 1 ? 's' : ''}`)).toBeTruthy(); }); - it('search by tx hash filters rows', () => { - renderBH(); + it('search by tx hash filters rows', async () => { + await renderBH(); const searchInput = screen.getByRole('searchbox', { name: /search transactions/i }); // Use the first 8 chars of the first tx hash const hashPrefix = MOCK_TRANSACTIONS[0].txHash.slice(0, 8).toLowerCase(); @@ -306,8 +328,8 @@ describe('BillingHistory — filters', () => { expect(screen.queryByRole('table')).toBeTruthy(); }); - it('shows empty state when no transactions match', () => { - renderBH(); + it('shows empty state when no transactions match', async () => { + await renderBH(); const searchInput = screen.getByRole('searchbox', { name: /search transactions/i }); fireEvent.change(searchInput, { target: { value: 'ZZZNOMATCH999' } }); @@ -318,8 +340,8 @@ describe('BillingHistory — filters', () => { expect(screen.queryByRole('table')).toBeNull(); }); - it('net balance is positive when only credits are shown', () => { - renderBH(); + it('net balance is positive when only credits are shown', async () => { + await renderBH(); const dirSelect = screen.getByRole('combobox', { name: /filter by transaction direction/i }); fireEvent.change(dirSelect, { target: { value: 'credit' } }); @@ -330,8 +352,8 @@ describe('BillingHistory — filters', () => { expect(pageText).toContain('+'); }); - it('live region contains filter summary after type filter change', () => { - renderBH(); + it('live region contains filter summary after type filter change', async () => { + await renderBH(); const typeSelect = screen.getByRole('combobox', { name: /filter by transaction type/i }); fireEvent.change(typeSelect, { target: { value: 'Fee' } }); @@ -339,8 +361,8 @@ describe('BillingHistory — filters', () => { expect(liveRegion.textContent).toContain('Fee'); }); - it('live region reads "Showing all transactions" when no filters active', () => { - renderBH(); + it('live region reads "Showing all transactions" when no filters active', async () => { + await renderBH(); const liveRegion = document.querySelector('[role="status"][aria-live="polite"]') as HTMLElement; expect(liveRegion.textContent).toMatch(/showing all transactions/i); }); @@ -351,8 +373,8 @@ describe('BillingHistory — filters', () => { describe('BillingHistory — table row content', () => { afterEach(cleanup); - it('shows truncated tx hash in the Tx Hash column', () => { - renderBH(); + it('shows truncated tx hash in the Tx Hash column', async () => { + await renderBH(); // First transaction: A3F9B2C1... → "A3F9B2…F0A1" (6+…+4) const hashCode = screen.getAllByTitle(MOCK_TRANSACTIONS[0].txHash)[0]; expect(hashCode).toBeTruthy(); @@ -362,14 +384,14 @@ describe('BillingHistory — table row content', () => { ); }); - it('amount cell has tabular-nums class for alignment', () => { - renderBH(); + it('amount cell has tabular-nums class for alignment', async () => { + await renderBH(); const cells = document.querySelectorAll('td.tabular-nums'); expect(cells.length).toBeGreaterThan(0); }); - it('date cells render