From 1b5f01b4bc2c994ef44d35c65b64432bb60652f9 Mon Sep 17 00:00:00 2001 From: aojomo Date: Wed, 22 Jul 2026 15:42:53 +0000 Subject: [PATCH] feat: durable transaction-tracking layer with persisted queue, status polling, and reconciliation (closes #46) --- app/(tabs)/index.tsx | 76 +++++- app/(tabs)/loans.tsx | 133 +++++++++- app/_layout.tsx | 31 +++ hooks/useRepayment.ts | 19 +- services/transactions.service.ts | 27 ++ .../__tests__/pending-queue.test.ts | 206 +++++++++++++++ src/transactions/pending-queue.ts | 158 ++++++++++++ src/transactions/transaction-poller.ts | 240 ++++++++++++++++++ stores/__tests__/loans.store.test.ts | 169 ++++++++++++ stores/loans.store.ts | 131 ++++++++-- types/transaction.types.ts | 45 ++++ 11 files changed, 1203 insertions(+), 32 deletions(-) create mode 100644 src/transactions/__tests__/pending-queue.test.ts create mode 100644 src/transactions/pending-queue.ts create mode 100644 src/transactions/transaction-poller.ts create mode 100644 stores/__tests__/loans.store.test.ts diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index ad1e942..d20b897 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -10,7 +10,10 @@ import { GraduationCap, Laptop, Calendar, - AlertCircle + AlertCircle, + Loader, + CheckCircle, + XCircle, } from 'lucide-react-native'; import { colors } from '../../constants/colors'; import { EmptyState } from '../../components/shared/EmptyState'; @@ -70,6 +73,16 @@ export default function HomeScreen() { void fetchDashboard(); }; + const pendingTransactions = useLoansStore((s) => s.pendingTransactions); + const setPendingTransactions = useLoansStore((s) => s.setPendingTransactions); + + // Sync pending txs from the persisted queue on mount + useEffect(() => { + import('../../src/transactions/pending-queue').then(({ pendingQueue }) => { + pendingQueue.getAll().then(setPendingTransactions); + }); + }, [setPendingTransactions]); + const activeLoans = loans.filter((l) => l.status === 'active'); const nextInstallment = activeLoans .flatMap((l) => l.installments.filter((i) => !i.paid).map((i) => ({ ...i, loanId: l.id }))) @@ -166,6 +179,67 @@ export default function HomeScreen() { + {/* Pending Transactions Banner */} + {pendingTransactions.filter((tx) => tx.status === 'pending').length > 0 && ( + + + + + + + Transaction In Progress + + + {pendingTransactions.filter((tx) => tx.status === 'pending').length} pending transaction(s) — tracking on-chain + + + + )} + + {/* Transaction Result Banner */} + {pendingTransactions.filter((tx) => tx.status === 'confirmed' || tx.status === 'failed' || tx.status === 'expired').length > 0 && ( + + + Recent Transactions + + {pendingTransactions + .filter((tx) => tx.status !== 'pending') + .slice(0, 5) + .map((tx) => { + const isConfirmed = tx.status === 'confirmed'; + const isFailed = tx.status === 'failed' || tx.status === 'expired'; + const StatusIcon = isConfirmed ? CheckCircle : XCircle; + const statusColor = isConfirmed ? colors.success : colors.error; + return ( + + + + {tx.type === 'REPAYMENT' ? 'Repayment' : tx.type === 'LOAN_CREATION' ? 'Loan Creation' : tx.type} — ${tx.amount?.toLocaleString() ?? ''} + + + {isConfirmed ? 'Confirmed' : 'Failed'} + + + ); + })} + + )} + {/* Quick Actions Grid */} {[ diff --git a/app/(tabs)/loans.tsx b/app/(tabs)/loans.tsx index 9fbc682..9966ca7 100644 --- a/app/(tabs)/loans.tsx +++ b/app/(tabs)/loans.tsx @@ -7,7 +7,8 @@ import { CheckCircle, Clock, XCircle, - ChevronRight, + Loader, + RefreshCw, } from 'lucide-react-native'; import { colors } from '../../constants/colors'; import { Card } from '../../components/shared/Card'; @@ -16,7 +17,9 @@ import { useLoansStore } from '../../stores/loans.store'; import { loansService } from '../../services/loans.service'; import { useTranslation } from '../../hooks/useTranslation'; import { formatDate } from '../../src/locales/i18n'; +import { pendingQueue } from '../../src/transactions/pending-queue'; import type { Loan, LoanStatus } from '../../types/loan.types'; +import type { PendingTransaction } from '../../types/transaction.types'; function getStatusConfig(status: LoanStatus, t: (key: string, opts?: any) => string): { label: string; @@ -40,6 +43,28 @@ function getStatusConfig(status: LoanStatus, t: (key: string, opts?: any) => str } } +/** Small badge showing if a loan has a pending on-chain tx. */ +function PendingTxBadge({ loanId }: { loanId: string }) { + const pendingTransactions = useLoansStore((s) => s.pendingTransactions); + const loanPendingTxs = pendingTransactions.filter( + (tx) => tx.targetLoanId === loanId && tx.status === 'pending', + ); + + if (loanPendingTxs.length === 0) return null; + + return ( + + + + {loanPendingTxs.length} pending + + + ); +} + interface LoanCardProps { loan: Loan; t: (key: string, opts?: any) => string; @@ -77,18 +102,21 @@ function LoanCard({ loan, t }: LoanCardProps) { - {/* Status badge */} - - - + + - {statusConfig.label} - + + + {statusConfig.label} + + @@ -149,27 +177,42 @@ export default function LoansScreen() { const { t } = useTranslation(); const loans = useLoansStore((s) => s.loans); const setLoans = useLoansStore((s) => s.setLoans); + const pendingTransactions = useLoansStore((s) => s.pendingTransactions); + const setPendingTransactions = useLoansStore((s) => s.setPendingTransactions); const [isLoading, setIsLoading] = useState(true); const [isRefreshing, setIsRefreshing] = useState(false); const [error, setError] = useState(null); + // Sync pending txs from the persisted queue on mount + const syncPendingTxs = useCallback(async () => { + const pendings = await pendingQueue.getAll(); + setPendingTransactions(pendings); + }, [setPendingTransactions]); + const fetchLoans = useCallback(async () => { setError(null); try { const data = await loansService.getMyLoans(); setLoans(data); + await syncPendingTxs(); } catch { setError(t('loans.errorLoading')); } finally { setIsLoading(false); setIsRefreshing(false); } - }, [setLoans]); + }, [setLoans, syncPendingTxs]); useEffect(() => { void fetchLoans(); }, [fetchLoans]); + // Re-sync pending txs periodically and whenever local count changes + useEffect(() => { + const interval = setInterval(syncPendingTxs, 5000); + return () => clearInterval(interval); + }, [syncPendingTxs]); + const handleRefresh = () => { setIsRefreshing(true); void fetchLoans(); @@ -205,6 +248,12 @@ export default function LoansScreen() { ); } + const pendingCount = pendingTransactions.filter((tx) => tx.status === 'pending').length; + const recentCompleted = pendingTransactions + .filter((tx) => tx.status !== 'pending') + .sort((a, b) => b.updatedAt - a.updatedAt) + .slice(0, 5); + return ( + {/* Pending Transactions Banner */} + {pendingCount > 0 && ( + + + + + + + Transactions In Progress + + + {pendingCount} pending transaction(s) — status being tracked on-chain + + + + )} + + {/* Recent Completed Transactions */} + {recentCompleted.length > 0 && ( + + + Recent Transaction Activity + + {recentCompleted.map((tx) => { + const isConfirmed = tx.status === 'confirmed'; + const StatusIcon = isConfirmed ? CheckCircle : XCircle; + const statusColor = isConfirmed ? colors.success : colors.error; + return ( + + + + {tx.type === 'REPAYMENT' ? 'Repayment' : tx.type === 'LOAN_CREATION' ? 'Loan' : tx.type} + {tx.amount ? ` — $${tx.amount.toLocaleString()}` : ''} + + + {isConfirmed ? 'Confirmed' : tx.status === 'expired' ? 'Expired' : 'Failed'} + + + ); + })} + + )} + {/* Summary */} {}); + } } else if (state === 'background' || state === 'inactive') { lastActiveRef.current = Date.now(); clearIdleTimer(); @@ -147,6 +159,25 @@ function RootLayout() { } }, [isLocked, isAuthenticated, startIdleTimer]); + // ─── Transaction poller lifecycle ───────────────────────────────────── + useEffect(() => { + if (!isLoading && isAuthenticated) { + startTxPoller(); + } + return () => { + stopTxPoller(); + }; + }, [isLoading, isAuthenticated]); + + // ─── Pending‑tx sync on store hydration ─────────────────────────────── + useEffect(() => { + if (!isLoading && isAuthenticated) { + pendingQueue.getAll().then((all) => { + useLoansStore.getState().setPendingTransactions(all); + }); + } + }, [isLoading, isAuthenticated]); + const prevConnectedRef = useRef(true); useEffect(() => { diff --git a/hooks/useRepayment.ts b/hooks/useRepayment.ts index 8bcd85d..7093fca 100644 --- a/hooks/useRepayment.ts +++ b/hooks/useRepayment.ts @@ -1,6 +1,8 @@ import { useCallback } from 'react'; import { loansService } from '../services/loans.service'; import { useTransaction } from './useTransaction'; +import { pendingQueue } from '../src/transactions/pending-queue'; +import { useLoansStore } from '../stores/loans.store'; import type { UseTransactionReturn } from './useTransaction'; export interface UseRepaymentReturn extends UseTransactionReturn { @@ -20,7 +22,22 @@ export function useRepayment(): UseRepaymentReturn { amount, ); - await execute(unsignedXdr); + const result = await execute(unsignedXdr); + + // On successful broadcast, persist the pending tx for background tracking + if (result?.txHash) { + const entry = await pendingQueue.add({ + txHash: result.txHash, + type: 'REPAYMENT', + targetLoanId: loanId, + targetInstallmentIndex: installmentIndex, + amount, + }); + + // Sync into the loans store for live UI updates + const all = await pendingQueue.getAll(); + useLoansStore.getState().setPendingTransactions(all); + } }, [status, execute], ); diff --git a/services/transactions.service.ts b/services/transactions.service.ts index efd5e81..78237ac 100644 --- a/services/transactions.service.ts +++ b/services/transactions.service.ts @@ -1,10 +1,20 @@ import api from './api'; +import { addBreadcrumb, captureServiceError } from './sentry'; import type { TransactionResult } from '../types/transaction.types'; interface SubmitSignedXdrResponse { txHash: string; } +/** + * On‑chain status returned by the API for a submitted transaction hash. + */ +export interface TxStatusResponse { + status: 'pending' | 'confirmed' | 'failed' | 'expired'; + /** Human‑readable message from the chain / API. */ + message?: string; +} + export const transactionsService = { async submitSignedXdr(signedXdr: string): Promise { const res = await api.post('/transactions/submit', { @@ -12,4 +22,21 @@ export const transactionsService = { }); return { txHash: res.data.txHash, signedXdr }; }, + + /** + * Poll the API for the on‑chain resolution of a previously submitted tx. + * Returns the current status and an optional human‑readable message. + */ + async getTxStatus(txHash: string): Promise { + addBreadcrumb('transactions.service', 'Polling tx status', { txHash }); + try { + const res = await api.get(`/transactions/${txHash}/status`); + return res.data; + } catch (error) { + captureServiceError('transactions', 'getTxStatus', error); + // Network errors during polling should be handled gracefully — + // return 'pending' so the poller retries with backoff. + return { status: 'pending', message: 'Network error — will retry' }; + } + }, }; diff --git a/src/transactions/__tests__/pending-queue.test.ts b/src/transactions/__tests__/pending-queue.test.ts new file mode 100644 index 0000000..5a56692 --- /dev/null +++ b/src/transactions/__tests__/pending-queue.test.ts @@ -0,0 +1,206 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +jest.mock('@react-native-async-storage/async-storage', () => ({ + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), +})); + +const mockGetItem = AsyncStorage.getItem as jest.Mock; +const mockSetItem = AsyncStorage.setItem as jest.Mock; +const mockRemoveItem = AsyncStorage.removeItem as jest.Mock; + +const QUEUE_KEY = '@stepfi/pending-transactions'; + +function createMockQueue(overrides: Record = {}) { + return { + id: 'test-id-1', + txHash: '0xabc123def456', + type: 'REPAYMENT', + targetLoanId: 'loan-1', + targetInstallmentIndex: 0, + amount: 100, + status: 'pending', + createdAt: Date.now(), + updatedAt: Date.now(), + retryCount: 0, + ...overrides, + }; +} + +describe('pendingQueue', () => { + let pendingQueue: typeof import('../pending-queue').pendingQueue; + + beforeEach(() => { + jest.clearAllMocks(); + mockGetItem.mockReset(); + mockSetItem.mockReset(); + mockRemoveItem.mockReset(); + }); + + beforeAll(async () => { + pendingQueue = (await import('../pending-queue')).pendingQueue; + }); + + describe('add', () => { + it('persists a new pending transaction to the queue', async () => { + mockGetItem.mockResolvedValue(JSON.stringify([])); + + const entry = await pendingQueue.add({ + txHash: '0xabc', + type: 'REPAYMENT', + targetLoanId: 'loan-1', + targetInstallmentIndex: 0, + amount: 100, + }); + + expect(entry.id).toBeDefined(); + expect(entry.txHash).toBe('0xabc'); + expect(entry.type).toBe('REPAYMENT'); + expect(entry.targetLoanId).toBe('loan-1'); + expect(entry.status).toBe('pending'); + expect(entry.retryCount).toBe(0); + expect(mockSetItem).toHaveBeenCalledTimes(1); + expect(mockSetItem).toHaveBeenCalledWith( + QUEUE_KEY, + expect.any(String), + ); + }); + + it('appends to existing queue entries', async () => { + const existing = [createMockQueue({ id: 'existing-1' })]; + mockGetItem.mockResolvedValue(JSON.stringify(existing)); + + await pendingQueue.add({ + txHash: '0xnew', + type: 'LOAN_CREATION', + }); + + const saved = JSON.parse(mockSetItem.mock.calls[0][1]); + expect(saved).toHaveLength(2); + expect(saved[0].id).toBe('existing-1'); + expect(saved[1].txHash).toBe('0xnew'); + }); + }); + + describe('updateStatus', () => { + it('updates status, retryCount, and lastPolledAt for an existing entry', async () => { + const entry = createMockQueue({ id: 'tx-1' }); + mockGetItem.mockResolvedValue(JSON.stringify([entry])); + + const result = await pendingQueue.updateStatus('tx-1', 'confirmed', undefined, 5000); + + expect(result).toBe(true); + const saved = JSON.parse(mockSetItem.mock.calls[0][1]); + expect(saved[0].status).toBe('confirmed'); + expect(saved[0].lastPolledAt).toBe(5000); + expect(saved[0].updatedAt).toBeGreaterThan(entry.updatedAt); + }); + + it('returns false if the entry does not exist', async () => { + mockGetItem.mockResolvedValue(JSON.stringify([])); + const result = await pendingQueue.updateStatus('nonexistent', 'confirmed'); + expect(result).toBe(false); + }); + + it('increments retryCount when provided', async () => { + const entry = createMockQueue({ id: 'tx-1', retryCount: 2 }); + mockGetItem.mockResolvedValue(JSON.stringify([entry])); + + await pendingQueue.updateStatus('tx-1', 'pending', 3, Date.now()); + const saved = JSON.parse(mockSetItem.mock.calls[0][1]); + expect(saved[0].retryCount).toBe(3); + }); + }); + + describe('remove', () => { + it('removes an entry by id', async () => { + const entries = [ + createMockQueue({ id: 'tx-1' }), + createMockQueue({ id: 'tx-2' }), + ]; + mockGetItem.mockResolvedValue(JSON.stringify(entries)); + + await pendingQueue.remove('tx-1'); + const saved = JSON.parse(mockSetItem.mock.calls[0][1]); + expect(saved).toHaveLength(1); + expect(saved[0].id).toBe('tx-2'); + }); + }); + + describe('get / getByTxHash', () => { + it('retrieves an entry by id', async () => { + const entries = [createMockQueue({ id: 'tx-1' })]; + mockGetItem.mockResolvedValue(JSON.stringify(entries)); + + const result = await pendingQueue.get('tx-1'); + expect(result).not.toBeNull(); + expect(result!.id).toBe('tx-1'); + }); + + it('retrieves an entry by txHash', async () => { + const entries = [createMockQueue({ id: 'tx-1', txHash: '0xhaha' })]; + mockGetItem.mockResolvedValue(JSON.stringify(entries)); + + const result = await pendingQueue.getByTxHash('0xhaha'); + expect(result).not.toBeNull(); + expect(result!.id).toBe('tx-1'); + }); + }); + + describe('getAll / getPending', () => { + it('returns all entries', async () => { + const entries = [ + createMockQueue({ id: 'tx-1', status: 'pending' }), + createMockQueue({ id: 'tx-2', status: 'confirmed' }), + createMockQueue({ id: 'tx-3', status: 'failed' }), + ]; + mockGetItem.mockResolvedValue(JSON.stringify(entries)); + + const all = await pendingQueue.getAll(); + expect(all).toHaveLength(3); + }); + + it('returns only pending entries', async () => { + const entries = [ + createMockQueue({ id: 'tx-1', status: 'pending' }), + createMockQueue({ id: 'tx-2', status: 'confirmed' }), + createMockQueue({ id: 'tx-3', status: 'pending' }), + ]; + mockGetItem.mockResolvedValue(JSON.stringify(entries)); + + const pendings = await pendingQueue.getPending(); + expect(pendings).toHaveLength(2); + expect(pendings.every((t: { status: string }) => t.status === 'pending')).toBe(true); + }); + }); + + describe('getSummary', () => { + it('returns counts grouped by status', async () => { + const entries = [ + createMockQueue({ id: 'tx-1', status: 'pending' }), + createMockQueue({ id: 'tx-2', status: 'confirmed' }), + createMockQueue({ id: 'tx-3', status: 'failed' }), + createMockQueue({ id: 'tx-4', status: 'pending' }), + createMockQueue({ id: 'tx-5', status: 'expired' }), + ]; + mockGetItem.mockResolvedValue(JSON.stringify(entries)); + + const summary = await pendingQueue.getSummary(); + expect(summary).toEqual({ + total: 5, + pending: 2, + confirmed: 1, + failed: 1, + expired: 1, + }); + }); + }); + + describe('clear', () => { + it('removes all entries from storage', async () => { + await pendingQueue.clear(); + expect(mockRemoveItem).toHaveBeenCalledWith(QUEUE_KEY); + }); + }); +}); diff --git a/src/transactions/pending-queue.ts b/src/transactions/pending-queue.ts new file mode 100644 index 0000000..a52203b --- /dev/null +++ b/src/transactions/pending-queue.ts @@ -0,0 +1,158 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import type { + PendingTransaction, + PendingTransactionStatus, + PendingTransactionType, +} from '../../types/transaction.types'; + +const QUEUE_KEY = '@stepfi/pending-transactions'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function generateId(): string { + return Date.now().toString(36) + Math.random().toString(36).substring(2, 11); +} + +// ─── Storage ────────────────────────────────────────────────────────────────── + +/** + * Read the full pending‑transaction queue from storage. + * Returns an empty array on any error. + */ +async function loadAll(): Promise { + try { + const raw = await AsyncStorage.getItem(QUEUE_KEY); + return raw ? JSON.parse(raw) : []; + } catch { + return []; + } +} + +/** + * Persist the full queue back to storage. + */ +async function saveAll(txns: PendingTransaction[]): Promise { + await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(txns)); +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +export const pendingQueue = { + /** + * Add a new pending transaction to the queue. + * Returns the newly created entry with its auto‑generated id and timestamp. + */ + async add(tx: { + txHash: string; + type: PendingTransactionType; + targetLoanId?: string; + targetInstallmentIndex?: number; + amount?: number; + }): Promise { + const queue = await loadAll(); + const entry: PendingTransaction = { + id: generateId(), + txHash: tx.txHash, + type: tx.type, + targetLoanId: tx.targetLoanId, + targetInstallmentIndex: tx.targetInstallmentIndex, + amount: tx.amount, + status: 'pending', + createdAt: Date.now(), + updatedAt: Date.now(), + retryCount: 0, + }; + queue.push(entry); + await saveAll(queue); + return entry; + }, + + /** + * Update the status (and optionally retryCount) of a queue entry. + * Returns `true` if the entry was found and updated, `false` otherwise. + */ + async updateStatus( + id: string, + status: PendingTransactionStatus, + retryCount?: number, + lastPolledAt?: number, + ): Promise { + const queue = await loadAll(); + const idx = queue.findIndex((t) => t.id === id); + if (idx === -1) return false; + + queue[idx].status = status; + queue[idx].updatedAt = Date.now(); + if (retryCount !== undefined) queue[idx].retryCount = retryCount; + if (lastPolledAt !== undefined) queue[idx].lastPolledAt = lastPolledAt; + await saveAll(queue); + return true; + }, + + /** + * Remove an entry from the queue by id. + */ + async remove(id: string): Promise { + const queue = await loadAll(); + const filtered = queue.filter((t) => t.id !== id); + await saveAll(filtered); + }, + + /** + * Retrieve a single entry by id. + */ + async get(id: string): Promise { + const queue = await loadAll(); + return queue.find((t) => t.id === id) ?? null; + }, + + /** + * Retrieve an entry by its on‑chain tx hash. + */ + async getByTxHash(txHash: string): Promise { + const queue = await loadAll(); + return queue.find((t) => t.txHash === txHash) ?? null; + }, + + /** + * Return all entries from the queue. + */ + async getAll(): Promise { + return loadAll(); + }, + + /** + * Return only entries whose status is 'pending' (i.e. still in flight). + */ + async getPending(): Promise { + const queue = await loadAll(); + return queue.filter((t) => t.status === 'pending'); + }, + + /** + * Return a count of entries grouped by status. + */ + async getSummary(): Promise<{ + total: number; + pending: number; + confirmed: number; + failed: number; + expired: number; + }> { + const queue = await loadAll(); + return { + total: queue.length, + pending: queue.filter((t) => t.status === 'pending').length, + confirmed: queue.filter((t) => t.status === 'confirmed').length, + failed: queue.filter((t) => t.status === 'failed').length, + expired: queue.filter((t) => t.status === 'expired').length, + }; + }, + + /** + * Clear all entries (e.g. after a full reconciliation). + */ + async clear(): Promise { + await AsyncStorage.removeItem(QUEUE_KEY); + }, +}; diff --git a/src/transactions/transaction-poller.ts b/src/transactions/transaction-poller.ts new file mode 100644 index 0000000..db78af9 --- /dev/null +++ b/src/transactions/transaction-poller.ts @@ -0,0 +1,240 @@ +import { transactionsService } from '../../services/transactions.service'; +import { addBreadcrumb } from '../../services/sentry'; +import { pendingQueue } from './pending-queue'; +import { useLoansStore } from '../../stores/loans.store'; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +/** Base interval in ms before the first retry. */ +const BASE_INTERVAL_MS = 2_000; + +/** Maximum interval cap so we don't keep polling forever at high frequency. */ +const MAX_INTERVAL_MS = 60_000; + +/** How many consecutive failures before we give up on a tx and mark expired. */ +const MAX_RETRIES = 15; + +/** Key under which the poller stores its timer id on the global scope. */ +const POLLER_TIMER_KEY = '__stepfi_tx_poller_timer'; + +// ─── Concurrency Guard ──────────────────────────────────────────────────────── + +/** Per‑tx lock to prevent the same tx from being polled concurrently. */ +const inFlightLocks = new Map>(); + +/** + * Execute `fn` for a given tx id, ensuring only one poll at a time per tx. + * Concurrent calls for the same id will await the in‑flight promise. + */ +async function withLock(txId: string, fn: () => Promise): Promise { + const existing = inFlightLocks.get(txId); + if (existing) { + // Already being polled — wait for the in‑flight attempt to complete + return existing; + } + + const promise = fn().finally(() => { + // Only clean up if our promise is still the one in the map + if (inFlightLocks.get(txId) === promise) { + inFlightLocks.delete(txId); + } + }); + + inFlightLocks.set(txId, promise); + return promise; +} + +// ─── Backoff Calculation ────────────────────────────────────────────────────── + +function getBackoffDelay(retryCount: number): number { + const delay = Math.min(BASE_INTERVAL_MS * 2 ** retryCount, MAX_INTERVAL_MS); + return delay; +} + +// ─── Polling Logic ──────────────────────────────────────────────────────────── + +/** + * Poll a single pending transaction. If the API returns a terminal status the + * queue entry is updated and the loan store is reconciled idempotently. + */ +async function pollOne(tx: { + id: string; + txHash: string; + targetLoanId?: string; + targetInstallmentIndex?: number; + amount?: number; +}): Promise { + try { + const result = await transactionsService.getTxStatus(tx.txHash); + + switch (result.status) { + case 'confirmed': { + await pendingQueue.updateStatus(tx.id, 'confirmed', undefined, Date.now()); + addBreadcrumb('tx.poller', 'Tx confirmed', { + txHash: tx.txHash, + loanId: tx.targetLoanId, + }); + + // Sync updated pending list into the store for live UI + const updated = await pendingQueue.getPending(); + useLoansStore.getState().setPendingTransactions( + await pendingQueue.getAll(), + ); + + // Idempotent loan‑store update — pass txHash as idempotency key + if (tx.targetLoanId && tx.targetInstallmentIndex !== undefined) { + useLoansStore.getState().markInstallmentPaid( + tx.txHash, + tx.targetLoanId, + tx.targetInstallmentIndex, + ); + } + break; + } + + case 'failed': { + await pendingQueue.updateStatus(tx.id, 'failed', 0, Date.now()); + addBreadcrumb('tx.poller', 'Tx failed', { + txHash: tx.txHash, + message: result.message, + }); + + useLoansStore.getState().setPendingTransactions( + await pendingQueue.getAll(), + ); + break; + } + + case 'expired': { + await pendingQueue.updateStatus(tx.id, 'expired', 0, Date.now()); + addBreadcrumb('tx.poller', 'Tx expired', { + txHash: tx.txHash, + }); + + useLoansStore.getState().setPendingTransactions( + await pendingQueue.getAll(), + ); + break; + } + + case 'pending': + default: { + // Still pending — increment retry count for backoff + const entry = await pendingQueue.get(tx.id); + if (!entry) return; + const nextRetry = entry.retryCount + 1; + await pendingQueue.updateStatus(tx.id, 'pending', nextRetry, Date.now()); + + // If we have exceeded the max retries, mark as expired + if (nextRetry >= MAX_RETRIES) { + await pendingQueue.updateStatus(tx.id, 'expired', nextRetry, Date.now()); + addBreadcrumb('tx.poller', 'Max retries reached — expired', { + txHash: tx.txHash, + retries: nextRetry, + }); + + useLoansStore.getState().setPendingTransactions( + await pendingQueue.getAll(), + ); + } + break; + } + } + } catch { + // Network-level error — increment retry and let the scheduler re‑visit + const entry = await pendingQueue.get(tx.id); + if (!entry) return; + const nextRetry = entry.retryCount + 1; + await pendingQueue.updateStatus(tx.id, 'pending', nextRetry, Date.now()); + } +} + +// ─── Scheduler ──────────────────────────────────────────────────────────────── + +/** + * Execute a single sweep of the pending queue: poll each pending tx at its + * current backoff interval. Entries that are not yet due for a retry are + * skipped. + */ +async function sweep(): Promise { + const pendings = await pendingQueue.getPending(); + if (pendings.length === 0) return; + + const now = Date.now(); + + for (const tx of pendings) { + const delay = getBackoffDelay(tx.retryCount); + const elapsed = tx.lastPolledAt ? now - tx.lastPolledAt : Infinity; + + // Only poll this tx if enough time has passed since the last attempt + if (elapsed >= delay) { + // Wrap with concurrency lock to prevent double-polling + withLock(tx.id, () => pollOne(tx)).catch(() => {}); + } + } +} + +// ─── Start / Stop ───────────────────────────────────────────────────────────── + +let _active = false; + +/** + * Start the background poller. It runs a sweep every `BASE_INTERVAL_MS` and + * automatically stops when the queue is empty. + */ +export function startTxPoller(): void { + if (_active) return; + _active = true; + + addBreadcrumb('tx.poller', 'Poller started'); + + // @ts-expect-error — storing timer id on global scope for cross‑module access + globalThis[POLLER_TIMER_KEY] = setInterval(() => { + sweep().catch(() => {}); + }, BASE_INTERVAL_MS); + + // Also kick off an immediate sweep + sweep().catch(() => {}); +} + +/** + * Stop the background poller. + */ +export function stopTxPoller(): void { + _active = false; + // @ts-expect-error + const timer = globalThis[POLLER_TIMER_KEY] as ReturnType | undefined; + if (timer !== undefined) { + clearInterval(timer); + // @ts-expect-error + delete globalThis[POLLER_TIMER_KEY]; + } + addBreadcrumb('tx.poller', 'Poller stopped'); +} + +// ─── Reconciliation (on app resume) ─────────────────────────────────────────── + +/** + * Force‑poll every pending transaction immediately and reconcile loan state. + * Called when the app returns from background / kill. + */ +export async function reconcilePendingTxs(): Promise { + addBreadcrumb('tx.poller', 'Reconciliation started'); + const pendings = await pendingQueue.getPending(); + + if (pendings.length === 0) { + addBreadcrumb('tx.poller', 'No pending txs to reconcile'); + return; + } + + addBreadcrumb('tx.poller', `Reconciling ${pendings.length} pending txs`); + + // Poll all in parallel for fast reconciliation, protected by per-tx locks + const results = await Promise.allSettled( + pendings.map((tx) => withLock(tx.id, () => pollOne(tx))), + ); + + const ok = results.filter((r) => r.status === 'fulfilled').length; + const failed = results.filter((r) => r.status === 'rejected').length; + addBreadcrumb('tx.poller', 'Reconciliation complete', { ok, failed }); +} diff --git a/stores/__tests__/loans.store.test.ts b/stores/__tests__/loans.store.test.ts new file mode 100644 index 0000000..45313bc --- /dev/null +++ b/stores/__tests__/loans.store.test.ts @@ -0,0 +1,169 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import type { Loan } from '../../types/loan.types'; + +// Mock AsyncStorage before any imports that depend on it +jest.mock('@react-native-async-storage/async-storage', () => ({ + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + getAllKeys: jest.fn(), + multiRemove: jest.fn(), +})); + +// We need to use require for the store because it has module-level persist init +let useLoansStore: typeof import('../loans.store').useLoansStore; +let store: ReturnType; + +function createMockLoan(overrides: Partial = {}): Loan { + return { + id: 'loan-1', + walletAddress: 'GABCDEF123456789', + vendorId: 'vendor-1', + totalAmount: 1000, + remainingBalance: 500, + status: 'active', + loanType: 'learner_installment', + installments: [ + { dueDate: '2026-01-01', amount: 200, paid: false }, + { dueDate: '2026-02-01', amount: 200, paid: false }, + { dueDate: '2026-03-01', amount: 200, paid: false }, + { dueDate: '2026-04-01', amount: 200, paid: false }, + { dueDate: '2026-05-01', amount: 200, paid: false }, + ], + createdAt: '2025-12-01T00:00:00Z', + ...overrides, + }; +} + +describe('LoansStore — markInstallmentPaid idempotency', () => { + beforeAll(async () => { + // Reset mocks before importing the store + jest.clearAllMocks(); + (AsyncStorage.getItem as jest.Mock).mockResolvedValue(null); + (AsyncStorage.setItem as jest.Mock).mockResolvedValue(undefined); + + useLoansStore = (await import('../loans.store')).useLoansStore; + }); + + beforeEach(() => { + // Reset store state before each test + useLoansStore.setState({ + loans: [], + selectedLoan: null, + isLoading: false, + simulatedAmount: null, + simulatedTerm: null, + processedTxHashes: [], + pendingTransactions: [], + }); + }); + + describe('markInstallmentPaid', () => { + it('marks an installment as paid and recalculates remaining balance', () => { + const loan = createMockLoan(); + useLoansStore.getState().setLoans([loan]); + + useLoansStore.getState().markInstallmentPaid( + 'tx-1', + 'loan-1', + 0, // first installment + ); + + const state = useLoansStore.getState(); + const updatedLoan = state.loans[0]; + + // First installment should be paid + expect(updatedLoan.installments[0].paid).toBe(true); + expect(updatedLoan.installments[0].paidAt).toBeDefined(); + + // Remaining balance should be reduced by the installment amount (200) + expect(updatedLoan.remainingBalance).toBe(loan.totalAmount - 200); + + // Processed tx hash should be tracked + expect(state.processedTxHashes).toContain('tx-1'); + }); + + it('is idempotent — calling with the same txHash twice does not double-count', () => { + const loan = createMockLoan(); + useLoansStore.getState().setLoans([loan]); + + // First call + useLoansStore.getState().markInstallmentPaid('tx-1', 'loan-1', 0); + const stateAfterFirst = useLoansStore.getState(); + const remainingAfterFirst = stateAfterFirst.loans[0].remainingBalance; + + // Second call with same txHash — should be no-op + useLoansStore.getState().markInstallmentPaid('tx-1', 'loan-1', 0); + const stateAfterSecond = useLoansStore.getState(); + + expect(stateAfterSecond.loans[0].remainingBalance).toBe(remainingAfterFirst); + expect(stateAfterSecond.processedTxHashes).toHaveLength(1); + }); + + it('auto-transitions loan status to "paid" when all installments are paid', () => { + const loan = createMockLoan(); // 5 installments, $200 each + useLoansStore.getState().setLoans([loan]); + + // Pay all installments with unique tx hashes + useLoansStore.getState().markInstallmentPaid('tx-1', 'loan-1', 0); + useLoansStore.getState().markInstallmentPaid('tx-2', 'loan-1', 1); + useLoansStore.getState().markInstallmentPaid('tx-3', 'loan-1', 2); + useLoansStore.getState().markInstallmentPaid('tx-4', 'loan-1', 3); + useLoansStore.getState().markInstallmentPaid('tx-5', 'loan-1', 4); + + const state = useLoansStore.getState(); + expect(state.loans[0].status).toBe('paid'); + expect(state.loans[0].remainingBalance).toBe(0); + expect(state.loans[0].installments.every((i) => i.paid)).toBe(true); + }); + + it('does not modify other loans when updating', () => { + const loan1 = createMockLoan({ id: 'loan-1' }); + const loan2 = createMockLoan({ id: 'loan-2', totalAmount: 2000, remainingBalance: 1000 }); + useLoansStore.getState().setLoans([loan1, loan2]); + + useLoansStore.getState().markInstallmentPaid('tx-1', 'loan-1', 0); + + const state = useLoansStore.getState(); + expect(state.loans[1].remainingBalance).toBe(1000); // Unchanged + expect(state.loans[1].installments.every((i) => !i.paid)).toBe(true); + }); + + it('is a no-op if the loan does not exist', () => { + useLoansStore.getState().setLoans([createMockLoan()]); + + // Should not throw and should not affect existing loans + expect(() => { + useLoansStore.getState().markInstallmentPaid('tx-404', 'nonexistent-loan', 0); + }).not.toThrow(); + + const state = useLoansStore.getState(); + expect(state.loans).toHaveLength(1); + expect(state.loans[0].installments.every((i) => !i.paid)).toBe(true); + }); + }); + + describe('persistence (partialize)', () => { + it('persists loans, simulatedAmount, simulatedTerm, and processedTxHashes', async () => { + // Store persist middleware calls setItem on state changes + // Set some state + useLoansStore.getState().setLoans([createMockLoan()]); + useLoansStore.getState().saveSimulation(5000, 6); + useLoansStore.getState().markInstallmentPaid('tx-1', 'loan-1', 0); + + // Verify AsyncStorage.setItem was called with the persisted state + expect(AsyncStorage.setItem).toHaveBeenCalledWith( + '@stepfi/loans-store', + expect.any(String), + ); + + const savedData = JSON.parse((AsyncStorage.setItem as jest.Mock).mock.calls[0][1]); + + // Should persist these fields + expect(savedData.state.loans).toHaveLength(1); + expect(savedData.state.simulatedAmount).toBe(5000); + expect(savedData.state.simulatedTerm).toBe(6); + expect(savedData.state.processedTxHashes).toContain('tx-1'); + }); + }); +}); diff --git a/stores/loans.store.ts b/stores/loans.store.ts index 7e5ef16..7a62955 100644 --- a/stores/loans.store.ts +++ b/stores/loans.store.ts @@ -1,5 +1,11 @@ import { create } from 'zustand'; +import { persist, createJSONStorage } from 'zustand/middleware'; +import AsyncStorage from '@react-native-async-storage/async-storage'; import type { Loan } from '../types/loan.types'; +import type { PendingTransaction } from '../types/transaction.types'; + +/** Maximum number of processed tx hashes kept in the persisted store to prevent unbounded growth. */ +const MAX_PROCESSED_HASHES = 200; interface LoansState { loans: Loan[]; @@ -9,30 +15,121 @@ interface LoansState { selectLoan: (id: string) => void; clearLoans: () => void; setLoading: (loading: boolean) => void; - - // Persisted simulation state + + // ─── Persisted simulation state ──────────────────────────────────────── simulatedAmount: number | null; simulatedTerm: number | null; saveSimulation: (amount: number, term: number) => void; + + // ─── Idempotent updates ─────────────────────────────────────────────── + /** Set of tx hashes that have already been reconciled so we never double‑count. */ + processedTxHashes: string[]; + + /** + * Idempotently mark an installment as paid. If `txHash` has already been + * processed this is a no‑op, preventing double‑counting on reconnect. + */ + markInstallmentPaid: ( + txHash: string, + loanId: string, + installmentIndex: number, + ) => void; + + // ─── Pending transaction sync ────────────────────────────────────────── + pendingTransactions: PendingTransaction[]; + setPendingTransactions: (txns: PendingTransaction[]) => void; } -export const useLoansStore = create((set, get) => ({ - loans: [], - selectedLoan: null, - isLoading: false, - simulatedAmount: null, - simulatedTerm: null, +export const useLoansStore = create()( + persist( + (set, get) => ({ + loans: [], + selectedLoan: null, + isLoading: false, + simulatedAmount: null, + simulatedTerm: null, + + processedTxHashes: [], + pendingTransactions: [], + + setLoans: (loans) => set({ loans }), + + selectLoan: (id) => { + const loan = get().loans.find((l) => l.id === id) ?? null; + set({ selectedLoan: loan }); + }, + + clearLoans: () => + set({ + loans: [], + selectedLoan: null, + pendingTransactions: [], + processedTxHashes: [], + }), + + setLoading: (isLoading) => set({ isLoading }), + + saveSimulation: (simulatedAmount, simulatedTerm) => + set({ simulatedAmount, simulatedTerm }), + + markInstallmentPaid: (txHash, loanId, installmentIndex) => { + const state = get(); + + // Idempotency guard — skip if we already processed this tx hash + if (state.processedTxHashes.includes(txHash)) return; + + const updatedLoans = state.loans.map((loan) => { + if (loan.id !== loanId) return loan; + + const updatedInstallments = loan.installments.map((inst, idx) => { + if (idx !== installmentIndex) return inst; + return { ...inst, paid: true, paidAt: new Date().toISOString() }; + }); + + // Recalculate remaining balance + const paidTotal = updatedInstallments + .filter((i) => i.paid) + .reduce((sum, i) => sum + i.amount, 0); + const remainingBalance = loan.totalAmount - paidTotal; - setLoans: (loans) => set({ loans }), + // Auto‑transition status if fully paid + const allPaid = updatedInstallments.every((i) => i.paid); + const status = allPaid ? ('paid' as const) : loan.status; - selectLoan: (id) => { - const loan = get().loans.find((l) => l.id === id) ?? null; - set({ selectedLoan: loan }); - }, + return { + ...loan, + installments: updatedInstallments, + remainingBalance: Math.max(0, remainingBalance), + status, + }; + }); - clearLoans: () => set({ loans: [], selectedLoan: null }), + // Keep the processed list bounded — trim oldest entries if over limit + const updatedHashes = [...state.processedTxHashes, txHash]; + const trimmedHashes = + updatedHashes.length > MAX_PROCESSED_HASHES + ? updatedHashes.slice(updatedHashes.length - MAX_PROCESSED_HASHES) + : updatedHashes; - setLoading: (isLoading) => set({ isLoading }), + set({ + loans: updatedLoans, + processedTxHashes: trimmedHashes, + }); + }, - saveSimulation: (simulatedAmount, simulatedTerm) => set({ simulatedAmount, simulatedTerm }), -})); + setPendingTransactions: (pendingTransactions) => set({ pendingTransactions }), + }), + { + name: '@stepfi/loans-store', + storage: createJSONStorage(() => AsyncStorage), + // Only persist these fields — derived / transient state stays in memory + partialize: (state) => ({ + loans: state.loans, + simulatedAmount: state.simulatedAmount, + simulatedTerm: state.simulatedTerm, + processedTxHashes: state.processedTxHashes, + pendingTransactions: state.pendingTransactions, + }), + }, + ), +); diff --git a/types/transaction.types.ts b/types/transaction.types.ts index a4191cd..5927837 100644 --- a/types/transaction.types.ts +++ b/types/transaction.types.ts @@ -32,3 +32,48 @@ export interface TransactionResult { txHash: string; signedXdr: string; } + +// ─── Pending Transaction Tracking ──────────────────────────────────────────── + +export type PendingTransactionStatus = + | 'pending' + | 'confirmed' + | 'failed' + | 'expired'; + +export type PendingTransactionType = + | 'LOAN_CREATION' + | 'REPAYMENT' + | 'VOUCH_SUBMIT' + | 'LIQUIDITY_DEPOSIT'; + +export interface PendingTransaction { + /** Unique local id for the queue entry. */ + id: string; + /** On-chain transaction hash returned after submission. */ + txHash: string; + /** High-level category for the transaction. */ + type: PendingTransactionType; + /** Target loan this tx affects (if applicable). */ + targetLoanId?: string; + /** Specific installment index within the loan (if repayment). */ + targetInstallmentIndex?: number; + /** Amount involved in the tx (for display / idempotency). */ + amount?: number; + /** Current resolution status. */ + status: PendingTransactionStatus; + /** ISO timestamp when the entry was first created. */ + createdAt: number; + /** ISO timestamp of the last status change. */ + updatedAt: number; + /** How many times we have polled so far (for backoff). */ + retryCount: number; + /** ISO timestamp of the last poll attempt. */ + lastPolledAt?: number; +} + +/** Public-facing summary surfaced to the user. */ +export interface PendingSummary { + pendingCount: number; + recent: PendingTransaction[]; +}