diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 6ef9207..7f10759 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -48,6 +48,7 @@ import ApiKeyManagementScreen from '../screens/ApiKeyManagementScreen'; import DocumentationPortalScreen from '../screens/DocumentationPortalScreen'; import IntegrationGuidesScreen from '../screens/IntegrationGuidesScreen'; import PerformanceDashboardScreen from '../screens/PerformanceDashboardScreen'; +import DunningDashboard from '../screens/DunningDashboard'; import { colors } from '../utils/constants'; import { RootStackParamList, TabParamList } from './types'; @@ -304,6 +305,11 @@ const SettingsStack = () => ( component={PerformanceDashboardScreen} options={{ title: 'Performance', headerShown: true }} /> + ); diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 4beeb89..b6f53b6 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -43,6 +43,7 @@ export type RootStackParamList = { LoyaltyDashboard: undefined; CampaignManagement: undefined; PerformanceDashboard: undefined; + DunningDashboard: undefined; }; export type TabParamList = { diff --git a/src/screens/DunningDashboard.tsx b/src/screens/DunningDashboard.tsx new file mode 100644 index 0000000..b74c51a --- /dev/null +++ b/src/screens/DunningDashboard.tsx @@ -0,0 +1,616 @@ +import React, { useState, useMemo } from 'react'; +import { + View, + Text, + StyleSheet, + SafeAreaView, + FlatList, + TouchableOpacity, + Alert, + ScrollView, +} from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { RootStackParamList } from '../navigation/types'; +import { useDunningStore, RETRY_SCHEDULE_DAYS } from '../store/dunningStore'; +import { DunningEntry, DunningStage } from '../types/dunning'; +import { colors, spacing, typography, borderRadius } from '../utils/constants'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const STAGE_COLOR: Record = { + retry: colors.warning, + warn: '#f97316', // orange + suspend: colors.error, + cancel: '#6b7280', // gray +}; + +const STAGE_LABEL: Record = { + retry: 'Retrying', + warn: 'Warning', + suspend: 'Suspended', + cancel: 'Cancelled', +}; + +const STAGE_ICON: Record = { + retry: '🔄', + warn: '⚠️', + suspend: '⏸️', + cancel: '❌', +}; + +function formatTs(ts: number): string { + return new Date(ts).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +function timeUntil(ts: number): string { + const diff = ts - Date.now(); + if (diff <= 0) return 'Now'; + const h = Math.floor(diff / 3_600_000); + const d = Math.floor(h / 24); + if (d > 0) return `${d}d ${h % 24}h`; + return `${h}h`; +} + +// ─── Analytics bar ──────────────────────────────────────────────────────────── + +const AnalyticsBar: React.FC = () => { + const getAnalytics = useDunningStore((s) => s.getAnalytics); + const analytics = getAnalytics(); + + const stats = [ + { label: 'Active', value: analytics.totalActiveDunning, color: colors.primary }, + { label: 'Retrying', value: analytics.stageBreakdown.retry, color: colors.warning }, + { label: 'Warned', value: analytics.stageBreakdown.warn, color: '#f97316' }, + { label: 'Suspended', value: analytics.stageBreakdown.suspend, color: colors.error }, + { label: 'Cancelled', value: analytics.stageBreakdown.cancel, color: '#6b7280' }, + { label: 'Recovery %', value: `${analytics.recoveryRate}%`, color: colors.success }, + ]; + + return ( + + {stats.map((s) => ( + + {s.value} + {s.label} + + ))} + + ); +}; + +// ─── Retry schedule chip row ────────────────────────────────────────────────── + +const RetrySchedule: React.FC = () => ( + + Retry schedule (days): + + {RETRY_SCHEDULE_DAYS.map((d, i) => ( + + + {i + 1}. Day {d} + + + ))} + + +); + +// ─── Entry card ─────────────────────────────────────────────────────────────── + +interface CardProps { + entry: DunningEntry; + onPress: (entry: DunningEntry) => void; +} + +const EntryCard: React.FC = ({ entry, onPress }) => ( + onPress(entry)} + accessibilityRole="button" + accessibilityLabel={`Dunning entry for subscription ${entry.subscriptionId}, stage ${STAGE_LABEL[entry.currentStage]}`}> + + {STAGE_ICON[entry.currentStage]} + + + {entry.subscriptionId} + + + Subscriber: {entry.subscriberId} + + + + + {STAGE_LABEL[entry.currentStage]} + + + + + + + Failed attempts + {entry.totalFailedCharges} + + + Next action + {timeUntil(entry.nextActionAt)} + + + First failure + {formatTs(entry.firstFailureAt)} + + + + {entry.isPaused && ( + + ⏸ Paused — awaiting manual review + + )} + +); + +// ─── Detail sheet ───────────────────────────────────────────────────────────── + +interface DetailProps { + entry: DunningEntry; + onClose: () => void; +} + +const DetailSheet: React.FC = ({ entry, onClose }) => { + const { pauseDunning, resumeDunning, overrideStage, escalateToSupport, overrideDunning, recordPaymentAttempt } = + useDunningStore(); + + const handleEscalate = () => { + Alert.alert( + 'Escalate to Support', + 'This will pause automated retries and flag the case for human review.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Escalate', + style: 'destructive', + onPress: () => { + escalateToSupport(entry.subscriptionId); + onClose(); + }, + }, + ] + ); + }; + + const handleOverride = (resolution: 'resolved' | 'waived' | 'cancelled') => { + Alert.alert( + 'Override Dunning', + `Mark this case as "${resolution}" and remove it from dunning?`, + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Confirm', + onPress: () => { + overrideDunning(entry.subscriptionId, resolution); + onClose(); + }, + }, + ] + ); + }; + + const handleManualPayment = () => { + Alert.alert( + 'Record Manual Payment', + 'Mark this subscription as paid and remove from dunning?', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Mark Paid', + onPress: () => { + recordPaymentAttempt(entry.subscriptionId, true); + onClose(); + }, + }, + ] + ); + }; + + const stages: DunningStage[] = ['retry', 'warn', 'suspend', 'cancel']; + + return ( + + + + + Dunning Details + + + + + + + {/* Info rows */} + {[ + ['Subscription ID', entry.subscriptionId], + ['Subscriber', entry.subscriberId], + ['Plan', entry.planId], + ['Stage', STAGE_LABEL[entry.currentStage]], + ['Failed attempts (stage)', String(entry.failedAttempts)], + ['Total failed charges', String(entry.totalFailedCharges)], + ['Next action', formatTs(entry.nextActionAt)], + ['First failure', formatTs(entry.firstFailureAt)], + ['Last failure', formatTs(entry.lastFailureAt)], + ['Status', entry.isPaused ? 'Paused' : 'Active'], + ].map(([label, value]) => ( + + {label} + {value} + + ))} + + {/* Communication log */} + {entry.communicationLog.length > 0 && ( + + Notification log + {entry.communicationLog.map((c) => ( + + {c.channel.toUpperCase()} + {STAGE_LABEL[c.stage]} + {formatTs(c.sentAt)} + + ))} + + )} + + {/* Stage override */} + Override stage + + {stages.map((stage) => ( + { + overrideStage(entry.subscriptionId, stage); + onClose(); + }} + accessibilityRole="button" + accessibilityLabel={`Set stage to ${STAGE_LABEL[stage]}`}> + + {STAGE_LABEL[stage]} + + + ))} + + + {/* Actions */} + Actions + + { + entry.isPaused + ? resumeDunning(entry.subscriptionId) + : pauseDunning(entry.subscriptionId); + onClose(); + }}> + + {entry.isPaused ? '▶ Resume' : '⏸ Pause'} + + + + + 💳 Manual Payment Override + + + + + 🚨 Escalate to Support + + + + handleOverride('resolved')}> + ✅ Mark Resolved + + + handleOverride('waived')}> + 🗑 Waive & Remove + + + + + + ); +}; + +// ─── Screen ─────────────────────────────────────────────────────────────────── + +const STAGE_FILTERS: Array = ['all', 'retry', 'warn', 'suspend', 'cancel']; + +const DunningDashboard: React.FC = () => { + const navigation = useNavigation>(); + const entries = useDunningStore((s) => s.entries); + const [filter, setFilter] = useState('all'); + const [selected, setSelected] = useState(null); + + const filtered = useMemo( + () => (filter === 'all' ? entries : entries.filter((e) => e.currentStage === filter)), + [entries, filter] + ); + + return ( + + {/* Header */} + + navigation.goBack()} + style={styles.backBtn} + accessibilityRole="button" + accessibilityLabel="Go back"> + ‹ Back + + Dunning + + + + {/* Analytics */} + + + {/* Retry schedule */} + + + {/* Filter chips */} + + {STAGE_FILTERS.map((f) => ( + setFilter(f)} + accessibilityRole="radio" + accessibilityState={{ checked: filter === f }} + accessibilityLabel={f === 'all' ? 'All stages' : STAGE_LABEL[f]}> + + {f === 'all' ? 'All' : STAGE_LABEL[f]} + + + ))} + + + {/* List */} + item.id} + renderItem={({ item }) => } + contentContainerStyle={filtered.length === 0 ? styles.listEmpty : styles.list} + showsVerticalScrollIndicator={false} + ListEmptyComponent={ + + + No active dunning cases + + Failed payments will appear here and be retried automatically on days{' '} + {RETRY_SCHEDULE_DAYS.join(', ')}. + + + } + /> + + {/* Detail sheet */} + {selected ? setSelected(null)} /> : null} + + ); +}; + +// ─── Styles ─────────────────────────────────────────────────────────────────── + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.background }, + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + }, + backBtn: { padding: spacing.sm }, + backText: { ...typography.body, color: colors.primary, fontWeight: '500' }, + title: { ...typography.h2, color: colors.text, flex: 1, textAlign: 'center' }, + headerSpacer: { width: 60 }, + + // Analytics + analyticsBar: { + flexDirection: 'row', + flexWrap: 'wrap', + paddingHorizontal: spacing.lg, + gap: spacing.sm, + marginBottom: spacing.sm, + }, + statBox: { + backgroundColor: colors.surface, + borderRadius: borderRadius.md, + padding: spacing.sm, + alignItems: 'center', + minWidth: 70, + borderWidth: 1, + borderColor: colors.border, + }, + statValue: { ...typography.h3, fontWeight: '700' }, + statLabel: { ...typography.small, color: colors.textSecondary, marginTop: 2 }, + + // Retry schedule + scheduleRow: { + paddingHorizontal: spacing.lg, + marginBottom: spacing.sm, + }, + scheduleTitle: { ...typography.caption, color: colors.textSecondary, marginBottom: spacing.xs }, + scheduleChips: { flexDirection: 'row', gap: spacing.xs }, + scheduleChip: { + backgroundColor: colors.surface, + borderRadius: borderRadius.full, + paddingHorizontal: spacing.sm, + paddingVertical: 2, + borderWidth: 1, + borderColor: colors.border, + }, + scheduleChipText: { ...typography.small, color: colors.textSecondary }, + + // Filters + filters: { + flexDirection: 'row', + paddingHorizontal: spacing.lg, + gap: spacing.xs, + marginBottom: spacing.sm, + flexWrap: 'wrap', + }, + chip: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs, + borderRadius: borderRadius.full, + backgroundColor: colors.surface, + borderWidth: 1, + borderColor: colors.border, + }, + chipActive: { backgroundColor: colors.primary, borderColor: colors.primary }, + chipText: { ...typography.caption, color: colors.textSecondary }, + chipTextActive: { color: colors.text, fontWeight: '600' }, + + // List + list: { paddingHorizontal: spacing.lg, paddingBottom: spacing.xl }, + listEmpty: { flex: 1 }, + + // Card + card: { + backgroundColor: colors.surface, + borderRadius: borderRadius.md, + padding: spacing.md, + marginBottom: spacing.sm, + borderWidth: 1, + borderColor: colors.border, + }, + cardPaused: { opacity: 0.7, borderStyle: 'dashed' }, + cardHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: spacing.sm }, + cardIcon: { fontSize: 24, marginRight: spacing.sm }, + cardHeaderText: { flex: 1 }, + cardSubId: { ...typography.body, color: colors.text, fontWeight: '600' }, + cardMeta: { ...typography.caption, color: colors.textSecondary }, + stageBadge: { + paddingHorizontal: spacing.sm, + paddingVertical: 2, + borderRadius: borderRadius.full, + }, + stageBadgeText: { ...typography.small, fontWeight: '600' }, + cardBody: { flexDirection: 'row', justifyContent: 'space-between' }, + cardStat: { alignItems: 'center' }, + cardStatLabel: { ...typography.small, color: colors.textSecondary }, + cardStatValue: { ...typography.body, color: colors.text, fontWeight: '600' }, + pausedBanner: { + marginTop: spacing.sm, + backgroundColor: colors.warning + '22', + borderRadius: borderRadius.sm, + padding: spacing.xs, + alignItems: 'center', + }, + pausedText: { ...typography.small, color: colors.warning }, + + // Empty + empty: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: spacing.xl }, + emptyIcon: { fontSize: 48, marginBottom: spacing.md }, + emptyTitle: { ...typography.h3, color: colors.text, marginBottom: spacing.sm }, + emptyBody: { ...typography.body, color: colors.textSecondary, textAlign: 'center', lineHeight: 22 }, + + // Detail sheet + sheetOverlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0,0,0,0.6)', + justifyContent: 'flex-end', + }, + sheet: { + backgroundColor: colors.surface, + borderTopLeftRadius: borderRadius.xl, + borderTopRightRadius: borderRadius.xl, + padding: spacing.lg, + paddingBottom: spacing.xxl, + maxHeight: '85%', + borderWidth: 1, + borderColor: colors.border, + }, + sheetHandle: { + width: 40, + height: 4, + backgroundColor: colors.border, + borderRadius: 2, + alignSelf: 'center', + marginBottom: spacing.md, + }, + sheetHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: spacing.md, + }, + sheetTitle: { ...typography.h3, color: colors.text }, + sheetClose: { ...typography.h3, color: colors.textSecondary, padding: spacing.sm }, + infoRow: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingVertical: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: colors.border, + }, + infoLabel: { ...typography.body, color: colors.textSecondary }, + infoValue: { ...typography.body, color: colors.text, fontWeight: '500' }, + commSection: { marginTop: spacing.md, marginBottom: spacing.sm }, + commTitle: { ...typography.caption, color: colors.textSecondary, marginBottom: spacing.xs, fontWeight: '600' }, + commRow: { + flexDirection: 'row', + gap: spacing.sm, + paddingVertical: spacing.xs, + borderBottomWidth: 1, + borderBottomColor: colors.border, + }, + commChannel: { ...typography.small, color: colors.accent, fontWeight: '600', width: 50 }, + commStage: { ...typography.small, color: colors.text, flex: 1 }, + commDate: { ...typography.small, color: colors.textSecondary }, + sectionLabel: { + ...typography.caption, + color: colors.textSecondary, + fontWeight: '600', + marginTop: spacing.md, + marginBottom: spacing.sm, + }, + stageRow: { flexDirection: 'row', gap: spacing.sm, flexWrap: 'wrap', marginBottom: spacing.sm }, + stageChip: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs, + borderRadius: borderRadius.full, + backgroundColor: colors.surface, + borderWidth: 1, + borderColor: colors.border, + }, + stageChipText: { ...typography.caption, color: colors.textSecondary }, + actions: { gap: spacing.sm, marginBottom: spacing.lg }, + actionBtn: { + backgroundColor: colors.surface, + borderRadius: borderRadius.md, + paddingVertical: spacing.md, + alignItems: 'center', + borderWidth: 1, + borderColor: colors.border, + }, + actionBtnWarn: { borderColor: colors.warning + '66' }, + actionBtnDanger: { borderColor: colors.error + '44' }, + actionBtnText: { ...typography.body, color: colors.text, fontWeight: '600' }, +}); + +export default DunningDashboard; diff --git a/src/store/dunningStore.ts b/src/store/dunningStore.ts new file mode 100644 index 0000000..2124f5b --- /dev/null +++ b/src/store/dunningStore.ts @@ -0,0 +1,306 @@ +import { create } from 'zustand'; +import { persist, createJSONStorage } from 'zustand/middleware'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { + DunningEntry, + DunningStage, + DunningAnalytics, + DunningConfiguration, + DunningCommunication, + DEFAULT_DUNNING_STAGES, +} from '../types/dunning'; + +const STORAGE_KEY = 'subtrackr-dunning'; +const ONE_HOUR_MS = 3_600_000; + +// Retry schedule in days → converted to hours: 1d, 3d, 7d, 14d +export const RETRY_SCHEDULE_DAYS = [1, 3, 7, 14]; + +const now = (): number => Date.now(); +const createId = (prefix: string): string => + `${prefix}_${now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; + +interface DunningState { + entries: DunningEntry[]; + configurations: Record; + isLoading: boolean; + error: string | null; + + // Core dunning lifecycle + startDunning: ( + subscriptionId: string, + subscriberId: string, + merchantId: string, + planId?: string + ) => DunningEntry; + recordPaymentAttempt: (subscriptionId: string, success: boolean) => DunningEntry | null; + escalateToSupport: (subscriptionId: string) => DunningEntry | null; + overrideDunning: ( + subscriptionId: string, + resolution: 'resolved' | 'waived' | 'cancelled' + ) => void; + + // Controls + pauseDunning: (subscriptionId: string) => void; + resumeDunning: (subscriptionId: string) => void; + overrideStage: (subscriptionId: string, stage: DunningStage) => void; + + // Config + configurePlan: (planId: string, config: Partial) => void; + + // Selectors + getEntry: (subscriptionId: string) => DunningEntry | undefined; + getActiveEntries: () => DunningEntry[]; + getAnalytics: () => DunningAnalytics; + + clearError: () => void; +} + +const DEFAULT_CONFIG: DunningConfiguration = { + planId: 'default', + stages: DEFAULT_DUNNING_STAGES, + maxRetries: RETRY_SCHEDULE_DAYS.length, + retryIntervalHours: 24, + warnAfterFailures: 3, + suspendAfterDays: 7, + cancelAfterDays: 14, + communicationChannels: ['email', 'push', 'in_app'], +}; + +export const useDunningStore = create()( + persist( + (set, get) => ({ + entries: [], + configurations: { default: DEFAULT_CONFIG }, + isLoading: false, + error: null, + + startDunning: (subscriptionId, subscriberId, merchantId, planId = 'default') => { + const existing = get().entries.find((e) => e.subscriptionId === subscriptionId); + if (existing) return existing; + + const config = get().configurations[planId] ?? DEFAULT_CONFIG; + const firstStage = config.stages[0] ?? DEFAULT_DUNNING_STAGES[0]; + const ts = now(); + + const entry: DunningEntry = { + id: createId('dun'), + subscriptionId, + subscriberId, + merchantId, + planId, + currentStage: firstStage.stage, + failedAttempts: 0, + totalFailedCharges: 0, + firstFailureAt: ts, + lastFailureAt: ts, + lastAttemptAt: ts, + nextActionAt: ts + firstStage.delayHours * ONE_HOUR_MS, + isPaused: false, + communicationLog: [], + createdAt: ts, + updatedAt: ts, + }; + + set((s) => ({ entries: [...s.entries, entry] })); + return entry; + }, + + recordPaymentAttempt: (subscriptionId, success) => { + const entry = get().entries.find((e) => e.subscriptionId === subscriptionId); + if (!entry || entry.isPaused) return null; + + if (success) { + // Payment recovered — remove from dunning + set((s) => ({ + entries: s.entries.filter((e) => e.subscriptionId !== subscriptionId), + })); + return null; + } + + const config = get().configurations[entry.planId] ?? DEFAULT_CONFIG; + const ts = now(); + const stageIdx = config.stages.findIndex((s) => s.stage === entry.currentStage); + const stageConfig = config.stages[stageIdx]; + const newFailedAttempts = entry.failedAttempts + 1; + + let nextStage: DunningStage = entry.currentStage; + let nextDelay = config.retryIntervalHours * ONE_HOUR_MS; + const newComm: DunningCommunication = { + id: createId('dcom'), + stage: entry.currentStage, + channel: 'push', + templateId: stageConfig?.templateId ?? 'payment_retry', + sentAt: ts, + status: 'sent', + metadata: { subscription_id: subscriptionId }, + }; + + // Advance stage when max attempts for current stage reached + if (stageConfig && newFailedAttempts >= stageConfig.maxAttempts) { + const nextIdx = stageIdx + 1; + if (nextIdx < config.stages.length) { + nextStage = config.stages[nextIdx].stage; + nextDelay = config.stages[nextIdx].delayHours * ONE_HOUR_MS; + } else { + nextStage = 'cancel'; + nextDelay = 24 * ONE_HOUR_MS; + } + } + + set((s) => ({ + entries: s.entries.map((e) => + e.subscriptionId === subscriptionId + ? { + ...e, + currentStage: nextStage, + failedAttempts: nextStage !== entry.currentStage ? 0 : newFailedAttempts, + totalFailedCharges: e.totalFailedCharges + 1, + lastFailureAt: ts, + lastAttemptAt: ts, + nextActionAt: ts + nextDelay, + communicationLog: [...e.communicationLog, newComm], + updatedAt: ts, + } + : e + ), + })); + + return get().entries.find((e) => e.subscriptionId === subscriptionId) ?? null; + }, + + escalateToSupport: (subscriptionId) => { + const entry = get().entries.find((e) => e.subscriptionId === subscriptionId); + if (!entry) return null; + + const ts = now(); + const comm: DunningCommunication = { + id: createId('dcom'), + stage: 'suspend', + channel: 'in_app', + templateId: 'escalate_support', + sentAt: ts, + status: 'sent', + metadata: { subscription_id: subscriptionId, escalated: 'true' }, + }; + + set((s) => ({ + entries: s.entries.map((e) => + e.subscriptionId === subscriptionId + ? { + ...e, + currentStage: 'suspend' as DunningStage, + isPaused: true, // pause automated retries while human reviews + communicationLog: [...e.communicationLog, comm], + updatedAt: ts, + } + : e + ), + })); + + return get().entries.find((e) => e.subscriptionId === subscriptionId) ?? null; + }, + + overrideDunning: (subscriptionId, _resolution) => { + set((s) => ({ + entries: s.entries.filter((e) => e.subscriptionId !== subscriptionId), + })); + }, + + pauseDunning: (subscriptionId) => { + set((s) => ({ + entries: s.entries.map((e) => + e.subscriptionId === subscriptionId + ? { ...e, isPaused: true, updatedAt: now() } + : e + ), + })); + }, + + resumeDunning: (subscriptionId) => { + const entry = get().entries.find((e) => e.subscriptionId === subscriptionId); + if (!entry) return; + const config = get().configurations[entry.planId] ?? DEFAULT_CONFIG; + const stageConfig = config.stages.find((s) => s.stage === entry.currentStage); + const delay = (stageConfig?.delayHours ?? 24) * ONE_HOUR_MS; + + set((s) => ({ + entries: s.entries.map((e) => + e.subscriptionId === subscriptionId + ? { ...e, isPaused: false, nextActionAt: now() + delay, updatedAt: now() } + : e + ), + })); + }, + + overrideStage: (subscriptionId, stage) => { + const entry = get().entries.find((e) => e.subscriptionId === subscriptionId); + if (!entry) return; + const config = get().configurations[entry.planId] ?? DEFAULT_CONFIG; + const stageConfig = config.stages.find((s) => s.stage === stage); + const delay = (stageConfig?.delayHours ?? 24) * ONE_HOUR_MS; + + set((s) => ({ + entries: s.entries.map((e) => + e.subscriptionId === subscriptionId + ? { + ...e, + currentStage: stage, + failedAttempts: 0, + nextActionAt: now() + delay, + updatedAt: now(), + } + : e + ), + })); + }, + + configurePlan: (planId, config) => { + const existing = get().configurations[planId] ?? DEFAULT_CONFIG; + set((s) => ({ + configurations: { + ...s.configurations, + [planId]: { ...existing, ...config, planId }, + }, + })); + }, + + getEntry: (subscriptionId) => + get().entries.find((e) => e.subscriptionId === subscriptionId), + + getActiveEntries: () => get().entries.filter((e) => !e.isPaused), + + getAnalytics: (): DunningAnalytics => { + const entries = get().entries; + const breakdown: Record = { + retry: 0, + warn: 0, + suspend: 0, + cancel: 0, + }; + for (const e of entries) { + breakdown[e.currentStage] = (breakdown[e.currentStage] ?? 0) + 1; + } + const totalLost = breakdown.cancel; + const totalActive = entries.length; + return { + totalActiveDunning: totalActive, + stageBreakdown: breakdown, + recoveryRate: totalActive > 0 ? Math.round(((totalActive - totalLost) / totalActive) * 100) : 0, + totalRecovered: 0, + totalLost, + averageDaysToRecovery: 0, + stageSuccessRates: { retry: 0, warn: 0, suspend: 0, cancel: 0 }, + }; + }, + + clearError: () => set({ error: null }), + }), + { + name: STORAGE_KEY, + version: 1, + storage: createJSONStorage(() => AsyncStorage), + partialize: (s) => ({ entries: s.entries, configurations: s.configurations }), + } + ) +); diff --git a/src/store/index.ts b/src/store/index.ts index 6cf489e..ad80b79 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,6 +1,7 @@ export { useSubscriptionStore } from './subscriptionStore'; export { useInvoiceStore } from './invoiceStore'; export { useTransactionQueueStore } from './transactionQueueStore'; +export { useDunningStore } from './dunningStore'; export { useWalletStore } from './walletStore'; export { useNetworkStore } from './networkStore'; export { useSettingsStore } from './settingsStore';