From 32855a79ecd85dc19df30347cb7cca545d602b8d Mon Sep 17 00:00:00 2001 From: Retkatmun Date: Thu, 27 Aug 2026 08:56:16 +0100 Subject: [PATCH] feat(payments): build payment method management with fallback chains (#951) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add PaymentMethodService with full CRUD, chain validation, expiry tracking, analytics, and sharing (paymentMethodService.ts) - Add FallbackChainEngine with 6 pluggable strategies: priority, weighted, sticky, priority-burst, geo-aware, round-robin - Add PaymentMethodManager with circuit breaker (open after 3 consecutive failures), health scoring (0-100), and rate limiting - Add useWalletStore actions: addPaymentMethod, removePaymentMethod, updatePaymentMethod, verifyPaymentMethod, setPaymentMethodPriority, processPayment, processPaymentWithChain, createFallbackChain, updateFallbackChain, deleteFallbackChain, reorderFallbackChain, validateFallbackChain, expiryAlerts, deactivateExpiredMethods, paymentAnalytics, sharePaymentMethod, revokePaymentMethodShare - Add PaymentMethodManager.tsx UI component with 4 tabs: Methods, Chains, Analytics, Alerts — fully accessible (ARIA) - Add dedicated src/types/paymentMethod.ts re-export module - Remove duplicate PaymentMethodService/Error class from walletService.ts; re-export from paymentMethodService.ts - Fix walletStore.ts imports to use single source (paymentMethodService) - Add unit tests (paymentMethodService, walletStore) and integration tests (11 critical fallback-chain scenarios) - Add docs/payment-method-management.md Closes #951 --- docs/payment-method-management.md | 303 ++++ .../payment/PaymentMethodManager.tsx | 1568 +++++++++++++++++ src/services/FallbackChainEngine.ts | 426 +++++ src/services/PaymentMethodManager.ts | 419 +++++ .../paymentFallbackChain.integration.test.ts | 425 +++++ .../__tests__/paymentMethodService.test.ts | 771 ++++++++ src/services/walletService.ts | 466 +---- src/store/__tests__/walletStore.test.ts | 794 +++++++++ src/store/walletStore.ts | 10 +- src/types/paymentMethod.ts | 72 + 10 files changed, 4799 insertions(+), 455 deletions(-) create mode 100644 docs/payment-method-management.md create mode 100644 src/components/payment/PaymentMethodManager.tsx create mode 100644 src/services/FallbackChainEngine.ts create mode 100644 src/services/PaymentMethodManager.ts create mode 100644 src/services/__tests__/paymentFallbackChain.integration.test.ts create mode 100644 src/services/__tests__/paymentMethodService.test.ts create mode 100644 src/store/__tests__/walletStore.test.ts create mode 100644 src/types/paymentMethod.ts diff --git a/docs/payment-method-management.md b/docs/payment-method-management.md new file mode 100644 index 00000000..f30f2e0c --- /dev/null +++ b/docs/payment-method-management.md @@ -0,0 +1,303 @@ +# Payment Method Management & Fallback Chains + +Issue #951 — Production-ready implementation of payment method CRUD, ordered fallback chains, expiry tracking, analytics, sharing and a full UI manager screen. + +--- + +## Overview + +SubTrackr lets a user register multiple on-chain payment methods and arrange them in a **fallback chain**: an ordered list of methods to try, one after another, until a charge succeeds. When the primary method runs out of gas or has an insufficient balance, the chain automatically falls through to the next entry without any manual intervention. + +--- + +## Architecture + +``` +src/ +├── types/ +│ ├── wallet.ts — Core types (PaymentMethod, FallbackChain, …) +│ └── paymentMethod.ts — Re-exports + UI-layer types (ManagerTab, …) +│ +├── services/ +│ ├── paymentMethodService.ts — Core service: CRUD, chain logic, analytics, sharing +│ ├── PaymentMethodManager.ts — Circuit breaker + health scoring + rate limiting +│ └── FallbackChainEngine.ts — Pluggable strategy engine (6 built-in strategies) +│ +├── store/ +│ └── walletStore.ts — Zustand store: all payment-method state + actions +│ +└── components/ + └── payment/ + └── PaymentMethodManager.tsx — Full-screen UI: 4 tabs (Methods / Chains / Analytics / Alerts) +``` + +--- + +## Key Concepts + +### PaymentMethod + +A payment method represents one on-chain funding source a user has authorised. + +| Field | Type | Notes | +|-----------------------|-------------------|------------------------------------------------| +| `id` | `string` | Stable identifier (`pm_…`) | +| `userId` | `string` | Wallet address of the owner | +| `tokenType` | `TokenType` | `NATIVE`, `USDC`, `ETH`, `MATIC`, `ARB`, `XLM` | +| `tokenAddress` | `string` | ERC-20 contract address; `0x00…` for natives | +| `chainId` | `number` | EVM chain (1, 137, 42161, …) | +| `label` | `string` | Human-readable name | +| `priority` | `PaymentPriority` | `primary` → `backup` → `fallback` | +| `maxSpendPerInterval` | `string` | Per-cycle spend cap (wei / token units) | +| `isVerified` | `boolean` | Confirmed against the on-chain contract | +| `isActive` | `boolean` | `false` when expired or manually deactivated | +| `expiresAt` | `Date \| null` | Set for time-limited methods | + +### FallbackChain + +A named, ordered sequence of payment method ids. + +| Field | Type | Notes | +|---------------------|------------------|--------------------------------------------------------------------| +| `methodIds` | `string[]` | Tried in this order; max 5 entries (`MAX_CHAIN_LENGTH`) | +| `subscriptionId` | `string \| null` | Scoped to one subscription; `null` applies globally | +| `maxAttempts` | `number` | Ceiling on methods tried; `0` = try the whole chain | +| `stopOnHardDecline` | `boolean` | Halt on expired/deactivated method instead of falling through | + +--- + +## Service Layer + +### PaymentMethodService (`paymentMethodService.ts`) + +The core service. Instantiated as a singleton via `PaymentMethodService.getInstance()`. + +**Payment method management** +```ts +svc.generateId() // "pm_1717…_abc123" +svc.validatePaymentMethodForm(data) // → { isValid, errors, warnings, … } +svc.canAddMethod(currentCount) // → { canAdd, reason? } +svc.isDuplicateMethod(existing, …) // boolean +svc.sortByPriority(methods) // primary → backup → fallback, then LRU +svc.getActiveVerifiedMethods(methods) // filter + sort +svc.checkExpiry(method) // → { isExpired, isExpiringSoon, daysUntilExpiry } +svc.getExpiredMethods(methods) +svc.getExpiringSoonMethods(methods) // ≤ 30 days +svc.markPaymentMethodExpired(method) // returns updated record +svc.detectTokenContractUpgrade(method, previousHash) +``` + +**Chain management** +```ts +svc.validateChain(chain, methods) // → { isValid, errors, warnings } +svc.resolveChainMethods(chain, methods) // active+verified only, maxAttempts cap +svc.buildDefaultChain(methods, name?) // from priority ordering +svc.selectChainForSubscription(chains, id) // subscription-specific → global → null +svc.processPaymentWithChain(chain, …) // → ChainPaymentResult +svc.processPaymentWithFallback(methods, …) // legacy priority-order fallback +``` + +**Analytics** +```ts +svc.computeAnalytics(methods, attempts) // → PaymentMethodAnalytics +svc.buildExpiryAlerts(methods, chains) // → PaymentMethodExpiryAlert[] +``` + +**Sharing** +```ts +svc.createShare(method, granteeId, role, options) // → PaymentMethodShare +svc.isShareActive(share) // boolean +svc.canGranteeCharge(shares, methodId, granteeId, amount) +svc.getSharedMethods(methods, shares, granteeId) // methods visible to grantee +``` + +### PaymentMethodManager (`PaymentMethodManager.ts`) + +Wraps `PaymentMethodService` with production-grade resilience features. + +- **Circuit breaker** — after 3 consecutive failures, a method's circuit opens and it is skipped for 60 seconds before a probe is allowed (half-open). +- **Health scoring** — 0-100 score per method based on recent success rate + priority bonus - circuit penalty. +- **Rate limiting** — max 10 attempts per method per 60-second rolling window. +- **Auto-routing** — methods are ordered by health score (best first) before each charge. + +```ts +const mgr = new PaymentMethodManager(svc); + +const result = await mgr.charge(methods, attempts, subscriptionId, amount, chainId); +// result.skippedDueToCircuit — method ids skipped because their circuit was open +// result.skippedDueToRateLimit — method ids that hit the rate limit +// result.healthScores — { [methodId]: 0-100 } + +mgr.getCircuitState(methodId) // 'closed' | 'open' | 'half-open' +mgr.resetCircuit(methodId) +mgr.tripCircuit(methodId) +mgr.isBlocked(methodId) +mgr.getSnapshot() // full diagnostic snapshot +``` + +### FallbackChainEngine (`FallbackChainEngine.ts`) + +Strategy-based ordering engine. Six built-in strategies: + +| Strategy ID | Description | +|------------------|----------------------------------------------------------------| +| `priority` | Primary → Backup → Fallback, then LRU within tier (default) | +| `weighted` | Probabilistic selection weighted by historical success rate | +| `sticky` | Last-successful method for this subscription goes first | +| `priority-burst` | All primaries, then all backups, then all fallbacks | +| `geo-aware` | Same-chainId methods before cross-chain methods | +| `round-robin` | Distribute load evenly across primaries by LRU | + +```ts +const engine = new FallbackChainEngine(svc); + +// Execute a charge using the sticky strategy +const result = await engine.execute('sticky', methods, attempts, { + subscriptionId: 'sub_42', + amount: '50', + chainId: 1, + maxGasPriceGwei: 500, +}); + +// Preview the ordering without executing +const preview = engine.preview('geo-aware', methods, attempts, ctx); +// preview.orderedMethods, preview.rationale + +// Register a custom strategy +engine.registerStrategy(myCustomStrategy); +``` + +--- + +## Store (`walletStore.ts`) + +All payment-method state lives in `useWalletStore`. + +```ts +import { useWalletStore } from '../store/walletStore'; + +// Selectors +const methods = useWalletStore(s => s.paymentMethods); +const chains = useWalletStore(s => s.fallbackChains); +const isLoading = useWalletStore(s => s.isLoading); + +// Payment methods +const { addPaymentMethod, removePaymentMethod, updatePaymentMethod } = useWalletStore.getState(); +await addPaymentMethod({ tokenType, tokenAddress, chainId, label, priority, maxSpendPerInterval }); +await removePaymentMethod(id); +await updatePaymentMethod(id, { label: 'New name' }); +await verifyPaymentMethod(id); +await setPaymentMethodPriority(id, PaymentPriority.BACKUP); + +// Payment processing +const result = await processPayment(subscriptionId, amount, chainId, maxGasPriceGwei); +const chainResult = await processPaymentWithChain(subscriptionId, amount, chainId); + +// Fallback chains +const chain = createFallbackChain('My chain', [pm1Id, pm2Id], { subscriptionId: 'sub_1' }); +updateFallbackChain(chain.id, { name: 'Renamed' }); +reorderFallbackChain(chain.id, [pm2Id, pm1Id]); +deleteFallbackChain(chain.id); +const validation = validateFallbackChain(chain.id); + +// Expiry +const { expired, expiringSoon } = getExpiryInfo(); +const alerts = expiryAlerts(); // PaymentMethodExpiryAlert[] +const deactivated = deactivateExpiredMethods(); // returns count + +// Analytics +const analytics = paymentAnalytics(); // PaymentMethodAnalytics + +// Sharing +sharePaymentMethod(methodId, granteeId, 'charger', { spendLimit: '100' }); +revokePaymentMethodShare(shareId); +const myShares = sharesForMethod(methodId); +const sharedWithMe = methodsSharedWith(granteeId); + +// Upgrade detection +const upgraded = await checkTokenContractUpgrade(methodId); +``` + +Persisted fields (via AsyncStorage): `paymentMethods`, `paymentAttempts`, `fallbackChains`, `paymentMethodShares`. Connection and streams are ephemeral and are **not** persisted. + +--- + +## UI Component (`components/payment/PaymentMethodManager.tsx`) + +Drop-in full-screen manager with four tabs: + +| Tab | Content | +|--------------|--------------------------------------------------------------------| +| **Methods** | List with priority badges + quick-select; add / edit / remove / verify | +| **Chains** | Create ordered fallback chains; delete; view resolved order | +| **Analytics**| Overview stats + per-method success rates + failure reason counts | +| **Alerts** | Expiry warnings with severity (warning / critical / expired); bulk deactivate | + +```tsx +import { PaymentMethodManager } from '../components/payment/PaymentMethodManager'; + +// As a full screen: + navigation.goBack()} /> +``` + +All actions delegate to `useWalletStore`. The component has no business logic of its own. + +Accessibility: every interactive element carries `accessibilityRole`, `accessibilityLabel` and `accessibilityState` props. Alerts use `accessibilityRole="alert"`. + +--- + +## Fallback Chain Execution Flow + +``` +processPaymentWithChain(chain, methods, …) +│ +├── resolveChainMethods() — filter active + verified + unexpired, apply maxAttempts +│ +└── for each method in order: + ├── checkExpiry() → fail with hard-decline (halt if stopOnHardDecline) + ├── validateGasPrice() → fail if gas exceeds maxGasPriceGwei + ├── checkBalance() → fail if insufficient balance + ├── compare maxSpendPerInterval → fail if amount exceeds cap + └── SUCCESS → return { success, attempt, fallbackAttempts, succeededAtPosition } +``` + +If the whole chain is exhausted without success, `{ success: false, attempt: null, succeededAtPosition: -1 }` is returned (no exception thrown at this layer). + +--- + +## Tests + +```bash +# Run all payment-related tests +npm test -- --testPathPattern="paymentMethodService|walletStore|paymentFallbackChain" + +# With coverage +npm run test:coverage -- --testPathPattern="paymentMethod|walletStore|fallbackChain" +``` + +Test files: + +| File | Type | Coverage | +|------|------|---------| +| `src/services/__tests__/paymentMethodService.test.ts` | Unit | `PaymentMethodService` — all public methods | +| `src/store/__tests__/walletStore.test.ts` | Unit | `useWalletStore` — all payment-method actions | +| `src/services/__tests__/paymentFallbackChain.integration.test.ts` | Integration | Sequential fallback, full failure, stopOnHardDecline, maxAttempts, gas spike, sticky/geo-aware strategies, circuit breaker, default chain, validate→process round-trip, analytics | + +--- + +## Error Codes + +| Code | Meaning | +|------|---------| +| `PAYMENT_METHOD_DUPLICATE` | Same token + chain already registered | +| `PAYMENT_METHOD_INVALID_TOKEN` | Unsupported token type or bad address | +| `PAYMENT_METHOD_INVALID_CHAIN` | Chain ID not in supported list | +| `PAYMENT_METHOD_MAX_REACHED` | 10-method limit hit | +| `PAYMENT_METHOD_VERIFICATION_FAILED` | On-chain contract check failed | +| `PAYMENT_METHOD_EXPIRED` | Method past its `expiresAt` | +| `INSUFFICIENT_BALANCE` | Wallet lacks funds for the charge | +| `GAS_PRICE_SPIKE` | Current gas exceeds `maxGasPriceGwei` threshold | +| `TOKEN_CONTRACT_UPGRADED` | Contract bytecode changed since last check | +| `FALLBACK_FAILED` | All methods in the chain exhausted | + +All errors are instances of `PaymentMethodError` with `.code`, `.userMessage`, and `.recovery` fields. diff --git a/src/components/payment/PaymentMethodManager.tsx b/src/components/payment/PaymentMethodManager.tsx new file mode 100644 index 00000000..b2c6bcd2 --- /dev/null +++ b/src/components/payment/PaymentMethodManager.tsx @@ -0,0 +1,1568 @@ +/** + * PaymentMethodManager + * + * Full-screen UI for managing payment methods and their fallback chains. + * Tabs: + * methods – list / add / edit / remove payment methods + * chains – configure ordered fallback chains per subscription + * analytics– success rates, failure reasons and volume + * alerts – expiry warnings + */ + +import React, { useState, useCallback, useMemo } from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, + TextInput, + Alert, + ActivityIndicator, + FlatList, + SafeAreaView, +} from 'react-native'; +import { useWalletStore } from '../../store/walletStore'; +import { + PaymentMethod, + PaymentPriority, + TokenType, + FallbackChain, + PaymentMethodExpiryAlert, + PaymentMethodAnalytics, +} from '../../types/wallet'; +import type { ManagerTab, PaymentMethodFormState, PaymentMethodManagerProps } from '../../types/paymentMethod'; + +// ── Colour palette (matches existing app theme) ──────────────────────────── + +const COLORS = { + primary: '#6366F1', + primaryLight: '#818CF8', + success: '#22C55E', + warning: '#F59E0B', + danger: '#EF4444', + muted: '#94A3B8', + surface: '#1E293B', + surfaceLight: '#334155', + background: '#0F172A', + text: '#F1F5F9', + textSecondary: '#94A3B8', + border: '#334155', + white: '#FFFFFF', +} as const; + +const PRIORITY_COLOR: Record = { + [PaymentPriority.PRIMARY]: COLORS.success, + [PaymentPriority.BACKUP]: COLORS.warning, + [PaymentPriority.FALLBACK]: COLORS.muted, +}; + +const PRIORITY_LABEL: Record = { + [PaymentPriority.PRIMARY]: 'Primary', + [PaymentPriority.BACKUP]: 'Backup', + [PaymentPriority.FALLBACK]: 'Fallback', +}; + +// ── Helper components ────────────────────────────────────────────────────── + +interface PillProps { + label: string; + color?: string; + small?: boolean; +} +const Pill: React.FC = ({ label, color = COLORS.primary, small = false }) => ( + + {label} + +); + +interface SectionHeaderProps { + title: string; + action?: { label: string; onPress: () => void }; +} +const SectionHeader: React.FC = ({ title, action }) => ( + + {title} + {action && ( + + {action.label} + + )} + +); + +// ── Tab bar ──────────────────────────────────────────────────────────────── + +const TABS: { id: ManagerTab; label: string }[] = [ + { id: 'methods', label: 'Methods' }, + { id: 'chains', label: 'Chains' }, + { id: 'analytics', label: 'Analytics' }, + { id: 'alerts', label: 'Alerts' }, +]; + +interface TabBarProps { + active: ManagerTab; + onChange: (tab: ManagerTab) => void; + alertCount: number; +} +const TabBar: React.FC = ({ active, onChange, alertCount }) => ( + + {TABS.map((tab) => { + const isActive = tab.id === active; + const badge = tab.id === 'alerts' && alertCount > 0 ? alertCount : 0; + return ( + onChange(tab.id)} + accessibilityRole="tab" + accessibilityState={{ selected: isActive }} + accessibilityLabel={`${tab.label}${badge ? `, ${badge} alerts` : ''}`} + > + {tab.label} + {badge > 0 && ( + + {badge} + + )} + + ); + })} + +); + +// ── Payment method card ──────────────────────────────────────────────────── + +interface MethodCardProps { + method: PaymentMethod; + onEdit: (method: PaymentMethod) => void; + onRemove: (id: string) => void; + onVerify: (id: string) => void; + onSetPriority: (id: string, priority: PaymentPriority) => void; +} +const MethodCard: React.FC = ({ + method, + onEdit, + onRemove, + onVerify, + onSetPriority, +}) => { + const expiryText = useMemo(() => { + if (!method.expiresAt) return null; + const days = Math.ceil((method.expiresAt.getTime() - Date.now()) / 86_400_000); + if (days <= 0) return `Expired ${Math.abs(days)}d ago`; + if (days <= 7) return `Expires in ${days}d (critical)`; + if (days <= 30) return `Expires in ${days}d`; + return null; + }, [method.expiresAt]); + + const expiryColor = + expiryText?.includes('Expired') || expiryText?.includes('critical') + ? COLORS.danger + : COLORS.warning; + + return ( + + {/* Header row */} + + + {method.label} + {!method.isActive && } + {method.isVerified ? ( + + ) : ( + + )} + + + + + {/* Details */} + + + Token: + {method.tokenType} + + + Chain: + {method.chainId} + + {method.maxSpendPerInterval ? ( + + Max spend: + {method.maxSpendPerInterval} + + ) : null} + {method.lastUsedAt && ( + + Last used: + {method.lastUsedAt.toLocaleDateString()} + + )} + + + {expiryText && ( + + ⚠ {expiryText} + + )} + + {/* Priority quick-select */} + + Priority: + {Object.values(PaymentPriority).map((p) => ( + onSetPriority(method.id, p)} + accessibilityRole="radio" + accessibilityState={{ checked: method.priority === p }} + accessibilityLabel={`Set priority to ${PRIORITY_LABEL[p]}`} + > + + {PRIORITY_LABEL[p]} + + + ))} + + + {/* Actions */} + + onEdit(method)} + accessibilityRole="button" + accessibilityLabel={`Edit ${method.label}`} + > + Edit + + {!method.isVerified && ( + onVerify(method.id)} + accessibilityRole="button" + accessibilityLabel={`Verify ${method.label}`} + > + Verify + + )} + + Alert.alert('Remove Method', `Remove "${method.label}"?`, [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Remove', style: 'destructive', onPress: () => onRemove(method.id) }, + ]) + } + accessibilityRole="button" + accessibilityLabel={`Remove ${method.label}`} + > + Remove + + + + ); +}; + +// ── Add / Edit form ──────────────────────────────────────────────────────── + +interface MethodFormValues { + label: string; + tokenType: TokenType; + tokenAddress: string; + chainId: string; + priority: PaymentPriority; + maxSpendPerInterval: string; +} + +const EMPTY_FORM: MethodFormValues = { + label: '', + tokenType: TokenType.NATIVE, + tokenAddress: '0x0000000000000000000000000000000000000000', + chainId: '1', + priority: PaymentPriority.PRIMARY, + maxSpendPerInterval: '100', +}; + +interface MethodFormProps { + initial?: Partial; + onSubmit: (values: MethodFormValues) => Promise; + onCancel: () => void; + isLoading: boolean; +} +const MethodForm: React.FC = ({ + initial, + onSubmit, + onCancel, + isLoading, +}) => { + const [values, setValues] = useState({ ...EMPTY_FORM, ...initial }); + const set = (key: keyof MethodFormValues, value: string) => + setValues((prev) => ({ ...prev, [key]: value })); + + return ( + + {initial ? 'Edit Method' : 'Add Payment Method'} + + Label * + set('label', v)} + placeholder="e.g. My ETH wallet" + placeholderTextColor={COLORS.muted} + accessibilityLabel="Label" + /> + + Token Type * + + {Object.values(TokenType).map((t) => ( + set('tokenType', t)} + accessibilityRole="radio" + accessibilityState={{ checked: values.tokenType === t }} + accessibilityLabel={t} + > + + {t} + + + ))} + + + {values.tokenType !== TokenType.NATIVE && ( + <> + Token Address * + set('tokenAddress', v)} + placeholder="0x..." + placeholderTextColor={COLORS.muted} + autoCapitalize="none" + accessibilityLabel="Token address" + /> + + )} + + Chain ID * + set('chainId', v)} + placeholder="1" + placeholderTextColor={COLORS.muted} + keyboardType="number-pad" + accessibilityLabel="Chain ID" + /> + + Priority * + + {Object.values(PaymentPriority).map((p) => ( + set('priority', p)} + accessibilityRole="radio" + accessibilityState={{ checked: values.priority === p }} + accessibilityLabel={PRIORITY_LABEL[p]} + > + + {PRIORITY_LABEL[p]} + + + ))} + + + Max spend per interval * + set('maxSpendPerInterval', v)} + placeholder="100" + placeholderTextColor={COLORS.muted} + keyboardType="decimal-pad" + accessibilityLabel="Max spend per interval" + /> + + + + Cancel + + onSubmit(values)} + accessibilityRole="button" + accessibilityLabel="Save payment method" + disabled={isLoading} + > + {isLoading ? ( + + ) : ( + Save + )} + + + + ); +}; + +// ── Methods tab ──────────────────────────────────────────────────────────── + +interface MethodsTabProps { + methods: PaymentMethod[]; + isLoading: boolean; + onAdd: (values: MethodFormValues) => Promise; + onEdit: (id: string, updates: Partial) => Promise; + onRemove: (id: string) => void; + onVerify: (id: string) => void; + onSetPriority: (id: string, priority: PaymentPriority) => void; +} + +const MethodsTab: React.FC = ({ + methods, + isLoading, + onAdd, + onEdit, + onRemove, + onVerify, + onSetPriority, +}) => { + const [formState, setFormState] = useState({ editingId: null, isOpen: false }); + const [editingMethod, setEditingMethod] = useState(null); + + const openAdd = () => { + setEditingMethod(null); + setFormState({ editingId: null, isOpen: true }); + }; + + const openEdit = (method: PaymentMethod) => { + setEditingMethod(method); + setFormState({ editingId: method.id, isOpen: true }); + }; + + const closeForm = () => { + setFormState({ editingId: null, isOpen: false }); + setEditingMethod(null); + }; + + const handleSubmit = useCallback( + async (values: MethodFormValues) => { + if (formState.editingId) { + await onEdit(formState.editingId, { + label: values.label, + priority: values.priority, + maxSpendPerInterval: values.maxSpendPerInterval, + }); + } else { + await onAdd(values); + } + closeForm(); + }, + [formState.editingId, onAdd, onEdit] + ); + + const byPriority = useMemo(() => { + const order: Record = { + [PaymentPriority.PRIMARY]: 0, + [PaymentPriority.BACKUP]: 1, + [PaymentPriority.FALLBACK]: 2, + }; + return [...methods].sort((a, b) => order[a.priority] - order[b.priority]); + }, [methods]); + + if (formState.isOpen) { + const initial = editingMethod + ? { + label: editingMethod.label, + tokenType: editingMethod.tokenType, + tokenAddress: editingMethod.tokenAddress, + chainId: String(editingMethod.chainId), + priority: editingMethod.priority, + maxSpendPerInterval: editingMethod.maxSpendPerInterval, + } + : undefined; + return ( + + + + ); + } + + return ( + + + {methods.length === 0 ? ( + + No payment methods yet. + + Add your first method + + + ) : ( + m.id} + renderItem={({ item }) => ( + + )} + contentContainerStyle={styles.listContent} + scrollEnabled={false} + /> + )} + + ); +}; + +// ── Chains tab ───────────────────────────────────────────────────────────── + +interface ChainsTabProps { + chains: FallbackChain[]; + methods: PaymentMethod[]; + onCreateChain: (name: string, methodIds: string[]) => void; + onDeleteChain: (id: string) => void; + onReorderChain: (id: string, methodIds: string[]) => void; +} + +const ChainsTab: React.FC = ({ + chains, + methods, + onCreateChain, + onDeleteChain, + onReorderChain, +}) => { + const [showNewForm, setShowNewForm] = useState(false); + const [newName, setNewName] = useState(''); + const [selectedIds, setSelectedIds] = useState([]); + + const toggleMethod = (id: string) => + setSelectedIds((prev) => + prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id] + ); + + const handleCreate = () => { + if (!newName.trim()) { + Alert.alert('Validation', 'Chain name is required.'); + return; + } + if (selectedIds.length === 0) { + Alert.alert('Validation', 'Select at least one method.'); + return; + } + onCreateChain(newName.trim(), selectedIds); + setNewName(''); + setSelectedIds([]); + setShowNewForm(false); + }; + + const methodById = useMemo( + () => new Map(methods.map((m) => [m.id, m])), + [methods] + ); + + return ( + + setShowNewForm(true) } : undefined} + /> + + {showNewForm && ( + + New Fallback Chain + + Name * + + + Select methods (in fallback order) + {methods.length === 0 ? ( + Add payment methods first. + ) : ( + methods.map((m) => ( + toggleMethod(m.id)} + accessibilityRole="checkbox" + accessibilityState={{ checked: selectedIds.includes(m.id) }} + accessibilityLabel={m.label} + > + + + {m.label} + + {m.tokenType} · {PRIORITY_LABEL[m.priority]} + + + {selectedIds.includes(m.id) && ( + {selectedIds.indexOf(m.id) + 1} + )} + + )) + )} + + + { setShowNewForm(false); setNewName(''); setSelectedIds([]); }} + accessibilityRole="button" + accessibilityLabel="Cancel new chain" + > + Cancel + + + Create + + + + )} + + {chains.length === 0 && !showNewForm ? ( + + No fallback chains configured. + + Chains let you specify the exact order methods are tried for each charge. + + + ) : ( + chains.map((chain) => { + const resolvedMethods = chain.methodIds + .map((id) => methodById.get(id)) + .filter((m): m is PaymentMethod => m !== undefined); + + return ( + + + {chain.name} + {!chain.isActive && } + + {chain.subscriptionId && ( + + Subscription: + {chain.subscriptionId} + + )} + Order: + {resolvedMethods.map((m, idx) => ( + + {idx + 1} + + {m.label} + + {m.tokenType} · {PRIORITY_LABEL[m.priority]} + {!m.isActive ? ' · Inactive' : ''} + {!m.isVerified ? ' · Unverified' : ''} + + + + ))} + + + Alert.alert('Delete Chain', `Delete "${chain.name}"?`, [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: () => onDeleteChain(chain.id), + }, + ]) + } + accessibilityRole="button" + accessibilityLabel={`Delete chain ${chain.name}`} + > + Delete + + + + ); + }) + )} + + ); +}; + +// ── Analytics tab ────────────────────────────────────────────────────────── + +interface AnalyticsTabProps { + analytics: PaymentMethodAnalytics; +} +const AnalyticsTab: React.FC = ({ analytics }) => { + const pct = (n: number) => `${(n * 100).toFixed(1)}%`; + + return ( + + + + {/* Overview */} + + {[ + { label: 'Total attempts', value: String(analytics.totalAttempts) }, + { label: 'Success rate', value: pct(analytics.successRate) }, + { label: 'Fallback rate', value: pct(analytics.fallbackRate) }, + { label: 'Active methods', value: String(analytics.activeMethods) }, + ].map(({ label, value }) => ( + + {value} + {label} + + ))} + + + {/* Per-method breakdown */} + + {analytics.byMethod.length === 0 ? ( + No payment attempts recorded yet. + ) : ( + analytics.byMethod.map((entry) => ( + + + {entry.label} + = 0.8 ? COLORS.success : COLORS.danger } + ]}> + {pct(entry.successRate)} + + + + + {entry.successes} / {entry.attempts} attempts + + Vol: {entry.volume.toFixed(4)} + + {entry.topFailureReason && ( + + Top failure: {entry.topFailureReason} + + )} + + )) + )} + + {/* Failure reasons */} + {analytics.failureReasons.length > 0 && ( + <> + + {analytics.failureReasons.map(({ reason, count }) => ( + + {reason} + {count}× + + ))} + + )} + + ); +}; + +// ── Alerts tab ───────────────────────────────────────────────────────────── + +interface AlertsTabProps { + alerts: PaymentMethodExpiryAlert[]; + onDeactivateExpired: () => void; +} +const AlertsTab: React.FC = ({ alerts, onDeactivateExpired }) => { + const severityColor: Record = { + expired: COLORS.danger, + critical: COLORS.danger, + warning: COLORS.warning, + }; + + const hasExpired = alerts.some((a) => a.severity === 'expired'); + + return ( + + + + {hasExpired && ( + + Alert.alert( + 'Deactivate Expired Methods', + 'This will mark all expired methods as inactive.', + [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Deactivate', style: 'destructive', onPress: onDeactivateExpired }, + ] + ) + } + accessibilityRole="button" + accessibilityLabel="Deactivate all expired methods" + > + Deactivate all expired + + )} + + {alerts.length === 0 ? ( + + ✓ No expiry alerts. + All your payment methods are in good standing. + + ) : ( + alerts.map((alert) => ( + + + {alert.label} + + + + {alert.message} + + {alert.inActiveChain && ( + + ⚠ Still in an active fallback chain + + )} + + Expires: {alert.expiresAt.toLocaleDateString()} + + + )) + )} + + ); +}; + +// ── Root component ───────────────────────────────────────────────────────── + +export const PaymentMethodManager: React.FC = ({ + initialTab = 'methods', + onClose, +}) => { + const [activeTab, setActiveTab] = useState(initialTab); + + // Store selectors + const paymentMethods = useWalletStore((s) => s.paymentMethods); + const fallbackChains = useWalletStore((s) => s.fallbackChains); + const isLoading = useWalletStore((s) => s.isLoading); + const error = useWalletStore((s) => s.error); + + const addPaymentMethod = useWalletStore((s) => s.addPaymentMethod); + const removePaymentMethod = useWalletStore((s) => s.removePaymentMethod); + const updatePaymentMethod = useWalletStore((s) => s.updatePaymentMethod); + const verifyPaymentMethod = useWalletStore((s) => s.verifyPaymentMethod); + const setPaymentMethodPriority = useWalletStore((s) => s.setPaymentMethodPriority); + const createFallbackChain = useWalletStore((s) => s.createFallbackChain); + const deleteFallbackChain = useWalletStore((s) => s.deleteFallbackChain); + const reorderFallbackChain = useWalletStore((s) => s.reorderFallbackChain); + const expiryAlerts = useWalletStore((s) => s.expiryAlerts); + const deactivateExpiredMethods = useWalletStore((s) => s.deactivateExpiredMethods); + const paymentAnalytics = useWalletStore((s) => s.paymentAnalytics); + + // Derived + const alerts = useMemo(() => expiryAlerts(), [expiryAlerts, paymentMethods, fallbackChains]); + const analytics = useMemo( + () => paymentAnalytics(), + [paymentAnalytics, paymentMethods] + ); + + // Handlers + const handleAdd = useCallback( + async (values: MethodFormValues) => { + try { + await addPaymentMethod({ + tokenType: values.tokenType, + tokenAddress: values.tokenAddress, + chainId: Number(values.chainId), + label: values.label, + priority: values.priority, + maxSpendPerInterval: values.maxSpendPerInterval, + }); + } catch (e) { + Alert.alert('Error', e instanceof Error ? e.message : 'Failed to add method'); + } + }, + [addPaymentMethod] + ); + + const handleEdit = useCallback( + async (id: string, updates: Partial) => { + try { + await updatePaymentMethod(id, updates); + } catch (e) { + Alert.alert('Error', e instanceof Error ? e.message : 'Failed to update method'); + } + }, + [updatePaymentMethod] + ); + + const handleRemove = useCallback( + (id: string) => { + removePaymentMethod(id).catch((e: unknown) => + Alert.alert('Error', e instanceof Error ? e.message : 'Failed to remove method') + ); + }, + [removePaymentMethod] + ); + + const handleVerify = useCallback( + (id: string) => { + verifyPaymentMethod(id).catch((e: unknown) => + Alert.alert('Verification failed', e instanceof Error ? e.message : 'Could not verify') + ); + }, + [verifyPaymentMethod] + ); + + const handleSetPriority = useCallback( + (id: string, priority: PaymentPriority) => { + setPaymentMethodPriority(id, priority).catch((e: unknown) => + Alert.alert('Error', e instanceof Error ? e.message : 'Failed to set priority') + ); + }, + [setPaymentMethodPriority] + ); + + const handleCreateChain = useCallback( + (name: string, methodIds: string[]) => { + try { + createFallbackChain(name, methodIds); + } catch (e) { + Alert.alert('Error', e instanceof Error ? e.message : 'Failed to create chain'); + } + }, + [createFallbackChain] + ); + + const handleDeactivateExpired = useCallback(() => { + const count = deactivateExpiredMethods(); + Alert.alert('Done', `${count} expired method(s) deactivated.`); + }, [deactivateExpiredMethods]); + + return ( + + {/* Header */} + + Payment Methods + {onClose && ( + + + + )} + + + {/* Global error banner */} + {error && ( + + {error} + + )} + + {/* Loading overlay */} + {isLoading && ( + + + + )} + + {/* Tab bar */} + + + {/* Tab content */} + {activeTab === 'methods' && ( + + )} + {activeTab === 'chains' && ( + + )} + {activeTab === 'analytics' && } + {activeTab === 'alerts' && ( + + )} + + ); +}; + +export default PaymentMethodManager; + +// ── Styles ───────────────────────────────────────────────────────────────── + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: COLORS.background, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: COLORS.border, + }, + headerTitle: { + fontSize: 20, + fontWeight: '700', + color: COLORS.text, + }, + closeBtn: { + padding: 8, + }, + closeBtnText: { + fontSize: 18, + color: COLORS.muted, + }, + errorBanner: { + backgroundColor: COLORS.danger, + paddingHorizontal: 16, + paddingVertical: 8, + }, + errorText: { + color: COLORS.white, + fontSize: 13, + }, + loadingOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(0,0,0,0.4)', + zIndex: 10, + }, + // Tab bar + tabBar: { + flexDirection: 'row', + borderBottomWidth: 1, + borderBottomColor: COLORS.border, + backgroundColor: COLORS.surface, + }, + tab: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 12, + gap: 4, + }, + tabActive: { + borderBottomWidth: 2, + borderBottomColor: COLORS.primary, + }, + tabLabel: { + fontSize: 13, + color: COLORS.muted, + }, + tabLabelActive: { + color: COLORS.primary, + fontWeight: '600', + }, + badge: { + backgroundColor: COLORS.danger, + borderRadius: 8, + paddingHorizontal: 5, + paddingVertical: 1, + minWidth: 16, + alignItems: 'center', + }, + badgeText: { + color: COLORS.white, + fontSize: 10, + fontWeight: '700', + }, + // Generic layout + tabContent: { + flex: 1, + paddingHorizontal: 16, + }, + listContent: { + paddingBottom: 24, + }, + sectionHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginTop: 16, + marginBottom: 8, + }, + sectionTitle: { + fontSize: 14, + fontWeight: '600', + color: COLORS.textSecondary, + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + sectionAction: { + fontSize: 14, + color: COLORS.primary, + fontWeight: '600', + }, + // Cards + card: { + backgroundColor: COLORS.surface, + borderRadius: 12, + padding: 14, + marginBottom: 10, + borderWidth: 1, + borderColor: COLORS.border, + }, + cardInactive: { + opacity: 0.55, + }, + cardHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 8, + gap: 8, + }, + cardTitleRow: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + gap: 6, + flexWrap: 'wrap', + }, + cardTitle: { + fontSize: 15, + fontWeight: '600', + color: COLORS.text, + flexShrink: 1, + }, + cardDetails: { + gap: 2, + marginBottom: 6, + }, + cardDetail: { + fontSize: 12, + color: COLORS.textSecondary, + }, + cardDetailLabel: { + fontWeight: '600', + color: COLORS.muted, + }, + expiryText: { + fontSize: 12, + fontWeight: '500', + marginBottom: 6, + }, + cardActions: { + flexDirection: 'row', + gap: 8, + marginTop: 10, + }, + actionBtn: { + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 6, + borderWidth: 1, + borderColor: COLORS.border, + }, + actionBtnVerify: { + borderColor: COLORS.warning, + }, + actionBtnDanger: { + borderColor: COLORS.danger, + }, + actionBtnText: { + fontSize: 12, + color: COLORS.textSecondary, + fontWeight: '500', + }, + // Priority row + priorityRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + marginTop: 6, + flexWrap: 'wrap', + }, + priorityLabel: { + fontSize: 11, + color: COLORS.muted, + marginRight: 2, + }, + priorityBtn: { + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 4, + borderWidth: 1, + borderColor: COLORS.border, + }, + priorityBtnText: { + fontSize: 11, + color: COLORS.muted, + }, + priorityBtnTextActive: { + color: COLORS.white, + fontWeight: '600', + }, + // Pill + pill: { + borderWidth: 1, + borderRadius: 4, + paddingHorizontal: 6, + paddingVertical: 2, + }, + pillSmall: { + paddingHorizontal: 4, + paddingVertical: 1, + }, + pillText: { + fontSize: 11, + fontWeight: '600', + }, + pillTextSmall: { + fontSize: 10, + }, + // Forms + form: { + backgroundColor: COLORS.surface, + borderRadius: 12, + padding: 16, + marginTop: 12, + borderWidth: 1, + borderColor: COLORS.border, + }, + formTitle: { + fontSize: 16, + fontWeight: '700', + color: COLORS.text, + marginBottom: 12, + }, + fieldLabel: { + fontSize: 12, + fontWeight: '600', + color: COLORS.textSecondary, + marginBottom: 4, + marginTop: 10, + }, + input: { + backgroundColor: COLORS.surfaceLight, + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 9, + fontSize: 14, + color: COLORS.text, + borderWidth: 1, + borderColor: COLORS.border, + }, + segmented: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 6, + marginTop: 2, + }, + segment: { + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: 6, + borderWidth: 1, + borderColor: COLORS.border, + backgroundColor: COLORS.surfaceLight, + }, + segmentActive: { + backgroundColor: COLORS.primary, + borderColor: COLORS.primary, + }, + segmentText: { + fontSize: 12, + color: COLORS.muted, + }, + segmentTextActive: { + color: COLORS.white, + fontWeight: '600', + }, + formActions: { + flexDirection: 'row', + gap: 10, + marginTop: 16, + justifyContent: 'flex-end', + }, + formBtn: { + paddingHorizontal: 18, + paddingVertical: 10, + borderRadius: 8, + minWidth: 80, + alignItems: 'center', + }, + formBtnCancel: { + backgroundColor: COLORS.surfaceLight, + }, + formBtnSubmit: { + backgroundColor: COLORS.primary, + }, + formBtnDisabled: { + opacity: 0.5, + }, + formBtnText: { + fontSize: 14, + fontWeight: '600', + color: COLORS.textSecondary, + }, + // Empty state + emptyState: { + alignItems: 'center', + paddingVertical: 40, + gap: 10, + }, + emptyStateText: { + fontSize: 16, + color: COLORS.textSecondary, + fontWeight: '500', + }, + emptyStateBtn: { + backgroundColor: COLORS.primary, + paddingHorizontal: 20, + paddingVertical: 10, + borderRadius: 8, + marginTop: 4, + }, + emptyStateBtnText: { + color: COLORS.white, + fontWeight: '600', + fontSize: 14, + }, + textMuted: { + fontSize: 12, + color: COLORS.muted, + marginTop: 2, + }, + // Chains + checkRow: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 8, + paddingHorizontal: 6, + borderRadius: 6, + gap: 10, + marginVertical: 2, + borderWidth: 1, + borderColor: 'transparent', + }, + checkRowSelected: { + backgroundColor: COLORS.surfaceLight, + borderColor: COLORS.primary, + }, + checkbox: { + width: 18, + height: 18, + borderRadius: 4, + borderWidth: 2, + borderColor: COLORS.muted, + }, + checkboxChecked: { + backgroundColor: COLORS.primary, + borderColor: COLORS.primary, + }, + checkRowContent: { + flex: 1, + }, + checkRowLabel: { + fontSize: 14, + color: COLORS.text, + }, + checkRowSub: { + fontSize: 11, + color: COLORS.muted, + marginTop: 1, + }, + checkOrder: { + fontSize: 12, + fontWeight: '700', + color: COLORS.primary, + minWidth: 18, + textAlign: 'right', + }, + chainMethodRow: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: 10, + marginVertical: 3, + }, + chainPosition: { + width: 22, + height: 22, + borderRadius: 11, + backgroundColor: COLORS.primaryLight, + textAlign: 'center', + lineHeight: 22, + fontSize: 12, + fontWeight: '700', + color: COLORS.white, + overflow: 'hidden', + }, + chainMethodInfo: { + flex: 1, + }, + chainMethodLabel: { + fontSize: 13, + color: COLORS.text, + fontWeight: '500', + }, + chainMethodSub: { + fontSize: 11, + color: COLORS.muted, + marginTop: 1, + }, + // Analytics + statsRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + marginBottom: 12, + }, + statCard: { + flex: 1, + minWidth: '45%', + backgroundColor: COLORS.surface, + borderRadius: 10, + padding: 12, + alignItems: 'center', + borderWidth: 1, + borderColor: COLORS.border, + }, + statValue: { + fontSize: 22, + fontWeight: '700', + color: COLORS.text, + }, + statLabel: { + fontSize: 11, + color: COLORS.muted, + marginTop: 2, + textAlign: 'center', + }, + analyticsRow: { + flexDirection: 'row', + justifyContent: 'space-between', + marginTop: 2, + }, + failureRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + backgroundColor: COLORS.surface, + borderRadius: 8, + padding: 10, + marginBottom: 6, + borderWidth: 1, + borderColor: COLORS.border, + }, + failureReason: { + flex: 1, + fontSize: 12, + color: COLORS.textSecondary, + marginRight: 8, + }, + failureCount: { + fontSize: 12, + fontWeight: '700', + color: COLORS.danger, + }, + // Alerts + alertCard: { + borderLeftWidth: 3, + }, + deactivateBtn: { + backgroundColor: COLORS.danger, + borderRadius: 8, + paddingVertical: 10, + paddingHorizontal: 16, + alignItems: 'center', + marginBottom: 12, + }, + deactivateBtnText: { + color: COLORS.white, + fontWeight: '600', + fontSize: 14, + }, +}); diff --git a/src/services/FallbackChainEngine.ts b/src/services/FallbackChainEngine.ts new file mode 100644 index 00000000..9e9d7b56 --- /dev/null +++ b/src/services/FallbackChainEngine.ts @@ -0,0 +1,426 @@ +/** + * FallbackChainEngine + * + * An advanced strategy engine for executing payment method fallback chains. + * Extends the basic sequential chain in PaymentMethodService with: + * + * - WeightedStrategy — probabilistic selection weighted by method health + * - StickyStrategy — prefer the last-successful method per subscription + * - PriorityBurstStrategy — use primary methods in burst, then fall back + * - GeoAwareStrategy — prefer methods matching the payment's chain region + * - RoundRobinStrategy — distribute load evenly across primary methods + * + * The engine is strategy-agnostic: new strategies can be registered at runtime. + */ + +import { PaymentMethod, PaymentAttempt, FallbackChain, PaymentPriority, TokenType } from '../types/wallet'; +import { + PaymentMethodService, + PaymentMethodError, + PaymentMethodErrorCode, + ChainPaymentResult, +} from './paymentMethodService'; + +// --------------------------------------------------------------------------- +// Strategy interface +// --------------------------------------------------------------------------- + +export interface ChainStrategyContext { + subscriptionId: string; + amount: string; + chainId: number; + maxGasPriceGwei: number; + /** Historical attempts for this subscription (most recent first) */ + priorAttempts: PaymentAttempt[]; + /** All available methods (active + inactive) */ + allMethods: PaymentMethod[]; +} + +export interface ChainStrategyResult { + /** Ordered list of methods to try, built by the strategy */ + orderedMethods: PaymentMethod[]; + /** Human-readable description of why the strategy produced this order */ + rationale: string; + /** Strategy identifier */ + strategyId: string; +} + +export interface ChainStrategy { + readonly id: string; + readonly name: string; + /** + * Given available methods and execution context, returns the order in which + * they should be tried. + */ + order(methods: PaymentMethod[], ctx: ChainStrategyContext): ChainStrategyResult; +} + +// --------------------------------------------------------------------------- +// Built-in strategies +// --------------------------------------------------------------------------- + +/** Ordered by PaymentPriority → lastUsedAt, same as PaymentMethodService default. */ +export class PriorityStrategy implements ChainStrategy { + readonly id = 'priority'; + readonly name = 'Priority'; + + order(methods: PaymentMethod[], _ctx: ChainStrategyContext): ChainStrategyResult { + const PRIORITY_ORDER: Record = { + [PaymentPriority.PRIMARY]: 0, + [PaymentPriority.BACKUP]: 1, + [PaymentPriority.FALLBACK]: 2, + }; + + const ordered = [...methods].sort((a, b) => { + const priorityDiff = PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]; + if (priorityDiff !== 0) return priorityDiff; + const aTime = a.lastUsedAt?.getTime() ?? a.createdAt.getTime(); + const bTime = b.lastUsedAt?.getTime() ?? b.createdAt.getTime(); + return bTime - aTime; + }); + + return { + orderedMethods: ordered, + rationale: 'Sorted by priority tier then most-recently-used.', + strategyId: this.id, + }; + } +} + +/** + * Probabilistic selection weighted by success rate. + * Methods with higher success rates are sampled first. + */ +export class WeightedStrategy implements ChainStrategy { + readonly id = 'weighted'; + readonly name = 'Weighted'; + + order(methods: PaymentMethod[], ctx: ChainStrategyContext): ChainStrategyResult { + const stats = this._computeStats(methods, ctx.priorAttempts); + + // Assign weights: successRate * 100 (floor at 5 so every method gets a chance) + const weighted = methods.map((m) => ({ + method: m, + weight: Math.max(5, Math.round((stats.get(m.id)?.successRate ?? 1) * 100)), + })); + + const ordered: PaymentMethod[] = []; + const pool = [...weighted]; + + while (pool.length > 0) { + const totalWeight = pool.reduce((sum, entry) => sum + entry.weight, 0); + let rand = Math.random() * totalWeight; + for (let i = 0; i < pool.length; i++) { + rand -= pool[i].weight; + if (rand <= 0) { + ordered.push(pool[i].method); + pool.splice(i, 1); + break; + } + } + } + + return { + orderedMethods: ordered, + rationale: `Weighted random ordering by success rate (${methods.length} methods).`, + strategyId: this.id, + }; + } + + private _computeStats( + methods: PaymentMethod[], + attempts: PaymentAttempt[] + ): Map { + const map = new Map(); + for (const method of methods) { + const methodAttempts = attempts.filter((a) => a.paymentMethodId === method.id); + const successes = methodAttempts.filter((a) => a.status === 'success').length; + const rate = methodAttempts.length === 0 ? 1 : successes / methodAttempts.length; + map.set(method.id, { successRate: rate }); + } + return map; + } +} + +/** + * Prefer the method that succeeded most recently for this subscription. + * Falls back to priority ordering when no history exists. + */ +export class StickyStrategy implements ChainStrategy { + readonly id = 'sticky'; + readonly name = 'Sticky'; + + order(methods: PaymentMethod[], ctx: ChainStrategyContext): ChainStrategyResult { + const subAttempts = ctx.priorAttempts + .filter((a) => a.subscriptionId === ctx.subscriptionId && a.status === 'success') + .sort((a, b) => b.attemptedAt.getTime() - a.attemptedAt.getTime()); + + const stickyMethodId = subAttempts[0]?.paymentMethodId ?? null; + + if (!stickyMethodId) { + const fallback = new PriorityStrategy().order(methods, ctx); + return { + ...fallback, + rationale: 'No prior success for this subscription — using priority order.', + strategyId: this.id, + }; + } + + const stickyMethod = methods.find((m) => m.id === stickyMethodId); + const rest = methods.filter((m) => m.id !== stickyMethodId); + const priorityRest = new PriorityStrategy().order(rest, ctx).orderedMethods; + + const ordered = stickyMethod ? [stickyMethod, ...priorityRest] : priorityRest; + + return { + orderedMethods: ordered, + rationale: `Sticky: prefer method ${stickyMethodId} (last success for sub ${ctx.subscriptionId}).`, + strategyId: this.id, + }; + } +} + +/** + * Use all primary methods in parallel (burst), then fall back. + * In practice "burst" means: try all primaries before any backup. + */ +export class PriorityBurstStrategy implements ChainStrategy { + readonly id = 'priority-burst'; + readonly name = 'Priority Burst'; + + order(methods: PaymentMethod[], _ctx: ChainStrategyContext): ChainStrategyResult { + const primaries = methods.filter((m) => m.priority === PaymentPriority.PRIMARY); + const backups = methods.filter((m) => m.priority === PaymentPriority.BACKUP); + const fallbacks = methods.filter((m) => m.priority === PaymentPriority.FALLBACK); + + const byLastUsed = (a: PaymentMethod, b: PaymentMethod): number => { + const aTime = a.lastUsedAt?.getTime() ?? a.createdAt.getTime(); + const bTime = b.lastUsedAt?.getTime() ?? b.createdAt.getTime(); + return bTime - aTime; + }; + + const ordered = [ + ...primaries.sort(byLastUsed), + ...backups.sort(byLastUsed), + ...fallbacks.sort(byLastUsed), + ]; + + return { + orderedMethods: ordered, + rationale: `Burst: ${primaries.length} primaries, then ${backups.length} backups, then ${fallbacks.length} fallbacks.`, + strategyId: this.id, + }; + } +} + +/** + * Prefer methods matching the payment's target chain ID, then fall back to + * cross-chain methods. Useful when a subscriber has methods on multiple chains. + */ +export class GeoAwareStrategy implements ChainStrategy { + readonly id = 'geo-aware'; + readonly name = 'Geo-Aware'; + + order(methods: PaymentMethod[], ctx: ChainStrategyContext): ChainStrategyResult { + const onChain = methods.filter((m) => m.chainId === ctx.chainId); + const offChain = methods.filter((m) => m.chainId !== ctx.chainId); + + const priorityFn = new PriorityStrategy(); + const onChainOrdered = priorityFn.order(onChain, ctx).orderedMethods; + const offChainOrdered = priorityFn.order(offChain, ctx).orderedMethods; + + return { + orderedMethods: [...onChainOrdered, ...offChainOrdered], + rationale: `Geo-aware: ${onChain.length} methods on chain ${ctx.chainId}, then ${offChain.length} on other chains.`, + strategyId: this.id, + }; + } +} + +/** + * Distribute charges evenly across primary methods (round-robin by last-used). + * Prevents over-reliance on a single method when all are equally healthy. + */ +export class RoundRobinStrategy implements ChainStrategy { + readonly id = 'round-robin'; + readonly name = 'Round Robin'; + + order(methods: PaymentMethod[], _ctx: ChainStrategyContext): ChainStrategyResult { + const primaries = methods.filter((m) => m.priority === PaymentPriority.PRIMARY); + const nonPrimaries = methods.filter((m) => m.priority !== PaymentPriority.PRIMARY); + + // Sort primaries by last used ascending (least-recently-used first) + const rrPrimaries = [...primaries].sort((a, b) => { + const aTime = a.lastUsedAt?.getTime() ?? 0; + const bTime = b.lastUsedAt?.getTime() ?? 0; + return aTime - bTime; // ascending: LRU first + }); + + const fallbackPriority = new PriorityStrategy().order(nonPrimaries, {} as ChainStrategyContext).orderedMethods; + + return { + orderedMethods: [...rrPrimaries, ...fallbackPriority], + rationale: `Round-robin ${primaries.length} primary methods by LRU, then ${nonPrimaries.length} others.`, + strategyId: this.id, + }; + } +} + +// --------------------------------------------------------------------------- +// FallbackChainEngine +// --------------------------------------------------------------------------- + +export type StrategyId = 'priority' | 'weighted' | 'sticky' | 'priority-burst' | 'geo-aware' | 'round-robin' | string; + +export interface FallbackChainEngineOptions { + defaultStrategy?: StrategyId; +} + +export interface EngineChargeResult extends ChainPaymentResult { + strategyId: string; + strategyRationale: string; + orderedMethodIds: string[]; +} + +export class FallbackChainEngine { + private readonly _service: PaymentMethodService; + private readonly _strategies = new Map(); + private readonly _defaultStrategyId: StrategyId; + + constructor( + service?: PaymentMethodService, + options: FallbackChainEngineOptions = {} + ) { + this._service = service ?? PaymentMethodService.getInstance(); + this._defaultStrategyId = options.defaultStrategy ?? 'priority'; + + // Register built-ins + this.registerStrategy(new PriorityStrategy()); + this.registerStrategy(new WeightedStrategy()); + this.registerStrategy(new StickyStrategy()); + this.registerStrategy(new PriorityBurstStrategy()); + this.registerStrategy(new GeoAwareStrategy()); + this.registerStrategy(new RoundRobinStrategy()); + } + + /** Register a custom strategy (or override a built-in). */ + registerStrategy(strategy: ChainStrategy): void { + this._strategies.set(strategy.id, strategy); + } + + /** List all registered strategy IDs. */ + listStrategies(): string[] { + return [...this._strategies.keys()]; + } + + /** + * Execute a charge using the named strategy to order the methods. + * + * @param strategyId - Which ordering strategy to apply. Defaults to 'priority'. + * @param methods - Payment methods to consider (will be filtered to active+verified). + * @param attempts - Prior attempt history used by sticky/weighted strategies. + * @param chain - Optional explicit fallback chain; if provided, its methodIds + * are used as the candidate pool (still filtered active+verified). + */ + async execute( + strategyId: StrategyId = this._defaultStrategyId, + methods: PaymentMethod[], + attempts: PaymentAttempt[], + ctx: Omit & { chain?: FallbackChain } + ): Promise { + const strategy = this._strategies.get(strategyId); + if (!strategy) { + throw new PaymentMethodError( + PaymentMethodErrorCode.FALLBACK_FAILED, + `Unknown fallback strategy: "${strategyId}". Registered: ${this.listStrategies().join(', ')}.`, + 'Use a registered strategy ID.' + ); + } + + // Candidate pool: active+verified methods, optionally scoped by chain + let candidates = this._service.getActiveVerifiedMethods(methods); + if (ctx.chain) { + const inChain = new Set(ctx.chain.methodIds); + candidates = candidates.filter((m) => inChain.has(m.id)); + } + + if (candidates.length === 0) { + throw new PaymentMethodError( + PaymentMethodErrorCode.FALLBACK_FAILED, + 'No active, verified payment methods available.', + 'Add or verify a payment method to continue.' + ); + } + + const fullCtx: ChainStrategyContext = { + ...ctx, + priorAttempts: attempts, + allMethods: methods, + }; + + const strategyResult = strategy.order(candidates, fullCtx); + + // Build a synthetic chain from the strategy's ordering + const syntheticChain: FallbackChain = ctx.chain + ? { + ...ctx.chain, + methodIds: strategyResult.orderedMethods.map((m) => m.id), + } + : { + id: `engine_${Date.now()}`, + name: `${strategy.name} chain`, + methodIds: strategyResult.orderedMethods.map((m) => m.id), + subscriptionId: ctx.subscriptionId, + maxAttempts: 0, + stopOnHardDecline: false, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const chargeResult = await this._service.processPaymentWithChain( + syntheticChain, + strategyResult.orderedMethods, + ctx.subscriptionId, + ctx.amount, + ctx.chainId, + ctx.maxGasPriceGwei + ); + + return { + ...chargeResult, + strategyId: strategy.id, + strategyRationale: strategyResult.rationale, + orderedMethodIds: strategyResult.orderedMethods.map((m) => m.id), + }; + } + + /** + * Preview the method ordering a strategy would produce without actually + * executing a charge. + */ + preview( + strategyId: StrategyId, + methods: PaymentMethod[], + attempts: PaymentAttempt[], + ctx: Omit + ): ChainStrategyResult { + const strategy = this._strategies.get(strategyId); + if (!strategy) { + throw new Error(`Unknown strategy: ${strategyId}`); + } + + const candidates = this._service.getActiveVerifiedMethods(methods); + return strategy.order(candidates, { + ...ctx, + priorAttempts: attempts, + allMethods: methods, + }); + } +} + +// --------------------------------------------------------------------------- +// Singleton export +// --------------------------------------------------------------------------- + +export const fallbackChainEngine = new FallbackChainEngine(); diff --git a/src/services/PaymentMethodManager.ts b/src/services/PaymentMethodManager.ts new file mode 100644 index 00000000..b7737bc0 --- /dev/null +++ b/src/services/PaymentMethodManager.ts @@ -0,0 +1,419 @@ +/** + * PaymentMethodManager + * + * A higher-level orchestrator that wraps PaymentMethodService with: + * - Per-method circuit breaker (open after N consecutive failures) + * - Health score tracking (0-100) based on recent attempt history + * - Automatic failover routing that prefers historically reliable methods + * - Rate limiting per method (max charges per rolling window) + * - Global and per-method metrics snapshots + * + * Usage: + * const manager = PaymentMethodManager.getInstance(); + * const result = await manager.charge(methods, attempts, subscriptionId, amount, chainId); + */ + +import { + PaymentMethod, + PaymentAttempt, + FallbackChain, + PaymentPriority, +} from '../types/wallet'; +import { + PaymentMethodService, + PaymentMethodError, + PaymentMethodErrorCode, + ChainPaymentResult, +} from './paymentMethodService'; + +// --------------------------------------------------------------------------- +// Circuit breaker +// --------------------------------------------------------------------------- + +export type CircuitState = 'closed' | 'open' | 'half-open'; + +export interface CircuitBreakerState { + methodId: string; + state: CircuitState; + consecutiveFailures: number; + lastFailureAt: number | null; + openedAt: number | null; + /** When half-open, how many test requests have been allowed through */ + halfOpenAttempts: number; +} + +const CIRCUIT_OPEN_THRESHOLD = 3; // consecutive failures before opening +const CIRCUIT_RESET_MS = 60_000; // 1 min before switching to half-open +const HALF_OPEN_MAX_ATTEMPTS = 1; // probes allowed while half-open + +// --------------------------------------------------------------------------- +// Health scoring +// --------------------------------------------------------------------------- + +export interface MethodHealthScore { + methodId: string; + score: number; // 0-100; higher is better + successRate: number; // 0-1 + recentAttempts: number; + averageLatencyMs: number; + lastUpdated: number; +} + +/** Window of recent attempts considered for health scoring */ +const HEALTH_WINDOW_MS = 10 * 60 * 1000; // last 10 minutes +const MAX_SCORE = 100; + +// --------------------------------------------------------------------------- +// Rate limiting +// --------------------------------------------------------------------------- + +export interface RateLimitConfig { + maxAttemptsPerWindow: number; + windowMs: number; +} + +const DEFAULT_RATE_LIMIT: RateLimitConfig = { + maxAttemptsPerWindow: 10, + windowMs: 60_000, +}; + +// --------------------------------------------------------------------------- +// Manager result types +// --------------------------------------------------------------------------- + +export interface ManagedChargeResult extends ChainPaymentResult { + /** Health-ordered sequence of methods that were tried */ + triedMethodIds: string[]; + /** Methods skipped because their circuit was open */ + skippedDueToCircuit: string[]; + /** Methods skipped because they hit the rate limit */ + skippedDueToRateLimit: string[]; + /** Health scores at the time of the charge */ + healthScores: Record; +} + +export interface ManagerSnapshot { + circuits: CircuitBreakerState[]; + healthScores: MethodHealthScore[]; + rateLimitStates: Record; +} + +// --------------------------------------------------------------------------- +// PaymentMethodManager +// --------------------------------------------------------------------------- + +export class PaymentMethodManager { + private static _instance: PaymentMethodManager; + + private readonly _service: PaymentMethodService; + private readonly _circuits = new Map(); + private readonly _healthScores = new Map(); + private readonly _rateLimitWindows = new Map(); // methodId → timestamps + private readonly _rateLimitConfig: RateLimitConfig; + + constructor( + service?: PaymentMethodService, + rateLimitConfig: RateLimitConfig = DEFAULT_RATE_LIMIT + ) { + this._service = service ?? PaymentMethodService.getInstance(); + this._rateLimitConfig = rateLimitConfig; + } + + static getInstance(service?: PaymentMethodService): PaymentMethodManager { + if (!PaymentMethodManager._instance) { + PaymentMethodManager._instance = new PaymentMethodManager(service); + } + return PaymentMethodManager._instance; + } + + /** Reset the singleton (useful in tests). */ + static resetInstance(): void { + PaymentMethodManager._instance = undefined as unknown as PaymentMethodManager; + } + + // ── Public API ──────────────────────────────────────────────────────────── + + /** + * Charge a subscription through the best available payment method. + * + * The method order is determined by health score (desc), then PaymentPriority, + * then last-used recency. Methods whose circuits are open or that have hit + * their rate limit are skipped. + */ + async charge( + methods: PaymentMethod[], + attempts: PaymentAttempt[], + subscriptionId: string, + amount: string, + chainId: number, + maxGasPriceGwei = 500 + ): Promise { + // Refresh health scores from existing attempt history + this._updateHealthScores(methods, attempts); + + const skippedCircuit: string[] = []; + const skippedRate: string[] = []; + + // Sort active+verified methods by health score (best first) + const candidates = this._service + .getActiveVerifiedMethods(methods) + .sort((a, b) => this._rankMethod(b) - this._rankMethod(a)); + + // Filter out circuit-open and rate-limited methods + const eligible = candidates.filter((m) => { + if (!this._canAttempt(m.id)) { + const circuit = this._getCircuit(m.id); + if (circuit.state === 'open') { + skippedCircuit.push(m.id); + } else { + skippedRate.push(m.id); + } + return false; + } + return true; + }); + + if (eligible.length === 0) { + throw new PaymentMethodError( + PaymentMethodErrorCode.FALLBACK_FAILED, + 'All payment methods are currently unavailable (circuit open or rate limited).', + 'Wait a moment and try again, or add a new payment method.' + ); + } + + // Build a synthetic chain from the eligible methods + const syntheticChain: FallbackChain = { + id: `managed_${Date.now()}`, + name: 'Managed charge', + methodIds: eligible.map((m) => m.id), + subscriptionId, + maxAttempts: 0, + stopOnHardDecline: false, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const result = await this._service.processPaymentWithChain( + syntheticChain, + eligible, + subscriptionId, + amount, + chainId, + maxGasPriceGwei + ); + + // Record timestamps for rate limiting and update circuit states + const now = Date.now(); + for (const attempt of [...result.fallbackAttempts, ...(result.attempt ? [result.attempt] : [])]) { + this._recordRateLimitTimestamp(attempt.paymentMethodId, now); + if (attempt.status === 'failed') { + this._recordFailure(attempt.paymentMethodId); + } else if (attempt.status === 'success') { + this._recordSuccess(attempt.paymentMethodId); + } + } + + const healthScores: Record = {}; + for (const m of methods) { + healthScores[m.id] = this._healthScores.get(m.id)?.score ?? MAX_SCORE; + } + + return { + ...result, + triedMethodIds: eligible.map((m) => m.id), + skippedDueToCircuit: skippedCircuit, + skippedDueToRateLimit: skippedRate, + healthScores, + }; + } + + /** + * Returns the health score for a single method (0-100). + * A method with no history returns 100 (assumed healthy). + */ + getHealthScore(methodId: string): number { + return this._healthScores.get(methodId)?.score ?? MAX_SCORE; + } + + /** Returns the circuit state for a method. */ + getCircuitState(methodId: string): CircuitState { + return this._getCircuit(methodId).state; + } + + /** Manually reset a method's circuit to closed. */ + resetCircuit(methodId: string): void { + this._circuits.set(methodId, this._freshCircuit(methodId)); + } + + /** Manually force a method's circuit open (e.g. after manual intervention). */ + tripCircuit(methodId: string): void { + const circuit = this._getCircuit(methodId); + circuit.state = 'open'; + circuit.openedAt = Date.now(); + this._circuits.set(methodId, circuit); + } + + /** Check whether a method is currently blocked (circuit open or rate limited). */ + isBlocked(methodId: string): boolean { + return !this._canAttempt(methodId); + } + + /** Returns a full manager snapshot (for debugging / monitoring dashboards). */ + getSnapshot(): ManagerSnapshot { + const now = Date.now(); + const rateLimitStates: Record = {}; + for (const [id, timestamps] of this._rateLimitWindows) { + const windowStart = now - this._rateLimitConfig.windowMs; + const recent = timestamps.filter((t) => t > windowStart); + rateLimitStates[id] = { + used: recent.length, + windowEndsAt: recent.length > 0 ? (recent[0] + this._rateLimitConfig.windowMs) : now, + }; + } + + return { + circuits: [...this._circuits.values()], + healthScores: [...this._healthScores.values()], + rateLimitStates, + }; + } + + /** + * Refresh all health scores from an external attempts array. + * Call this after loading persisted state. + */ + refreshHealthScores(methods: PaymentMethod[], attempts: PaymentAttempt[]): void { + this._updateHealthScores(methods, attempts); + } + + // ── Private helpers ─────────────────────────────────────────────────────── + + private _getCircuit(methodId: string): CircuitBreakerState { + if (!this._circuits.has(methodId)) { + this._circuits.set(methodId, this._freshCircuit(methodId)); + } + return this._circuits.get(methodId)!; + } + + private _freshCircuit(methodId: string): CircuitBreakerState { + return { + methodId, + state: 'closed', + consecutiveFailures: 0, + lastFailureAt: null, + openedAt: null, + halfOpenAttempts: 0, + }; + } + + private _canAttempt(methodId: string): boolean { + const circuit = this._getCircuit(methodId); + const now = Date.now(); + + // Check circuit + if (circuit.state === 'open') { + if (circuit.openedAt !== null && now - circuit.openedAt >= CIRCUIT_RESET_MS) { + // Transition to half-open for a probe + circuit.state = 'half-open'; + circuit.halfOpenAttempts = 0; + this._circuits.set(methodId, circuit); + } else { + return false; + } + } + if (circuit.state === 'half-open' && circuit.halfOpenAttempts >= HALF_OPEN_MAX_ATTEMPTS) { + return false; + } + if (circuit.state === 'half-open') { + circuit.halfOpenAttempts += 1; + this._circuits.set(methodId, circuit); + } + + // Check rate limit + const timestamps = this._rateLimitWindows.get(methodId) ?? []; + const windowStart = now - this._rateLimitConfig.windowMs; + const recent = timestamps.filter((t) => t > windowStart); + if (recent.length >= this._rateLimitConfig.maxAttemptsPerWindow) { + return false; + } + + return true; + } + + private _recordFailure(methodId: string): void { + const circuit = this._getCircuit(methodId); + circuit.consecutiveFailures += 1; + circuit.lastFailureAt = Date.now(); + + if (circuit.state === 'half-open') { + // Failed probe — reopen + circuit.state = 'open'; + circuit.openedAt = Date.now(); + } else if (circuit.consecutiveFailures >= CIRCUIT_OPEN_THRESHOLD) { + circuit.state = 'open'; + circuit.openedAt = Date.now(); + } + this._circuits.set(methodId, circuit); + } + + private _recordSuccess(methodId: string): void { + const circuit = this._getCircuit(methodId); + circuit.consecutiveFailures = 0; + if (circuit.state === 'half-open') { + // Successful probe — close circuit + circuit.state = 'closed'; + circuit.openedAt = null; + circuit.halfOpenAttempts = 0; + } + this._circuits.set(methodId, circuit); + } + + private _recordRateLimitTimestamp(methodId: string, now: number): void { + const timestamps = this._rateLimitWindows.get(methodId) ?? []; + // Prune old entries + const windowStart = now - this._rateLimitConfig.windowMs; + const pruned = timestamps.filter((t) => t > windowStart); + pruned.push(now); + this._rateLimitWindows.set(methodId, pruned); + } + + private _updateHealthScores(methods: PaymentMethod[], attempts: PaymentAttempt[]): void { + const now = Date.now(); + const windowStart = now - HEALTH_WINDOW_MS; + + const recentAttempts = attempts.filter((a) => a.attemptedAt.getTime() > windowStart); + + for (const method of methods) { + const methodAttempts = recentAttempts.filter((a) => a.paymentMethodId === method.id); + const successes = methodAttempts.filter((a) => a.status === 'success').length; + const total = methodAttempts.length; + const successRate = total === 0 ? 1 : successes / total; + + // Penalise open circuits heavily + const circuitPenalty = this._getCircuit(method.id).state === 'open' ? 50 : 0; + + // Score: 60% success rate + 40% priority weighting - circuit penalty + const priorityBonus = + method.priority === PaymentPriority.PRIMARY ? 20 + : method.priority === PaymentPriority.BACKUP ? 10 + : 0; + + const raw = Math.round(successRate * 60 + priorityBonus - circuitPenalty); + const score = Math.max(0, Math.min(MAX_SCORE, raw)); + + this._healthScores.set(method.id, { + methodId: method.id, + score, + successRate, + recentAttempts: total, + averageLatencyMs: 0, // latency not tracked at this layer + lastUpdated: now, + }); + } + } + + private _rankMethod(method: PaymentMethod): number { + return this._healthScores.get(method.id)?.score ?? MAX_SCORE; + } +} diff --git a/src/services/__tests__/paymentFallbackChain.integration.test.ts b/src/services/__tests__/paymentFallbackChain.integration.test.ts new file mode 100644 index 00000000..2faab6a1 --- /dev/null +++ b/src/services/__tests__/paymentFallbackChain.integration.test.ts @@ -0,0 +1,425 @@ +/** + * Integration tests — fallback chain critical paths + * + * Tests here exercise PaymentMethodService + FallbackChainEngine end-to-end, + * with only the network layer (ethers providers) mocked. No store, no React. + * + * Critical paths covered: + * 1. Sequential fallback — first method fails, second succeeds + * 2. Full chain failure — every method exhausted + * 3. stopOnHardDecline — chain halts on an expired method + * 4. maxAttempts cap — only N entries tried even if more exist + * 5. Gas price spike blocks entire chain + * 6. Strategy engine — sticky strategy prefers last-success method + * 7. Strategy engine — geo-aware prefers matching chainId + * 8. PaymentMethodManager — circuit breaker opens after threshold + * 9. Default chain is used when no explicit chain configured + */ + +import { PaymentMethodService, PaymentMethodErrorCode } from '../../services/paymentMethodService'; +import { FallbackChainEngine } from '../../services/FallbackChainEngine'; +import { PaymentMethodManager } from '../../services/PaymentMethodManager'; +import { + PaymentMethod, + PaymentPriority, + TokenType, + FallbackChain, + PaymentAttempt, +} from '../../types/wallet'; + +// ── Mock ethers ──────────────────────────────────────────────────────────── + +jest.mock('ethers', () => { + const actual = jest.requireActual('ethers') as Record; + return { + ...actual, + providers: { + JsonRpcProvider: jest.fn().mockImplementation(() => ({ + getBalance: jest.fn().mockResolvedValue({ gte: jest.fn().mockReturnValue(true) }), + getGasPrice: jest.fn().mockResolvedValue({ toString: () => '20000000000' }), + getCode: jest.fn().mockResolvedValue('0x1234'), + })), + }, + utils: { + ...(actual.utils as Record), + isAddress: jest.fn().mockReturnValue(true), + formatUnits: jest.fn().mockImplementation((_v: unknown, unit: string) => + unit === 'gwei' ? '20.0' : '1.0' + ), + parseUnits: jest.fn().mockReturnValue({ + gte: jest.fn().mockReturnValue(true), + }), + keccak256: jest.fn().mockReturnValue('0xhash'), + }, + BigNumber: { + from: jest.fn().mockImplementation(() => ({ + gt: jest.fn().mockReturnValue(false), + lte: jest.fn().mockReturnValue(true), + gte: jest.fn().mockReturnValue(true), + })), + }, + Contract: jest.fn().mockImplementation(() => ({ + decimals: jest.fn().mockResolvedValue(18), + symbol: jest.fn().mockResolvedValue('ETH'), + balanceOf: jest.fn().mockResolvedValue({ gte: jest.fn().mockReturnValue(true) }), + })), + }; +}); + +jest.mock('../../config/evm', () => ({ + getEvmRpcUrl: jest.fn().mockReturnValue('https://rpc.example.com'), +})); + +// ── Helpers ──────────────────────────────────────────────────────────────── + +const NOW = new Date('2026-01-01T00:00:00Z'); + +function makeMethod(overrides: Partial = {}): PaymentMethod { + return { + id: `pm_${Math.random().toString(36).slice(2, 9)}`, + userId: '0xOwner', + tokenType: TokenType.NATIVE, + tokenAddress: '0x0000000000000000000000000000000000000000', + chainId: 1, + label: 'Method', + priority: PaymentPriority.PRIMARY, + maxSpendPerInterval: '10000', + isVerified: true, + isActive: true, + expiresAt: null, + lastUsedAt: null, + createdAt: NOW, + updatedAt: NOW, + metadata: {}, + ...overrides, + }; +} + +function makeChain( + methodIds: string[], + overrides: Partial = {} +): FallbackChain { + return { + id: `chain_${Math.random().toString(36).slice(2, 9)}`, + name: 'Test chain', + methodIds, + subscriptionId: null, + maxAttempts: 0, + stopOnHardDecline: false, + isActive: true, + createdAt: NOW, + updatedAt: NOW, + ...overrides, + }; +} + +function freshService(): PaymentMethodService { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (PaymentMethodService as any).instance = undefined; + const svc = PaymentMethodService.getInstance(); + svc.setWalletManager({ + getConnection: () => ({ address: '0xOwner', chainId: 1, isConnected: true }), + }); + return svc; +} + +// ── 1. Sequential fallback ───────────────────────────────────────────────── + +describe('Integration: sequential fallback', () => { + it('succeeds on the second method when the first has insufficient balance', async () => { + const svc = freshService(); + const m1 = makeMethod({ id: 'pm_m1', label: 'Primary (empty)' }); + const m2 = makeMethod({ id: 'pm_m2', label: 'Backup (funded)', priority: PaymentPriority.BACKUP }); + const chain = makeChain([m1.id, m2.id]); + + jest.spyOn(svc, 'validateGasPrice').mockResolvedValue({ acceptable: true, currentGasPrice: '20' }); + jest.spyOn(svc, 'checkBalance') + .mockResolvedValueOnce({ sufficient: false, balance: '0', symbol: 'ETH' }) + .mockResolvedValueOnce({ sufficient: true, balance: '500', symbol: 'ETH' }); + + const result = await svc.processPaymentWithChain(chain, [m1, m2], 'sub_1', '10', 1); + + expect(result.success).toBe(true); + expect(result.attempt?.paymentMethodId).toBe('pm_m2'); + expect(result.succeededAtPosition).toBe(1); + expect(result.fallbackAttempts).toHaveLength(1); + expect(result.fallbackAttempts[0].paymentMethodId).toBe('pm_m1'); + expect(result.fallbackAttempts[0].status).toBe('failed'); + }); +}); + +// ── 2. Full chain failure ────────────────────────────────────────────────── + +describe('Integration: full chain failure', () => { + it('returns success=false with all attempts when every method fails', async () => { + const svc = freshService(); + const methods = [ + makeMethod({ id: 'pm_f1' }), + makeMethod({ id: 'pm_f2', priority: PaymentPriority.BACKUP }), + makeMethod({ id: 'pm_f3', priority: PaymentPriority.FALLBACK }), + ]; + const chain = makeChain(methods.map((m) => m.id)); + + jest.spyOn(svc, 'validateGasPrice').mockResolvedValue({ acceptable: true, currentGasPrice: '20' }); + jest.spyOn(svc, 'checkBalance').mockResolvedValue({ sufficient: false, balance: '0', symbol: 'ETH' }); + + const result = await svc.processPaymentWithChain(chain, methods, 'sub_2', '10', 1); + + expect(result.success).toBe(false); + expect(result.attempt).toBeNull(); + expect(result.succeededAtPosition).toBe(-1); + expect(result.fallbackAttempts).toHaveLength(3); + expect(result.fallbackAttempts.every((a) => a.status === 'failed')).toBe(true); + }); +}); + +// ── 3. stopOnHardDecline ─────────────────────────────────────────────────── + +describe('Integration: stopOnHardDecline', () => { + it('halts the chain immediately when the first method is expired', async () => { + const svc = freshService(); + const expired = makeMethod({ id: 'pm_exp', expiresAt: new Date(0) }); + const backup = makeMethod({ id: 'pm_bk', priority: PaymentPriority.BACKUP }); + const chain = makeChain([expired.id, backup.id], { stopOnHardDecline: true }); + + // Inject expired into resolved list so the expiry check is reached + jest.spyOn(svc, 'resolveChainMethods').mockReturnValue([expired, backup]); + jest.spyOn(svc, 'validateGasPrice').mockResolvedValue({ acceptable: true, currentGasPrice: '20' }); + + const result = await svc.processPaymentWithChain(chain, [expired, backup], 'sub_3', '10', 1); + + expect(result.haltedOnHardDecline).toBe(true); + expect(result.success).toBe(false); + // Only the expired method was attempted before halting + expect(result.fallbackAttempts).toHaveLength(1); + expect(result.fallbackAttempts[0].paymentMethodId).toBe('pm_exp'); + }); +}); + +// ── 4. maxAttempts cap ───────────────────────────────────────────────────── + +describe('Integration: maxAttempts cap', () => { + it('tries at most maxAttempts methods even if more exist', async () => { + const svc = freshService(); + const methods = Array.from({ length: 5 }, (_, i) => + makeMethod({ id: `pm_cap_${i}`, priority: PaymentPriority.PRIMARY }) + ); + const chain = makeChain(methods.map((m) => m.id), { maxAttempts: 2 }); + + jest.spyOn(svc, 'validateGasPrice').mockResolvedValue({ acceptable: true, currentGasPrice: '20' }); + jest.spyOn(svc, 'checkBalance').mockResolvedValue({ sufficient: false, balance: '0', symbol: 'ETH' }); + + const result = await svc.processPaymentWithChain(chain, methods, 'sub_4', '10', 1); + + expect(result.success).toBe(false); + // Only 2 methods should have been tried + expect(result.fallbackAttempts).toHaveLength(2); + }); +}); + +// ── 5. Gas price spike ───────────────────────────────────────────────────── + +describe('Integration: gas price spike', () => { + it('rejects every method and reports gas reason in each attempt', async () => { + const svc = freshService(); + const m1 = makeMethod({ id: 'pm_gas1' }); + const m2 = makeMethod({ id: 'pm_gas2', priority: PaymentPriority.BACKUP }); + const chain = makeChain([m1.id, m2.id]); + + jest.spyOn(svc, 'validateGasPrice').mockResolvedValue({ acceptable: false, currentGasPrice: '999' }); + + const result = await svc.processPaymentWithChain(chain, [m1, m2], 'sub_5', '10', 1); + + expect(result.success).toBe(false); + result.fallbackAttempts.forEach((a) => { + expect(a.failureReason).toMatch(/Gas price/i); + }); + }); +}); + +// ── 6. Sticky strategy ───────────────────────────────────────────────────── + +describe('Integration: sticky strategy', () => { + it('places last-successful method first for the same subscription', async () => { + const svc = freshService(); + const engine = new FallbackChainEngine(svc); + + const m1 = makeMethod({ id: 'pm_s1', label: 'Method 1' }); + const m2 = makeMethod({ id: 'pm_s2', label: 'Method 2', priority: PaymentPriority.BACKUP }); + + const priorAttempts: PaymentAttempt[] = [ + { + id: 'att_prev', + paymentMethodId: m2.id, + subscriptionId: 'sub_sticky', + amount: '10', + tokenType: TokenType.NATIVE, + status: 'success', + attemptedAt: new Date('2025-12-01'), + resolvedAt: new Date('2025-12-01'), + }, + ]; + + const preview = engine.preview('sticky', [m1, m2], priorAttempts, { + subscriptionId: 'sub_sticky', + amount: '10', + chainId: 1, + maxGasPriceGwei: 500, + }); + + // m2 was the last success for this subscription — it should be first + expect(preview.orderedMethods[0].id).toBe(m2.id); + expect(preview.strategyId).toBe('sticky'); + }); +}); + +// ── 7. Geo-aware strategy ────────────────────────────────────────────────── + +describe('Integration: geo-aware strategy', () => { + it('puts same-chain methods before cross-chain methods', () => { + const svc = freshService(); + const engine = new FallbackChainEngine(svc); + + const onChain = makeMethod({ id: 'pm_on', chainId: 137 }); + const offChain = makeMethod({ id: 'pm_off', chainId: 1 }); + + const preview = engine.preview('geo-aware', [onChain, offChain], [], { + subscriptionId: 'sub_geo', + amount: '10', + chainId: 137, + maxGasPriceGwei: 500, + }); + + expect(preview.orderedMethods[0].id).toBe('pm_on'); + expect(preview.orderedMethods[1].id).toBe('pm_off'); + }); +}); + +// ── 8. Circuit breaker ───────────────────────────────────────────────────── + +describe('Integration: circuit breaker', () => { + it('opens circuit after CIRCUIT_OPEN_THRESHOLD consecutive failures', async () => { + // Reset manager singleton for a clean state + PaymentMethodManager.resetInstance(); + const svc = freshService(); + const manager = new PaymentMethodManager(svc); + + const m = makeMethod({ id: 'pm_cb' }); + + jest.spyOn(svc, 'validateGasPrice').mockResolvedValue({ acceptable: true, currentGasPrice: '20' }); + jest.spyOn(svc, 'checkBalance').mockResolvedValue({ sufficient: false, balance: '0', symbol: 'ETH' }); + + // Run 3 consecutive failing charges (threshold is 3) + for (let i = 0; i < 3; i++) { + try { + await manager.charge([m], [], 'sub_cb', '10', 1); + } catch { + // expected failures — ignore + } + } + + expect(manager.getCircuitState(m.id)).toBe('open'); + expect(manager.isBlocked(m.id)).toBe(true); + }); + + it('resets circuit after manual reset', async () => { + PaymentMethodManager.resetInstance(); + const svc = freshService(); + const manager = new PaymentMethodManager(svc); + + const m = makeMethod({ id: 'pm_reset' }); + manager.tripCircuit(m.id); + expect(manager.getCircuitState(m.id)).toBe('open'); + + manager.resetCircuit(m.id); + expect(manager.getCircuitState(m.id)).toBe('closed'); + expect(manager.isBlocked(m.id)).toBe(false); + }); +}); + +// ── 9. Default chain ─────────────────────────────────────────────────────── + +describe('Integration: default chain generation', () => { + it('builds a default chain from active verified methods', () => { + const svc = freshService(); + const methods = [ + makeMethod({ priority: PaymentPriority.PRIMARY }), + makeMethod({ priority: PaymentPriority.BACKUP }), + makeMethod({ priority: PaymentPriority.FALLBACK }), + ]; + + const chain = svc.buildDefaultChain(methods); + + expect(chain.subscriptionId).toBeNull(); + expect(chain.methodIds.length).toBeGreaterThan(0); + expect(chain.methodIds.length).toBeLessThanOrEqual(5); // MAX_CHAIN_LENGTH + }); + + it('stores null subscriptionId so the chain applies globally', () => { + const svc = freshService(); + const chain = svc.buildDefaultChain([makeMethod()]); + expect(chain.subscriptionId).toBeNull(); + }); +}); + +// ── 10. Round-trip: validate → process ──────────────────────────────────── + +describe('Integration: validate then process', () => { + it('processes successfully after validation passes', async () => { + const svc = freshService(); + const m1 = makeMethod({ id: 'pm_vp1' }); + const m2 = makeMethod({ id: 'pm_vp2', priority: PaymentPriority.BACKUP }); + const chain = makeChain([m1.id, m2.id]); + + const validation = svc.validateChain(chain, [m1, m2]); + expect(validation.isValid).toBe(true); + + jest.spyOn(svc, 'validateGasPrice').mockResolvedValue({ acceptable: true, currentGasPrice: '20' }); + jest.spyOn(svc, 'checkBalance').mockResolvedValue({ sufficient: true, balance: '1000', symbol: 'ETH' }); + + const result = await svc.processPaymentWithChain(chain, [m1, m2], 'sub_rt', '5', 1); + expect(result.success).toBe(true); + }); +}); + +// ── 11. Analytics after a mixed run ─────────────────────────────────────── + +describe('Integration: analytics after mixed attempts', () => { + it('correctly computes success rate and identifies fallback usage', () => { + const svc = freshService(); + const m1 = makeMethod({ id: 'pm_a1', label: 'Primary' }); + const m2 = makeMethod({ id: 'pm_a2', label: 'Backup', priority: PaymentPriority.BACKUP }); + + const attempts: PaymentAttempt[] = [ + // Sub 1: m1 failed, m2 succeeded (fallback) + { + id: 'att_1a', paymentMethodId: m1.id, subscriptionId: 'sub_a1', + amount: '10', tokenType: TokenType.NATIVE, status: 'failed', + failureReason: 'Insufficient balance', + attemptedAt: new Date('2026-01-01T01:00:00Z'), resolvedAt: new Date(), + }, + { + id: 'att_1b', paymentMethodId: m2.id, subscriptionId: 'sub_a1', + amount: '10', tokenType: TokenType.NATIVE, status: 'success', + attemptedAt: new Date('2026-01-01T01:00:01Z'), resolvedAt: new Date(), + }, + // Sub 2: m1 succeeded directly + { + id: 'att_2a', paymentMethodId: m1.id, subscriptionId: 'sub_a2', + amount: '10', tokenType: TokenType.NATIVE, status: 'success', + attemptedAt: new Date('2026-01-01T02:00:00Z'), resolvedAt: new Date(), + }, + ]; + + const analytics = svc.computeAnalytics([m1, m2], attempts); + + expect(analytics.totalAttempts).toBe(3); + expect(analytics.totalSuccesses).toBe(2); + expect(analytics.totalFailures).toBe(1); + expect(analytics.successRate).toBeCloseTo(2 / 3, 2); + // 1 out of 2 successes used a fallback + expect(analytics.fallbackRate).toBe(0.5); + expect(analytics.mostReliableMethodId).toBe(m2.id); // 100% success rate + expect(analytics.failureReasons[0].reason).toMatch(/Insufficient balance/); + }); +}); diff --git a/src/services/__tests__/paymentMethodService.test.ts b/src/services/__tests__/paymentMethodService.test.ts new file mode 100644 index 00000000..2f0df70d --- /dev/null +++ b/src/services/__tests__/paymentMethodService.test.ts @@ -0,0 +1,771 @@ +/** + * Unit tests for PaymentMethodService + * + * Covers: validation, expiry, priority sorting, chain validation, analytics, + * sharing, and the processPaymentWithChain fallback logic. + * + * All network I/O (ethers providers) is mocked so tests run without a node. + */ + +import { + PaymentMethodService, + PaymentMethodError, + PaymentMethodErrorCode, + PaymentMethodExpiryCheck, + ChainPaymentResult, +} from '../paymentMethodService'; +import { + PaymentMethod, + PaymentPriority, + TokenType, + FallbackChain, + PaymentAttempt, + PaymentMethodShare, +} from '../../types/wallet'; + +// ── ethers mock ──────────────────────────────────────────────────────────── + +jest.mock('ethers', () => { + const actual = jest.requireActual('ethers') as Record; + return { + ...actual, + providers: { + JsonRpcProvider: jest.fn().mockImplementation(() => ({ + getBalance: jest.fn().mockResolvedValue({ gte: jest.fn().mockReturnValue(true), toString: () => '1000000000000000000' }), + getGasPrice: jest.fn().mockResolvedValue({ toString: () => '20000000000' }), + getCode: jest.fn().mockResolvedValue('0x1234'), + })), + }, + utils: { + ...(actual.utils as Record), + isAddress: jest.fn().mockReturnValue(true), + formatUnits: jest.fn().mockReturnValue('20.0'), + parseUnits: jest.fn().mockReturnValue({ gte: jest.fn().mockReturnValue(true) }), + keccak256: jest.fn().mockReturnValue('0xabc'), + }, + BigNumber: { + from: jest.fn().mockImplementation((v) => ({ + gt: jest.fn().mockReturnValue(false), + lte: jest.fn().mockReturnValue(true), + gte: jest.fn().mockReturnValue(true), + toString: () => String(v), + })), + }, + Contract: jest.fn().mockImplementation(() => ({ + decimals: jest.fn().mockResolvedValue(18), + symbol: jest.fn().mockResolvedValue('ETH'), + balanceOf: jest.fn().mockResolvedValue({ gte: jest.fn().mockReturnValue(true), toString: () => '1000000000000000000' }), + })), + }; +}); + +jest.mock('../../config/evm', () => ({ + getEvmRpcUrl: jest.fn().mockReturnValue('https://rpc.example.com'), +})); + +// ── Fixtures ─────────────────────────────────────────────────────────────── + +const NOW = new Date('2026-01-01T00:00:00Z'); + +function makeMethod(overrides: Partial = {}): PaymentMethod { + return { + id: `pm_${Math.random().toString(36).slice(2, 8)}`, + userId: '0xUser', + tokenType: TokenType.NATIVE, + tokenAddress: '0x0000000000000000000000000000000000000000', + chainId: 1, + label: 'Test method', + priority: PaymentPriority.PRIMARY, + maxSpendPerInterval: '1000', + isVerified: true, + isActive: true, + expiresAt: null, + lastUsedAt: null, + createdAt: NOW, + updatedAt: NOW, + metadata: {}, + ...overrides, + }; +} + +function makeChain(overrides: Partial = {}): FallbackChain { + return { + id: `chain_${Math.random().toString(36).slice(2, 8)}`, + name: 'Test chain', + methodIds: [], + subscriptionId: null, + maxAttempts: 0, + stopOnHardDecline: false, + isActive: true, + createdAt: NOW, + updatedAt: NOW, + ...overrides, + }; +} + +function makeAttempt(overrides: Partial = {}): PaymentAttempt { + return { + id: `att_${Math.random().toString(36).slice(2, 8)}`, + paymentMethodId: 'pm_test', + subscriptionId: 'sub_test', + amount: '10', + tokenType: TokenType.NATIVE, + status: 'success', + attemptedAt: NOW, + resolvedAt: NOW, + ...overrides, + }; +} + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function freshService(): PaymentMethodService { + // Reset singleton to get a clean instance for each test. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (PaymentMethodService as any).instance = undefined; + const svc = PaymentMethodService.getInstance(); + svc.setWalletManager({ + getConnection: () => ({ + address: '0xUser', + chainId: 1, + isConnected: true, + }), + }); + return svc; +} + +// ── Tests ────────────────────────────────────────────────────────────────── + +describe('PaymentMethodService', () => { + // ── singleton ──────────────────────────────────────────────────────────── + + describe('getInstance', () => { + it('returns the same instance twice', () => { + const a = PaymentMethodService.getInstance(); + const b = PaymentMethodService.getInstance(); + expect(a).toBe(b); + }); + }); + + // ── generateId ──────────────────────────────────────────────────────────── + + describe('generateId', () => { + it('generates unique ids with pm_ prefix', () => { + const svc = freshService(); + const a = svc.generateId(); + const b = svc.generateId(); + expect(a).toMatch(/^pm_/); + expect(b).toMatch(/^pm_/); + expect(a).not.toBe(b); + }); + }); + + // ── validatePaymentMethodForm ───────────────────────────────────────────── + + describe('validatePaymentMethodForm', () => { + let svc: PaymentMethodService; + beforeEach(() => { svc = freshService(); }); + + const validInput = { + tokenType: TokenType.NATIVE, + tokenAddress: '0x0000000000000000000000000000000000000000', + chainId: 1, + label: 'My wallet', + priority: PaymentPriority.PRIMARY, + maxSpendPerInterval: '100', + }; + + it('passes valid input', () => { + const result = svc.validatePaymentMethodForm(validInput); + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('rejects empty label', () => { + const result = svc.validatePaymentMethodForm({ ...validInput, label: '' }); + expect(result.isValid).toBe(false); + expect(result.errors).toContain('Label is required'); + }); + + it('rejects non-positive maxSpendPerInterval', () => { + const result = svc.validatePaymentMethodForm({ ...validInput, maxSpendPerInterval: '-5' }); + expect(result.isValid).toBe(false); + expect(result.errors).toContain('Max spend per interval must be a positive number'); + }); + + it('rejects unsupported chain ID', () => { + const result = svc.validatePaymentMethodForm({ ...validInput, chainId: 999_999 }); + expect(result.isValid).toBe(false); + expect(result.errors.some((e) => e.includes('Unsupported chain ID'))).toBe(true); + }); + + it('requiresVerification false for NATIVE tokens', () => { + const result = svc.validatePaymentMethodForm(validInput); + expect(result.requiresVerification).toBe(false); + }); + + it('requiresVerification true for ERC20 tokens', () => { + const result = svc.validatePaymentMethodForm({ ...validInput, tokenType: TokenType.USDC }); + expect(result.requiresVerification).toBe(true); + }); + + it('warns when maxSpendPerInterval is very high', () => { + const result = svc.validatePaymentMethodForm({ ...validInput, maxSpendPerInterval: '2e15' }); + expect(result.warnings.length).toBeGreaterThan(0); + }); + }); + + // ── canAddMethod ────────────────────────────────────────────────────────── + + describe('canAddMethod', () => { + it('allows adding when under limit', () => { + const svc = freshService(); + expect(svc.canAddMethod(5).canAdd).toBe(true); + }); + + it('rejects when at limit (10)', () => { + const svc = freshService(); + const result = svc.canAddMethod(10); + expect(result.canAdd).toBe(false); + expect(result.reason).toMatch(/Maximum/); + }); + }); + + // ── isDuplicateMethod ───────────────────────────────────────────────────── + + describe('isDuplicateMethod', () => { + it('detects duplicates by tokenAddress + chainId + tokenType', () => { + const svc = freshService(); + const existing = [makeMethod({ tokenAddress: '0xABCD', chainId: 1, tokenType: TokenType.USDC })]; + expect(svc.isDuplicateMethod(existing, '0xabcd', 1, TokenType.USDC)).toBe(true); + }); + + it('returns false for different chain', () => { + const svc = freshService(); + const existing = [makeMethod({ tokenAddress: '0xABCD', chainId: 1, tokenType: TokenType.USDC })]; + expect(svc.isDuplicateMethod(existing, '0xABCD', 137, TokenType.USDC)).toBe(false); + }); + }); + + // ── sortByPriority ──────────────────────────────────────────────────────── + + describe('sortByPriority', () => { + it('places PRIMARY before BACKUP before FALLBACK', () => { + const svc = freshService(); + const methods = [ + makeMethod({ priority: PaymentPriority.FALLBACK }), + makeMethod({ priority: PaymentPriority.PRIMARY }), + makeMethod({ priority: PaymentPriority.BACKUP }), + ]; + const sorted = svc.sortByPriority(methods); + expect(sorted[0].priority).toBe(PaymentPriority.PRIMARY); + expect(sorted[1].priority).toBe(PaymentPriority.BACKUP); + expect(sorted[2].priority).toBe(PaymentPriority.FALLBACK); + }); + + it('within same priority, prefers more recently used', () => { + const svc = freshService(); + const older = makeMethod({ priority: PaymentPriority.PRIMARY, lastUsedAt: new Date('2025-01-01') }); + const newer = makeMethod({ priority: PaymentPriority.PRIMARY, lastUsedAt: new Date('2026-01-01') }); + const sorted = svc.sortByPriority([older, newer]); + expect(sorted[0]).toBe(newer); + }); + }); + + // ── getActiveVerifiedMethods ─────────────────────────────────────────────── + + describe('getActiveVerifiedMethods', () => { + it('excludes inactive or unverified methods', () => { + const svc = freshService(); + const active = makeMethod({ isActive: true, isVerified: true }); + const inactive = makeMethod({ isActive: false, isVerified: true }); + const unverified = makeMethod({ isActive: true, isVerified: false }); + const result = svc.getActiveVerifiedMethods([active, inactive, unverified]); + expect(result).toHaveLength(1); + expect(result[0]).toBe(active); + }); + }); + + // ── checkExpiry ─────────────────────────────────────────────────────────── + + describe('checkExpiry', () => { + it('returns no expiry when expiresAt is null', () => { + const svc = freshService(); + const method = makeMethod({ expiresAt: null }); + const check = svc.checkExpiry(method); + expect(check.daysUntilExpiry).toBeNull(); + expect(check.isExpired).toBe(false); + expect(check.isExpiringSoon).toBe(false); + }); + + it('flags expired method', () => { + const svc = freshService(); + const method = makeMethod({ expiresAt: new Date(Date.now() - 86_400_000) }); + const check = svc.checkExpiry(method); + expect(check.isExpired).toBe(true); + }); + + it('flags expiring within 30 days', () => { + const svc = freshService(); + const method = makeMethod({ expiresAt: new Date(Date.now() + 15 * 86_400_000) }); + const check = svc.checkExpiry(method); + expect(check.isExpiringSoon).toBe(true); + expect(check.isExpired).toBe(false); + }); + + it('does not flag as expiring when >30 days remain', () => { + const svc = freshService(); + const method = makeMethod({ expiresAt: new Date(Date.now() + 60 * 86_400_000) }); + const check = svc.checkExpiry(method); + expect(check.isExpiringSoon).toBe(false); + }); + }); + + // ── getExpiredMethods / getExpiringSoonMethods ───────────────────────────── + + describe('getExpiredMethods', () => { + it('returns only expired methods', () => { + const svc = freshService(); + const expired = makeMethod({ expiresAt: new Date(Date.now() - 86_400_000) }); + const valid = makeMethod({ expiresAt: null }); + expect(svc.getExpiredMethods([expired, valid])).toEqual([expired]); + }); + }); + + describe('getExpiringSoonMethods', () => { + it('returns methods expiring within 30 days', () => { + const svc = freshService(); + const soon = makeMethod({ expiresAt: new Date(Date.now() + 10 * 86_400_000) }); + const later = makeMethod({ expiresAt: new Date(Date.now() + 60 * 86_400_000) }); + expect(svc.getExpiringSoonMethods([soon, later])).toEqual([soon]); + }); + }); + + // ── markPaymentMethodExpired ─────────────────────────────────────────────── + + describe('markPaymentMethodExpired', () => { + it('sets isActive to false and adds metadata', () => { + const svc = freshService(); + const method = makeMethod(); + const result = svc.markPaymentMethodExpired(method); + expect(result.isActive).toBe(false); + expect(result.metadata['deactivated_reason']).toBe('expired'); + }); + }); + + // ── Chain validation ─────────────────────────────────────────────────────── + + describe('validateChain', () => { + it('passes a valid chain with one active verified method', () => { + const svc = freshService(); + const m = makeMethod(); + const chain = makeChain({ methodIds: [m.id] }); + const result = svc.validateChain(chain, [m]); + expect(result.isValid).toBe(true); + }); + + it('fails when chain has no methods', () => { + const svc = freshService(); + const chain = makeChain({ methodIds: [] }); + const result = svc.validateChain(chain, []); + expect(result.isValid).toBe(false); + expect(result.errors.some((e) => e.includes('at least one'))).toBe(true); + }); + + it('fails when a method id is duplicated', () => { + const svc = freshService(); + const m = makeMethod(); + const chain = makeChain({ methodIds: [m.id, m.id] }); + const result = svc.validateChain(chain, [m]); + expect(result.isValid).toBe(false); + expect(result.errors.some((e) => e.includes('twice'))).toBe(true); + }); + + it('fails when chain exceeds max length', () => { + const svc = freshService(); + const methods = Array.from({ length: 6 }, () => makeMethod()); + const chain = makeChain({ methodIds: methods.map((m) => m.id) }); + const result = svc.validateChain(chain, methods); + expect(result.isValid).toBe(false); + expect(result.errors.some((e) => e.includes('at most'))).toBe(true); + }); + + it('warns when chain has only one method', () => { + const svc = freshService(); + const m = makeMethod(); + const chain = makeChain({ methodIds: [m.id] }); + const result = svc.validateChain(chain, [m]); + expect(result.warnings.length).toBeGreaterThan(0); + }); + + it('fails when chain name is blank', () => { + const svc = freshService(); + const m = makeMethod(); + const chain = makeChain({ name: '', methodIds: [m.id] }); + const result = svc.validateChain(chain, [m]); + expect(result.isValid).toBe(false); + }); + }); + + // ── resolveChainMethods ──────────────────────────────────────────────────── + + describe('resolveChainMethods', () => { + it('excludes inactive, unverified and expired methods', () => { + const svc = freshService(); + const active = makeMethod({ isActive: true, isVerified: true }); + const inactive = makeMethod({ isActive: false, isVerified: true }); + const expired = makeMethod({ isActive: true, isVerified: true, expiresAt: new Date(0) }); + const chain = makeChain({ methodIds: [active.id, inactive.id, expired.id] }); + const resolved = svc.resolveChainMethods(chain, [active, inactive, expired]); + expect(resolved).toHaveLength(1); + expect(resolved[0]).toBe(active); + }); + + it('respects maxAttempts cap', () => { + const svc = freshService(); + const methods = [makeMethod(), makeMethod(), makeMethod()]; + const chain = makeChain({ methodIds: methods.map((m) => m.id), maxAttempts: 2 }); + const resolved = svc.resolveChainMethods(chain, methods); + expect(resolved).toHaveLength(2); + }); + }); + + // ── selectChainForSubscription ───────────────────────────────────────────── + + describe('selectChainForSubscription', () => { + it('prefers subscription-specific chain over global', () => { + const svc = freshService(); + const global = makeChain({ subscriptionId: null }); + const specific = makeChain({ subscriptionId: 'sub_1' }); + const result = svc.selectChainForSubscription([global, specific], 'sub_1'); + expect(result).toBe(specific); + }); + + it('falls back to global chain when no specific chain exists', () => { + const svc = freshService(); + const global = makeChain({ subscriptionId: null }); + const result = svc.selectChainForSubscription([global], 'sub_unknown'); + expect(result).toBe(global); + }); + + it('returns null when no chains are active', () => { + const svc = freshService(); + const inactive = makeChain({ subscriptionId: null, isActive: false }); + const result = svc.selectChainForSubscription([inactive], 'sub_1'); + expect(result).toBeNull(); + }); + }); + + // ── buildDefaultChain ───────────────────────────────────────────────────── + + describe('buildDefaultChain', () => { + it('builds a chain from active verified methods up to MAX_CHAIN_LENGTH', () => { + const svc = freshService(); + const methods = Array.from({ length: 7 }, () => makeMethod()); + const chain = svc.buildDefaultChain(methods); + expect(chain.methodIds.length).toBeLessThanOrEqual(5); + expect(chain.subscriptionId).toBeNull(); + }); + }); + + // ── processPaymentWithChain ──────────────────────────────────────────────── + + describe('processPaymentWithChain', () => { + it('succeeds with the first eligible method', async () => { + const svc = freshService(); + const m1 = makeMethod({ id: 'pm_a', isActive: true, isVerified: true }); + const m2 = makeMethod({ id: 'pm_b', isActive: true, isVerified: true }); + const chain = makeChain({ methodIds: [m1.id, m2.id] }); + + // Mock gas and balance checks to pass + jest.spyOn(svc, 'validateGasPrice').mockResolvedValue({ acceptable: true, currentGasPrice: '20' }); + jest.spyOn(svc, 'checkBalance').mockResolvedValue({ sufficient: true, balance: '1000', symbol: 'ETH' }); + + const result: ChainPaymentResult = await svc.processPaymentWithChain( + chain, + [m1, m2], + 'sub_1', + '10', + 1, + ); + + expect(result.success).toBe(true); + expect(result.attempt?.paymentMethodId).toBe('pm_a'); + expect(result.succeededAtPosition).toBe(0); + expect(result.fallbackAttempts).toHaveLength(0); + }); + + it('falls through to second method when first fails balance check', async () => { + const svc = freshService(); + const m1 = makeMethod({ id: 'pm_a' }); + const m2 = makeMethod({ id: 'pm_b' }); + const chain = makeChain({ methodIds: [m1.id, m2.id] }); + + jest.spyOn(svc, 'validateGasPrice').mockResolvedValue({ acceptable: true, currentGasPrice: '20' }); + jest + .spyOn(svc, 'checkBalance') + .mockResolvedValueOnce({ sufficient: false, balance: '0', symbol: 'ETH' }) + .mockResolvedValueOnce({ sufficient: true, balance: '1000', symbol: 'ETH' }); + + const result = await svc.processPaymentWithChain(chain, [m1, m2], 'sub_1', '10', 1); + + expect(result.success).toBe(true); + expect(result.attempt?.paymentMethodId).toBe('pm_b'); + expect(result.succeededAtPosition).toBe(1); + expect(result.fallbackAttempts).toHaveLength(1); + }); + + it('returns failure when all methods fail', async () => { + const svc = freshService(); + const m1 = makeMethod({ id: 'pm_a' }); + const chain = makeChain({ methodIds: [m1.id] }); + + jest.spyOn(svc, 'validateGasPrice').mockResolvedValue({ acceptable: false, currentGasPrice: '999' }); + + const result = await svc.processPaymentWithChain(chain, [m1], 'sub_1', '10', 1); + + expect(result.success).toBe(false); + expect(result.attempt).toBeNull(); + expect(result.succeededAtPosition).toBe(-1); + }); + + it('throws when chain has no usable methods', async () => { + const svc = freshService(); + const chain = makeChain({ methodIds: [] }); + await expect(svc.processPaymentWithChain(chain, [], 'sub_1', '10', 1)).rejects.toBeInstanceOf( + PaymentMethodError + ); + }); + + it('halts on hard decline when stopOnHardDecline is true', async () => { + const svc = freshService(); + const expired = makeMethod({ + id: 'pm_exp', + expiresAt: new Date(0), // already expired + }); + const backup = makeMethod({ id: 'pm_bk' }); + const chain = makeChain({ + methodIds: [expired.id, backup.id], + stopOnHardDecline: true, + }); + + // resolveChainMethods will exclude expired, so let's test at the higher level + // with an expired method that passes the filter but fails the expiry check. + // We need to circumvent resolveChainMethods by manually injecting. + jest.spyOn(svc, 'resolveChainMethods').mockReturnValue([expired, backup]); + + jest.spyOn(svc, 'validateGasPrice').mockResolvedValue({ acceptable: true, currentGasPrice: '20' }); + jest.spyOn(svc, 'checkBalance').mockResolvedValue({ sufficient: true, balance: '1000', symbol: 'ETH' }); + + const result = await svc.processPaymentWithChain(chain, [expired, backup], 'sub_1', '10', 1); + expect(result.haltedOnHardDecline).toBe(true); + expect(result.success).toBe(false); + }); + }); + + // ── buildExpiryAlerts ───────────────────────────────────────────────────── + + describe('buildExpiryAlerts', () => { + it('generates expired alert', () => { + const svc = freshService(); + const method = makeMethod({ expiresAt: new Date(Date.now() - 86_400_000 * 2) }); + const alerts = svc.buildExpiryAlerts([method], []); + expect(alerts).toHaveLength(1); + expect(alerts[0].severity).toBe('expired'); + }); + + it('generates critical alert within 7 days', () => { + const svc = freshService(); + const method = makeMethod({ expiresAt: new Date(Date.now() + 3 * 86_400_000) }); + const alerts = svc.buildExpiryAlerts([method], []); + expect(alerts[0].severity).toBe('critical'); + }); + + it('flags inActiveChain when method is in an active chain', () => { + const svc = freshService(); + const method = makeMethod({ expiresAt: new Date(Date.now() + 5 * 86_400_000) }); + const chain = makeChain({ methodIds: [method.id], isActive: true }); + const alerts = svc.buildExpiryAlerts([method], [chain]); + expect(alerts[0].inActiveChain).toBe(true); + }); + + it('skips methods with no expiry', () => { + const svc = freshService(); + const method = makeMethod({ expiresAt: null }); + const alerts = svc.buildExpiryAlerts([method], []); + expect(alerts).toHaveLength(0); + }); + }); + + // ── computeAnalytics ────────────────────────────────────────────────────── + + describe('computeAnalytics', () => { + it('returns zero metrics with no attempts', () => { + const svc = freshService(); + const analytics = svc.computeAnalytics([], []); + expect(analytics.totalAttempts).toBe(0); + expect(analytics.successRate).toBe(0); + }); + + it('computes success rate correctly', () => { + const svc = freshService(); + const m = makeMethod(); + const attempts = [ + makeAttempt({ paymentMethodId: m.id, status: 'success', subscriptionId: 'sub_1' }), + makeAttempt({ paymentMethodId: m.id, status: 'failed', subscriptionId: 'sub_2' }), + ]; + const analytics = svc.computeAnalytics([m], attempts); + expect(analytics.totalSuccesses).toBe(1); + expect(analytics.totalFailures).toBe(1); + expect(analytics.successRate).toBe(0.5); + }); + + it('counts fallback rate when success follows failures', () => { + const svc = freshService(); + const m1 = makeMethod({ id: 'pm_1' }); + const m2 = makeMethod({ id: 'pm_2' }); + const sub = 'sub_fb'; + const attempts = [ + makeAttempt({ paymentMethodId: m1.id, status: 'failed', subscriptionId: sub }), + makeAttempt({ paymentMethodId: m2.id, status: 'success', subscriptionId: sub }), + ]; + const analytics = svc.computeAnalytics([m1, m2], attempts); + expect(analytics.fallbackRate).toBe(1); // 100% of successes were fallbacks + }); + + it('identifies most reliable method', () => { + const svc = freshService(); + const reliable = makeMethod({ id: 'pm_reliable' }); + const unreliable = makeMethod({ id: 'pm_unreliable' }); + const attempts = [ + makeAttempt({ paymentMethodId: reliable.id, status: 'success', subscriptionId: 'sub_1' }), + makeAttempt({ paymentMethodId: reliable.id, status: 'success', subscriptionId: 'sub_2' }), + makeAttempt({ paymentMethodId: unreliable.id, status: 'failed', subscriptionId: 'sub_3' }), + ]; + const analytics = svc.computeAnalytics([reliable, unreliable], attempts); + expect(analytics.mostReliableMethodId).toBe(reliable.id); + }); + }); + + // ── Sharing ─────────────────────────────────────────────────────────────── + + describe('createShare', () => { + it('creates a valid share', () => { + const svc = freshService(); + const method = makeMethod({ userId: '0xOwner' }); + const share = svc.createShare(method, '0xGrantee', 'viewer'); + expect(share.methodId).toBe(method.id); + expect(share.granteeId).toBe('0xGrantee'); + expect(share.role).toBe('viewer'); + expect(share.revokedAt).toBeNull(); + }); + + it('throws when grantee is blank', () => { + const svc = freshService(); + const method = makeMethod({ userId: '0xOwner' }); + expect(() => svc.createShare(method, '', 'viewer')).toThrow(PaymentMethodError); + }); + + it('throws when sharing with own owner', () => { + const svc = freshService(); + const method = makeMethod({ userId: '0xOwner' }); + expect(() => svc.createShare(method, '0xOwner', 'viewer')).toThrow(PaymentMethodError); + }); + + it('throws when method is inactive', () => { + const svc = freshService(); + const method = makeMethod({ userId: '0xOwner', isActive: false }); + expect(() => svc.createShare(method, '0xGrantee', 'charger')).toThrow(PaymentMethodError); + }); + }); + + describe('isShareActive', () => { + it('returns true for un-revoked share with no expiry', () => { + const svc = freshService(); + const share: PaymentMethodShare = { + id: 'sh_1', + methodId: 'pm_1', + granteeId: '0xG', + role: 'viewer', + spendLimit: null, + expiresAt: null, + createdAt: new Date(), + revokedAt: null, + }; + expect(svc.isShareActive(share)).toBe(true); + }); + + it('returns false for revoked share', () => { + const svc = freshService(); + const share: PaymentMethodShare = { + id: 'sh_2', + methodId: 'pm_1', + granteeId: '0xG', + role: 'viewer', + spendLimit: null, + expiresAt: null, + createdAt: new Date(), + revokedAt: new Date(), + }; + expect(svc.isShareActive(share)).toBe(false); + }); + + it('returns false for share past its expiry', () => { + const svc = freshService(); + const share: PaymentMethodShare = { + id: 'sh_3', + methodId: 'pm_1', + granteeId: '0xG', + role: 'viewer', + spendLimit: null, + expiresAt: new Date(Date.now() - 1000), + createdAt: new Date(), + revokedAt: null, + }; + expect(svc.isShareActive(share)).toBe(false); + }); + }); + + describe('getSharedMethods', () => { + it('returns methods visible to a grantee via active shares', () => { + const svc = freshService(); + const m1 = makeMethod({ id: 'pm_shared' }); + const m2 = makeMethod({ id: 'pm_private' }); + const share: PaymentMethodShare = { + id: 'sh_1', + methodId: m1.id, + granteeId: '0xGrantee', + role: 'viewer', + spendLimit: null, + expiresAt: null, + createdAt: new Date(), + revokedAt: null, + }; + const result = svc.getSharedMethods([m1, m2], [share], '0xGrantee'); + expect(result).toHaveLength(1); + expect(result[0]).toBe(m1); + }); + }); + + // ── processPaymentWithFallback (legacy) ─────────────────────────────────── + + describe('processPaymentWithFallback', () => { + it('throws when no methods are available', async () => { + const svc = freshService(); + await expect( + svc.processPaymentWithFallback([], 'sub_1', '10', 1) + ).rejects.toBeInstanceOf(PaymentMethodError); + }); + + it('succeeds when primary method has sufficient balance', async () => { + const svc = freshService(); + const m = makeMethod({ id: 'pm_ok' }); + jest.spyOn(svc, 'validateGasPrice').mockResolvedValue({ acceptable: true, currentGasPrice: '20' }); + jest.spyOn(svc, 'checkBalance').mockResolvedValue({ sufficient: true, balance: '1000', symbol: 'ETH' }); + + const result = await svc.processPaymentWithFallback([m], 'sub_1', '10', 1); + expect(result.success).toBe(true); + expect(result.attempt.paymentMethodId).toBe('pm_ok'); + }); + }); +}); diff --git a/src/services/walletService.ts b/src/services/walletService.ts index 67167bfa..fe79a67e 100644 --- a/src/services/walletService.ts +++ b/src/services/walletService.ts @@ -11,11 +11,6 @@ import { ADDRESS_CONSTANTS, } from '../utils/constants/values'; import { - PaymentMethod, - PaymentPriority, - TokenType, - PaymentMethodValidationResult, - PaymentAttempt, GasEstimate, } from '../types/wallet'; @@ -716,451 +711,22 @@ export class WalletServiceManager { } // ── Payment method management ─────────────────────────────────────── - -export enum PaymentMethodErrorCode { - DUPLICATE = 'PAYMENT_METHOD_DUPLICATE', - INVALID_TOKEN = 'PAYMENT_METHOD_INVALID_TOKEN', - INVALID_CHAIN = 'PAYMENT_METHOD_INVALID_CHAIN', - MAX_METHODS = 'PAYMENT_METHOD_MAX_REACHED', - VERIFICATION_FAILED = 'PAYMENT_METHOD_VERIFICATION_FAILED', - EXPIRED = 'PAYMENT_METHOD_EXPIRED', - INSUFFICIENT_BALANCE = 'INSUFFICIENT_BALANCE', - GAS_PRICE_SPIKE = 'GAS_PRICE_SPIKE', - TOKEN_CONTRACT_UPGRADED = 'TOKEN_CONTRACT_UPGRADED', - FALLBACK_FAILED = 'FALLBACK_FAILED', -} - -export class PaymentMethodError extends Error { - readonly code: PaymentMethodErrorCode; - readonly userMessage: string; - readonly recovery?: string; - - constructor( - code: PaymentMethodErrorCode, - userMessage: string, - recovery?: string, - cause?: unknown - ) { - super(userMessage); - this.name = 'PaymentMethodError'; - this.code = code; - this.userMessage = userMessage; - this.recovery = recovery; - if (cause instanceof Error && cause.stack) { - this.stack = `${this.stack}\nCaused by: ${cause.stack}`; - } - } -} - -const MAX_PAYMENT_METHODS_PER_USER = 10; -const EXPIRY_WARNING_DAYS = 30; -const TOKEN_TYPE_TO_NATIVE_SYMBOL: Record> = { - [CHAIN_IDS.ETHEREUM]: { XLM: '', USDC: 'USDC', ETH: 'ETH', NATIVE: 'ETH', MATIC: '', ARB: '' }, - [CHAIN_IDS.POLYGON]: { XLM: '', USDC: 'USDC', ETH: 'ETH', NATIVE: 'MATIC', MATIC: 'MATIC', ARB: '' }, - [CHAIN_IDS.ARBITRUM]: { XLM: '', USDC: 'USDC', ETH: 'ETH', NATIVE: 'ETH', MATIC: '', ARB: 'ARB' }, -}; - -const PRIORITY_ORDER: Record = { - [PaymentPriority.PRIMARY]: 0, - [PaymentPriority.BACKUP]: 1, - [PaymentPriority.FALLBACK]: 2, -}; - -export interface PaymentMethodExpiryCheck { - method: PaymentMethod; - daysUntilExpiry: number | null; - isExpired: boolean; - isExpiringSoon: boolean; -} - -export class PaymentMethodService { - private static instance: PaymentMethodService; - private readonly walletManager: WalletServiceManager; - - static getInstance(): PaymentMethodService { - if (!PaymentMethodService.instance) { - PaymentMethodService.instance = new PaymentMethodService(); - } - return PaymentMethodService.instance; - } - - private constructor() { - this.walletManager = WalletServiceManager.getInstance(); - } - - generateId(): string { - return `pm_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; - } - - validatePaymentMethodForm(data: { - tokenType: TokenType; - tokenAddress: string; - chainId: number; - label: string; - priority: PaymentPriority; - maxSpendPerInterval: string; - }): PaymentMethodValidationResult { - const errors: string[] = []; - const warnings: string[] = []; - - if (!Object.values(TokenType).includes(data.tokenType)) { - errors.push(`Unsupported token type: ${data.tokenType}`); - } - - if (data.tokenType !== TokenType.NATIVE && !ethers.utils.isAddress(data.tokenAddress)) { - errors.push('Invalid token address'); - } - - const validChainIds = Object.values(CHAIN_IDS) as number[]; - if (!validChainIds.includes(data.chainId)) { - errors.push(`Unsupported chain ID: ${data.chainId}`); - } - - if (!data.label || data.label.trim().length === 0) { - errors.push('Label is required'); - } - - if (!data.maxSpendPerInterval || isNaN(Number(data.maxSpendPerInterval)) || Number(data.maxSpendPerInterval) <= 0) { - errors.push('Max spend per interval must be a positive number'); - } - - const nativeSymbol = TOKEN_TYPE_TO_NATIVE_SYMBOL[data.chainId]?.[data.tokenType]; - if (nativeSymbol === '') { - warnings.push(`Token type ${data.tokenType} may not be supported on chain ${data.chainId}`); - } - - if (Number(data.maxSpendPerInterval) > 1e12) { - warnings.push('Max spend per interval is very high; consider setting a lower cap'); - } - - return { - isValid: errors.length === 0, - errors, - warnings, - requiresVerification: data.tokenType !== TokenType.NATIVE, - estimatedGas: null, - }; - } - - async verifyPaymentMethod(method: PaymentMethod): Promise { - const conn = this.walletManager.getConnection(); - if (!conn || !conn.isConnected) { - throw new PaymentMethodError( - PaymentMethodErrorCode.VERIFICATION_FAILED, - 'Wallet not connected.', - 'Connect your wallet to verify payment methods.' - ); - } - - if (method.tokenType === TokenType.NATIVE) { - return true; - } - - try { - const provider = new ethers.providers.JsonRpcProvider(getEvmRpcUrl(method.chainId)); - const erc20Abi = ['function decimals() view returns (uint8)', 'function symbol() view returns (string)']; - const contract = new ethers.Contract(method.tokenAddress, erc20Abi, provider); - - const decimals = await contract.decimals(); - if (decimals < 0 || decimals > 18) { - throw new Error('Invalid decimals'); - } - - const symbol = await contract.symbol(); - const expectedSymbol = method.tokenType.toString(); - if (symbol.toUpperCase() !== expectedSymbol.toUpperCase() && expectedSymbol !== 'NATIVE') { - throw new Error(`Symbol mismatch: expected ${expectedSymbol}, got ${symbol}`); - } - - return true; - } catch (error) { - throw new PaymentMethodError( - PaymentMethodErrorCode.VERIFICATION_FAILED, - `Failed to verify token ${method.tokenAddress}.`, - 'Check the token address and try again.', - error - ); - } - } - - sortByPriority(methods: PaymentMethod[]): PaymentMethod[] { - return [...methods].sort((a, b) => { - const priorityDiff = PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]; - if (priorityDiff !== 0) return priorityDiff; - - const aTime = a.lastUsedAt?.getTime() ?? a.createdAt.getTime(); - const bTime = b.lastUsedAt?.getTime() ?? b.createdAt.getTime(); - return bTime - aTime; - }); - } - - getPrimaryMethods(methods: PaymentMethod[]): PaymentMethod[] { - return methods.filter((m) => m.priority === PaymentPriority.PRIMARY && m.isActive && m.isVerified); - } - - getBackupMethods(methods: PaymentMethod[]): PaymentMethod[] { - return methods.filter((m) => m.priority === PaymentPriority.BACKUP && m.isActive && m.isVerified); - } - - getFallbackMethods(methods: PaymentMethod[]): PaymentMethod[] { - return methods.filter((m) => m.priority === PaymentPriority.FALLBACK && m.isActive && m.isVerified); - } - - getActiveVerifiedMethods(methods: PaymentMethod[]): PaymentMethod[] { - return this.sortByPriority(methods.filter((m) => m.isActive && m.isVerified)); - } - - calculateFallbackOrder(methods: PaymentMethod[]): PaymentMethod[] { - const active = this.getActiveVerifiedMethods(methods); - return this.sortByPriority(active); - } - - canAddMethod(currentCount: number): { canAdd: boolean; reason?: string } { - if (currentCount >= MAX_PAYMENT_METHODS_PER_USER) { - return { - canAdd: false, - reason: `Maximum of ${MAX_PAYMENT_METHODS_PER_USER} payment methods reached.`, - }; - } - return { canAdd: true }; - } - - isDuplicateMethod( - existingMethods: PaymentMethod[], - tokenAddress: string, - chainId: number, - tokenType: TokenType - ): boolean { - return existingMethods.some( - (m) => - m.tokenAddress.toLowerCase() === tokenAddress.toLowerCase() && - m.chainId === chainId && - m.tokenType === tokenType - ); - } - - ensurePriorityBalance(methods: PaymentMethod[]): void { - const priorities = [PaymentPriority.PRIMARY, PaymentPriority.BACKUP, PaymentPriority.FALLBACK]; - const present = new Set(methods.map((m) => m.priority)); - - for (const priority of priorities) { - if (!present.has(priority)) { - throw new PaymentMethodError( - PaymentMethodErrorCode.INVALID_TOKEN, - `No payment method with priority "${priority}" exists. Add a method with this priority level.`, - 'Configure at least one payment method per priority level.' - ); - } - } - } - - async checkBalance( - method: PaymentMethod, - requiredAmount: string, - chainId: number - ): Promise<{ sufficient: boolean; balance: string; symbol: string }> { - try { - const provider = new ethers.providers.JsonRpcProvider(getEvmRpcUrl(chainId)); - const conn = this.walletManager.getConnection(); - if (!conn) { - return { sufficient: false, balance: '0', symbol: method.tokenType }; - } - - let balance: ethers.BigNumber; - - if (method.tokenType === TokenType.NATIVE) { - balance = await provider.getBalance(conn.address); - } else { - const erc20Abi = ['function balanceOf(address) view returns (uint256)']; - const contract = new ethers.Contract(method.tokenAddress, erc20Abi, provider); - balance = await contract.balanceOf(conn.address); - } - - const required = ethers.utils.parseUnits(requiredAmount, method.tokenType === TokenType.USDC ? 6 : 18); - return { - sufficient: balance.gte(required), - balance: balance.toString(), - symbol: method.tokenType.toString(), - }; - } catch { - return { sufficient: false, balance: '0', symbol: method.tokenType.toString() }; - } - } - - async validateGasPrice( - chainId: number, - maxGasPriceGwei: number - ): Promise<{ acceptable: boolean; currentGasPrice: string }> { - try { - const provider = new ethers.providers.JsonRpcProvider(getEvmRpcUrl(chainId)); - const gasPrice = await provider.getGasPrice(); - const gasPriceGwei = parseFloat(ethers.utils.formatUnits(gasPrice, 'gwei')); - - return { - acceptable: gasPriceGwei <= maxGasPriceGwei, - currentGasPrice: gasPriceGwei.toFixed(2), - }; - } catch { - return { acceptable: false, currentGasPrice: '0' }; - } - } - - checkExpiry(method: PaymentMethod): PaymentMethodExpiryCheck { - if (!method.expiresAt) { - return { method, daysUntilExpiry: null, isExpired: false, isExpiringSoon: false }; - } - - const now = Date.now(); - const expiryTime = method.expiresAt.getTime(); - const daysUntilExpiry = Math.ceil((expiryTime - now) / (1000 * 60 * 60 * 24)); - const isExpired = daysUntilExpiry <= 0; - const isExpiringSoon = !isExpired && daysUntilExpiry <= EXPIRY_WARNING_DAYS; - - return { method, daysUntilExpiry, isExpired, isExpiringSoon }; - } - - getExpiredMethods(methods: PaymentMethod[]): PaymentMethod[] { - return methods.filter((m) => { - const check = this.checkExpiry(m); - return check.isExpired; - }); - } - - getExpiringSoonMethods(methods: PaymentMethod[]): PaymentMethod[] { - return methods.filter((m) => { - const check = this.checkExpiry(m); - return check.isExpiringSoon; - }); - } - - async processPaymentWithFallback( - paymentMethods: PaymentMethod[], - subscriptionId: string, - amount: string, - chainId: number, - maxGasPriceGwei: number = 500 - ): Promise<{ success: boolean; attempt: PaymentAttempt; fallbackAttempts: PaymentAttempt[] }> { - const sorted = this.calculateFallbackOrder(paymentMethods); - if (sorted.length === 0) { - throw new PaymentMethodError( - PaymentMethodErrorCode.FALLBACK_FAILED, - 'No active payment methods available.', - 'Add at least one verified payment method.' - ); - } - - const fallbackAttempts: PaymentAttempt[] = []; - - for (const method of sorted) { - const attempt: PaymentAttempt = { - id: `attempt_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`, - paymentMethodId: method.id, - subscriptionId, - amount, - tokenType: method.tokenType, - status: 'pending', - attemptedAt: new Date(), - }; - - try { - const expiry = this.checkExpiry(method); - if (expiry.isExpired) { - attempt.status = 'failed'; - attempt.failureReason = `Payment method expired ${expiry.daysUntilExpiry} days ago`; - attempt.resolvedAt = new Date(); - fallbackAttempts.push(attempt); - continue; - } - - const gasCheck = await this.validateGasPrice(chainId, maxGasPriceGwei); - if (!gasCheck.acceptable) { - attempt.status = 'failed'; - attempt.failureReason = `Gas price ${gasCheck.currentGasPrice} gwei exceeds max ${maxGasPriceGwei} gwei`; - attempt.gasPrice = gasCheck.currentGasPrice; - attempt.resolvedAt = new Date(); - fallbackAttempts.push(attempt); - continue; - } - - const balanceCheck = await this.checkBalance(method, amount, chainId); - if (!balanceCheck.sufficient) { - attempt.status = 'failed'; - attempt.failureReason = `Insufficient ${method.tokenType} balance: have ${balanceCheck.balance}, need ${amount}`; - attempt.resolvedAt = new Date(); - fallbackAttempts.push(attempt); - continue; - } - - if (method.maxSpendPerInterval && ethers.BigNumber.from(amount).gt(method.maxSpendPerInterval)) { - attempt.status = 'failed'; - attempt.failureReason = `Amount ${amount} exceeds max spend per interval ${method.maxSpendPerInterval}`; - attempt.resolvedAt = new Date(); - fallbackAttempts.push(attempt); - continue; - } - - attempt.status = 'success'; - attempt.gasPrice = gasCheck.currentGasPrice; - attempt.resolvedAt = new Date(); - method.lastUsedAt = new Date(); - - return { success: true, attempt, fallbackAttempts }; - } catch (error) { - attempt.status = 'failed'; - attempt.failureReason = error instanceof Error ? error.message : 'Unknown error'; - attempt.resolvedAt = new Date(); - fallbackAttempts.push(attempt); - } - } - - throw new PaymentMethodError( - PaymentMethodErrorCode.FALLBACK_FAILED, - `All ${sorted.length} payment methods failed.`, - 'Check your balances, gas prices, and payment method configurations.', - new Error( - `Failed attempts: ${fallbackAttempts.map((a) => `${a.tokenType}: ${a.failureReason}`).join('; ')}` - ) - ); - } - - async detectTokenContractUpgrade( - method: PaymentMethod, - previousHash: string | null - ): Promise<{ upgraded: boolean; newHash?: string }> { - if (method.tokenType === TokenType.NATIVE || !method.tokenAddress) { - return { upgraded: false }; - } - - try { - const provider = new ethers.providers.JsonRpcProvider(getEvmRpcUrl(method.chainId)); - const code = await provider.getCode(method.tokenAddress); - const newHash = ethers.utils.keccak256(code); - - if (previousHash && newHash !== previousHash) { - return { upgraded: true, newHash }; - } - - return { upgraded: false, newHash }; - } catch { - return { upgraded: false }; - } - } - - markPaymentMethodExpired(method: PaymentMethod): PaymentMethod { - return { - ...method, - isActive: false, - metadata: { - ...method.metadata, - deactivated_reason: 'expired', - deactivated_at: new Date().toISOString(), - }, - updatedAt: new Date(), - }; - } -} - -// Export singleton instance +// +// All payment-method types, errors, and the service class live in +// paymentMethodService.ts. Re-export them from here so existing imports of +// walletService keep working unchanged. + +export { + PaymentMethodErrorCode, + PaymentMethodError, + PaymentMethodService, +} from './paymentMethodService'; +export type { + PaymentMethodExpiryCheck, + ChainPaymentResult, +} from './paymentMethodService'; + +// Export singleton instances export const walletServiceManager = WalletServiceManager.getInstance(); export const paymentMethodService = PaymentMethodService.getInstance(); export default walletServiceManager; diff --git a/src/store/__tests__/walletStore.test.ts b/src/store/__tests__/walletStore.test.ts new file mode 100644 index 00000000..527f0745 --- /dev/null +++ b/src/store/__tests__/walletStore.test.ts @@ -0,0 +1,794 @@ +/** + * Unit tests for useWalletStore — payment method management slice. + * + * The store is reset between tests via setState so each test starts clean. + * ethers and the underlying services are fully mocked. + */ + +import { act } from '@testing-library/react-native'; +import { PaymentPriority, TokenType, PaymentMethod, FallbackChain } from '../../types/wallet'; +import { PaymentMethodError, PaymentMethodErrorCode } from '../../services/paymentMethodService'; + +// ── Zustand persist storage mock ─────────────────────────────────────────── + +jest.mock('../../utils/storage', () => ({ + asyncStorageAdapter: { + getItem: jest.fn().mockResolvedValue(null), + setItem: jest.fn().mockResolvedValue(undefined), + removeItem: jest.fn().mockResolvedValue(undefined), + }, +})); + +// ── WalletServiceManager mock ────────────────────────────────────────────── + +const mockDisconnect = jest.fn().mockResolvedValue(undefined); +const mockGetConnection = jest.fn().mockReturnValue({ + address: '0xUser', + chainId: 1, + isConnected: true, +}); + +jest.mock('../../services/walletService', () => ({ + WalletServiceManager: { + getInstance: () => ({ + disconnectWallet: mockDisconnect, + getConnection: mockGetConnection, + addListener: jest.fn(), + isConnected: jest.fn().mockReturnValue(true), + }), + }, +})); + +// ── PaymentMethodService mock ────────────────────────────────────────────── + +const mockValidate = jest.fn().mockReturnValue({ isValid: true, errors: [], warnings: [] }); +const mockCanAdd = jest.fn().mockReturnValue({ canAdd: true }); +const mockIsDuplicate = jest.fn().mockReturnValue(false); +const mockVerify = jest.fn().mockResolvedValue(true); +const mockGenerateId = jest.fn().mockImplementation( + () => `pm_${Math.random().toString(36).slice(2, 8)}` +); +const mockCheckExpiry = jest.fn().mockReturnValue({ + isExpired: false, isExpiringSoon: false, daysUntilExpiry: null, +}); +const mockGetExpired = jest.fn().mockReturnValue([]); +const mockBuildAlerts = jest.fn().mockReturnValue([]); +const mockComputeAnalytics = jest.fn().mockReturnValue({ + totalAttempts: 0, totalSuccesses: 0, totalFailures: 0, successRate: 0, + fallbackRate: 0, byMethod: [], failureReasons: [], + mostReliableMethodId: null, activeMethods: 0, expiringMethods: 0, +}); +const mockProcessFallback = jest.fn(); +const mockValidateChain = jest.fn().mockReturnValue({ isValid: true, errors: [], warnings: [] }); +const mockSelectChain = jest.fn().mockReturnValue(null); +const mockBuildDefaultChain = jest.fn().mockReturnValue({ + id: 'chain_default', name: 'Default', methodIds: [], subscriptionId: null, + maxAttempts: 0, stopOnHardDecline: false, isActive: true, + createdAt: new Date(), updatedAt: new Date(), +}); +const mockProcessWithChain = jest.fn(); +const mockDetectUpgrade = jest.fn().mockResolvedValue({ upgraded: false }); +const mockMarkExpired = jest.fn().mockImplementation( + (m: PaymentMethod) => ({ ...m, isActive: false }) +); +const mockCreateShare = jest.fn(); +const mockIsShareActive = jest.fn().mockReturnValue(true); +const mockGetSharedMethods = jest.fn().mockReturnValue([]); + +jest.mock('../../services/paymentMethodService', () => ({ + PaymentMethodService: { + getInstance: () => ({ + validatePaymentMethodForm: mockValidate, + canAddMethod: mockCanAdd, + isDuplicateMethod: mockIsDuplicate, + verifyPaymentMethod: mockVerify, + generateId: mockGenerateId, + checkExpiry: mockCheckExpiry, + getExpiredMethods: mockGetExpired, + getExpiringSoonMethods: jest.fn().mockReturnValue([]), + buildExpiryAlerts: mockBuildAlerts, + computeAnalytics: mockComputeAnalytics, + processPaymentWithFallback: mockProcessFallback, + validateChain: mockValidateChain, + selectChainForSubscription: mockSelectChain, + buildDefaultChain: mockBuildDefaultChain, + processPaymentWithChain: mockProcessWithChain, + detectTokenContractUpgrade: mockDetectUpgrade, + markPaymentMethodExpired: mockMarkExpired, + createShare: mockCreateShare, + isShareActive: mockIsShareActive, + getSharedMethods: mockGetSharedMethods, + getPrimaryMethods: jest.fn().mockReturnValue([]), + getBackupMethods: jest.fn().mockReturnValue([]), + getFallbackMethods: jest.fn().mockReturnValue([]), + sortByPriority: (ms: PaymentMethod[]) => ms, + getActiveVerifiedMethods: (ms: PaymentMethod[]) => + ms.filter((m) => m.isActive && m.isVerified), + }), + }, + PaymentMethodError, + PaymentMethodErrorCode, +})); + +// ── Store import (after mocks) ───────────────────────────────────────────── + +import { useWalletStore } from '../../store/walletStore'; + +// ── Fixtures ─────────────────────────────────────────────────────────────── + +const NOW = new Date('2026-01-01T00:00:00Z'); + +function baseMethod(overrides: Partial = {}): PaymentMethod { + return { + id: `pm_${Math.random().toString(36).slice(2, 8)}`, + userId: '0xUser', + tokenType: TokenType.NATIVE, + tokenAddress: '0x0000000000000000000000000000000000000000', + chainId: 1, + label: 'Test method', + priority: PaymentPriority.PRIMARY, + maxSpendPerInterval: '100', + isVerified: true, + isActive: true, + expiresAt: null, + lastUsedAt: null, + createdAt: NOW, + updatedAt: NOW, + metadata: {}, + ...overrides, + }; +} + +function baseChain(overrides: Partial = {}): FallbackChain { + return { + id: `chain_${Math.random().toString(36).slice(2, 8)}`, + name: 'Test chain', + methodIds: [], + subscriptionId: null, + maxAttempts: 0, + stopOnHardDecline: false, + isActive: true, + createdAt: NOW, + updatedAt: NOW, + ...overrides, + }; +} + +function resetStore() { + useWalletStore.setState({ + connection: { address: '0xUser', chainId: 1, isConnected: true }, + paymentMethods: [], + paymentAttempts: [], + fallbackChains: [], + paymentMethodShares: [], + isLoading: false, + error: null, + cryptoStreams: [], + }); +} + +// ── Setup ────────────────────────────────────────────────────────────────── + +beforeEach(() => { + resetStore(); + jest.clearAllMocks(); + // restore default mock returns after clearAllMocks + mockGetConnection.mockReturnValue({ address: '0xUser', chainId: 1, isConnected: true }); + mockValidate.mockReturnValue({ isValid: true, errors: [], warnings: [] }); + mockCanAdd.mockReturnValue({ canAdd: true }); + mockIsDuplicate.mockReturnValue(false); + mockVerify.mockResolvedValue(true); + mockValidateChain.mockReturnValue({ isValid: true, errors: [], warnings: [] }); + mockDetectUpgrade.mockResolvedValue({ upgraded: false }); + mockGetExpired.mockReturnValue([]); + mockBuildAlerts.mockReturnValue([]); + mockComputeAnalytics.mockReturnValue({ + totalAttempts: 0, totalSuccesses: 0, totalFailures: 0, successRate: 0, + fallbackRate: 0, byMethod: [], failureReasons: [], + mostReliableMethodId: null, activeMethods: 0, expiringMethods: 0, + }); +}); + +// ── Tests ────────────────────────────────────────────────────────────────── + +describe('useWalletStore — payment methods', () => { + + // addPaymentMethod ───────────────────────────────────────────────────────── + + describe('addPaymentMethod', () => { + it('adds a new method to the store', async () => { + mockGenerateId.mockReturnValue('pm_fixed01'); + + await act(async () => { + await useWalletStore.getState().addPaymentMethod({ + tokenType: TokenType.NATIVE, + tokenAddress: '0x0000000000000000000000000000000000000000', + chainId: 1, + label: 'My wallet', + priority: PaymentPriority.PRIMARY, + maxSpendPerInterval: '100', + }); + }); + + const { paymentMethods } = useWalletStore.getState(); + expect(paymentMethods).toHaveLength(1); + expect(paymentMethods[0].id).toBe('pm_fixed01'); + expect(paymentMethods[0].label).toBe('My wallet'); + expect(paymentMethods[0].isActive).toBe(true); + }); + + it('throws PaymentMethodError when wallet not connected', async () => { + useWalletStore.setState({ connection: null }); + + await expect( + act(async () => { + await useWalletStore.getState().addPaymentMethod({ + tokenType: TokenType.NATIVE, + tokenAddress: '0x0', + chainId: 1, + label: 'x', + priority: PaymentPriority.PRIMARY, + maxSpendPerInterval: '10', + }); + }) + ).rejects.toBeInstanceOf(PaymentMethodError); + }); + + it('throws when max methods limit reached', async () => { + mockCanAdd.mockReturnValue({ canAdd: false, reason: 'Maximum of 10 reached.' }); + + await expect( + act(async () => { + await useWalletStore.getState().addPaymentMethod({ + tokenType: TokenType.NATIVE, + tokenAddress: '0x0', + chainId: 1, + label: 'overflow', + priority: PaymentPriority.PRIMARY, + maxSpendPerInterval: '10', + }); + }) + ).rejects.toBeInstanceOf(PaymentMethodError); + }); + + it('throws when validation fails', async () => { + mockValidate.mockReturnValue({ isValid: false, errors: ['Label required'], warnings: [] }); + + await expect( + act(async () => { + await useWalletStore.getState().addPaymentMethod({ + tokenType: TokenType.NATIVE, + tokenAddress: '0x0', + chainId: 1, + label: '', + priority: PaymentPriority.PRIMARY, + maxSpendPerInterval: '10', + }); + }) + ).rejects.toBeInstanceOf(PaymentMethodError); + }); + + it('throws on duplicate method', async () => { + mockIsDuplicate.mockReturnValue(true); + + await expect( + act(async () => { + await useWalletStore.getState().addPaymentMethod({ + tokenType: TokenType.NATIVE, + tokenAddress: '0x0', + chainId: 1, + label: 'dup', + priority: PaymentPriority.PRIMARY, + maxSpendPerInterval: '10', + }); + }) + ).rejects.toBeInstanceOf(PaymentMethodError); + }); + + it('sets isVerified=true automatically for NATIVE tokens', async () => { + await act(async () => { + await useWalletStore.getState().addPaymentMethod({ + tokenType: TokenType.NATIVE, + tokenAddress: '0x0000000000000000000000000000000000000000', + chainId: 1, + label: 'Native', + priority: PaymentPriority.PRIMARY, + maxSpendPerInterval: '50', + }); + }); + + expect(useWalletStore.getState().paymentMethods[0].isVerified).toBe(true); + }); + }); + + // removePaymentMethod ────────────────────────────────────────────────────── + + describe('removePaymentMethod', () => { + it('removes a method by id', async () => { + const m = baseMethod({ id: 'pm_del' }); + useWalletStore.setState({ paymentMethods: [m] }); + + await act(async () => { + await useWalletStore.getState().removePaymentMethod('pm_del'); + }); + + expect(useWalletStore.getState().paymentMethods).toHaveLength(0); + }); + + it('leaves other methods untouched', async () => { + const keep = baseMethod({ id: 'pm_keep' }); + const gone = baseMethod({ id: 'pm_gone' }); + useWalletStore.setState({ paymentMethods: [keep, gone] }); + + await act(async () => { + await useWalletStore.getState().removePaymentMethod('pm_gone'); + }); + + const { paymentMethods } = useWalletStore.getState(); + expect(paymentMethods).toHaveLength(1); + expect(paymentMethods[0].id).toBe('pm_keep'); + }); + }); + + // updatePaymentMethod ────────────────────────────────────────────────────── + + describe('updatePaymentMethod', () => { + it('updates a field on the method', async () => { + const m = baseMethod({ id: 'pm_upd', label: 'old' }); + useWalletStore.setState({ paymentMethods: [m] }); + + await act(async () => { + await useWalletStore.getState().updatePaymentMethod('pm_upd', { label: 'new' }); + }); + + expect(useWalletStore.getState().paymentMethods[0].label).toBe('new'); + }); + + it('bumps updatedAt to a later timestamp', async () => { + const m = baseMethod({ id: 'pm_ts', updatedAt: NOW }); + useWalletStore.setState({ paymentMethods: [m] }); + + await act(async () => { + await useWalletStore.getState().updatePaymentMethod('pm_ts', { label: 'x' }); + }); + + expect( + useWalletStore.getState().paymentMethods[0].updatedAt.getTime() + ).toBeGreaterThanOrEqual(NOW.getTime()); + }); + }); + + // verifyPaymentMethod ────────────────────────────────────────────────────── + + describe('verifyPaymentMethod', () => { + it('marks method as verified', async () => { + const m = baseMethod({ id: 'pm_ver', isVerified: false }); + useWalletStore.setState({ paymentMethods: [m] }); + + await act(async () => { + await useWalletStore.getState().verifyPaymentMethod('pm_ver'); + }); + + expect(useWalletStore.getState().paymentMethods[0].isVerified).toBe(true); + }); + + it('throws when method not found', async () => { + await expect( + act(async () => { + await useWalletStore.getState().verifyPaymentMethod('pm_missing'); + }) + ).rejects.toThrow('Payment method not found'); + }); + }); + + // setPaymentMethodPriority ───────────────────────────────────────────────── + + describe('setPaymentMethodPriority', () => { + it('changes the priority field', async () => { + const m = baseMethod({ id: 'pm_pri', priority: PaymentPriority.PRIMARY }); + useWalletStore.setState({ paymentMethods: [m] }); + + await act(async () => { + await useWalletStore.getState().setPaymentMethodPriority('pm_pri', PaymentPriority.BACKUP); + }); + + expect(useWalletStore.getState().paymentMethods[0].priority).toBe(PaymentPriority.BACKUP); + }); + }); + + // processPayment ─────────────────────────────────────────────────────────── + + describe('processPayment', () => { + it('appends attempt and updates lastUsedAt on success', async () => { + const m = baseMethod({ id: 'pm_pay' }); + useWalletStore.setState({ paymentMethods: [m] }); + + const mockAttempt = { + id: 'att_1', + paymentMethodId: 'pm_pay', + subscriptionId: 'sub_1', + amount: '10', + tokenType: TokenType.NATIVE, + status: 'success' as const, + attemptedAt: new Date(), + resolvedAt: new Date(), + }; + mockProcessFallback.mockResolvedValue({ + success: true, + attempt: mockAttempt, + fallbackAttempts: [], + }); + + await act(async () => { + await useWalletStore.getState().processPayment('sub_1', '10', 1); + }); + + const { paymentAttempts, paymentMethods } = useWalletStore.getState(); + expect(paymentAttempts).toHaveLength(1); + expect(paymentMethods[0].lastUsedAt).not.toBeNull(); + }); + }); + + // getExpiryInfo ──────────────────────────────────────────────────────────── + + describe('getExpiryInfo', () => { + it('returns structured expired/expiringSoon lists', () => { + const expired = baseMethod({ id: 'pm_exp', expiresAt: new Date(0) }); + useWalletStore.setState({ paymentMethods: [expired] }); + + mockGetExpired.mockReturnValue([expired]); + mockCheckExpiry.mockReturnValue({ + method: expired, daysUntilExpiry: -2, isExpired: true, isExpiringSoon: false, + }); + + const info = useWalletStore.getState().getExpiryInfo(); + expect(info.expired).toHaveLength(1); + expect(info.expired[0].isExpired).toBe(true); + }); + }); + + // getPaymentMethodsByPriority ────────────────────────────────────────────── + + describe('getPaymentMethodsByPriority', () => { + it('returns an object with primary, backup, fallback arrays', () => { + const result = useWalletStore.getState().getPaymentMethodsByPriority(); + expect(result).toHaveProperty('primary'); + expect(result).toHaveProperty('backup'); + expect(result).toHaveProperty('fallback'); + }); + }); +}); + +// ── Fallback chains ──────────────────────────────────────────────────────── + +describe('useWalletStore — fallback chains', () => { + + describe('createFallbackChain', () => { + it('adds chain to the store', () => { + const m = baseMethod({ id: 'pm_c1' }); + useWalletStore.setState({ paymentMethods: [m] }); + + act(() => { + useWalletStore.getState().createFallbackChain('My chain', [m.id]); + }); + + const { fallbackChains } = useWalletStore.getState(); + expect(fallbackChains).toHaveLength(1); + expect(fallbackChains[0].name).toBe('My chain'); + expect(fallbackChains[0].methodIds).toContain(m.id); + }); + + it('throws and sets error when validation fails', () => { + mockValidateChain.mockReturnValue({ isValid: false, errors: ['No methods'], warnings: [] }); + + expect(() => { + act(() => { + useWalletStore.getState().createFallbackChain('Bad chain', []); + }); + }).toThrow(PaymentMethodError); + + expect(useWalletStore.getState().error).not.toBeNull(); + }); + + it('sets optional subscriptionId', () => { + const m = baseMethod({ id: 'pm_sub' }); + useWalletStore.setState({ paymentMethods: [m] }); + + act(() => { + useWalletStore.getState().createFallbackChain('Sub chain', [m.id], { + subscriptionId: 'sub_42', + }); + }); + + expect(useWalletStore.getState().fallbackChains[0].subscriptionId).toBe('sub_42'); + }); + }); + + describe('updateFallbackChain', () => { + it('updates name', () => { + const chain = baseChain({ id: 'chain_u', name: 'old' }); + useWalletStore.setState({ fallbackChains: [chain] }); + + act(() => { + useWalletStore.getState().updateFallbackChain('chain_u', { name: 'new' }); + }); + + expect(useWalletStore.getState().fallbackChains[0].name).toBe('new'); + }); + }); + + describe('deleteFallbackChain', () => { + it('removes chain by id', () => { + const chain = baseChain({ id: 'chain_d' }); + useWalletStore.setState({ fallbackChains: [chain] }); + + act(() => { + useWalletStore.getState().deleteFallbackChain('chain_d'); + }); + + expect(useWalletStore.getState().fallbackChains).toHaveLength(0); + }); + }); + + describe('reorderFallbackChain', () => { + it('replaces methodIds with new order', () => { + const chain = baseChain({ id: 'chain_r', methodIds: ['a', 'b', 'c'] }); + useWalletStore.setState({ fallbackChains: [chain] }); + + act(() => { + useWalletStore.getState().reorderFallbackChain('chain_r', ['c', 'a', 'b']); + }); + + expect(useWalletStore.getState().fallbackChains[0].methodIds).toEqual(['c', 'a', 'b']); + }); + }); + + describe('validateFallbackChain', () => { + it('returns null for unknown id', () => { + expect(useWalletStore.getState().validateFallbackChain('unknown')).toBeNull(); + }); + + it('returns validation result for known chain', () => { + const chain = baseChain({ id: 'chain_v', methodIds: ['pm_1'] }); + useWalletStore.setState({ fallbackChains: [chain] }); + mockValidateChain.mockReturnValue({ isValid: true, errors: [], warnings: ['Only one method'] }); + + const result = useWalletStore.getState().validateFallbackChain('chain_v'); + expect(result?.isValid).toBe(true); + expect(result?.warnings).toHaveLength(1); + }); + }); + + describe('chainForSubscription', () => { + it('delegates to paymentService.selectChainForSubscription', () => { + const chain = baseChain({ subscriptionId: 'sub_99' }); + useWalletStore.setState({ fallbackChains: [chain] }); + mockSelectChain.mockReturnValue(chain); + + const result = useWalletStore.getState().chainForSubscription('sub_99'); + expect(result).toBe(chain); + }); + }); +}); + +// ── Expiry & alerts ──────────────────────────────────────────────────────── + +describe('useWalletStore — expiry', () => { + + describe('deactivateExpiredMethods', () => { + it('deactivates expired methods and returns count', () => { + const expired = baseMethod({ id: 'pm_e1', expiresAt: new Date(0) }); + useWalletStore.setState({ paymentMethods: [expired] }); + mockGetExpired.mockReturnValue([expired]); + mockMarkExpired.mockReturnValue({ ...expired, isActive: false }); + + let count = 0; + act(() => { + count = useWalletStore.getState().deactivateExpiredMethods(); + }); + + expect(count).toBe(1); + expect(useWalletStore.getState().paymentMethods[0].isActive).toBe(false); + }); + + it('returns 0 when nothing expired', () => { + useWalletStore.setState({ paymentMethods: [baseMethod()] }); + mockGetExpired.mockReturnValue([]); + + let count = 0; + act(() => { + count = useWalletStore.getState().deactivateExpiredMethods(); + }); + + expect(count).toBe(0); + }); + }); + + describe('expiryAlerts', () => { + it('returns alerts from the service', () => { + mockBuildAlerts.mockReturnValue([{ methodId: 'pm_x', severity: 'warning' }]); + const alerts = useWalletStore.getState().expiryAlerts(); + expect(alerts).toHaveLength(1); + }); + + it('returns empty array when no alerts', () => { + mockBuildAlerts.mockReturnValue([]); + expect(useWalletStore.getState().expiryAlerts()).toHaveLength(0); + }); + }); +}); + +// ── Analytics ────────────────────────────────────────────────────────────── + +describe('useWalletStore — analytics', () => { + it('delegates to paymentService.computeAnalytics', () => { + mockComputeAnalytics.mockReturnValue({ totalAttempts: 7, successRate: 0.9 }); + const analytics = useWalletStore.getState().paymentAnalytics(); + expect(analytics.totalAttempts).toBe(7); + expect(mockComputeAnalytics).toHaveBeenCalled(); + }); +}); + +// ── Sharing ──────────────────────────────────────────────────────────────── + +describe('useWalletStore — sharing', () => { + + describe('sharePaymentMethod', () => { + it('adds share to store', () => { + const m = baseMethod({ id: 'pm_sh' }); + useWalletStore.setState({ paymentMethods: [m] }); + + const share = { + id: 'sh_1', methodId: m.id, granteeId: '0xG', role: 'viewer' as const, + spendLimit: null, expiresAt: null, createdAt: new Date(), revokedAt: null, + }; + mockCreateShare.mockReturnValue(share); + + act(() => { + useWalletStore.getState().sharePaymentMethod(m.id, '0xG', 'viewer'); + }); + + expect(useWalletStore.getState().paymentMethodShares).toHaveLength(1); + expect(useWalletStore.getState().paymentMethodShares[0].granteeId).toBe('0xG'); + }); + + it('throws PaymentMethodError when method not found', () => { + expect(() => { + act(() => { + useWalletStore.getState().sharePaymentMethod('pm_missing', '0xG', 'viewer'); + }); + }).toThrow(PaymentMethodError); + }); + }); + + describe('revokePaymentMethodShare', () => { + it('sets revokedAt on the targeted share', () => { + const share = { + id: 'sh_rev', methodId: 'pm_1', granteeId: '0xG', role: 'viewer' as const, + spendLimit: null, expiresAt: null, createdAt: new Date(), revokedAt: null, + }; + useWalletStore.setState({ paymentMethodShares: [share] }); + + act(() => { + useWalletStore.getState().revokePaymentMethodShare('sh_rev'); + }); + + expect(useWalletStore.getState().paymentMethodShares[0].revokedAt).not.toBeNull(); + }); + + it('ignores already-revoked shares', () => { + const alreadyRevoked = { + id: 'sh_already', methodId: 'pm_1', granteeId: '0xG', role: 'viewer' as const, + spendLimit: null, expiresAt: null, createdAt: new Date(), + revokedAt: new Date('2025-01-01'), + }; + useWalletStore.setState({ paymentMethodShares: [alreadyRevoked] }); + + act(() => { + useWalletStore.getState().revokePaymentMethodShare('sh_already'); + }); + + // revokedAt should remain the original date, not updated + expect( + useWalletStore.getState().paymentMethodShares[0].revokedAt?.toISOString() + ).toBe('2025-01-01T00:00:00.000Z'); + }); + }); + + describe('sharesForMethod', () => { + it('returns active shares for a method via service delegation', () => { + const share = { + id: 'sh_1', methodId: 'pm_1', granteeId: '0xG', role: 'viewer' as const, + spendLimit: null, expiresAt: null, createdAt: new Date(), revokedAt: null, + }; + useWalletStore.setState({ paymentMethodShares: [share] }); + mockIsShareActive.mockReturnValue(true); + + const result = useWalletStore.getState().sharesForMethod('pm_1'); + expect(result).toHaveLength(1); + }); + }); + + describe('methodsSharedWith', () => { + it('delegates to paymentService.getSharedMethods', () => { + const m = baseMethod({ id: 'pm_shared' }); + mockGetSharedMethods.mockReturnValue([m]); + + const result = useWalletStore.getState().methodsSharedWith('0xGrantee'); + expect(result).toContain(m); + }); + }); +}); + +// ── Disconnect ───────────────────────────────────────────────────────────── + +describe('useWalletStore — disconnect', () => { + it('clears all payment data and connection', async () => { + useWalletStore.setState({ + paymentMethods: [baseMethod()], + fallbackChains: [baseChain()], + paymentAttempts: [], + paymentMethodShares: [], + }); + + await act(async () => { + await useWalletStore.getState().disconnect(); + }); + + const s = useWalletStore.getState(); + expect(s.paymentMethods).toHaveLength(0); + expect(s.fallbackChains).toHaveLength(0); + expect(s.paymentMethodShares).toHaveLength(0); + expect(s.connection).toBeNull(); + }); +}); + +// ── checkTokenContractUpgrade ────────────────────────────────────────────── + +describe('useWalletStore — checkTokenContractUpgrade', () => { + it('returns false when no upgrade detected', async () => { + const m = baseMethod({ id: 'pm_code', tokenType: TokenType.USDC, tokenAddress: '0xToken' }); + useWalletStore.setState({ paymentMethods: [m] }); + mockDetectUpgrade.mockResolvedValue({ upgraded: false, newHash: '0xabc' }); + + let result = false; + await act(async () => { + result = await useWalletStore.getState().checkTokenContractUpgrade('pm_code'); + }); + + expect(result).toBe(false); + // Hash should still be persisted + expect( + useWalletStore.getState().paymentMethods[0].metadata['token_code_hash'] + ).toBe('0xabc'); + }); + + it('stores new hash and returns true when upgrade detected', async () => { + const m = baseMethod({ + id: 'pm_upg', + tokenType: TokenType.USDC, + tokenAddress: '0xToken', + metadata: { token_code_hash: '0xold' }, + }); + useWalletStore.setState({ paymentMethods: [m] }); + mockDetectUpgrade.mockResolvedValue({ upgraded: true, newHash: '0xnewhash' }); + + let result = false; + await act(async () => { + result = await useWalletStore.getState().checkTokenContractUpgrade('pm_upg'); + }); + + expect(result).toBe(true); + expect( + useWalletStore.getState().paymentMethods[0].metadata['token_code_hash'] + ).toBe('0xnewhash'); + }); + + it('returns false when method id does not exist', async () => { + let result = false; + await act(async () => { + result = await useWalletStore.getState().checkTokenContractUpgrade('pm_none'); + }); + expect(result).toBe(false); + }); +}); diff --git a/src/store/walletStore.ts b/src/store/walletStore.ts index 1138e840..99751975 100644 --- a/src/store/walletStore.ts +++ b/src/store/walletStore.ts @@ -17,14 +17,14 @@ import { } from '../types/wallet'; import { WalletServiceManager, + WalletConnection, +} from '../services/walletService'; +import { PaymentMethodService, PaymentMethodError, PaymentMethodErrorCode, - WalletConnection, -} from '../services/walletService'; -import type { - ChainPaymentResult, - PaymentMethodExpiryCheck, + type ChainPaymentResult, + type PaymentMethodExpiryCheck, } from '../services/paymentMethodService'; import { Network } from '../config/networks'; diff --git a/src/types/paymentMethod.ts b/src/types/paymentMethod.ts new file mode 100644 index 00000000..9a3f5b07 --- /dev/null +++ b/src/types/paymentMethod.ts @@ -0,0 +1,72 @@ +/** + * paymentMethod.ts + * + * Dedicated type re-exports and extensions for payment method management. + * Primary types live in `types/wallet.ts`; this module exposes them through + * a stable public surface so consumers don't need to import from wallet.ts + * directly and avoids circular dependency problems. + */ + +export { + TokenType, + PaymentPriority, + PaymentMethod, + PaymentMethodFormData, + PaymentAttempt, + PaymentMethodValidationResult, + FallbackChain, + FallbackChainValidation, + ExpiryAlertSeverity, + PaymentMethodExpiryAlert, + PaymentMethodShareRole, + PaymentMethodShare, + PaymentMethodStats, + PaymentMethodAnalytics, +} from './wallet'; + +export type { + GasEstimate, + TokenBalance, + CryptoStream, + StreamSetup, +} from './wallet'; + +// ── Additional UI-layer types not present in wallet.ts ────────────────────── + +/** + * Sections for the PaymentMethodManager UI screen. + */ +export type ManagerTab = 'methods' | 'chains' | 'analytics' | 'alerts'; + +/** + * View state for add/edit forms in the manager UI. + */ +export interface PaymentMethodFormState { + /** `null` means "add new"; non-null means editing an existing id. */ + editingId: string | null; + isOpen: boolean; +} + +/** + * Props for the PaymentMethodManager screen component. + */ +export interface PaymentMethodManagerProps { + /** Initial tab to display. Defaults to 'methods'. */ + initialTab?: ManagerTab; + /** Called when the user navigates away from the manager. */ + onClose?: () => void; +} + +/** + * Minimal shape exposed to share/delegation UIs so they can display the + * grantee without needing the full PaymentMethod record. + */ +export interface SharePreview { + shareId: string; + methodLabel: string; + granteeId: string; + role: import('./wallet').PaymentMethodShareRole; + spendLimit: string | null; + expiresAt: Date | null; + isActive: boolean; +}