Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/BillingHistoryPreview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'` |
Expand Down
105 changes: 105 additions & 0 deletions src/data/billingFixtures.ts
Original file line number Diff line number Diff line change
@@ -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,
},
];
123 changes: 123 additions & 0 deletions src/hooks/useDepositHistory.ts
Original file line number Diff line number Diff line change
@@ -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<Loaded | null>(null);
const [reloadKey, setReloadKey] = useState(0);
const [loadingMore, setLoadingMore] = useState(false);
const [loadMoreError, setLoadMoreError] = useState<string | null>(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,
};
}
Loading