diff --git a/apps/web-wallet/app/features/transactions/transaction-hooks.ts b/apps/web-wallet/app/features/transactions/transaction-hooks.ts index ff7d79394..b81792e0a 100644 --- a/apps/web-wallet/app/features/transactions/transaction-hooks.ts +++ b/apps/web-wallet/app/features/transactions/transaction-hooks.ts @@ -1,9 +1,9 @@ -import type { Transaction } from '@agicash/wallet-sdk'; -import type { - AgicashDbTransaction, - Cursor, -} from '@agicash/wallet-sdk/temporary'; -import { NotFoundError } from '@agicash/wallet-sdk/temporary'; +import { + type Cursor, + NotFoundError, + type Transaction, +} from '@agicash/wallet-sdk'; +import type { AgicashDbTransaction } from '@agicash/wallet-sdk/temporary'; import { type InfiniteData, type QueryClient, @@ -14,13 +14,13 @@ import { useSuspenseQuery, } from '@tanstack/react-query'; import { useMemo } from 'react'; +import { sdk } from '~/features/shared/sdk.client'; import { useLatest } from '~/lib/use-latest'; import { useGetCashuAccount } from '../accounts/account-hooks'; import { useCashuSendSwapRepository, useCashuSendSwapService, } from '../send/cashu-send-swap-hooks'; -import { useUser } from '../user/user-hooks'; import { useTransactionRepository } from './transaction-repository-hooks'; /** @@ -89,12 +89,10 @@ export function useTransactionsCache() { } export function useTransaction(id: string) { - const transactionRepository = useTransactionRepository(); - return useSuspenseQuery({ queryKey: [TransactionsCache.Key, id], queryFn: async () => { - const transaction = await transactionRepository.get(id); + const transaction = await sdk.transactions.get(id); if (!transaction) { throw new NotFoundError(`Transaction not found for id: ${id}`); @@ -117,16 +115,13 @@ export function useTransaction(id: string) { const PAGE_SIZE = 25; export function useTransactions(accountId?: string) { - const userId = useUser((user) => user.id); - const transactionRepository = useTransactionRepository(); const transactionsCache = useTransactionsCache(); const result = useInfiniteQuery({ queryKey: [TransactionsCache.AllTransactionsKey, accountId], initialPageParam: null, queryFn: async ({ pageParam }: { pageParam: Cursor | null }) => { - const result = await transactionRepository.list({ - userId, + const result = await sdk.transactions.list({ cursor: pageParam, pageSize: PAGE_SIZE, accountId, @@ -136,11 +131,7 @@ export function useTransactions(accountId?: string) { transactionsCache.upsert(transaction); } - return { - transactions: result.transactions, - nextCursor: - result.transactions.length === PAGE_SIZE ? result.nextCursor : null, - }; + return result; }, getNextPageParam: (lastPage) => lastPage.nextCursor, refetchOnWindowFocus: 'always', @@ -152,13 +143,9 @@ export function useTransactions(accountId?: string) { } export function useHasTransactionsPendingAck() { - const transactionRepository = useTransactionRepository(); - const userId = useUser((user) => user.id); - const result = useQuery({ queryKey: [TransactionsCache.UnacknowledgedCountKey], - queryFn: () => - transactionRepository.countTransactionsPendingAck({ userId }), + queryFn: () => sdk.transactions.countPendingAck(), select: (data) => data > 0, staleTime: Number.POSITIVE_INFINITY, refetchOnWindowFocus: 'always', @@ -199,17 +186,12 @@ const acknowledgeTransactionInHistoryCache = ( }; export function useAcknowledgeTransaction() { - const transactionRepository = useTransactionRepository(); - const userId = useUser((user) => user.id); const queryClient = useQueryClient(); const transactionsCache = useTransactionsCache(); return useMutation({ mutationFn: async ({ transaction }: { transaction: Transaction }) => { - await transactionRepository.acknowledgeTransaction({ - userId, - transactionId: transaction.id, - }); + await sdk.transactions.acknowledge(transaction.id); }, onSuccess: (_, { transaction }) => { acknowledgeTransactionInHistoryCache(queryClient, transaction); diff --git a/docs/superpowers/plans/2026-08-12-wallet-sdk-transactions-slice.md b/docs/superpowers/plans/2026-08-12-wallet-sdk-transactions-slice.md new file mode 100644 index 000000000..566a52f97 --- /dev/null +++ b/docs/superpowers/plans/2026-08-12-wallet-sdk-transactions-slice.md @@ -0,0 +1,134 @@ +# Wallet SDK Transactions Slice (Step 8) Implementation Plan + +> **Orchestration note:** tasks 1–4 are delivered as maxplayer marketplace greenfield jobs (file-payload deliverables; the orchestrator pastes all needed context into the job text and integrates + verifies locally). Tasks 0 and 5 run locally. A post-integration adversarial-review job and the PR-description job also run on the marketplace. + +**Goal:** Wire the `transactions` namespace of the SDK contract and flip the web transactions feature from `@agicash/wallet-sdk/temporary` to `sdk.transactions.*`. + +**Architecture:** Step 8 of the 19-step no-cache extraction (spec: `docs/superpowers/specs/2026-06-24-wallet-sdk-no-cache-production-design.md`). The already-moved `domain/transactions` code gets a session-fenced API wrapper (`createTransactionsApi`), the `AgicashSdk` constructor wires it (replacing the `NotImplementedError` getter), and the web hooks call `sdk.transactions.*`. Web keeps its TanStack cache and its realtime invalidation layer (flips in step 18). + +**Tech stack:** TypeScript, bun workspaces, bun:test, Supabase (postgrest-js), TanStack Query v5 (web side only). + +## Global constraints + +- The SDK stays React-agnostic: `packages/wallet-sdk` never imports `react` or `@tanstack/react-query`. +- Do not touch other domains' `/temporary` imports — only transactions files listed here. +- The web realtime invalidation layer stays as-is: `useTransactionChangeHandlers` keeps decrypting realtime rows via a `TransactionRepository` instance until step 18. +- No event emission from `TransactionsApi`. `transaction.created`/`transaction.updated` stay type-only until the step-18 realtime feed (contacts precedent). +- Package manager: `bun` / `bunx` only. +- Base branch: `master`. Work branch: `sdk/transactions-slice`. +- No DB schema changes, no dependency changes. The `list_transactions` RPC is used unchanged. +- `packages/wallet-sdk/domain/sdk/sdk.test.ts` has no transactions assertions — leave it untouched. + +## Resolved design decisions + +1. **Delete `userId` from the domain entity** (`BaseTransactionSchema`) and from `toTransaction`'s mapping. Nothing reads `transaction.userId` (repo-wide grep; every other `userId` in the domain is an input parameter). The contract file then re-exports the domain `Transaction` directly instead of `Omit`. Rationale: `Omit` over the discriminated union collapses it to a flat object type and breaks narrowing — `transaction-list.tsx:156` (`details.destinationDetails?.sendType` behind `type === 'CASHU_LIGHTNING' && direction === 'SEND'`) stops compiling the moment the `index.ts` shadow export lifts. Same resolution as the contacts slice (entity field deleted, one type, root `index.ts` export stays the sole origin). +2. **Normalize `Cursor` to the non-null keyset tuple** `{ stateSortOrder: number; createdAt: string; id: string }`. The `| null` moves to the use sites: repository `ListOptions.cursor?: Cursor | null`, contract `cursor?: Cursor | null`, and `nextCursor: Cursor | null`. The old `type Cursor = {...} | null` made `Cursor | null` redundant and `cursor?: Cursor` confusingly nullable-by-type. +3. **Fold the page-end rule into `TransactionRepository.list`**: `nextCursor` is `null` when `transactions.length < pageSize`. Previously the web hook applied this rule (`length === PAGE_SIZE ? nextCursor : null`); folding it into the repository gives headless consumers true end-of-list semantics and the web hook becomes a pass-through. Behavior is identical for every case including the exact-full last page (next fetch returns an empty page → `null`). +4. **`createTransactionsApi` follows the accounts template, not contacts**: `TransactionRepository` needs `Encryption` from `await keys.getEncryption()`, so the test seam is `createRepository?: () => Promise` and every method captures `sessionSignal()` **before** `await getRepository()`, re-checks after it, threads it as `abortSignal`, and re-checks after the repository call (fence order of `accounts-api.ts:51-62`). +5. `requireUserId()` only where the repository needs it: `list`, `countPendingAck`, `acknowledge`. `get` is id-scoped (RLS enforces ownership), same as contacts. +6. **`transaction-repository-hooks.ts` survives.** `toTransaction` is an instance method (needs decryption), so the realtime change handlers keep `useTransactionRepository()` — the accounts slice kept `account-repository-hooks.ts` for exactly this reason. No `getRepository` bridge from `createTransactionsApi`: the web echo path keeps its own `useEncryption()`-built repository until step 18 (accounts precedent — no unification attempt). +7. **`useAcknowledgeTransaction` keeps `{ transaction }` mutation variables** (`onSuccess` needs the whole transaction for the history-cache patch); only the `mutationFn` body changes to `sdk.transactions.acknowledge(transaction.id)`. +8. `transaction-hooks.ts` flips `NotFoundError` and `Cursor` imports to `@agicash/wallet-sdk` (both are on the public surface; `root.tsx` already imports `NotFoundError` from the root). `AgicashDbTransaction` stays on `/temporary` (realtime payload type). Other files' `/temporary` `NotFoundError` imports are out of scope. +9. **Canary prunes the dead transaction re-exports in `temporary.ts`**: `TransactionDetailsParserInput`/`TransactionDetailsParserShape`, the four enum schemas, `BaseTransactionSchema`/`TransactionSchema`, all per-variant details schemas/parsers, `TransactionDetailsDbDataSchema`/`TransactionDetailsSchema`, and `TransactionDetailsParser` — zero importers repo-wide. `AgicashDbTransaction`, `TransactionRepository` stay (live realtime consumers); the `Cursor` re-export is pruned at integration, after the web flip stops importing it. +10. The contract `list` gains JSDoc for semantics the RPC enforces: `DRAFT` and `FAILED` transactions never appear (state filter), ordering is `state_sort_order desc, created_at desc, id desc` (pending first), default `pageSize` 25. +11. `events.ts` flips its `Transaction` import from the contract file to `../transactions/transaction` (contacts precedent: events import domain entities). +12. Wiring in `sdk.ts`: `readonly transactions: TransactionsApi;` assigned in the constructor tail after `this.contacts`, via `createTransactionsApi({ db, getSession: getLiveSession, keys })`. The throwing getter is deleted; `NotImplementedError` stays imported (other getters still throw it). + +## Delegation map (maxplayer) + +| Task | Route | Deliverable files | +|---|---|---| +| 0 branch + plan commit | local | — | +| 1 canary: prune dead transaction `/temporary` exports | marketplace | `packages/wallet-sdk/temporary.ts` | +| 2 SDK: entity + repository + contract + transactions-api + events import + sdk.ts wiring | marketplace | 6 files (see file map) | +| 3 SDK: transactions-api tests | marketplace | `packages/wallet-sdk/domain/transactions/transactions-api.test.ts` | +| 4 web flip: transaction-hooks → `sdk.transactions` | marketplace | `apps/web-wallet/app/features/transactions/transaction-hooks.ts` | +| 5 integration, gates, smoke | local | — | +| 6 adversarial review of the integrated diff | marketplace | findings report | +| 7 PR description | marketplace | PR body markdown | + +Jobs 1–4 are posted in parallel: the plan pins the shared seams (contract shape, `Deps` type, factory signature) so the test and web-flip jobs compile against the pinned contract rather than job 2's delivery. Greenfield mechanics: all context is pasted into the job text; sellers deliver full file contents at exact repo-relative paths; the orchestrator verifies with local gates after `collect`, before commit. Failed deliveries: log, re-route or do locally. + +## Pinned seams (authoritative for jobs 2–4) + +Contract (`packages/wallet-sdk/domain/sdk/transactions.ts`, full new content shape): + +```ts +import type { Transaction } from '../transactions/transaction'; +import type { Cursor } from '../transactions/transaction-repository'; + +export type { Cursor }; + +export type TransactionsApi = { + get(id: string): Promise; + /** + * Transaction history, newest first with `PENDING` transactions on top + * (`state_sort_order desc, created_at desc, id desc`). `DRAFT` and `FAILED` + * transactions are excluded; `get` still returns them. `nextCursor` is + * `null` on the last page. + */ + list(params: { + /** Opaque pagination token from a previous page's `nextCursor`. */ + cursor?: Cursor | null; + /** Defaults to 25. */ + pageSize?: number; + accountId?: string; + }): Promise<{ transactions: Transaction[]; nextCursor: Cursor | null }>; + countPendingAck(): Promise; + acknowledge(transactionId: string): Promise; +}; +``` + +API factory (`packages/wallet-sdk/domain/transactions/transactions-api.ts`): + +```ts +type Deps = { + db: AgicashDb; + getSession: () => AuthSession; + keys: SessionKeys; + /** Test seam; defaults to building the repository from db + session keys. */ + createRepository?: () => Promise; +}; + +export function createTransactionsApi(deps: Deps): TransactionsApi; +``` + +Cursor (`transaction-repository.ts`): + +```ts +export type Cursor = { + stateSortOrder: number; + createdAt: string; + id: string; +}; +``` + +## File map + +- Modify: `packages/wallet-sdk/temporary.ts` (canary prune + `Cursor` line at integration) +- Modify: `packages/wallet-sdk/domain/transactions/transaction.ts` (drop `userId` from `BaseTransactionSchema`) +- Modify: `packages/wallet-sdk/domain/transactions/transaction-repository.ts` (drop `userId` mapping; non-null `Cursor`; fold page-end rule) +- Modify: `packages/wallet-sdk/domain/sdk/transactions.ts` (contract per pinned seam) +- Create: `packages/wallet-sdk/domain/transactions/transactions-api.ts` +- Create: `packages/wallet-sdk/domain/transactions/transactions-api.test.ts` +- Modify: `packages/wallet-sdk/domain/sdk/events.ts` (domain `Transaction` import) +- Modify: `packages/wallet-sdk/domain/sdk/sdk.ts` (wire the namespace) +- Modify: `apps/web-wallet/app/features/transactions/transaction-hooks.ts` (flip to `sdk.transactions`) +- Untouched on purpose: `packages/wallet-sdk/index.ts` (line 65 stays the sole `Transaction` origin), `domain/sdk/index.ts`, `sdk.test.ts`, `transaction-repository-hooks.ts`, `transaction-ack-status-store.ts`, all `.tsx` components, `use-track-wallet-changes.ts`, `sdk.client.ts`, `list_transactions` RPC and migrations. + +## Verification summary + +| Gate | Command | Expectation | +|---|---|---| +| Lint/format | `bun run fix:all` | exit 0 | +| Types (4 pkgs) | `bun run typecheck` | exit 0 | +| Unit tests | `bun run test` | green (existing suites + new transactions-api tests) | +| Smoke | manual, browser | app boots; transaction history renders + paginates; transaction detail opens; pending-ack badge works; no `sdk.transactions.*` console errors | + +## Execution notes (post-integration) + +- All four marketplace jobs (canary prune, SDK namespace, tests, web flip) delivered exactly to the pinned specs; the only integration-time correction was a single biome line-width reformat in the delivered test file. +- The `Cursor` re-export in `temporary.ts` was pruned at integration after the web flip landed, as planned (decision 9). +- The post-integration adversarial review returned READY (0 Critical, 0 Important, 2 Minor, 2 Nit). Follow-up commit abc84f86 added the two tests it identified as missing: direct `TransactionRepository.list` pagination-rule coverage (full page / pending-first cursor / short page / empty page) and an `acknowledge` mid-write session-fence test. +- Two review items were kept as-is for sibling-namespace consistency: `get` throws no `NoSessionError` (id-scoped reads rely on RLS, same as the contacts and accounts namespaces), and the repository factory runs before the first abort check (accounts fence order). +- The browser smoke included a live testnut receive: the money path created a real transaction end-to-end, exercising `sdk.transactions.list`, `get`, `acknowledge`, and `countPendingAck` with zero console errors. diff --git a/packages/wallet-sdk/domain/sdk/events.ts b/packages/wallet-sdk/domain/sdk/events.ts index cbad6023e..9188291bf 100644 --- a/packages/wallet-sdk/domain/sdk/events.ts +++ b/packages/wallet-sdk/domain/sdk/events.ts @@ -2,6 +2,7 @@ import type { Money } from '@agicash/money'; import type { Logger } from '.'; import type { SdkError } from '../../lib/error'; import type { Contact } from '../contacts/contact'; +import type { Transaction } from '../transactions/transaction'; import type { User } from '../user/user'; import type { Account } from './accounts'; import type { @@ -11,7 +12,6 @@ import type { } from './receive'; import type { CashuSendQuote, CashuSendSwap, SparkSendQuote } from './send'; import type { TaskProcessorState } from './task-processor'; -import type { Transaction } from './transactions'; /** * Payloads are decrypted domain objects. Naming: `.` (e.g. diff --git a/packages/wallet-sdk/domain/sdk/sdk.ts b/packages/wallet-sdk/domain/sdk/sdk.ts index 33747a329..2f306bb9f 100644 --- a/packages/wallet-sdk/domain/sdk/sdk.ts +++ b/packages/wallet-sdk/domain/sdk/sdk.ts @@ -26,6 +26,7 @@ import { } from '../../lib/spark/wallet'; import { createAccountsApi } from '../accounts/accounts-api'; import { createContactsApi } from '../contacts/contacts-api'; +import { createTransactionsApi } from '../transactions/transactions-api'; import { AuthService } from '../user/auth-service'; import { createUserApi } from '../user/user-api'; import { WalletEventEmitter } from './events'; @@ -48,11 +49,9 @@ export class AgicashSdk implements Sdk { readonly user: UserApi; readonly accounts: AccountsApi; readonly contacts: ContactsApi; + readonly transactions: TransactionsApi; readonly events: WalletEvents; - get transactions(): TransactionsApi { - throw new NotImplementedError('transactions'); - } get receive(): ReceiveApi { throw new NotImplementedError('receive'); } @@ -175,6 +174,11 @@ export class AgicashSdk implements Sdk { keys, lightningAddressDomain: config.lightningAddressDomain, }); + this.transactions = createTransactionsApi({ + db, + getSession: getLiveSession, + keys, + }); this.events = events; } diff --git a/packages/wallet-sdk/domain/sdk/transactions.ts b/packages/wallet-sdk/domain/sdk/transactions.ts index 49aced82f..6dbc9d238 100644 --- a/packages/wallet-sdk/domain/sdk/transactions.ts +++ b/packages/wallet-sdk/domain/sdk/transactions.ts @@ -1,15 +1,20 @@ -import type { Transaction as DomainTransaction } from '../transactions/transaction'; +import type { Transaction } from '../transactions/transaction'; import type { Cursor } from '../transactions/transaction-repository'; export type { Cursor }; -export type Transaction = Omit; - export type TransactionsApi = { get(id: string): Promise; + /** + * Transaction history, newest first with `PENDING` transactions on top + * (`state_sort_order desc, created_at desc, id desc`). `DRAFT` and `FAILED` + * transactions are excluded; `get` still returns them. `nextCursor` is + * `null` on the last page. + */ list(params: { /** Opaque pagination token from a previous page's `nextCursor`. */ - cursor?: Cursor; + cursor?: Cursor | null; + /** Defaults to 25. */ pageSize?: number; accountId?: string; }): Promise<{ transactions: Transaction[]; nextCursor: Cursor | null }>; diff --git a/packages/wallet-sdk/domain/transactions/transaction-repository.test.ts b/packages/wallet-sdk/domain/transactions/transaction-repository.test.ts new file mode 100644 index 000000000..d18bd7d00 --- /dev/null +++ b/packages/wallet-sdk/domain/transactions/transaction-repository.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'bun:test'; +import type { AgicashDb, AgicashDbTransaction } from '../../db/database'; +import type { Transaction } from './transaction'; +import { TransactionRepository } from './transaction-repository'; + +const makeRow = (id: string) => ({ id }) as unknown as AgicashDbTransaction; + +const makeRepository = ( + rows: AgicashDbTransaction[], + states: Record = {}, +) => { + const db = { + rpc: () => + Object.assign(Promise.resolve({ data: rows, error: null }), { + abortSignal: () => undefined, + }), + } as unknown as AgicashDb; + + const repository = new TransactionRepository(db, { + encrypt: async () => '', + decrypt: async () => ({}) as T, + }); + repository.toTransaction = async (data) => + ({ + id: data.id, + state: states[data.id] ?? 'COMPLETED', + createdAt: `2026-01-0${data.id.slice(-1)}T00:00:00Z`, + }) as unknown as Transaction; + + return repository; +}; + +describe('TransactionRepository.list', () => { + it('returns a keyset cursor from the last row of a full page', async () => { + const repository = makeRepository([makeRow('tx-1'), makeRow('tx-2')]); + + const { nextCursor } = await repository.list({ + userId: 'user-x', + pageSize: 2, + }); + + expect(nextCursor).toEqual({ + stateSortOrder: 1, + createdAt: '2026-01-02T00:00:00Z', + id: 'tx-2', + }); + }); + + it('marks the cursor with the pending sort order when the last row is pending', async () => { + const repository = makeRepository([makeRow('tx-1'), makeRow('tx-2')], { + 'tx-2': 'PENDING', + }); + + const { nextCursor } = await repository.list({ + userId: 'user-x', + pageSize: 2, + }); + + expect(nextCursor?.stateSortOrder).toBe(2); + }); + + it('returns a null cursor for a short page', async () => { + const repository = makeRepository([makeRow('tx-1'), makeRow('tx-2')]); + + const { transactions, nextCursor } = await repository.list({ + userId: 'user-x', + pageSize: 3, + }); + + expect(transactions).toHaveLength(2); + expect(nextCursor).toBeNull(); + }); + + it('returns a null cursor for an empty page', async () => { + const repository = makeRepository([]); + + const { transactions, nextCursor } = await repository.list({ + userId: 'user-x', + pageSize: 25, + }); + + expect(transactions).toHaveLength(0); + expect(nextCursor).toBeNull(); + }); +}); diff --git a/packages/wallet-sdk/domain/transactions/transaction-repository.ts b/packages/wallet-sdk/domain/transactions/transaction-repository.ts index dc65dcc49..424548417 100644 --- a/packages/wallet-sdk/domain/transactions/transaction-repository.ts +++ b/packages/wallet-sdk/domain/transactions/transaction-repository.ts @@ -24,11 +24,11 @@ export type Cursor = { stateSortOrder: number; createdAt: string; id: string; -} | null; +}; type ListOptions = Options & { userId: string; - cursor?: Cursor; + cursor?: Cursor | null; pageSize?: number; accountId?: string; }; @@ -88,13 +88,14 @@ export class TransactionRepository { return { transactions, - nextCursor: lastTransaction - ? { - stateSortOrder: lastTransaction.state === 'PENDING' ? 2 : 1, - createdAt: lastTransaction.createdAt, - id: lastTransaction.id, - } - : null, + nextCursor: + lastTransaction && transactions.length === pageSize + ? { + stateSortOrder: lastTransaction.state === 'PENDING' ? 2 : 1, + createdAt: lastTransaction.createdAt, + id: lastTransaction.id, + } + : null, }; } @@ -180,7 +181,6 @@ export class TransactionRepository { return TransactionSchema.parse({ id: data.id, - userId: data.user_id, accountId: data.account_id, accountName: data.account_name, accountType: data.account_type, diff --git a/packages/wallet-sdk/domain/transactions/transaction.ts b/packages/wallet-sdk/domain/transactions/transaction.ts index 8c4e76408..e1375e947 100644 --- a/packages/wallet-sdk/domain/transactions/transaction.ts +++ b/packages/wallet-sdk/domain/transactions/transaction.ts @@ -32,10 +32,6 @@ export const BaseTransactionSchema = z.object({ * UUID of the transaction. */ id: z.string(), - /** - * UUID of the user that the transaction belongs to. - */ - userId: z.string(), /** * Direction of the transaction. */ diff --git a/packages/wallet-sdk/domain/transactions/transactions-api.test.ts b/packages/wallet-sdk/domain/transactions/transactions-api.test.ts new file mode 100644 index 000000000..130fd48dd --- /dev/null +++ b/packages/wallet-sdk/domain/transactions/transactions-api.test.ts @@ -0,0 +1,292 @@ +import { describe, expect, it } from 'bun:test'; +import type { AgicashDb } from '../../db/database'; +import { NoSessionError, SessionEndedError } from '../../lib/error'; +import type { AuthSession, AuthUser } from '../sdk'; +import { createSessionKeys } from '../sdk/session-keys'; +import type { Transaction } from './transaction'; +import type { TransactionRepository } from './transaction-repository'; +import { createTransactionsApi } from './transactions-api'; + +const authUser = (id: string): AuthUser => + ({ + id, + name: null, + email: 'a@b.c', + email_verified: true, + login_method: 'email', + created_at: '2026-01-01', + updated_at: '2026-01-01', + }) as AuthUser; + +const loggedIn = (id: string): AuthSession => ({ + isLoggedIn: true, + user: authUser(id), +}); + +const makeTransaction = (overrides: Partial> = {}) => + ({ + id: 'tx-1', + state: 'PENDING', + direction: 'RECEIVE', + type: 'CASHU_LIGHTNING', + createdAt: '2026-01-01T00:00:00Z', + version: 1, + ...overrides, + }) as unknown as Transaction; + +const makeApi = (deps: { + session: AuthSession; + repository?: Partial; +}) => + createTransactionsApi({ + db: {} as unknown as AgicashDb, + keys: createSessionKeys(), + getSession: () => deps.session, + createRepository: async () => + (deps.repository ?? {}) as unknown as TransactionRepository, + }); + +describe('createTransactionsApi', () => { + describe('list', () => { + it('passes the session userId and params through and returns the page verbatim', async () => { + let captured: + | { + userId: string; + cursor?: { + stateSortOrder: number; + createdAt: string; + id: string; + } | null; + pageSize?: number; + accountId?: string; + abortSignal?: AbortSignal; + } + | undefined; + const nextCursor = { + stateSortOrder: 2, + createdAt: '2026-01-03', + id: 'tx-10', + }; + const page = { + transactions: [makeTransaction()], + nextCursor, + }; + const api = makeApi({ + session: loggedIn('user-x'), + repository: { + list: (async (options) => { + captured = options; + return page; + }) as TransactionRepository['list'], + }, + }); + + const cursor = { + stateSortOrder: 1, + createdAt: '2026-01-02', + id: 'tx-9', + }; + const result = await api.list({ + cursor, + pageSize: 10, + accountId: 'acct-1', + }); + + expect(captured?.userId).toBe('user-x'); + expect(captured?.cursor).toEqual(cursor); + expect(captured?.pageSize).toBe(10); + expect(captured?.accountId).toBe('acct-1'); + expect(captured?.abortSignal).toBeDefined(); + expect(result).toEqual(page); + }); + + it('throws NoSessionError without a session', async () => { + const api = makeApi({ session: { isLoggedIn: false } }); + await expect(api.list({})).rejects.toBeInstanceOf(NoSessionError); + }); + + it('rejects with SessionEndedError and issues no read after dispose', async () => { + const keys = createSessionKeys(); + let listCalls = 0; + const api = createTransactionsApi({ + db: {} as unknown as AgicashDb, + keys, + getSession: () => loggedIn('user-x'), + createRepository: async () => + ({ + list: (async () => { + listCalls += 1; + return { transactions: [], nextCursor: null }; + }) as TransactionRepository['list'], + }) as unknown as TransactionRepository, + }); + + keys.dispose(); + + await expect(api.list({})).rejects.toBeInstanceOf(SessionEndedError); + expect(listCalls).toBe(0); + }); + + it('rejects with SessionEndedError and issues no read when the session ends before the read', async () => { + const keys = createSessionKeys(); + let listCalls = 0; + const api = createTransactionsApi({ + db: {} as unknown as AgicashDb, + keys, + getSession: () => loggedIn('user-x'), + createRepository: async () => { + // The session ends between the signal capture and the read. + keys.reset(); + return { + list: (async () => { + listCalls += 1; + return { transactions: [], nextCursor: null }; + }) as TransactionRepository['list'], + } as unknown as TransactionRepository; + }, + }); + + await expect(api.list({})).rejects.toBeInstanceOf(SessionEndedError); + expect(listCalls).toBe(0); + }); + + it('rejects with SessionEndedError when the session ends during the read', async () => { + const keys = createSessionKeys(); + const api = createTransactionsApi({ + db: {} as unknown as AgicashDb, + keys, + getSession: () => loggedIn('user-x'), + createRepository: async () => + ({ + list: (async () => { + keys.reset(); + return { + transactions: [makeTransaction()], + nextCursor: null, + }; + }) as TransactionRepository['list'], + }) as unknown as TransactionRepository, + }); + + await expect(api.list({})).rejects.toBeInstanceOf(SessionEndedError); + }); + }); + + describe('get', () => { + it('returns the transaction', async () => { + const api = makeApi({ + session: loggedIn('user-x'), + repository: { + get: (async () => makeTransaction()) as TransactionRepository['get'], + }, + }); + + const transaction = await api.get('tx-1'); + + expect(transaction).toEqual(makeTransaction()); + }); + + it('returns null when the transaction does not exist', async () => { + const api = makeApi({ + session: loggedIn('user-x'), + repository: { + get: (async () => null) as TransactionRepository['get'], + }, + }); + + await expect(api.get('missing')).resolves.toBeNull(); + }); + + it('rejects with SessionEndedError when the session ends during the read', async () => { + const keys = createSessionKeys(); + const api = createTransactionsApi({ + db: {} as unknown as AgicashDb, + keys, + getSession: () => loggedIn('user-x'), + createRepository: async () => + ({ + get: (async () => { + keys.reset(); + return makeTransaction(); + }) as TransactionRepository['get'], + }) as unknown as TransactionRepository, + }); + + await expect(api.get('tx-1')).rejects.toBeInstanceOf(SessionEndedError); + }); + }); + + describe('countPendingAck', () => { + it('passes the session userId and returns the count', async () => { + let capturedUserId: string | undefined; + const api = makeApi({ + session: loggedIn('user-x'), + repository: { + countTransactionsPendingAck: (async (params) => { + capturedUserId = params.userId; + return 3; + }) as TransactionRepository['countTransactionsPendingAck'], + }, + }); + + const count = await api.countPendingAck(); + + expect(capturedUserId).toBe('user-x'); + expect(count).toBe(3); + }); + + it('throws NoSessionError without a session', async () => { + const api = makeApi({ session: { isLoggedIn: false } }); + await expect(api.countPendingAck()).rejects.toBeInstanceOf( + NoSessionError, + ); + }); + }); + + describe('acknowledge', () => { + it('passes the session userId and the transactionId', async () => { + let captured: { userId: string; transactionId: string } | undefined; + const api = makeApi({ + session: loggedIn('user-x'), + repository: { + acknowledgeTransaction: (async (params) => { + captured = params; + }) as TransactionRepository['acknowledgeTransaction'], + }, + }); + + await api.acknowledge('tx-1'); + + expect(captured).toEqual({ + userId: 'user-x', + transactionId: 'tx-1', + }); + }); + + it('throws NoSessionError without a session', async () => { + const api = makeApi({ session: { isLoggedIn: false } }); + await expect(api.acknowledge('tx-1')).rejects.toBeInstanceOf( + NoSessionError, + ); + }); + + it('rejects with SessionEndedError when the session ends during the write', async () => { + const keys = createSessionKeys(); + const api = createTransactionsApi({ + db: {} as unknown as AgicashDb, + keys, + getSession: () => loggedIn('user-x'), + createRepository: async () => + ({ + acknowledgeTransaction: (async () => { + keys.reset(); + }) as TransactionRepository['acknowledgeTransaction'], + }) as unknown as TransactionRepository, + }); + + await expect(api.acknowledge('tx-1')).rejects.toBeInstanceOf( + SessionEndedError, + ); + }); + }); +}); diff --git a/packages/wallet-sdk/domain/transactions/transactions-api.ts b/packages/wallet-sdk/domain/transactions/transactions-api.ts new file mode 100644 index 000000000..bf9fca5d1 --- /dev/null +++ b/packages/wallet-sdk/domain/transactions/transactions-api.ts @@ -0,0 +1,95 @@ +import type { AgicashDb } from '../../db/database'; +import { NoSessionError, SessionEndedError } from '../../lib/error'; +import type { AuthSession, TransactionsApi } from '../sdk'; +import type { SessionKeys } from '../sdk/session-keys'; +import { TransactionRepository } from './transaction-repository'; + +type Deps = { + db: AgicashDb; + getSession: () => AuthSession; + keys: SessionKeys; + /** Test seam; defaults to building the repository from db + session keys. */ + createRepository?: () => Promise; +}; + +export function createTransactionsApi(deps: Deps): TransactionsApi { + const requireUserId = (): string => { + const session = deps.getSession(); + if (!session.isLoggedIn) { + throw new NoSessionError(); + } + return session.user.id; + }; + + const getRepository = + deps.createRepository ?? + (async (): Promise => { + const encryption = await deps.keys.getEncryption(); + return new TransactionRepository(deps.db, encryption); + }); + + return { + get: async (id) => { + const signal = deps.keys.sessionSignal(); + const repository = await getRepository(); + if (signal.aborted) { + throw new SessionEndedError(); + } + const transaction = await repository.get(id, { abortSignal: signal }); + if (signal.aborted) { + throw new SessionEndedError(); + } + return transaction; + }, + list: async (params) => { + const userId = requireUserId(); + const signal = deps.keys.sessionSignal(); + const repository = await getRepository(); + if (signal.aborted) { + throw new SessionEndedError(); + } + const result = await repository.list({ + userId, + cursor: params.cursor, + pageSize: params.pageSize, + accountId: params.accountId, + abortSignal: signal, + }); + if (signal.aborted) { + throw new SessionEndedError(); + } + return result; + }, + countPendingAck: async () => { + const userId = requireUserId(); + const signal = deps.keys.sessionSignal(); + const repository = await getRepository(); + if (signal.aborted) { + throw new SessionEndedError(); + } + const count = await repository.countTransactionsPendingAck( + { userId }, + { abortSignal: signal }, + ); + if (signal.aborted) { + throw new SessionEndedError(); + } + return count; + }, + acknowledge: async (transactionId) => { + const userId = requireUserId(); + const signal = deps.keys.sessionSignal(); + const repository = await getRepository(); + if (signal.aborted) { + throw new SessionEndedError(); + } + await repository.acknowledgeTransaction( + { userId, transactionId }, + { abortSignal: signal }, + ); + if (signal.aborted) { + throw new SessionEndedError(); + } + }, + }; +} diff --git a/packages/wallet-sdk/temporary.ts b/packages/wallet-sdk/temporary.ts index ba3be1d3d..2e427ad8b 100644 --- a/packages/wallet-sdk/temporary.ts +++ b/packages/wallet-sdk/temporary.ts @@ -30,11 +30,6 @@ export type { SparkLightningSendDbData } from './db/json-models/spark-lightning- export type { CashuCryptography } from './lib/cashu'; export type { Encryption } from './lib/encryption'; export type { UpdateUser } from './domain/user/user-repository'; -export type { Cursor } from './domain/transactions/transaction-repository'; -export type { - TransactionDetailsParserInput, - TransactionDetailsParserShape, -} from './domain/transactions/transaction-details/transaction-details-types'; export type { RepositoryCreateQuoteParams } from './domain/receive/spark-receive-quote-core'; export { decryptBatchWithPrivateKey, @@ -113,51 +108,6 @@ export { } from './domain/user/user-repository'; export { UserService } from './domain/user/user-service'; export { ContactRepository } from './domain/contacts/contact-repository'; -export { - TransactionDirectionSchema, - TransactionTypeSchema, - TransactionStateSchema, - TransactionPurposeSchema, -} from './domain/transactions/transaction-enums'; -export { - BaseTransactionSchema, - TransactionSchema, -} from './domain/transactions/transaction'; -export { - CashuLightningReceiveTransactionDetailsSchema, - CashuLightningReceiveTransactionDetailsParser, -} from './domain/transactions/transaction-details/cashu-lightning-receive-transaction-details'; -export { - IncompleteCashuLightningSendTransactionDetailsSchema, - CompletedCashuLightningSendTransactionDetailsSchema, - CashuLightningSendTransactionDetailsSchema, - CashuLightningSendTransactionDetailsParser, -} from './domain/transactions/transaction-details/cashu-lightning-send-transaction-details'; -export { - CashuTokenReceiveTransactionDetailsSchema, - CashuTokenReceiveTransactionDetailsParser, -} from './domain/transactions/transaction-details/cashu-token-receive-transaction-details'; -export { - CashuTokenSendTransactionDetailsSchema, - CashuTokenSendTransactionDetailsParser, -} from './domain/transactions/transaction-details/cashu-token-send-transaction-details'; -export { - IncompleteSparkLightningReceiveTransactionDetailsSchema, - CompletedSparkLightningReceiveTransactionDetailsSchema, - SparkLightningReceiveTransactionDetailsSchema, - SparkLightningReceiveTransactionDetailsParser, -} from './domain/transactions/transaction-details/spark-lightning-receive-transaction-details'; -export { - IncompleteSparkLightningSendTransactionDetailsSchema, - CompletedSparkLightningSendTransactionDetailsSchema, - SparkLightningSendTransactionDetailsSchema, - SparkLightningSendTransactionDetailsParser, -} from './domain/transactions/transaction-details/spark-lightning-send-transaction-details'; -export { - TransactionDetailsDbDataSchema, - TransactionDetailsSchema, -} from './domain/transactions/transaction-details/transaction-details-types'; -export { TransactionDetailsParser } from './domain/transactions/transaction-details/transaction-details-parser'; export { TransactionRepository } from './domain/transactions/transaction-repository'; export { CashuTokenMeltDataSchema } from './domain/receive/cashu-token-melt-data'; export { SparkReceiveQuoteSchema } from './domain/receive/spark-receive-quote';