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';