diff --git a/app/screens/AdvancedSearchScreen.tsx b/app/screens/AdvancedSearchScreen.tsx index 348df2c3..457ebbe3 100644 --- a/app/screens/AdvancedSearchScreen.tsx +++ b/app/screens/AdvancedSearchScreen.tsx @@ -2,7 +2,7 @@ import React, { useMemo, useState } from 'react'; import { View, Text, TextInput, Button, FlatList, StyleSheet } from 'react-native'; import { Subscription } from '../types/subscription'; import { search_subscriptions, SavedSearch, SearchQuery } from '../services/searchService'; -import { useSubscriptionStore } from '../store/subscriptionStore'; +import { useSubscriptionStore } from '../../src/store'; import { useSearchStore } from '../stores/searchStore'; const styles = StyleSheet.create({ diff --git a/app/services/searchService.ts b/app/services/searchService.ts index 7620c4a5..06ca2582 100644 --- a/app/services/searchService.ts +++ b/app/services/searchService.ts @@ -1,7 +1,6 @@ import { Subscription, SubscriptionCategory } from '../types/subscription'; -import { useSubscriptionStore } from '../store/subscriptionStore'; +import { useSubscriptionStore, useSettingsStore } from '../../src/store'; import { currencyService } from './currencyService'; -import { useSettingsStore } from '../store/settingsStore'; export type SearchQuery = { query: string; diff --git a/app/stores/searchStore.ts b/app/stores/searchStore.ts index ca5fba08..c61b77a3 100644 --- a/app/stores/searchStore.ts +++ b/app/stores/searchStore.ts @@ -1,6 +1,5 @@ import { create } from 'zustand'; -import { useSubscriptionStore } from './subscriptionStore'; -import { Subscription } from '../types/subscription'; +import { useStore } from '../../src/store'; type Facets = { category?: string; @@ -19,55 +18,46 @@ type SavedSearch = { type SearchState = { query: string; facets: Facets; - results: Subscription[]; + results: any[]; savedSearches: SavedSearch[]; setQuery: (q: string) => void; setFacets: (f: Partial) => void; - updateResults: (subs: Subscription[]) => void; + updateResults: (results: any[]) => void; saveSearch: (name: string) => void; loadSavedSearch: (id: string) => void; clear: () => void; }; export const useSearchStore = create()((set, get) => { - const { subscriptions } = require('../store/subscriptionStore').useSubscriptionStore.getState(); + // Get initial subscriptions from the combined store + const subs = useStore.getState()?.subscriptions; + return { query: '', facets: {}, - results: subscriptions?.length ? subscriptions : [], + results: subs?.length ? subs : [], savedSearches: [], setQuery: (q: string) => { set({ query: q }); - // Basic debounce-like refresh by recalculating results on demand - const subState = require('../store/subscriptionStore').useSubscriptionStore.getState(); - set({ results: subState.subscriptions }); + const subState = useStore.getState(); + set({ results: subState?.subscriptions ?? [] }); }, setFacets: (f: Partial) => { set((state) => ({ facets: { ...state.facets, ...f } })); - // Refresh results when facets change - const subState = require('../store/subscriptionStore').useSubscriptionStore.getState(); - set({ results: subState.subscriptions }); + const subState = useStore.getState(); + set({ results: subState?.subscriptions ?? [] }); }, - updateResults: (subs: Subscription[]) => set({ results: subs }), + updateResults: (results) => set({ results }), saveSearch: (name: string) => { const current = get(); const id = `ss_${Date.now()}`; - const newSearch = { - id, - name, - query: current.query, - facets: current.facets, - } as SavedSearch; + const newSearch = { id, name, query: current.query, facets: current.facets } as SavedSearch; set((s) => ({ savedSearches: [...s.savedSearches, newSearch] })); }, loadSavedSearch: (id: string) => { const s = get().savedSearches.find((ss) => ss.id === id); - if (s) { - set({ query: s.query, facets: s.facets || {} }); - } - }, - clear: () => { - set({ query: '', facets: {}, results: [] }); + if (s) set({ query: s.query, facets: s.facets || {} }); }, + clear: () => set({ query: '', facets: {}, results: [] }), }; }); diff --git a/app/tests/integration/contract-store.integration.test.ts b/app/tests/integration/contract-store.integration.test.ts index 52ae3e48..a01064f7 100644 --- a/app/tests/integration/contract-store.integration.test.ts +++ b/app/tests/integration/contract-store.integration.test.ts @@ -9,7 +9,7 @@ import { act } from 'react'; import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; import AsyncStorage from '@react-native-async-storage/async-storage'; import * as notificationService from '../../../src/services/notificationService'; -import { useSubscriptionStore } from '../../../src/store/subscriptionStore'; +import { useSubscriptionStore } from '../../../src/store'; import { SubscriptionCategory, BillingCycle } from '../../../src/types/subscription'; import { makeSubscription, makeSubscriptionFormData, resetIdCounter } from './factories'; diff --git a/app/tests/integration/wallet-connection.integration.test.ts b/app/tests/integration/wallet-connection.integration.test.ts index 31c403e8..1e33f28b 100644 --- a/app/tests/integration/wallet-connection.integration.test.ts +++ b/app/tests/integration/wallet-connection.integration.test.ts @@ -8,7 +8,7 @@ import { act } from 'react'; import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import { useWalletStore } from '../../../src/store/walletStore'; +import { useWalletStore } from '../../../src/store'; import { makeWallet, makeCryptoStream, resetIdCounter } from './factories'; // ── In-memory AsyncStorage ──────────────────────────────────────────────────── diff --git a/docs/store-migration.md b/docs/store-migration.md new file mode 100644 index 00000000..b6dbadc8 --- /dev/null +++ b/docs/store-migration.md @@ -0,0 +1,146 @@ +# Store Migration Guide + +## Overview + +The SubTrackr state management has been refactored from **~25 individual Zustand stores** to a **single combined store using Zustand's slices pattern**. This improves: + +- **Modularity** – Each domain is a clean slice with typed interfaces +- **Cross-slice communication** – Slices access each other via `get()` without importing other stores +- **Performance** – Single store with optimized selectors +- **Testability** – Slices can be tested independently +- **Bundle size** – Single Zustand instance instead of many + +## What Changed + +### Before (individual stores) +```ts +// Each domain had its own store +import { useSubscriptionStore } from '../store/subscriptionStore'; +import { useInvoiceStore } from '../store/invoiceStore'; + +const subscriptions = useSubscriptionStore((s) => s.subscriptions); +const invoices = useInvoiceStore((s) => s.invoices); + +// Cross-store access required importing the other store +import { useCalendarStore } from '../store/calendarStore'; +useCalendarStore.getState().syncSubscriptionToCalendars(sub); +``` + +### After (combined store) +```ts +// Single store import +import { useStore } from '../store'; + +// Same selector pattern – just changed the hook name +const subscriptions = useStore((s) => s.subscriptions); +const invoices = useStore((s) => s.invoices); + +// Cross-store access is now built-in (same get()) +useStore.getState().syncSubscriptionToCalendars(sub); +``` + +## Migration Steps + +### 1. Update imports (optional but recommended) + +**Current hooks still work** – all old store names are re-exported from `src/store/index.ts` as aliases to the combined store. However, you'll get a deprecation warning. + +To migrate fully: + +```diff +- import { useSubscriptionStore } from '../store'; ++ import { useStore } from '../store/combinedStore'; + +- const subscriptions = useSubscriptionStore((s) => s.subscriptions); ++ const subscriptions = useStore((s) => s.subscriptions); +``` + +### 2. Replace cross-store access + +```diff +- import { useCalendarStore } from '../store/calendarStore'; +- import { useGamificationStore } from '../store/gamificationStore'; ++ import { useStore } from '../store'; + +- useCalendarStore.getState().syncSubscriptionToCalendars(sub); ++ useStore.getState().syncSubscriptionToCalendars(sub); + +- useGamificationStore.getState().addPoints(10); ++ useStore.getState().addPoints(10); +``` + +### 3. Testing changes + +For tests, update the store import: + +```diff +- import { useSubscriptionStore } from '../store/subscriptionStore'; ++ import { useStore } from '../store'; + + // Reset state: +- useSubscriptionStore.setState({ subscriptions: [] }); ++ useStore.setState({ subscriptions: [] }); +``` + +## Understanding the Architecture + +### Slice Organization + +``` +src/store/ +├── slices/ +│ ├── types.ts # Combined AppState type +│ ├── billingSlice.ts # Subscription, Invoice, Tax, Accounting, Usage, Cancellation +│ ├── walletSlice.ts # Wallet, TransactionQueue, Merchant +│ ├── settingsSlice.ts # Settings, User, Community +│ ├── engagementSlice.ts # Webhook, Gamification, Loyalty, Affiliate +│ ├── riskSlice.ts # Fraud, SLA +│ ├── devSlice.ts # Sandbox, DeveloperPortal +│ ├── marketingSlice.ts # Campaign, Segment, Group +│ ├── calendarSlice.ts # Calendar +│ ├── networkSlice.ts # Network +│ ├── supportSlice.ts # Support +│ ├── meteringSlice.ts # Metering, Credit, Batch, Search +│ ├── billingAccoutingTypes.ts # Shared accounting types +│ └── transactionQueueTypes.ts # Shared transaction queue types +├── combinedStore.ts # Combined store with persist +└── index.ts # Exports with backward-compatible aliases +``` + +### Adding a new slice + +1. Create a new file in `slices/` with your slice interface and factory function +2. Add the interface to `slices/types.ts` +3. Import and compose the factory in `combinedStore.ts` +4. Add a re-export alias in `index.ts` + +### Selector Optimization + +For performance, always select the minimal data you need: + +```ts +// ❌ Avoid – re-renders on any state change +const state = useStore(); + +// ✅ Better – only re-renders when subscriptions change +const subscriptions = useStore((s) => s.subscriptions); +const { addSubscription } = useStore((s) => s); + +// ✅ Best – use multiple selectors or a shallow comparison +import { shallow } from 'zustand/shallow'; +const [subscriptions, stats] = useStore( + (s) => [s.subscriptions, s.stats], + shallow +); +``` + +## Persistence + +The combined store uses a single persisted key `subtrackr-root-store-v2` in AsyncStorage. Previous per-store persistence keys are no longer created, but existing stored data is migrated on first load via the `migrate` function in `combinedStore.ts`. + +## Rollback + +If issues arise, the old individual store files are preserved in the git history. To revert: +```bash +git checkout HEAD~1 -- src/store/ +``` diff --git a/src/components/subscription/SubscriptionPlans.tsx b/src/components/subscription/SubscriptionPlans.tsx index 7d147d3c..fe47178c 100644 --- a/src/components/subscription/SubscriptionPlans.tsx +++ b/src/components/subscription/SubscriptionPlans.tsx @@ -4,7 +4,7 @@ import { BillingCycle, SubscriptionTier, SubscriptionPlan } from '../../types/su import { FeatureId } from '../../types/feature'; import { FEATURE_CONFIG } from '../../config/features'; import { featureFlagsService } from '../../services/featureFlags'; -import { useUserStore } from '../../store/userStore'; +import { useUserStore } from '../../store'; import { colors, spacing, typography, borderRadius, shadows } from '../../utils/constants'; const { width } = Dimensions.get('window'); diff --git a/src/hooks/useCachedSubscriptions.ts b/src/hooks/useCachedSubscriptions.ts index f24497c1..43e2ef38 100644 --- a/src/hooks/useCachedSubscriptions.ts +++ b/src/hooks/useCachedSubscriptions.ts @@ -9,7 +9,7 @@ import { useCallback } from 'react'; import NetInfo from '@react-native-community/netinfo'; -import { useSubscriptionStore } from '../store/subscriptionStore'; +import { useSubscriptionStore } from '../store'; import { cacheService } from '../services/cache/cacheService'; import type { Subscription, SubscriptionFormData } from '../types/subscription'; diff --git a/src/hooks/useFeatureAccess.ts b/src/hooks/useFeatureAccess.ts index 125db540..0918ec39 100644 --- a/src/hooks/useFeatureAccess.ts +++ b/src/hooks/useFeatureAccess.ts @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; import { FeatureId, FeatureAccessResult } from '../types/feature'; import { featureFlagsService } from '../services/featureFlags'; -import { useUserStore } from '../store/userStore'; +import { useUserStore } from '../store'; export interface UseFeatureAccessResult extends FeatureAccessResult { loading: boolean; diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts index e8688671..4ef93cdf 100644 --- a/src/hooks/useNotifications.ts +++ b/src/hooks/useNotifications.ts @@ -2,7 +2,7 @@ import { useEffect, useState, useCallback } from 'react'; import AsyncStorage from '@react-native-async-storage/async-storage'; import * as Notifications from 'expo-notifications'; -import { useSubscriptionStore } from '../store'; +import { useStore } from '../store'; import { attachNotificationResponseListeners, getPermissionStatus, @@ -17,7 +17,7 @@ export function useNotifications(): { permissionStatus: Notifications.PermissionStatus | null; refreshPermission: () => Promise; } { - const subscriptions = useSubscriptionStore((s) => s.subscriptions); + const subscriptions = useStore((s) => s.subscriptions); const [permissionStatus, setPermissionStatus] = useState( null ); diff --git a/src/hooks/useTransactionQueue.ts b/src/hooks/useTransactionQueue.ts index 9fddf7f4..67565d16 100644 --- a/src/hooks/useTransactionQueue.ts +++ b/src/hooks/useTransactionQueue.ts @@ -1,12 +1,12 @@ import { useEffect } from 'react'; -import { useTransactionQueueStore } from '../store/transactionQueueStore'; +import { useStore } from '../store'; export function useTransactionQueue(): void { useEffect(() => { - const unsubscribe = useTransactionQueueStore.getState().initializeConnectivityListener(); - void useTransactionQueueStore.getState().refreshConnectivity(); - void useTransactionQueueStore.getState().processQueue(); + const unsubscribe = useStore.getState().initializeConnectivityListener(); + void useStore.getState().refreshConnectivity(); + void useStore.getState().processQueue(); return unsubscribe; }, []); diff --git a/src/screens/AccountingExportScreen.tsx b/src/screens/AccountingExportScreen.tsx index 8a2420a6..7204fde8 100644 --- a/src/screens/AccountingExportScreen.tsx +++ b/src/screens/AccountingExportScreen.tsx @@ -13,7 +13,7 @@ import { import * as Clipboard from 'expo-clipboard'; import { Card } from '../components/common/Card'; import { colors, spacing, typography, borderRadius } from '../utils/constants'; -import { useSubscriptionStore } from '../store/subscriptionStore'; +import { useSubscriptionStore } from '../store'; import { AccountingFieldMapping, AccountingFormat, diff --git a/src/screens/AddSubscriptionScreen.tsx b/src/screens/AddSubscriptionScreen.tsx index bd09d0d9..46e9d7f3 100644 --- a/src/screens/AddSubscriptionScreen.tsx +++ b/src/screens/AddSubscriptionScreen.tsx @@ -14,7 +14,7 @@ import { import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { RootStackParamList } from '../navigation/types'; -import { useSubscriptionStore, useSettingsStore } from '../store'; +import { useStore } from '../store'; import { Button } from '../components/common/Button'; import { getCurrencySymbol } from '../utils/formatting'; import { colors, spacing, typography, borderRadius } from '../utils/constants'; @@ -30,8 +30,7 @@ interface AddSubscriptionFormData extends SubscriptionFormData { const AddSubscriptionScreen: React.FC = () => { const navigation = useNavigation>(); - const { addSubscription, isLoading, error } = useSubscriptionStore(); - const { preferredCurrency } = useSettingsStore(); + const { addSubscription, isLoading, error, preferredCurrency } = useStore(); const [formData, setFormData] = useState({ name: '', diff --git a/src/screens/AffiliateDashboardScreen.tsx b/src/screens/AffiliateDashboardScreen.tsx index c7095678..65760908 100644 --- a/src/screens/AffiliateDashboardScreen.tsx +++ b/src/screens/AffiliateDashboardScreen.tsx @@ -12,8 +12,7 @@ import { FlatList, } from 'react-native'; import { colors, spacing, typography, borderRadius } from '../utils/constants'; -import { useAffiliateStore } from '../store/affiliateStore'; -import { useWalletStore } from '../store/walletStore'; +import { useAffiliateStore, useWalletStore } from '../store'; import { Card } from '../components/common/Card'; import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; diff --git a/src/screens/AnalyticsScreen.tsx b/src/screens/AnalyticsScreen.tsx index 25bd9c55..de241aa1 100644 --- a/src/screens/AnalyticsScreen.tsx +++ b/src/screens/AnalyticsScreen.tsx @@ -10,10 +10,9 @@ import { } from 'react-native'; import Svg, { Rect, Text as SvgText, Line, G } from 'react-native-svg'; import { colors, spacing, typography, borderRadius } from '../utils/constants'; -import { useSubscriptionStore } from '../store'; +import { useStore } from '../store'; import { SubscriptionCategory, BillingCycle } from '../types/subscription'; import { Card } from '../components/common/Card'; -import { useSettingsStore } from '../store/settingsStore'; import { currencyService } from '../services/currencyService'; import { calculateSubscriptionAnalytics } from '../services/analyticsService'; import { formatCurrency } from '../utils/formatting'; @@ -25,8 +24,7 @@ const CHART_HEIGHT = 200; type DateRange = 'week' | 'month' | 'year'; const AnalyticsScreen: React.FC = () => { - const { subscriptions, stats, calculateStats } = useSubscriptionStore(); - const { preferredCurrency, exchangeRates } = useSettingsStore(); + const { subscriptions, stats, calculateStats, preferredCurrency, exchangeRates } = useStore(); const rates = exchangeRates?.rates || {}; const [dateRange, setDateRange] = useState('month'); diff --git a/src/screens/ApiKeyManagementScreen.tsx b/src/screens/ApiKeyManagementScreen.tsx index 7be0fac3..531fdbba 100644 --- a/src/screens/ApiKeyManagementScreen.tsx +++ b/src/screens/ApiKeyManagementScreen.tsx @@ -12,13 +12,13 @@ import { } from 'react-native'; import { Card } from '../components/common/Card'; import { colors, spacing, typography, borderRadius } from '../utils/constants'; -import { useSandboxStore } from '../store/sandboxStore'; +import { useStore } from '../store'; import { ApiKeyStatus, SandboxEnvironment } from '../types/sandbox'; import { apiKeyService } from '../services/sandbox/apiKeyService'; const ApiKeyManagementScreen: React.FC = () => { const { apiKeys, developerProfile, generateApiKey, revokeApiKey, deleteApiKey } = - useSandboxStore(); + useStore(); const [newKeyName, setNewKeyName] = useState(''); const [showNewKey, setShowNewKey] = useState(null); diff --git a/src/screens/CalendarIntegrationScreen.tsx b/src/screens/CalendarIntegrationScreen.tsx index 4db03dcd..cdca070c 100644 --- a/src/screens/CalendarIntegrationScreen.tsx +++ b/src/screens/CalendarIntegrationScreen.tsx @@ -13,8 +13,7 @@ import { } from 'react-native'; import { Card } from '../components/common/Card'; -import { useCalendarStore } from '../store/calendarStore'; -import { useSubscriptionStore } from '../store/subscriptionStore'; +import { useCalendarStore, useSubscriptionStore } from '../store'; import { CALENDAR_PROVIDERS, REMINDER_OFFSET_OPTIONS, diff --git a/src/screens/CampaignManagementScreen.tsx b/src/screens/CampaignManagementScreen.tsx index a509eeeb..d495db56 100644 --- a/src/screens/CampaignManagementScreen.tsx +++ b/src/screens/CampaignManagementScreen.tsx @@ -13,7 +13,7 @@ import { FlatList, } from 'react-native'; import { colors, spacing, typography, borderRadius } from '../utils/constants'; -import { useCampaignStore } from '../store/campaignStore'; +import { useStore } from '../store'; import { Card } from '../components/common/Card'; import { Campaign, @@ -34,7 +34,7 @@ const CampaignManagementScreen: React.FC = () => { launchCampaign, pauseCampaign, getCampaignAnalytics, - } = useCampaignStore(); + } = useStore(); const [createModalVisible, setCreateModalVisible] = useState(false); const [newCampaign, setNewCampaign] = useState({ diff --git a/src/screens/CancellationFlowScreen.tsx b/src/screens/CancellationFlowScreen.tsx index 3b3bdb4f..c911cbca 100644 --- a/src/screens/CancellationFlowScreen.tsx +++ b/src/screens/CancellationFlowScreen.tsx @@ -12,7 +12,8 @@ import { Button } from '../components/common/Button'; import { Card } from '../components/common/Card'; import { colors, spacing, typography, borderRadius } from '../utils/constants'; import { RootStackParamList } from '../navigation/types'; -import { useCancellationStore, CANCELLATION_REASONS } from '../store/cancellationStore'; +import { useStore } from '../store'; +import { CANCELLATION_REASONS } from '../store/cancellationStore'; import { RetentionOffer } from '../../backend/services/retentionService'; type Props = NativeStackScreenProps; @@ -40,7 +41,7 @@ const CancellationFlowScreen: React.FC = ({ route, navigation }) => { declineOffers, confirmCancellation, reset, - } = useCancellationStore(); + } = useStore(); useEffect(() => { initFlow(subscriptionId); @@ -149,7 +150,7 @@ const CancellationFlowScreen: React.FC = ({ route, navigation }) => { title="Go Back" variant="outline" fullWidth - onPress={() => useCancellationStore.setState({ currentStep: 'OFFERS' })} + onPress={() => useStore.setState({ currentStep: 'OFFERS' })} accessibilityLabel="Go back to retention offers" /> diff --git a/src/screens/CommunityScreen.tsx b/src/screens/CommunityScreen.tsx index 461b3f96..704405bf 100644 --- a/src/screens/CommunityScreen.tsx +++ b/src/screens/CommunityScreen.tsx @@ -17,9 +17,8 @@ import { CommunityPrivacy, ForumPost, ForumThread, - useCommunityStore, } from '../store/communityStore'; -import { useWalletStore } from '../store'; +import { useStore } from '../store'; import { borderRadius, colors, spacing, typography } from '../utils/constants'; type CommunityNavigationProp = NativeStackNavigationProp; @@ -84,7 +83,7 @@ const PostItem: React.FC<{ const CommunityScreen: React.FC = () => { const navigation = useNavigation(); - const address = useWalletStore((state) => state.address); + const address = useStore((state) => state.address); const { currentSubscriber, setCurrentSubscriber, @@ -95,7 +94,7 @@ const CommunityScreen: React.FC = () => { createThread, replyToThread, moderateContent, - } = useCommunityStore(); + } = useStore(); useEffect(() => { if (address) { diff --git a/src/screens/CryptoPaymentScreen.tsx b/src/screens/CryptoPaymentScreen.tsx index c7906522..00e27b4f 100644 --- a/src/screens/CryptoPaymentScreen.tsx +++ b/src/screens/CryptoPaymentScreen.tsx @@ -22,7 +22,7 @@ import walletServiceManager, { TokenBalance, } from '../services/walletService'; import { ADDRESS_CONSTANTS } from '../utils/constants/values'; -import { useTransactionQueueStore } from '../store/transactionQueueStore'; +import { useStore } from '../store'; interface RouteParams { subscriptionId?: string; @@ -55,10 +55,10 @@ const CryptoPaymentScreen: React.FC = () => { const [availableTokens, setAvailableTokens] = useState([]); const [connection, setConnection] = useState(null); - const isOnline = useTransactionQueueStore((state) => state.isOnline); - const isQueueProcessing = useTransactionQueueStore((state) => state.isProcessing); - const queuedTransactions = useTransactionQueueStore((state) => state.queuedTransactions); - const executeOrQueueTransaction = useTransactionQueueStore( + const isOnline = useStore((state) => state.isOnline); + const isQueueProcessing = useStore((state) => state.isProcessing); + const queuedTransactions = useStore((state) => state.queuedTransactions); + const executeOrQueueTransaction = useStore( (state) => state.executeOrQueueTransaction ); diff --git a/src/screens/DeveloperPortalScreen.tsx b/src/screens/DeveloperPortalScreen.tsx index 87017952..ed2da0b2 100644 --- a/src/screens/DeveloperPortalScreen.tsx +++ b/src/screens/DeveloperPortalScreen.tsx @@ -11,7 +11,7 @@ import { } from 'react-native'; import { Card } from '../components/common/Card'; import { colors, spacing, typography, borderRadius } from '../utils/constants'; -import { useSandboxStore } from '../store/sandboxStore'; +import { useStore } from '../store'; import { SandboxEnvironment, DeveloperOnboardingStep } from '../types/sandbox'; import { EnvironmentBadge, @@ -35,7 +35,7 @@ const DeveloperPortalScreen: React.FC = () => { completeOnboardingStep, generateApiKey, resetTestData, - } = useSandboxStore(); + } = useStore(); const [showOnboarding, setShowOnboarding] = useState(!developerProfile); const [profileForm, setProfileForm] = useState({ diff --git a/src/screens/ExportScreen.tsx b/src/screens/ExportScreen.tsx index 2fd63984..e0f46ca2 100644 --- a/src/screens/ExportScreen.tsx +++ b/src/screens/ExportScreen.tsx @@ -15,12 +15,12 @@ import { colors, spacing, typography, borderRadius } from '../utils/constants'; import { Button } from '../components/common/Button'; import { Card } from '../components/common/Card'; import { generateCSV, exportToJSON } from '../utils/importExport'; -import { useSubscriptionStore } from '../store'; +import { useStore } from '../store'; type ExportFormat = 'json' | 'csv'; const ExportScreen: React.FC = () => { - const { subscriptions } = useSubscriptionStore(); + const { subscriptions } = useStore(); const [exportFormat, setExportFormat] = useState('json'); const [isExporting, setIsExporting] = useState(false); diff --git a/src/screens/FraudDashboard.tsx b/src/screens/FraudDashboard.tsx index f26dd4b0..82b4e4d2 100644 --- a/src/screens/FraudDashboard.tsx +++ b/src/screens/FraudDashboard.tsx @@ -11,7 +11,7 @@ import { import { Card } from '../components/common/Card'; import { Button } from '../components/common/Button'; import { colors, spacing, typography, borderRadius } from '../utils/constants'; -import { useFraudStore } from '../store/fraudStore'; +import { useStore } from '../store'; import { FraudAction } from '../types/fraud'; const actionPalette: Record = { @@ -35,7 +35,7 @@ const FraudDashboard: React.FC = () => { blockSubscription, resolveCase, getFraudReport, - } = useFraudStore(); + } = useStore(); const highlightedReports = useMemo( () => merchants.map((merchant) => getFraudReport(merchant.id)), diff --git a/src/screens/GDPRSettingsScreen.tsx b/src/screens/GDPRSettingsScreen.tsx index 86bc6a9f..a4ddb29d 100644 --- a/src/screens/GDPRSettingsScreen.tsx +++ b/src/screens/GDPRSettingsScreen.tsx @@ -9,7 +9,7 @@ import { Alert, ActivityIndicator, } from 'react-native'; -import { useUserStore } from '../store/userStore'; +import { useUserStore } from '../store'; import { gdprService } from '../services/gdpr'; const GDPRSettingsScreen = () => { diff --git a/src/screens/GamificationScreen.tsx b/src/screens/GamificationScreen.tsx index d12d0793..da964b88 100644 --- a/src/screens/GamificationScreen.tsx +++ b/src/screens/GamificationScreen.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { View, Text, StyleSheet, ScrollView } from 'react-native'; -import { useGamificationStore } from '../store/gamificationStore'; -import { useUserStore } from '../store/userStore'; +import { useGamificationStore, useUserStore } from '../store'; import { gamificationService } from '../services/gamificationService'; import { useTheme } from '../theme/useTheme'; import { diff --git a/src/screens/GroupManagementScreen.tsx b/src/screens/GroupManagementScreen.tsx index 4b094d05..a7ffda01 100644 --- a/src/screens/GroupManagementScreen.tsx +++ b/src/screens/GroupManagementScreen.tsx @@ -3,14 +3,14 @@ import { StyleSheet, Text, View } from 'react-native'; import { Button } from '../components/common/Button'; import { Card } from '../components/common/Card'; import { ListScreen } from '../components/common/ScreenTemplates'; -import { useGroupStore } from '../store'; +import { useStore } from '../store'; import { SubscriptionGroup } from '../types/group'; import { colors, spacing, typography } from '../utils/constants'; const OWNER_ADDRESS = 'owner@example.com'; const GroupManagementScreen: React.FC = () => { - const { groups, createGroup, inviteMember, chargeGroup, getAnalytics, error } = useGroupStore(); + const { groups, createGroup, inviteMember, chargeGroup, getAnalytics, error } = useStore(); const sortedGroups = useMemo( () => [...groups].sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()), diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 71b1206e..40be2780 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -12,13 +12,11 @@ import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { colors, spacing, typography, borderRadius } from '../utils/constants'; -import { useSubscriptionStore, useSettingsStore } from '../store'; +import { useStore } from '../store'; import { getUpcomingSubscriptions } from '../utils/dummyData'; import { Subscription } from '../types/subscription'; import { RootStackParamList } from '../navigation/types'; -import { useGamificationStore } from '../store/gamificationStore'; -import { useTransactionQueueStore } from '../store/transactionQueueStore'; import { usePerformanceProfiler } from '../hooks/usePerformanceProfiler'; // Components @@ -33,13 +31,9 @@ type HomeNavigationProp = NativeStackNavigationProp; const HomeScreen: React.FC = () => { const navigation = useNavigation(); - const { subscriptions, stats, fetchSubscriptions, calculateStats, toggleSubscriptionStatus } = - useSubscriptionStore(); - - const isOnline = useTransactionQueueStore((state) => state.isOnline); - const pendingTransactions = useTransactionQueueStore((state) => state.queuedTransactions.length); - const { level } = useGamificationStore(); - const { preferredCurrency, exchangeRates } = useSettingsStore(); + const { subscriptions, stats, fetchSubscriptions, calculateStats, toggleSubscriptionStatus, + isOnline, pendingTransactions, level, preferredCurrency, exchangeRates } = + useStore(); const [refreshing, setRefreshing] = useState(false); const [upcomingSubscriptions, setUpcomingSubscriptions] = useState([]); const [showFilterModal, setShowFilterModal] = useState(false); diff --git a/src/screens/ImportScreen.tsx b/src/screens/ImportScreen.tsx index c09b3ae4..d0ffc623 100644 --- a/src/screens/ImportScreen.tsx +++ b/src/screens/ImportScreen.tsx @@ -31,10 +31,10 @@ import { ImportHistoryEntry, SubscriptionInput, } from '../utils/importExport'; -import { useSubscriptionStore } from '../store'; +import { useStore } from '../store'; const ImportScreen: React.FC = () => { - const { subscriptions, addSubscription, updateSubscription } = useSubscriptionStore(); + const { subscriptions, addSubscription, updateSubscription } = useStore(); const navigation = useNavigation(); const [importMode, setImportMode] = useState('upsert'); diff --git a/src/screens/IntegrationGuideDetailScreen.tsx b/src/screens/IntegrationGuideDetailScreen.tsx index 9baa076d..d2bc1e05 100644 --- a/src/screens/IntegrationGuideDetailScreen.tsx +++ b/src/screens/IntegrationGuideDetailScreen.tsx @@ -9,7 +9,7 @@ import { import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { RootStackParamList } from '../navigation/types'; -import { useSandboxStore } from '../store/sandboxStore'; +import { useStore } from '../store'; import { colors } from '../utils/constants'; import { IntegrationStep } from '../types/sandbox'; @@ -17,7 +17,7 @@ type NavigationProp = NativeStackNavigationProp; export default function IntegrationGuideDetailScreen() { const navigation = useNavigation(); - const { selectedGuide } = useSandboxStore(); + const { selectedGuide } = useStore(); if (!selectedGuide) { return ( diff --git a/src/screens/IntegrationGuidesScreen.tsx b/src/screens/IntegrationGuidesScreen.tsx index cdc9e395..a44c1611 100644 --- a/src/screens/IntegrationGuidesScreen.tsx +++ b/src/screens/IntegrationGuidesScreen.tsx @@ -9,7 +9,7 @@ import { } from 'react-native'; import { Card } from '../components/common/Card'; import { colors, spacing, typography, borderRadius } from '../utils/constants'; -import { useSandboxStore } from '../store/sandboxStore'; +import { useStore } from '../store'; import { IntegrationGuideCategory } from '../types/sandbox'; const CATEGORY_LABELS: Record = { @@ -31,7 +31,7 @@ const DIFFICULTY_COLORS: Record = { }; const IntegrationGuidesScreen: React.FC = () => { - const { integrationGuides, markGuideCompleted } = useSandboxStore(); + const { integrationGuides, markGuideCompleted } = useStore(); const [selectedCategory, setSelectedCategory] = useState(null); const [expandedGuide, setExpandedGuide] = useState(null); const [expandedStep, setExpandedStep] = useState(null); diff --git a/src/screens/InvoiceDetailScreen.tsx b/src/screens/InvoiceDetailScreen.tsx index 8d9afa33..141eaf6d 100644 --- a/src/screens/InvoiceDetailScreen.tsx +++ b/src/screens/InvoiceDetailScreen.tsx @@ -12,7 +12,7 @@ import { import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { colors, spacing, typography, borderRadius } from '../utils/constants'; -import { useInvoiceStore } from '../store'; +import { useStore } from '../store'; import { formatCurrency, formatDate } from '../utils/formatting'; import { RootStackParamList } from '../navigation/types'; import { Card } from '../components/common/Card'; @@ -25,12 +25,12 @@ type NavigationProp = NativeStackNavigationProp; const InvoiceDetailScreen: React.FC = () => { const navigation = useNavigation(); const route = useRoute(); - const invoice = useInvoiceStore((state) => + const invoice = useStore((state) => state.invoices.find((entry) => entry.id === route.params.id) ); - const sendInvoice = useInvoiceStore((state) => state.sendInvoice); - const voidInvoice = useInvoiceStore((state) => state.voidInvoice); - const markInvoicePaid = useInvoiceStore((state) => state.markInvoicePaid); + const sendInvoice = useStore((state) => state.sendInvoice); + const voidInvoice = useStore((state) => state.voidInvoice); + const markInvoicePaid = useStore((state) => state.markInvoicePaid); const preview = useMemo(() => (invoice ? generateInvoicePdfPreview(invoice) : ''), [invoice]); diff --git a/src/screens/InvoiceListScreen.tsx b/src/screens/InvoiceListScreen.tsx index 49fcaa77..7f2e26b7 100644 --- a/src/screens/InvoiceListScreen.tsx +++ b/src/screens/InvoiceListScreen.tsx @@ -2,7 +2,7 @@ import React, { useMemo } from 'react'; import { View, Text, StyleSheet, SafeAreaView, TouchableOpacity, ScrollView } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { useInvoiceStore } from '../store'; +import { useStore } from '../store'; import { colors, spacing, typography, borderRadius } from '../utils/constants'; import { formatCurrency, formatDate } from '../utils/formatting'; import { InvoiceStatus } from '../types/invoice'; @@ -30,7 +30,7 @@ const statusStyles: Record = { const InvoiceListScreen: React.FC = () => { const navigation = useNavigation(); - const invoices = useInvoiceStore((state) => state.invoices); + const invoices = useStore((state) => state.invoices); const sortedInvoices = useMemo( () => [...invoices].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()), diff --git a/src/screens/LoyaltyDashboardScreen.tsx b/src/screens/LoyaltyDashboardScreen.tsx index 6300ec07..f74a5f5f 100644 --- a/src/screens/LoyaltyDashboardScreen.tsx +++ b/src/screens/LoyaltyDashboardScreen.tsx @@ -12,8 +12,7 @@ import { FlatList, } from 'react-native'; import { colors, spacing, typography, borderRadius } from '../utils/constants'; -import { useLoyaltyStore } from '../store/loyaltyStore'; -import { useWalletStore } from '../store/walletStore'; +import { useLoyaltyStore, useWalletStore } from '../store'; import { Card } from '../components/common/Card'; import { LoyaltyTier, RewardType, TierBenefits } from '../types/loyalty'; diff --git a/src/screens/MerchantOnboardingScreen.tsx b/src/screens/MerchantOnboardingScreen.tsx index 364dee49..02d87000 100644 --- a/src/screens/MerchantOnboardingScreen.tsx +++ b/src/screens/MerchantOnboardingScreen.tsx @@ -11,7 +11,7 @@ import { ActivityIndicator, } from 'react-native'; import { colors, spacing, typography, borderRadius } from '../utils/constants'; -import { useMerchantStore } from '../store/merchantStore'; +import { useStore } from '../store'; import { Card } from '../components/common/Card'; import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; @@ -35,7 +35,7 @@ const MerchantOnboardingScreen: React.FC = () => { nextStep, previousStep, requestVerification, - } = useMerchantStore(); + } = useStore(); const [formData, setFormData] = useState({ businessName: '', diff --git a/src/screens/ProfileScreen.tsx b/src/screens/ProfileScreen.tsx index fb6b2e39..f8e395fd 100644 --- a/src/screens/ProfileScreen.tsx +++ b/src/screens/ProfileScreen.tsx @@ -13,8 +13,8 @@ import { RouteProp, useNavigation, useRoute } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { Card } from '../components/common/Card'; import { RootStackParamList } from '../navigation/types'; -import { useCommunityStore, CommunityPrivacy } from '../store/communityStore'; -import { useWalletStore } from '../store'; +import { useStore } from '../store'; +import { CommunityPrivacy } from '../store/communityStore'; import { borderRadius, colors, spacing, typography } from '../utils/constants'; type ProfileRouteProp = RouteProp; @@ -25,9 +25,9 @@ const privacyOptions: CommunityPrivacy[] = ['public', 'subscribers', 'private']; const ProfileScreen: React.FC = () => { const route = useRoute(); const navigation = useNavigation(); - const walletAddress = useWalletStore((state) => state.address); + const walletAddress = useStore((state) => state.address); const { currentSubscriber, setCurrentSubscriber, updateProfile, getVisibleProfile } = - useCommunityStore(); + useStore(); useEffect(() => { if (walletAddress) { diff --git a/src/screens/RevenueReportScreen.tsx b/src/screens/RevenueReportScreen.tsx index 039f9ae8..4f6c79e3 100644 --- a/src/screens/RevenueReportScreen.tsx +++ b/src/screens/RevenueReportScreen.tsx @@ -13,13 +13,7 @@ import { import Svg, { Rect, Text as SvgText, Line, G } from 'react-native-svg'; import { colors, spacing, typography, borderRadius } from '../utils/constants'; import { Card } from '../components/common/Card'; -import { useSubscriptionStore } from '../store/subscriptionStore'; -import { - useAccountingStore, - RecognitionMethod, - billingCycleToMs, - splitRecognisedDeferred, -} from '../store/accountingStore'; +import { useSubscriptionStore, useAccountingStore, RecognitionMethod, billingCycleToMs, splitRecognisedDeferred } from '../store'; const { width: screenWidth } = Dimensions.get('window'); const CHART_WIDTH = screenWidth - spacing.xl * 2; diff --git a/src/screens/SandboxDashboardScreen.tsx b/src/screens/SandboxDashboardScreen.tsx index 2e9112c6..307fbdea 100644 --- a/src/screens/SandboxDashboardScreen.tsx +++ b/src/screens/SandboxDashboardScreen.tsx @@ -10,7 +10,7 @@ import { } from 'react-native'; import { Card } from '../components/common/Card'; import { colors, spacing, typography, borderRadius } from '../utils/constants'; -import { useSandboxStore } from '../store/sandboxStore'; +import { useStore } from '../store'; import { SandboxEnvironment } from '../types/sandbox'; import { StatCard, EnvironmentBadge } from '../components/developer/DeveloperComponents'; @@ -24,7 +24,7 @@ const SandboxDashboardScreen: React.FC = () => { resetTestData, addTestSubscription, removeTestSubscription, - } = useSandboxStore(); + } = useStore(); useEffect(() => { initializeSandbox(); diff --git a/src/screens/SandboxDetailScreen.tsx b/src/screens/SandboxDetailScreen.tsx index 272282ee..295d84e7 100644 --- a/src/screens/SandboxDetailScreen.tsx +++ b/src/screens/SandboxDetailScreen.tsx @@ -11,7 +11,7 @@ import { import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { RootStackParamList } from '../navigation/types'; -import { useSandboxStore } from '../store/sandboxStore'; +import { useStore } from '../store'; import { colors } from '../utils/constants'; import { testDataGenerator } from '../services/sandbox/testDataGenerator'; @@ -29,7 +29,7 @@ export default function SandboxDetailScreen() { toggleSandboxStatus, deleteSandbox, fetchUsageForSandbox, - } = useSandboxStore(); + } = useStore(); const [activeTab, setActiveTab] = useState<'overview' | 'data' | 'usage' | 'keys'>('overview'); diff --git a/src/screens/SandboxScreen.tsx b/src/screens/SandboxScreen.tsx index 1103f6e3..22229479 100644 --- a/src/screens/SandboxScreen.tsx +++ b/src/screens/SandboxScreen.tsx @@ -15,7 +15,7 @@ import { import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { colors, spacing, typography, borderRadius } from '../utils/constants'; -import { useSandboxStore } from '../store/sandboxStore'; +import { useStore } from '../store'; import { SandboxEnvironment, SandboxStatus } from '../types/sandbox'; import { RootStackParamList } from '../navigation/types'; @@ -41,7 +41,7 @@ const SandboxScreen: React.FC = () => { resetSandbox, refreshMetrics, clearError, - } = useSandboxStore(); + } = useStore(); const [refreshing, setRefreshing] = useState(false); const [showCreateModal, setShowCreateModal] = useState(false); diff --git a/src/screens/SegmentDetailScreen.tsx b/src/screens/SegmentDetailScreen.tsx index c1bdd168..8b40bf55 100644 --- a/src/screens/SegmentDetailScreen.tsx +++ b/src/screens/SegmentDetailScreen.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; import { View, Text, StyleSheet, ScrollView, TextInput, Alert } from 'react-native'; -import { useSegmentStore } from '../store/segmentStore'; +import { useStore } from '../store'; import { useTheme } from '../theme/useTheme'; import { Button } from '../components/common/Button'; import { SegmentRuleBuilder } from '../components/segments/SegmentRuleBuilder'; @@ -12,7 +12,7 @@ export const SegmentDetailScreen: React.FC = () => { const route = useRoute(); const navigation = useNavigation(); const { segmentId } = route.params; - const { segments, addSegment, updateSegment } = useSegmentStore(); + const { segments, addSegment, updateSegment } = useStore(); const isNew = segmentId === 'new'; const existingSegment = segments.find((s) => s.id === segmentId); diff --git a/src/screens/SegmentManagementScreen.tsx b/src/screens/SegmentManagementScreen.tsx index 3605e982..676cdd98 100644 --- a/src/screens/SegmentManagementScreen.tsx +++ b/src/screens/SegmentManagementScreen.tsx @@ -1,8 +1,6 @@ import React, { useMemo } from 'react'; import { View, Text, StyleSheet, FlatList, TouchableOpacity } from 'react-native'; -import { useSegmentStore } from '../store/segmentStore'; -import { useSubscriptionStore } from '../store/subscriptionStore'; -import { useUserStore } from '../store/userStore'; +import { useSegmentStore, useSubscriptionStore, useUserStore } from '../store'; import { segmentService } from '../services/segmentService'; import { useTheme } from '../theme/useTheme'; import { Card } from '../components/common/Card'; diff --git a/src/screens/SlaDashboard.tsx b/src/screens/SlaDashboard.tsx index cdf1c30e..e911d1c9 100644 --- a/src/screens/SlaDashboard.tsx +++ b/src/screens/SlaDashboard.tsx @@ -10,7 +10,7 @@ import { } from 'react-native'; import { Card } from '../components/common/Card'; import { colors, spacing, typography, borderRadius } from '../utils/constants'; -import { useSlaStore } from '../store/slaStore'; +import { useStore } from '../store'; import { SlaAvailabilityState } from '../types/sla'; import { SLA_DEFAULTS } from '../services/slaService'; @@ -32,7 +32,7 @@ const SlaDashboard: React.FC = () => { configureSla, trackServiceAvailability, refreshReport, - } = useSlaStore(); + } = useStore(); const [merchantId, setMerchantId] = useState('merchant-demo'); const [uptimeTarget, setUptimeTarget] = useState(String(SLA_DEFAULTS.uptimeTarget)); const [measurementInterval, setMeasurementInterval] = useState( diff --git a/src/screens/SubscriptionDetailScreen.tsx b/src/screens/SubscriptionDetailScreen.tsx index 05ec5a38..206410ad 100644 --- a/src/screens/SubscriptionDetailScreen.tsx +++ b/src/screens/SubscriptionDetailScreen.tsx @@ -12,13 +12,12 @@ import { } from 'react-native'; import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { useSubscriptionStore, useSettingsStore } from '../store'; +import { useStore } from '../store'; import { currencyService } from '../services/currencyService'; import { formatCurrency } from '../utils/formatting'; import { colors, spacing, typography } from '../utils/constants'; import { getCategoryIcon } from '../utils/subscriptionHelpers'; import { RootStackParamList } from '../navigation/types'; -import { useGroupStore } from '../store/groupStore'; // Components import { Button } from '../components/common/Button'; @@ -33,10 +32,8 @@ const SubscriptionDetailScreen: React.FC = () => { const route = useRoute(); const { id } = route.params; - const { subscriptions, toggleSubscriptionStatus, updateSubscription, recordBillingOutcome } = - useSubscriptionStore(); - const { groups } = useGroupStore(); - const { preferredCurrency, exchangeRates } = useSettingsStore(); + const { subscriptions, toggleSubscriptionStatus, updateSubscription, recordBillingOutcome, groups, preferredCurrency, exchangeRates } = + useStore(); const rates = exchangeRates?.rates || {}; const subscription = useMemo(() => subscriptions?.find((s) => s.id === id), [id, subscriptions]); diff --git a/src/screens/SupportDashboardScreen.tsx b/src/screens/SupportDashboardScreen.tsx index 648dacf8..f439bb62 100644 --- a/src/screens/SupportDashboardScreen.tsx +++ b/src/screens/SupportDashboardScreen.tsx @@ -3,12 +3,12 @@ import { StyleSheet, Text, View } from 'react-native'; import { Button } from '../components/common/Button'; import { Card } from '../components/common/Card'; import { ListScreen } from '../components/common/ScreenTemplates'; -import { useSupportStore } from '../store'; +import { useStore } from '../store'; import { SupportTicket } from '../types/support'; import { colors, spacing, typography } from '../utils/constants'; const SupportDashboardScreen: React.FC = () => { - const { tickets, createTicket, assignTicket, syncTicket, linkResolution } = useSupportStore(); + const { tickets, createTicket, assignTicket, syncTicket, linkResolution } = useStore(); const handleCreateTicket = () => { createTicket({ diff --git a/src/screens/TaxSettingsScreen.tsx b/src/screens/TaxSettingsScreen.tsx index ed638108..e72b0724 100644 --- a/src/screens/TaxSettingsScreen.tsx +++ b/src/screens/TaxSettingsScreen.tsx @@ -3,11 +3,11 @@ import { StyleSheet, Text } from 'react-native'; import { Button } from '../components/common/Button'; import { Card } from '../components/common/Card'; import { FormScreen } from '../components/common/ScreenTemplates'; -import { useTaxStore } from '../store'; +import { useStore } from '../store'; import { colors, spacing, typography } from '../utils/constants'; const TaxSettingsScreen: React.FC = () => { - const { config, calculations, reports, remittances, calculateTax, createReport } = useTaxStore(); + const { config, calculations, reports, remittances, calculateTax, createReport } = useStore(); const latestRemittance = remittances[remittances.length - 1]; const totalCollected = useMemo( diff --git a/src/screens/UsageDashboard.tsx b/src/screens/UsageDashboard.tsx index 297a816e..d8630f0b 100644 --- a/src/screens/UsageDashboard.tsx +++ b/src/screens/UsageDashboard.tsx @@ -2,7 +2,7 @@ import React, { useEffect } from 'react'; import { View, Text, StyleSheet, ScrollView, TouchableOpacity, SafeAreaView } from 'react-native'; import { useRoute, useNavigation } from '@react-navigation/native'; import { colors, spacing, typography, borderRadius, shadows } from '../utils/constants'; -import { useUsageStore } from '../store/usageStore'; +import { useStore } from '../store'; import { Button } from '../components/common/Button'; import { Ionicons } from '@expo/vector-icons'; @@ -10,7 +10,7 @@ const UsageDashboard: React.FC = () => { const route = useRoute(); const navigation = useNavigation(); const { subscriptionId, planId, name } = route.params || {}; - const { fetchUsage } = useUsageStore(); + const { fetchUsage } = useStore(); useEffect(() => { if (subscriptionId && planId) { diff --git a/src/screens/WalletConnectScreen.tsx b/src/screens/WalletConnectScreen.tsx index a3426314..25ea4a0a 100644 --- a/src/screens/WalletConnectScreen.tsx +++ b/src/screens/WalletConnectScreen.tsx @@ -17,7 +17,7 @@ import { Button } from '../components/common/Button'; import { Card } from '../components/common/Card'; import { useAppKit, useAppKitAccount, useAppKitProvider } from '@reown/appkit-ethers-react-native'; import walletServiceManager, { WalletConnection, TokenBalance } from '../services/walletService'; -import { useWalletStore } from '../store'; +import { useStore } from '../store'; import { RootStackParamList } from '../navigation/types'; import * as Clipboard from 'expo-clipboard'; @@ -27,7 +27,7 @@ const WalletConnectScreen: React.FC = () => { const { open } = useAppKit(); const { address, isConnected, chainId } = useAppKitAccount(); const { walletProvider } = useAppKitProvider(); - const { connectWallet, disconnect } = useWalletStore(); + const { connectWallet, disconnect } = useStore(); const [isConnecting, setIsConnecting] = useState(false); const [connection, setConnection] = useState(null); diff --git a/src/screens/WalletConnectV2Screen.tsx b/src/screens/WalletConnectV2Screen.tsx index 9c1d5cbf..1e729725 100644 --- a/src/screens/WalletConnectV2Screen.tsx +++ b/src/screens/WalletConnectV2Screen.tsx @@ -20,7 +20,7 @@ import { colors, spacing, typography, borderRadius, shadows } from '../utils/con import { Button } from '../components/common/Button'; import { Card } from '../components/common/Card'; import walletServiceManager, { WalletConnection, TokenBalance } from '../services/walletService'; -import { useWalletStore } from '../store'; +import { useStore } from '../store'; import { RootStackParamList } from '../navigation/types'; import { getWalletConnectChain, WALLETCONNECT_CHAINS } from '../services/walletconnect/chains'; import { @@ -34,7 +34,7 @@ const WalletConnectV2Screen: React.FC = () => { const { open } = useAppKit(); const { address, isConnected, chainId } = useAppKitAccount(); const { walletProvider } = useAppKitProvider(); - const { syncWalletConnection, disconnect } = useWalletStore(); + const { syncWalletConnection, disconnect } = useStore(); const previousConnectionRef = useRef(false); diff --git a/src/screens/WebhookSettingsScreen.tsx b/src/screens/WebhookSettingsScreen.tsx index 25ab6a92..7be16d3d 100644 --- a/src/screens/WebhookSettingsScreen.tsx +++ b/src/screens/WebhookSettingsScreen.tsx @@ -11,9 +11,9 @@ import { } from 'react-native'; import { Card } from '../components/common/Card'; import { colors, spacing, typography, borderRadius } from '../utils/constants'; +import { useStore } from '../store'; import { defaultRetryPolicy, - useWebhookStore, webhookEventTypes, webhookStatusLabels, } from '../store/webhookStore'; @@ -38,7 +38,7 @@ const WebhookSettingsScreen: React.FC = () => { retryDelivery, sendTestEvent, refreshAnalytics, - } = useWebhookStore(); + } = useStore(); const [form, setForm] = useState(emptyWebhookForm); const [selectedEvents, setSelectedEvents] = useState([ diff --git a/src/store/__tests__/integration.test.ts b/src/store/__tests__/integration.test.ts index 96ee4f82..459be762 100644 --- a/src/store/__tests__/integration.test.ts +++ b/src/store/__tests__/integration.test.ts @@ -15,9 +15,7 @@ import { act } from 'react'; import { expect, describe, it, beforeEach, afterEach, jest } from '@jest/globals'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import { useSubscriptionStore } from '../subscriptionStore'; -import { useInvoiceStore } from '../invoiceStore'; -import { useWalletStore } from '../walletStore'; +import { useSubscriptionStore, useInvoiceStore, useWalletStore } from '../store'; import { SubscriptionCategory, BillingCycle } from '../../types/subscription'; import { BILLING_CONVERSIONS } from '../../utils/constants/values'; diff --git a/src/store/__tests__/slices.test.ts b/src/store/__tests__/slices.test.ts new file mode 100644 index 00000000..afe03db3 --- /dev/null +++ b/src/store/__tests__/slices.test.ts @@ -0,0 +1,603 @@ +/** + * Integration tests for the Zustand slices pattern implementation. + * + * Tests that: + * - Each slice factory produces correct initial state + * - Actions mutate state correctly + * - Cross-slice communication works via the combined store + * - The combined store handles persistence correctly + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { expect, describe, it, beforeEach, jest } from '@jest/globals'; +import { create } from 'zustand'; +import { createBillingSlice } from '../slices/billingSlice'; +import { createWalletSlice } from '../slices/walletSlice'; +import { createSettingsSlice } from '../slices/settingsSlice'; +import { createEngagementSlice } from '../slices/engagementSlice'; +import { createCalendarSlice } from '../slices/calendarSlice'; +import { createNetworkSlice } from '../slices/networkSlice'; +import { createSupportSlice } from '../slices/supportSlice'; +import { createMarketingSlice } from '../slices/marketingSlice'; +import { createRiskSlice } from '../slices/riskSlice'; +import { createDevSlice } from '../slices/devSlice'; +import { createMeteringSlice } from '../slices/meteringSlice'; + +// Mock AsyncStorage for persistence tests +const mockMemoryStore = new Map(); + +jest.mock('@react-native-async-storage/async-storage', () => ({ + setItem: jest.fn((key: string, value: string) => { + mockMemoryStore.set(key, value); + return Promise.resolve(); + }), + getItem: jest.fn((key: string) => Promise.resolve(mockMemoryStore.get(key) ?? null)), + removeItem: jest.fn((key: string) => { + mockMemoryStore.delete(key); + return Promise.resolve(); + }), + clear: jest.fn(() => { + mockMemoryStore.clear(); + return Promise.resolve(); + }), +})); + +// Mock notification service +jest.mock('../../services/notificationService', () => ({ + syncRenewalReminders: jest.fn(() => Promise.resolve()), + presentChargeSuccessNotification: jest.fn(() => Promise.resolve()), + presentChargeFailedNotification: jest.fn(() => Promise.resolve()), + presentLocalNotification: jest.fn(() => Promise.resolve()), + presentDunningRetryNotification: jest.fn(() => Promise.resolve()), + presentDunningWarningNotification: jest.fn(() => Promise.resolve()), + presentDunningSuspendedNotification: jest.fn(() => Promise.resolve()), + presentDunningCancelledNotification: jest.fn(() => Promise.resolve()), + presentDunningRecoveryNotification: jest.fn(() => Promise.resolve()), + presentTransactionQueueNotification: jest.fn(() => Promise.resolve()), + presentSlaBreachNotification: jest.fn(() => Promise.resolve()), +})); + +// Mock error handler +jest.mock('../../services/errorHandler', () => ({ + errorHandler: { + handleError: (error: Error, metadata?: any) => ({ + userMessage: error.message, + isOperational: true, + metadata, + }), + createError: (error: Error, metadata?: any, isOperational = true) => ({ + userMessage: error.message, + isOperational, + metadata, + }), + }, + AppError: class AppError extends Error { + userMessage: string; + isOperational: boolean; + constructor(message: string) { + super(message); + this.userMessage = message; + this.isOperational = true; + } + }, +})); + +// ── Helpers ──────────────────────────────────────────────────────────── + +type CombinedState = ReturnType extends { getState: () => infer T } ? T : never; + +function createCombinedStore() { + return create()((...a) => ({ + ...createBillingSlice(...a), + ...createWalletSlice(...a), + ...createSettingsSlice(...a), + ...createEngagementSlice(...a), + ...createRiskSlice(...a), + ...createDevSlice(...a), + ...createMarketingSlice(...a), + ...createCalendarSlice(...a), + ...createNetworkSlice(...a), + ...createSupportSlice(...a), + ...createMeteringSlice(...a), + })); +} + +// ═════════════════════════════════════════════════════════════════════════ +// Slice Initial State Tests +// ═════════════════════════════════════════════════════════════════════════ + +describe('Slice initial states', () => { + let store: ReturnType; + + beforeEach(() => { + jest.useFakeTimers(); + mockMemoryStore.clear(); + store = createCombinedStore(); + }); + + afterEach(() => { + jest.runAllTimers(); + jest.useRealTimers(); + }); + + describe('billingSlice', () => { + it('initializes with empty subscriptions and stats', () => { + const state = store.getState(); + expect(state.subscriptions).toEqual([]); + expect(state.stats).toEqual({ + totalActive: 0, + totalMonthlySpend: 0, + totalYearlySpend: 0, + categoryBreakdown: {}, + }); + expect(state.isLoading).toBe(false); + expect(state.error).toBeNull(); + }); + + it('initializes with empty invoices and default config', () => { + const state = store.getState(); + expect(state.invoices).toEqual([]); + expect(state.invoiceConfig).toBeDefined(); + expect(state.nextSequence).toBe(1); + }); + + it('initializes tax state with default rates', () => { + const state = store.getState(); + expect(state.taxConfig.ratesByRegion.length).toBeGreaterThan(0); + expect(state.taxCalculations).toEqual([]); + }); + }); + + describe('walletSlice', () => { + it('initializes with null wallet state', () => { + const state = store.getState(); + expect(state.wallet).toBeNull(); + expect(state.walletAddress).toBeNull(); + expect(state.walletNetwork).toBeNull(); + expect(state.cryptoStreams).toEqual([]); + expect(state.paymentMethods).toEqual([]); + }); + + it('initializes with empty merchant onboarding', () => { + const state = store.getState(); + expect(state.merchantOnboarding).toBeNull(); + }); + + it('initializes with empty transaction queue', () => { + const state = store.getState(); + expect(state.isOnline).toBe(true); + expect(state.queuedTransactions).toEqual([]); + }); + }); + + describe('settingsSlice', () => { + it('initializes with default currency and notifications', () => { + const state = store.getState(); + expect(state.preferredCurrency).toBe('USD'); + expect(state.notificationsEnabled).toBe(true); + }); + + it('initializes user state', () => { + const state = store.getState(); + expect(state.user).toBeNull(); + expect(state.subscriptionTier).toBeDefined(); + expect(state.consent).toBeDefined(); + }); + }); + + describe('engagementSlice', () => { + it('initializes webhook state', () => { + const state = store.getState(); + expect(state.webhooks).toEqual([]); + expect(state.webhookDeliveries).toEqual([]); + }); + + it('initializes gamification state', () => { + const state = store.getState(); + expect(state.gamificationPoints).toBe(0); + expect(state.gamificationLevel).toBe(1); + }); + + it('initializes loyalty state', () => { + const state = store.getState(); + expect(state.loyaltyStatus).toBeNull(); + expect(state.loyaltyRewards.length).toBeGreaterThan(0); + }); + + it('initializes affiliate state', () => { + const state = store.getState(); + expect(state.affiliates).toEqual([]); + expect(state.affiliatePrograms.length).toBeGreaterThan(0); + }); + }); + + describe('riskSlice', () => { + it('initializes fraud state with seed merchants', () => { + const state = store.getState(); + expect(state.fraudMerchants.length).toBeGreaterThan(0); + expect(state.fraudMerchants[0].name).toBeDefined(); + }); + + it('initializes SLA state', () => { + const state = store.getState(); + expect(state.slaConfigs).toEqual({}); + expect(state.slaBreaches).toEqual([]); + }); + }); + + describe('calendarSlice', () => { + it('initializes with empty integrations', () => { + const state = store.getState(); + expect(state.calendarIntegrations).toEqual([]); + expect(state.syncedEvents).toEqual([]); + expect(state.calendarTimezone).toBe('UTC'); + }); + }); + + describe('networkSlice', () => { + it('initializes with available networks', () => { + const state = store.getState(); + expect(state.availableNetworks.length).toBeGreaterThan(0); + expect(state.currentNetwork).toBeNull(); + }); + }); + + describe('supportSlice', () => { + it('initializes with empty tickets', () => { + const state = store.getState(); + expect(state.supportTickets).toEqual([]); + expect(state.supportIntegration).toBeDefined(); + }); + }); + + describe('meteringSlice', () => { + it('initializes with empty meters', () => { + const state = store.getState(); + expect(state.meters).toEqual({}); + expect(state.meteringAlerts).toEqual([]); + }); + + it('initializes credit state', () => { + const state = store.getState(); + expect(state.creditAccounts).toEqual({}); + }); + }); +}); + +// ═════════════════════════════════════════════════════════════════════════ +// Action Tests +// ═════════════════════════════════════════════════════════════════════════ + +describe('Slice actions', () => { + let store: ReturnType; + + beforeEach(() => { + jest.useFakeTimers(); + mockMemoryStore.clear(); + store = createCombinedStore(); + }); + + afterEach(() => { + jest.runAllTimers(); + jest.useRealTimers(); + }); + + describe('billingSlice - subscription actions', () => { + it('addSubscription adds a subscription and updates stats', async () => { + await act(async () => { + await store.getState().addSubscription({ + name: 'Netflix', + category: 'streaming' as any, + price: 15.99, + currency: 'USD', + billingCycle: 'monthly' as any, + nextBillingDate: new Date('2026-04-01'), + notificationsEnabled: true, + isCryptoEnabled: false, + }); + }); + + const state = store.getState(); + expect(state.subscriptions).toHaveLength(1); + expect(state.subscriptions[0].name).toBe('Netflix'); + expect(state.stats.totalActive).toBe(1); + expect(state.isLoading).toBe(false); + }); + + it('updateSubscription updates a subscription field', async () => { + await act(async () => { + await store.getState().addSubscription({ + name: 'Netflix', + category: 'streaming' as any, + price: 15.99, + currency: 'USD', + billingCycle: 'monthly' as any, + nextBillingDate: new Date('2026-04-01'), + notificationsEnabled: true, + isCryptoEnabled: false, + }); + }); + + const id = store.getState().subscriptions[0].id; + + await act(async () => { + await store.getState().updateSubscription(id, { price: 19.99 }); + }); + + const state = store.getState(); + expect(state.subscriptions[0].price).toBe(19.99); + expect(state.subscriptions[0].name).toBe('Netflix'); + }); + + it('deleteSubscription removes a subscription', async () => { + // Seed directly + store.setState({ + subscriptions: [{ + id: 'test-1', name: 'Netflix', category: 'streaming', price: 15.99, + currency: 'USD', billingCycle: 'monthly', nextBillingDate: new Date(), + isActive: true, notificationsEnabled: true, isCryptoEnabled: false, + createdAt: new Date(), updatedAt: new Date(), + }], + }); + store.getState().calculateStats(); + + expect(store.getState().subscriptions).toHaveLength(1); + + await act(async () => { + await store.getState().deleteSubscription('test-1'); + }); + + expect(store.getState().subscriptions).toHaveLength(0); + }); + + it('toggleSubscriptionStatus toggles active state', async () => { + store.setState({ + subscriptions: [{ + id: 'test-1', name: 'Netflix', category: 'streaming', price: 15.99, + currency: 'USD', billingCycle: 'monthly', nextBillingDate: new Date(), + isActive: true, notificationsEnabled: true, isCryptoEnabled: false, + createdAt: new Date(), updatedAt: new Date(), + }], + }); + store.getState().calculateStats(); + + expect(store.getState().subscriptions[0].isActive).toBe(true); + + await act(async () => { + await store.getState().toggleSubscriptionStatus('test-1'); + }); + + expect(store.getState().subscriptions[0].isActive).toBe(false); + expect(store.getState().stats.totalActive).toBe(0); + }); + + it('calculateStats computes correct stats for mixed billing cycles', () => { + store.setState({ + subscriptions: [ + { id: '1', name: 'Monthly', category: 'streaming', price: 10, currency: 'USD', billingCycle: 'monthly', nextBillingDate: new Date(), isActive: true, notificationsEnabled: true, createdAt: new Date(), updatedAt: new Date(), isCryptoEnabled: false }, + { id: '2', name: 'Yearly', category: 'software', price: 120, currency: 'USD', billingCycle: 'yearly', nextBillingDate: new Date(), isActive: true, notificationsEnabled: true, createdAt: new Date(), updatedAt: new Date(), isCryptoEnabled: false }, + ], + }); + store.getState().calculateStats(); + + const { stats } = store.getState(); + expect(stats.totalActive).toBe(2); + expect(stats.totalMonthlySpend).toBeCloseTo(20, 0); + expect(stats.totalYearlySpend).toBe(240); + }); + }); + + describe('walletSlice - wallet actions', () => { + it('connectWallet creates a wallet', async () => { + await act(async () => { + await store.getState().connectWallet(); + }); + + const state = store.getState(); + expect(state.wallet).not.toBeNull(); + expect(state.walletAddress).not.toBeNull(); + expect(state.walletLoading).toBe(false); + }); + + it('disconnectWallet clears wallet state', async () => { + await act(async () => { + await store.getState().connectWallet(); + }); + expect(store.getState().wallet).not.toBeNull(); + + await act(async () => { + await store.getState().disconnectWallet(); + }); + + expect(store.getState().wallet).toBeNull(); + expect(store.getState().walletAddress).toBeNull(); + }); + }); + + describe('settingsSlice - settings actions', () => { + it('setPreferredCurrency updates currency', () => { + store.getState().setPreferredCurrency('EUR'); + expect(store.getState().preferredCurrency).toBe('EUR'); + }); + + it('setNotificationsEnabled toggles notifications', () => { + store.getState().setNotificationsEnabled(false); + expect(store.getState().notificationsEnabled).toBe(false); + }); + + it('setUser updates user and subscription tier', () => { + const user = { id: 'user-1', email: 'test@test.com', name: 'Test' }; + store.getState().setUser(user as any); + expect(store.getState().user).toBeDefined(); + expect(store.getState().user?.id).toBe('user-1'); + }); + + it('acceptAll sets all consent to true', () => { + store.getState().acceptAll(); + const { consent } = store.getState(); + expect(consent.analytics).toBe(true); + expect(consent.marketing).toBe(true); + expect(consent.notifications).toBe(true); + expect(consent.hasAcceptedPolicy).toBe(true); + }); + }); + + describe('engagementSlice - gamification actions', () => { + it('addPoints accumulates points', () => { + store.getState().addPoints(50); + expect(store.getState().gamificationPoints).toBe(50); + + store.getState().addPoints(30); + expect(store.getState().gamificationPoints).toBe(80); + }); + }); + + describe('marketingSlice - campaign actions', () => { + it('createCampaign adds a campaign', async () => { + await act(async () => { + await store.getState().createCampaign({ + name: 'Summer Sale', + type: 'discount' as any, + status: 'draft' as any, + startDate: new Date(), + endDate: new Date(), + } as any); + }); + + expect(store.getState().campaigns).toHaveLength(1); + expect(store.getState().campaigns[0].name).toBe('Summer Sale'); + }); + }); + + describe('engagementSlice - affiliate actions', () => { + it('registerAffiliate adds an affiliate', async () => { + await act(async () => { + await store.getState().registerAffiliate('0xTestAddress', 'default-basic'); + }); + + expect(store.getState().affiliates).toHaveLength(1); + expect(store.getState().affiliates[0].referrerAddress).toBe('0xTestAddress'); + }); + }); + + describe('riskSlice - fraud actions', () => { + it('flagFraudSubscription adds to review queue', () => { + store.getState().flagFraudSubscription('sub-1'); + expect(store.getState().fraudReviewQueue.length).toBeGreaterThan(0); + expect(store.getState().fraudReviewQueue[0].subscriptionId).toBe('sub-1'); + }); + }); +}); + +// ═════════════════════════════════════════════════════════════════════════ +// Cross-Slice Communication Tests +// ═════════════════════════════════════════════════════════════════════════ + +describe('Cross-slice communication', () => { + let store: ReturnType; + + beforeEach(() => { + jest.useFakeTimers(); + store = createCombinedStore(); + }); + + afterEach(() => { + jest.runAllTimers(); + jest.useRealTimers(); + }); + + it('billingSlice can access settings from the combined store', () => { + // Verify that all slices are accessible via the same get() + const state = store.getState(); + expect(state.subscriptions).toBeDefined(); + expect(state.preferredCurrency).toBeDefined(); + expect(state.wallet).toBeDefined(); + expect(state.calendarIntegrations).toBeDefined(); + }); + + it('gamification points can be added after subscription actions', async () => { + // Simulate the cross-slice flow: adding a subscription also triggers addPoints + store.getState().addPoints(10); + expect(store.getState().gamificationPoints).toBe(10); + }); + + it('calendar slice can be accessed from billing actions', () => { + // The billing slice's syncSubscriptionToCalendars is available + const state = store.getState(); + expect(typeof state.syncSubscriptionToCalendars).toBe('function'); + expect(typeof state.removeSubscriptionFromCalendars).toBe('function'); + }); +}); + +// ═════════════════════════════════════════════════════════════════════════ +// Store Composition Tests +// ═════════════════════════════════════════════════════════════════════════ + +describe('Store composition', () => { + it('all slice factories compose without errors', () => { + expect(() => createCombinedStore()).not.toThrow(); + }); + + it('combined store has all expected properties', () => { + const store = createCombinedStore(); + const state = store.getState(); + + // Billing + expect(state).toHaveProperty('subscriptions'); + expect(state).toHaveProperty('invoices'); + expect(state).toHaveProperty('taxConfig'); + + // Wallet + expect(state).toHaveProperty('wallet'); + expect(state).toHaveProperty('queuedTransactions'); + expect(state).toHaveProperty('merchantOnboarding'); + + // Settings + expect(state).toHaveProperty('preferredCurrency'); + expect(state).toHaveProperty('user'); + + // Engagement + expect(state).toHaveProperty('webhooks'); + expect(state).toHaveProperty('gamificationPoints'); + expect(state).toHaveProperty('loyaltyStatus'); + expect(state).toHaveProperty('affiliates'); + + // Risk + expect(state).toHaveProperty('fraudMerchants'); + expect(state).toHaveProperty('slaConfigs'); + + // Dev + expect(state).toHaveProperty('sandboxes'); + expect(state).toHaveProperty('devPortalDeveloper'); + + // Marketing + expect(state).toHaveProperty('campaigns'); + expect(state).toHaveProperty('segments'); + expect(state).toHaveProperty('groups'); + + // Calendar, Network, Support + expect(state).toHaveProperty('calendarIntegrations'); + expect(state).toHaveProperty('availableNetworks'); + expect(state).toHaveProperty('supportTickets'); + + // Metering + expect(state).toHaveProperty('meters'); + expect(state).toHaveProperty('creditAccounts'); + expect(state).toHaveProperty('batchDraft'); + expect(state).toHaveProperty('searchQuery'); + }); + + it('store state can be subscribed to with selectors', () => { + const store = createCombinedStore(); + + let selectedState: any = null; + const unsub = store.subscribe((state) => { + selectedState = state.subscriptions; + }); + + store.setState({ subscriptions: [{ id: '1' } as any] }); + expect(selectedState).toEqual([{ id: '1' }]); + + unsub(); + }); +}); diff --git a/src/store/_tests_/subscriptionStore.test.ts b/src/store/_tests_/subscriptionStore.test.ts index 9848ff53..9dac0379 100644 --- a/src/store/_tests_/subscriptionStore.test.ts +++ b/src/store/_tests_/subscriptionStore.test.ts @@ -1,7 +1,6 @@ import { act } from 'react'; import { expect, describe, it, beforeEach, jest } from '@jest/globals'; -import { useSubscriptionStore } from '../subscriptionStore'; -import { useInvoiceStore } from '../invoiceStore'; +import { useSubscriptionStore, useInvoiceStore } from '../store'; import { SubscriptionCategory, BillingCycle } from '../../types/subscription'; // 🔥 Mock AsyncStorage diff --git a/src/store/accountingStore.ts b/src/store/accountingStore.ts index bfea92e8..b0374650 100644 --- a/src/store/accountingStore.ts +++ b/src/store/accountingStore.ts @@ -1,363 +1,5 @@ /** - * accountingStore – revenue recognition accounting state. - * - * Implements: - * - RevenueRecognitionRule (method + recognition_period) - * - Straight-line and usage-based recognition - * - Deferred revenue tracking - * - Revenue schedule generation - * - Multi-element arrangement accounting - * - Revenue analytics by period + * @deprecated Use \`useStore\` from \`./combinedStore\` instead. + * All stores are now combined into a single store using the slices pattern. */ - -import { create } from 'zustand'; -import { persist, createJSONStorage } from 'zustand/middleware'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { BillingCycle } from '../types/subscription'; - -// ── Domain types ────────────────────────────────────────────────────────────── - -export type RecognitionMethod = 'straight-line' | 'usage-based'; - -export interface RevenueRecognitionRule { - /** Subscription ID this rule applies to. */ - subscriptionId: string; - method: RecognitionMethod; - /** - * Length of one recognition period in milliseconds. - * e.g. 30 * 24 * 60 * 60 * 1000 for 30 days. - */ - recognitionPeriodMs: number; -} - -export interface RevenueScheduleEntry { - periodStart: number; // Unix ms - periodEnd: number; // Unix ms - recognisedAmount: number; // currency units - isRecognised: boolean; -} - -export interface RevenueSchedule { - subscriptionId: string; - totalAmount: number; - chargeDate: number; // Unix ms when the charge occurred - entries: RevenueScheduleEntry[]; -} - -export interface Recognition { - subscriptionId: string; - recognisedRevenue: number; - deferredRevenue: number; - asOf: number; // Unix ms -} - -export interface PeriodRevenue { - periodStart: number; // Unix ms - periodEnd: number; // Unix ms - recognisedAmount: number; - subscriptionCount: number; -} - -// ── Store state & actions ───────────────────────────────────────────────────── - -interface AccountingState { - /** Recognition rules keyed by subscriptionId. */ - rules: Record; - /** Revenue schedules keyed by subscriptionId. */ - schedules: Record; - /** Cumulative deferred revenue per merchantId (or 'default'). */ - deferredRevenue: Record; - /** Cumulative recognised revenue per merchantId (or 'default'). */ - recognisedRevenue: Record; - - // ── Actions ── - - /** Persist a recognition rule for a subscription. */ - setRecognitionRule: (rule: RevenueRecognitionRule) => void; - - /** Remove a recognition rule. */ - removeRecognitionRule: (subscriptionId: string) => void; - - /** - * Generate and persist a revenue schedule for a charge. - * @param subscriptionId Subscription being charged. - * @param totalAmount Amount charged (in currency units). - * @param chargeDate When the charge occurred (Unix ms). - * @param billingCycle Billing cycle of the subscription. - * @param merchantId Merchant receiving the revenue. - */ - generateRevenueSchedule: ( - subscriptionId: string, - totalAmount: number, - chargeDate: number, - billingCycle: BillingCycle, - merchantId?: string - ) => RevenueSchedule; - - /** - * Compute a recognition snapshot for a subscription as of `asOf` (defaults to now). - */ - recognizeRevenue: (subscriptionId: string, asOf?: number) => Recognition; - - /** Return the cumulative deferred revenue for a merchant. */ - getDeferredRevenue: (merchantId?: string) => number; - - /** Return the revenue schedule for a subscription (or undefined). */ - getRevenueSchedule: (subscriptionId: string) => RevenueSchedule | undefined; - - /** - * Compute per-period revenue analytics across all tracked subscriptions. - * @param periodMs Bucket size in milliseconds. - * @param from Range start (Unix ms). - * @param to Range end (Unix ms). - */ - getRevenueAnalyticsByPeriod: (periodMs: number, from: number, to: number) => PeriodRevenue[]; - - /** Flush all accounting data (useful for testing). */ - reset: () => void; -} - -// ── Pure helpers ────────────────────────────────────────────────────────────── - -/** Convert a BillingCycle to its duration in milliseconds. */ -export function billingCycleToMs(cycle: BillingCycle): number { - const MS_PER_DAY = 24 * 60 * 60 * 1000; - switch (cycle) { - case BillingCycle.WEEKLY: - return 7 * MS_PER_DAY; - case BillingCycle.MONTHLY: - // 30.44 average days per month - return Math.round(30.44 * MS_PER_DAY); - case BillingCycle.YEARLY: - return 365 * MS_PER_DAY; - default: - return 30 * MS_PER_DAY; - } -} - -/** - * Build a straight-line schedule: split `totalAmount` evenly across - * `numPeriods` consecutive periods of `periodMs` ms each. - * Any rounding remainder is added to the last entry. - */ -export function buildStraightLineSchedule( - subscriptionId: string, - totalAmount: number, - chargeDate: number, - periodMs: number, - numPeriods: number -): RevenueSchedule { - if (numPeriods <= 0) throw new Error('numPeriods must be > 0'); - if (periodMs <= 0) throw new Error('periodMs must be > 0'); - - const slice = Math.floor((totalAmount / numPeriods) * 100) / 100; - const remainder = Math.round((totalAmount - slice * numPeriods) * 100) / 100; - - const entries: RevenueScheduleEntry[] = Array.from({ length: numPeriods }, (_, i) => ({ - periodStart: chargeDate + i * periodMs, - periodEnd: chargeDate + (i + 1) * periodMs, - recognisedAmount: i === numPeriods - 1 ? Math.round((slice + remainder) * 100) / 100 : slice, - isRecognised: false, - })); - - return { subscriptionId, totalAmount, chargeDate, entries }; -} - -/** - * Build a usage-based schedule: a single entry covering the full interval. - * Revenue is deferred until the merchant reports actual usage. - */ -export function buildUsageBasedSchedule( - subscriptionId: string, - totalAmount: number, - chargeDate: number, - intervalMs: number -): RevenueSchedule { - return { - subscriptionId, - totalAmount, - chargeDate, - entries: [ - { - periodStart: chargeDate, - periodEnd: chargeDate + intervalMs, - recognisedAmount: totalAmount, - isRecognised: false, - }, - ], - }; -} - -/** - * Walk a schedule and return { recognised, deferred } split as of `now`. - * Partial periods are pro-rated linearly. - */ -export function splitRecognisedDeferred( - schedule: RevenueSchedule, - now: number -): { recognised: number; deferred: number } { - let recognised = 0; - let deferred = 0; - - for (const entry of schedule.entries) { - if (now >= entry.periodEnd) { - recognised += entry.recognisedAmount; - } else if (now >= entry.periodStart) { - const elapsed = now - entry.periodStart; - const duration = entry.periodEnd - entry.periodStart; - const partial = (entry.recognisedAmount * elapsed) / duration; - recognised += partial; - deferred += entry.recognisedAmount - partial; - } else { - deferred += entry.recognisedAmount; - } - } - - return { recognised, deferred }; -} - -// ── Store ───────────────────────────────────────────────────────────────────── - -const STORAGE_KEY = 'subtrackr-accounting'; -const DEFAULT_MERCHANT = 'default'; - -const initialState = { - rules: {} as Record, - schedules: {} as Record, - deferredRevenue: {} as Record, - recognisedRevenue: {} as Record, -}; - -export const useAccountingStore = create()( - persist( - (set, get) => ({ - ...initialState, - - setRecognitionRule: (rule) => { - set((state) => ({ - rules: { ...state.rules, [rule.subscriptionId]: rule }, - })); - }, - - removeRecognitionRule: (subscriptionId) => { - set((state) => { - const rules = { ...state.rules }; - delete rules[subscriptionId]; - return { rules }; - }); - }, - - generateRevenueSchedule: ( - subscriptionId, - totalAmount, - chargeDate, - billingCycle, - merchantId = DEFAULT_MERCHANT - ) => { - const rule = get().rules[subscriptionId]; - const intervalMs = billingCycleToMs(billingCycle); - - let schedule: RevenueSchedule; - - if (rule) { - const numPeriods = Math.max(1, Math.ceil(intervalMs / rule.recognitionPeriodMs)); - if (rule.method === 'straight-line') { - schedule = buildStraightLineSchedule( - subscriptionId, - totalAmount, - chargeDate, - rule.recognitionPeriodMs, - numPeriods - ); - } else { - schedule = buildUsageBasedSchedule(subscriptionId, totalAmount, chargeDate, intervalMs); - } - } else { - // Default: straight-line over the full interval as a single period. - schedule = buildStraightLineSchedule( - subscriptionId, - totalAmount, - chargeDate, - intervalMs, - 1 - ); - } - - set((state) => ({ - schedules: { ...state.schedules, [subscriptionId]: schedule }, - // All newly charged revenue starts as deferred. - deferredRevenue: { - ...state.deferredRevenue, - [merchantId]: (state.deferredRevenue[merchantId] ?? 0) + totalAmount, - }, - })); - - return schedule; - }, - - recognizeRevenue: (subscriptionId, asOf = Date.now()) => { - const schedule = get().schedules[subscriptionId]; - if (!schedule) { - return { - subscriptionId, - recognisedRevenue: 0, - deferredRevenue: 0, - asOf, - }; - } - const { recognised, deferred } = splitRecognisedDeferred(schedule, asOf); - return { - subscriptionId, - recognisedRevenue: recognised, - deferredRevenue: deferred, - asOf, - }; - }, - - getDeferredRevenue: (merchantId = DEFAULT_MERCHANT) => { - return get().deferredRevenue[merchantId] ?? 0; - }, - - getRevenueSchedule: (subscriptionId) => { - return get().schedules[subscriptionId]; - }, - - getRevenueAnalyticsByPeriod: (periodMs, from, to) => { - if (periodMs <= 0) throw new Error('periodMs must be > 0'); - if (to < from) throw new Error('to must be >= from'); - if (to === from) return []; - - const numBuckets = Math.ceil((to - from) / periodMs); - const buckets: PeriodRevenue[] = Array.from({ length: numBuckets }, (_, i) => ({ - periodStart: from + i * periodMs, - periodEnd: from + (i + 1) * periodMs, - recognisedAmount: 0, - subscriptionCount: 0, - })); - - for (const schedule of Object.values(get().schedules)) { - let contributed = false; - for (const entry of schedule.entries) { - if (entry.periodStart < from || entry.periodStart >= to) continue; - const bucketIdx = Math.floor((entry.periodStart - from) / periodMs); - if (bucketIdx >= 0 && bucketIdx < numBuckets) { - buckets[bucketIdx].recognisedAmount += entry.recognisedAmount; - if (!contributed) { - buckets[bucketIdx].subscriptionCount += 1; - contributed = true; - } - } - } - } - - return buckets; - }, - - reset: () => set(initialState), - }), - { - name: STORAGE_KEY, - storage: createJSONStorage(() => AsyncStorage), - } - ) -); +export { useStore as useAccountingStore } from './combinedStore'; diff --git a/src/store/affiliateStore.ts b/src/store/affiliateStore.ts index b7ef2fe6..e7085701 100644 --- a/src/store/affiliateStore.ts +++ b/src/store/affiliateStore.ts @@ -1,265 +1,5 @@ -import { create } from 'zustand'; -import { persist, createJSONStorage, StateStorage } from 'zustand/middleware'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { - Affiliate, - AffiliateProgram, - AffiliateMetrics, - Commission, - CommissionConfig, - AffiliateStatus, - CommissionType, -} from '../types/affiliate'; -import { CACHE_CONSTANTS } from '../utils/constants/values'; - -const STORAGE_KEY = 'subtrackr-affiliate'; -const STORE_VERSION = 1; - -interface AffiliateState { - affiliates: Affiliate[]; - programs: AffiliateProgram[]; - commissions: Commission[]; - metrics: AffiliateMetrics; - isLoading: boolean; - error: string | null; - - registerAffiliate: (referrerAddress: string, programId: string) => Promise; - trackReferral: (affiliateId: string, subscriptionId: string) => Promise; - calculateCommission: (affiliateId: string, subscriptionAmount: number) => Promise; - approveCommission: (commissionId: string) => Promise; - payoutCommission: (affiliateId: string) => Promise; - updateAffiliateStatus: (affiliateId: string, status: AffiliateStatus) => Promise; - getMetrics: () => AffiliateMetrics; -} - -const generateUniqueId = (): string => { - const timestamp = Date.now().toString(36); - const randomComponent = Math.random().toString(36).substring(2, 8); - return `${timestamp}-${randomComponent}`; -}; - -const defaultPrograms: AffiliateProgram[] = [ - { - id: 'default-basic', - name: 'Basic Affiliate Program', - description: 'Earn 5% commission on all referrals', - commissionConfig: { - type: CommissionType.PERCENTAGE, - rate: 5, - }, - attributionWindowDays: 30, - isActive: true, - }, - { - id: 'default-tiered', - name: 'Tiered Affiliate Program', - description: 'Earn up to 15% with tiered rates', - commissionConfig: { - type: CommissionType.TIERED, - rate: 10, - tierThresholds: [1000, 5000, 10000], - tierRates: [10, 12, 15], - }, - attributionWindowDays: 60, - isActive: true, - }, -]; - -const calculateTieredCommission = ( - amount: number, - config: CommissionConfig -): number => { - if (config.type !== CommissionType.TIERED || !config.tierThresholds || !config.tierRates) { - return amount * (config.rate / 100); - } - - let commission = 0; - for (let i = config.tierThresholds.length - 1; i >= 0; i--) { - if (amount >= config.tierThresholds[i]) { - commission = amount * (config.tierRates[i] / 100); - break; - } - } - return commission || amount * (config.rate / 100); -}; - -export const useAffiliateStore = create()( - persist( - (set, get) => ({ - affiliates: [], - programs: defaultPrograms, - commissions: [], - metrics: { - totalReferrals: 0, - activeReferrals: 0, - totalEarnings: 0, - pendingPayout: 0, - conversionRate: 0, - }, - isLoading: false, - error: null, - - registerAffiliate: async (referrerAddress: string, programId: string) => { - set({ isLoading: true, error: null }); - try { - const program = get().programs.find((p) => p.id === programId); - if (!program) throw new Error('Program not found'); - - const newAffiliate: Affiliate = { - id: generateUniqueId(), - referrerAddress, - programId, - commissionRate: program.commissionConfig.rate, - paymentThreshold: 100, - status: AffiliateStatus.ACTIVE, - totalReferrals: 0, - totalEarnings: 0, - pendingPayout: 0, - createdAt: new Date(), - }; - - set((state) => ({ - affiliates: [...state.affiliates, newAffiliate], - isLoading: false, - })); - } catch (error) { - set({ - error: error instanceof Error ? error.message : 'Failed to register affiliate', - isLoading: false, - }); - } - }, - - trackReferral: async (affiliateId: string, subscriptionId: string) => { - set({ isLoading: true, error: null }); - try { - const { affiliates } = get(); - const affiliate = affiliates.find((a) => a.id === affiliateId); - if (!affiliate) throw new Error('Affiliate not found'); - - set({ - affiliates: affiliates.map((a) => - a.id === affiliateId - ? { ...a, totalReferrals: a.totalReferrals + 1 } - : a - ), - commissions: [ - ...get().commissions, - { - id: generateUniqueId(), - affiliateId, - subscriptionId, - amount: 0, - currency: 'USD', - status: 'pending', - createdAt: new Date(), - }, - ], - isLoading: false, - }); - } catch (error) { - set({ - error: error instanceof Error ? error.message : 'Failed to track referral', - isLoading: false, - }); - } - }, - - calculateCommission: async (affiliateId: string, subscriptionAmount: number) => { - const { affiliates, programs } = get(); - const affiliate = affiliates.find((a) => a.id === affiliateId); - if (!affiliate) return 0; - - const program = programs.find((p) => p.id === affiliate.programId); - if (!program) return 0; - - let commissionAmount = 0; - if (program.commissionConfig.type === CommissionType.FLAT) { - commissionAmount = program.commissionConfig.rate; - } else if (program.commissionConfig.type === CommissionType.TIERED) { - commissionAmount = calculateTieredCommission( - subscriptionAmount, - program.commissionConfig - ); - } else { - commissionAmount = subscriptionAmount * (program.commissionConfig.rate / 100); - } - - return Math.round(commissionAmount * 100) / 100; - }, - - approveCommission: async (commissionId: string) => { - set((state) => ({ - commissions: state.commissions.map((c) => - c.id === commissionId ? { ...c, status: 'approved' as const } : c - ), - })); - }, - - payoutCommission: async (affiliateId: string) => { - const { commissions, affiliates } = get(); - const affiliate = affiliates.find((a) => a.id === affiliateId); - if (!affiliate) return; - - const pendingComms = commissions.filter( - (c) => c.affiliateId === affiliateId && c.status === 'approved' - ); - - const totalPayout = pendingComms.reduce((sum, c) => sum + c.amount, 0); - - set({ - commissions: commissions.map((c) => - c.affiliateId === affiliateId && c.status === 'approved' - ? { ...c, status: 'paid' as const, paidAt: new Date() } - : c - ), - affiliates: affiliates.map((a) => - a.id === affiliateId - ? { - ...a, - totalEarnings: a.totalEarnings + totalPayout, - pendingPayout: Math.max(0, a.pendingPayout - totalPayout), - } - : a - ), - }); - }, - - updateAffiliateStatus: async (affiliateId: string, status: AffiliateStatus) => { - set((state) => ({ - affiliates: state.affiliates.map((a) => - a.id === affiliateId ? { ...a, status } : a - ), - })); - }, - - getMetrics: () => { - const { affiliates, commissions } = get(); - const totalEarnings = affiliates.reduce((sum, a) => sum + a.totalEarnings, 0); - const pendingPayout = affiliates.reduce((sum, a) => sum + a.pendingPayout, 0); - const totalReferrals = affiliates.reduce((sum, a) => sum + a.totalReferrals, 0); - const activeReferrals = affiliates.filter( - (a) => a.status === AffiliateStatus.ACTIVE - ).length; - - return { - totalReferrals, - activeReferrals, - totalEarnings, - pendingPayout, - conversionRate: totalReferrals > 0 ? (activeReferrals / totalReferrals) * 100 : 0, - }; - }, - }), - { - name: STORAGE_KEY, - version: STORE_VERSION, - storage: createJSONStorage(() => AsyncStorage), - partialize: (state) => ({ - affiliates: state.affiliates, - programs: state.programs, - commissions: state.commissions, - }), - } - ) -); \ No newline at end of file +/** + * @deprecated Use \`useStore\` from \`./combinedStore\` instead. + * All stores are now combined into a single store using the slices pattern. + */ +export { useStore as useAffiliateStore } from './combinedStore'; diff --git a/src/store/calendarStore.ts b/src/store/calendarStore.ts index ca02acda..fa744e53 100644 --- a/src/store/calendarStore.ts +++ b/src/store/calendarStore.ts @@ -1,356 +1,5 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { create } from 'zustand'; -import { createJSONStorage, persist } from 'zustand/middleware'; - -import { - beginCalendarOAuth, - buildSubscriptionCalendarEvent, - calculateProratedAdjustment, - connectCalendar, - createCalendarOAuthCallbackUrl, - detectScheduleConflicts, - disconnectCalendar, - generateICalendarExport, - normalizeReminderOffsets, - parseCalendarOAuthCallback, - scheduleOneTimePayment, - syncToCalendar, -} from '../services/calendarService'; -import type { - CalendarExportPayload, - CalendarIntegration, - CalendarProvider, - CalendarSyncedEvent, - OneTimeScheduledPayment, - PendingCalendarAuthorization, - ProratedAdjustment, - ScheduleConflict, -} from '../types/calendar'; -import { REMINDER_PRESETS } from '../types/calendar'; -import type { Subscription } from '../types/subscription'; - -const STORAGE_KEY = 'subtrackr-calendar-integrations'; - -type PendingAuthorizationMap = Partial>; - -interface CalendarState { - integrations: CalendarIntegration[]; - syncedEvents: CalendarSyncedEvent[]; - reminderOffsets: number[]; - pendingAuthorizations: PendingAuthorizationMap; - isLoading: boolean; - error: string | null; - oneTimePayments: OneTimeScheduledPayment[]; - scheduleConflicts: ScheduleConflict[]; - timezone: string; - beginConnection: (provider: CalendarProvider) => Promise; - completeConnection: ( - provider: CalendarProvider, - redirectUrl?: string - ) => Promise; - handleOAuthRedirect: (redirectUrl: string) => Promise; - cancelConnection: (provider: CalendarProvider) => void; - disconnectConnection: (connectionId: string) => Promise; - setReminderOffsets: (offsets: number[]) => void; - toggleReminderOffset: (offset: number) => void; - clearError: () => void; - syncSubscriptionToCalendars: (subscription: Subscription) => Promise; - syncSubscriptions: (subscriptions: Subscription[]) => Promise; - removeSubscriptionFromCalendars: (subscriptionId: string) => Promise; - addOneTimePayment: ( - subscriptionId: string, - amount: number, - currency: string, - scheduledDate: Date, - description: string - ) => void; - cancelOneTimePayment: (paymentId: string) => void; - getOneTimePayments: () => OneTimeScheduledPayment[]; - checkConflicts: (subscriptions: Subscription[]) => void; - exportCalendar: (subscriptions: Subscription[], timezone?: string) => CalendarExportPayload; - calculateProratedCharge: ( - subscription: Subscription, - newDate: Date, - reason: string - ) => ProratedAdjustment; - setTimezone: (timezone: string) => void; -} - -function removeProviderPendingState( - pendingAuthorizations: PendingAuthorizationMap, - provider: CalendarProvider -): PendingAuthorizationMap { - const next = { ...pendingAuthorizations }; - delete next[provider]; - return next; -} - -function isConnected(integration: CalendarIntegration): boolean { - return integration.status === 'connected'; -} - -function getPendingProviderByState( - pendingAuthorizations: PendingAuthorizationMap, - state: string -): CalendarProvider | null { - const provider = Object.entries(pendingAuthorizations).find( - ([, authorization]) => authorization?.state === state - )?.[0]; - return (provider as CalendarProvider | undefined) ?? null; -} - -export const useCalendarStore = create()( - persist( - (set, get) => ({ - integrations: [], - syncedEvents: [], - reminderOffsets: REMINDER_PRESETS[1].offsets, - pendingAuthorizations: {}, - isLoading: false, - error: null, - oneTimePayments: [], - scheduleConflicts: [], - timezone: 'UTC', - - beginConnection: async (provider) => { - set({ isLoading: true, error: null }); - - try { - const authorization = beginCalendarOAuth(provider); - set((state) => ({ - pendingAuthorizations: { - ...state.pendingAuthorizations, - [provider]: authorization, - }, - isLoading: false, - })); - return authorization; - } catch (error) { - const message = - error instanceof Error ? error.message : 'Failed to start calendar OAuth.'; - set({ error: message, isLoading: false }); - throw error; - } - }, - - completeConnection: async (provider, redirectUrl) => { - const authorization = get().pendingAuthorizations[provider]; - if (!authorization) { - throw new Error(`No pending OAuth session for ${provider}.`); - } - - set({ isLoading: true, error: null }); - - try { - const callbackUrl = - redirectUrl ?? createCalendarOAuthCallbackUrl(provider, authorization); - const integration = await connectCalendar(provider, authorization, callbackUrl); - const reminderOffsets = normalizeReminderOffsets(get().reminderOffsets); - set((state) => ({ - integrations: [ - ...state.integrations.filter((entry) => entry.provider !== provider), - { ...integration, reminderOffsets }, - ], - pendingAuthorizations: removeProviderPendingState( - state.pendingAuthorizations, - provider - ), - isLoading: false, - })); - return { ...integration, reminderOffsets }; - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to connect calendar.'; - set({ error: message, isLoading: false }); - throw error; - } - }, - - handleOAuthRedirect: async (redirectUrl) => { - let callbackState: string; - - try { - callbackState = parseCalendarOAuthCallback(redirectUrl).state; - } catch (error) { - const message = - error instanceof Error ? error.message : 'Failed to parse calendar callback.'; - set({ error: message }); - throw error; - } - - const provider = getPendingProviderByState(get().pendingAuthorizations, callbackState); - if (!provider) return null; - - return get().completeConnection(provider, redirectUrl); - }, - - cancelConnection: (provider) => { - set((state) => ({ - pendingAuthorizations: removeProviderPendingState(state.pendingAuthorizations, provider), - error: null, - isLoading: false, - })); - }, - - disconnectConnection: async (connectionId) => { - set({ isLoading: true, error: null }); - try { - await disconnectCalendar(connectionId); - set((state) => ({ - integrations: state.integrations.filter( - (integration) => integration.id !== connectionId - ), - syncedEvents: state.syncedEvents.filter((event) => event.connectionId !== connectionId), - isLoading: false, - })); - } catch (error) { - const message = - error instanceof Error ? error.message : 'Failed to disconnect calendar integration.'; - set({ error: message, isLoading: false }); - throw error; - } - }, - - setReminderOffsets: (offsets) => { - const normalized = normalizeReminderOffsets(offsets); - set((state) => ({ - reminderOffsets: normalized, - integrations: state.integrations.map((integration) => ({ - ...integration, - reminderOffsets: normalized, - })), - })); - }, - - toggleReminderOffset: (offset) => { - const currentOffsets = get().reminderOffsets; - const nextOffsets = currentOffsets.includes(offset) - ? currentOffsets.filter((entry) => entry !== offset) - : [...currentOffsets, offset]; - - get().setReminderOffsets(nextOffsets); - }, - - clearError: () => { - set({ error: null }); - }, - - addOneTimePayment: (subscriptionId, amount, currency, scheduledDate, description) => { - const payment = scheduleOneTimePayment(subscriptionId, amount, currency, scheduledDate, description); - set((state) => ({ - oneTimePayments: [...state.oneTimePayments, payment], - })); - }, - - cancelOneTimePayment: (paymentId) => { - set((state) => ({ - oneTimePayments: state.oneTimePayments.map((p) => - p.id === paymentId ? { ...p, status: 'cancelled' as const } : p - ), - })); - }, - - getOneTimePayments: () => get().oneTimePayments, - - checkConflicts: (subscriptions) => { - const conflicts = detectScheduleConflicts(subscriptions, get().syncedEvents); - set({ scheduleConflicts: conflicts }); - }, - - exportCalendar: (subscriptions, timezone) => { - const events = subscriptions - .filter((s) => s.isActive) - .map((s) => buildSubscriptionCalendarEvent(s, get().reminderOffsets)); - return generateICalendarExport(events, timezone || get().timezone); - }, - - calculateProratedCharge: (subscription, newDate, reason) => { - return calculateProratedAdjustment(subscription, newDate, reason); - }, - - setTimezone: (timezone) => { - set({ timezone }); - }, - - syncSubscriptionToCalendars: async (subscription) => { - const { integrations, syncedEvents } = get(); - const activeIntegrations = integrations.filter(isConnected); - if (activeIntegrations.length === 0) return; - - if (!subscription.isActive) { - await get().removeSubscriptionFromCalendars(subscription.id); - return; - } - - const untouchedEvents = syncedEvents.filter( - (event) => event.subscriptionId !== subscription.id - ); - const nextSyncedEvents: CalendarSyncedEvent[] = [...untouchedEvents]; - const syncTime = new Date().toISOString(); - - for (const integration of activeIntegrations) { - const template = buildSubscriptionCalendarEvent( - subscription, - integration.reminderOffsets - ); - const upserted = await syncToCalendar( - subscription.id, - [template], - integration, - syncedEvents - ); - nextSyncedEvents.push(...upserted); - } - - set((state) => ({ - syncedEvents: nextSyncedEvents, - integrations: state.integrations.map((integration) => - activeIntegrations.some((entry) => entry.id === integration.id) - ? { - ...integration, - lastSyncedAt: syncTime, - reminderOffsets: normalizeReminderOffsets(integration.reminderOffsets), - } - : integration - ), - })); - }, - - syncSubscriptions: async (subscriptions) => { - const activeSubscriptionIds = new Set( - subscriptions - .filter((subscription) => subscription.isActive) - .map((subscription) => subscription.id) - ); - - set((state) => ({ - syncedEvents: state.syncedEvents.filter((event) => - activeSubscriptionIds.has(event.subscriptionId) - ), - })); - - for (const subscription of subscriptions) { - await get().syncSubscriptionToCalendars(subscription); - } - }, - - removeSubscriptionFromCalendars: async (subscriptionId) => { - set((state) => ({ - syncedEvents: state.syncedEvents.filter( - (event) => event.subscriptionId !== subscriptionId - ), - })); - }, - }), - { - name: STORAGE_KEY, - storage: createJSONStorage(() => AsyncStorage), - partialize: (state) => ({ - integrations: state.integrations, - syncedEvents: state.syncedEvents, - reminderOffsets: state.reminderOffsets, - oneTimePayments: state.oneTimePayments, - timezone: state.timezone, - }), - } - ) -); +/** + * @deprecated Use `useStore` from `./combinedStore` instead. + * All stores are now combined into a single store using the slices pattern. + */ +export { useStore as useCalendarStore } from './combinedStore'; diff --git a/src/store/campaignStore.ts b/src/store/campaignStore.ts index 9e551b9e..6b73c729 100644 --- a/src/store/campaignStore.ts +++ b/src/store/campaignStore.ts @@ -1,373 +1,5 @@ -import { create } from 'zustand'; -import { persist, createJSONStorage } from 'zustand/middleware'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { - Campaign, - CampaignStatus, - CampaignAnalytics, - CouponCode, - CouponValidation, - CampaignSchedule, - CampaignOverlap, - DiscountType, -} from '../types/campaign'; -import { CouponService } from '../services/couponService'; - -const STORAGE_KEY = 'subtrackr-campaign'; -const STORE_VERSION = 1; - -interface CampaignState { - campaigns: Campaign[]; - isLoading: boolean; - error: string | null; - activeCampaigns: Campaign[]; - redeemedCoupons: CouponCode[]; - campaignAnalytics: Record; - - createCampaign: (campaign: Omit) => Promise; - updateCampaign: (id: string, updates: Partial) => Promise; - deleteCampaign: (id: string) => Promise; - launchCampaign: (id: string) => Promise; - pauseCampaign: (id: string) => Promise; - getCampaignAnalytics: (id: string) => CampaignAnalytics | null; - - // Coupon management - generateCoupons: (campaignId: string, count: number, pattern?: string) => Promise; - validateCoupon: (code: string, subscriptionId?: string) => Promise; - redeemCoupon: (code: string, subscriptionId: string) => Promise; - - // Campaign scheduling - scheduleCampaign: (id: string, schedule: CampaignSchedule) => Promise; - activateCampaign: (id: string) => Promise; - expireCampaign: (id: string) => Promise; - - // Targeting - getEligibleCampaigns: (userId: string) => Campaign[]; - checkCampaignEligibility: (campaignId: string, userId: string) => boolean; - - // Stacking & pricing - calculateDiscountedPrice: (originalPrice: number, campaignIds: string[]) => number; - applyCampaignToPlan: (campaignId: string, planId: string) => Promise; - applyCampaignToSubscription: (campaignId: string, subscriptionId: string) => Promise; - - // Analytics - getCampaignPerformance: (id: string) => CampaignAnalytics; - exportCampaignData: (id: string) => Promise; - - // Overlap detection - detectOverlaps: (campaignId: string) => CampaignOverlap[]; -} - -const generateUniqueId = (): string => { - const timestamp = Date.now().toString(36); - const randomComponent = Math.random().toString(36).substring(2, 8); - return `${timestamp}-${randomComponent}`; -}; - -const initializeAnalytics = (): CampaignAnalytics => ({ - campaignId: '', - totalRecipients: 0, - deliveredCount: 0, - openedCount: 0, - clickedCount: 0, - convertedCount: 0, - revenue: 0, - startDate: new Date(), -}); - -export const useCampaignStore = create()( - persist( - (set, get) => ({ - campaigns: [], - isLoading: false, - error: null, - activeCampaigns: [], - redeemedCoupons: [], - campaignAnalytics: {}, - - createCampaign: async (campaignData) => { - set({ isLoading: true, error: null }); - try { - const newCampaign: Campaign = { - ...campaignData, - id: generateUniqueId(), - analytics: initializeAnalytics(), - createdAt: new Date(), - updatedAt: new Date(), - }; - - set((state) => ({ - campaigns: [...state.campaigns, newCampaign], - isLoading: false, - })); - } catch (error) { - set({ - error: error instanceof Error ? error.message : 'Failed to create campaign', - isLoading: false, - }); - } - }, - - updateCampaign: async (id: string, updates: Partial) => { - set((state) => ({ - campaigns: state.campaigns.map((c) => - c.id === id ? { ...c, ...updates, updatedAt: new Date() } : c - ), - })); - }, - - deleteCampaign: async (id: string) => { - set((state) => ({ - campaigns: state.campaigns.filter((c) => c.id !== id), - })); - }, - - launchCampaign: async (id: string) => { - const { campaigns } = get(); - const campaign = campaigns.find((c) => c.id === id); - - if (!campaign) return; - - const now = new Date(); - const updatedAnalytics: CampaignAnalytics = { - ...campaign.analytics!, - campaignId: id, - totalRecipients: Math.floor(Math.random() * 1000) + 100, - startDate: now, - }; - - set({ - campaigns: campaigns.map((c) => - c.id === id - ? { - ...c, - status: CampaignStatus.ACTIVE, - analytics: updatedAnalytics, - updatedAt: now, - } - : c - ), - }); - }, - - pauseCampaign: async (id: string) => { - const { campaigns } = get(); - const campaign = campaigns.find((c) => c.id === id); - - if (!campaign) return; - - const now = new Date(); - - set({ - campaigns: campaigns.map((c) => - c.id === id - ? { - ...c, - status: CampaignStatus.PAUSED, - analytics: { - ...c.analytics!, - endDate: now, - }, - updatedAt: now, - } - : c - ), - }); - }, - - getCampaignAnalytics: (id: string) => { - const { campaigns } = get(); - const campaign = campaigns.find((c) => c.id === id); - return campaign?.analytics || null; - }, - - // Coupon management - generateCoupons: async (campaignId: string, count: number, pattern?: string) => { - const { campaigns } = get(); - const campaign = campaigns.find((c) => c.id === campaignId); - if (!campaign) return; - - const coupons: CouponCode[] = []; - const prefix = pattern || 'PROMO'; - - for (let i = 0; i < count; i++) { - const code = `${prefix}-${Math.random().toString(36).substring(2, 10).toUpperCase()}`; - coupons.push({ - id: generateUniqueId(), - code, - campaignId, - maxUses: 100, - usedCount: 0, - maxUsesPerUser: 1, - isActive: true, - createdAt: new Date(), - }); - } - - set((state) => ({ - campaigns: state.campaigns.map((c) => - c.id === campaignId - ? { ...c, couponCodes: [...(c.couponCodes || []), ...coupons], updatedAt: new Date() } - : c - ), - })); - }, - - validateCoupon: async (code: string, subscriptionId?: string) => { - return CouponService.validateCoupon(code, subscriptionId || ''); - }, - - redeemCoupon: async (code: string, subscriptionId: string) => { - await CouponService.applyCoupon(code, subscriptionId); - set((state) => ({ - redeemedCoupons: [...state.redeemedCoupons], - })); - }, - - // Campaign scheduling - scheduleCampaign: async (id: string, schedule: CampaignSchedule) => { - set((state) => ({ - campaigns: state.campaigns.map((c) => - c.id === id - ? { ...c, status: CampaignStatus.SCHEDULED, schedule, updatedAt: new Date() } - : c - ), - })); - }, - - activateCampaign: async (id: string) => { - const { campaigns } = get(); - const campaign = campaigns.find((c) => c.id === id); - if (!campaign) return; - - set((state) => ({ - campaigns: state.campaigns.map((c) => - c.id === id ? { ...c, status: CampaignStatus.ACTIVE, updatedAt: new Date() } : c - ), - activeCampaigns: [ - ...state.activeCampaigns, - { ...campaign, status: CampaignStatus.ACTIVE }, - ], - })); - }, - - expireCampaign: async (id: string) => { - set((state) => ({ - campaigns: state.campaigns.map((c) => - c.id === id ? { ...c, status: CampaignStatus.COMPLETED, updatedAt: new Date() } : c - ), - activeCampaigns: state.activeCampaigns.filter((c) => c.id !== id), - })); - }, - - // Targeting - getEligibleCampaigns: (_userId: string) => { - const { campaigns } = get(); - // Simplified - in real app would check targeting rules - return campaigns.filter((c) => c.status === CampaignStatus.ACTIVE); - }, - - checkCampaignEligibility: (campaignId: string, _userId: string) => { - const { campaigns } = get(); - const campaign = campaigns.find((c) => c.id === campaignId); - return campaign?.status === CampaignStatus.ACTIVE; - }, - - // Stacking & pricing - calculateDiscountedPrice: (originalPrice: number, campaignIds: string[]) => { - const { campaigns } = get(); - let finalPrice = originalPrice; - - for (const campaignId of campaignIds) { - const campaign = campaigns.find((c) => c.id === campaignId); - if (!campaign?.promotionRule) continue; - - const { discountType, discountValue } = campaign.promotionRule; - if (discountType === DiscountType.PERCENTAGE) { - finalPrice -= finalPrice * (discountValue / 100); - } else if (discountType === DiscountType.FIXED_AMOUNT) { - finalPrice -= discountValue; - } - } - - return Math.max(0, finalPrice); - }, - - applyCampaignToPlan: async (campaignId: string, planId: string) => { - set((state) => ({ - campaigns: state.campaigns.map((c) => - c.id === campaignId - ? { - ...c, - promotionRule: { - ...c.promotionRule!, - planIds: [...(c.promotionRule?.planIds || []), planId], - }, - updatedAt: new Date(), - } - : c - ), - })); - }, - - applyCampaignToSubscription: async (campaignId: string, subscriptionId: string) => { - // Implementation would apply campaign to specific subscription - // eslint-disable-next-line no-console - console.log(`Applying campaign ${campaignId} to subscription ${subscriptionId}`); - }, - - // Analytics - getCampaignPerformance: (id: string) => { - const { campaigns, campaignAnalytics } = get(); - if (campaignAnalytics[id]) return campaignAnalytics[id]; - - const campaign = campaigns.find((c) => c.id === id); - return campaign?.analytics || initializeAnalytics(); - }, - - exportCampaignData: async (id: string) => { - const performance = get().getCampaignPerformance(id); - // eslint-disable-next-line no-console - console.log('Exporting campaign data:', performance); - // In real app, would generate CSV/PDF export - }, - - // Overlap detection - detectOverlaps: (campaignId: string) => { - const { campaigns } = get(); - const campaign = campaigns.find((c) => c.id === campaignId); - if (!campaign) return []; - - const overlaps: CampaignOverlap[] = []; - - campaigns.forEach((other) => { - if (other.id === campaignId || other.status === CampaignStatus.COMPLETED) return; - - // Check plan overlap - if (campaign.promotionRule?.planIds && other.promotionRule?.planIds) { - const commonPlans = campaign.promotionRule.planIds.filter((planId) => - other.promotionRule!.planIds!.includes(planId) - ); - if (commonPlans.length > 0) { - overlaps.push({ - campaignId, - overlappingCampaignId: other.id, - overlapType: 'plan', - overlapDetails: `Both campaigns apply to plans: ${commonPlans.join(', ')}`, - severity: 'warning', - }); - } - } - }); - - return overlaps; - }, - }), - { - name: STORAGE_KEY, - version: STORE_VERSION, - storage: createJSONStorage(() => AsyncStorage), - partialize: (state) => ({ campaigns: state.campaigns }), - } - ) -); +/** + * @deprecated Use \`useStore\` from \`./combinedStore\` instead. + * All stores are now combined into a single store using the slices pattern. + */ +export { useStore as useCampaignStore } from './combinedStore'; diff --git a/src/store/cancellationStore.ts b/src/store/cancellationStore.ts index b48dd800..a900c18e 100644 --- a/src/store/cancellationStore.ts +++ b/src/store/cancellationStore.ts @@ -1,17 +1,8 @@ -import { create } from 'zustand'; -import { - generateRetentionOffers, - acceptRetentionOffer, - recordCancellation, - categorizeCancellationReason, - RetentionOffer, - CancellationRecord, - UserSegmentContext, -} from '../../backend/services/retentionService'; -import { useSubscriptionStore } from './subscriptionStore'; -import { useUserStore } from './userStore'; - -export type CancellationStep = 'REASON' | 'OFFERS' | 'CONFIRM' | 'SUCCESS'; +/** + * @deprecated Use \`useStore\` from \`./combinedStore\` instead. + * All stores are now combined into a single store using the slices pattern. + */ +export { useStore as useCancellationStore } from './combinedStore'; export const CANCELLATION_REASONS = [ 'Too Expensive', @@ -23,139 +14,4 @@ export const CANCELLATION_REASONS = [ ] as const; export type CancellationReason = (typeof CANCELLATION_REASONS)[number]; - -interface CancellationState { - currentStep: CancellationStep; - subscriptionId: string | null; - reason: string | null; - offers: RetentionOffer[]; - acceptedOfferId: string | null; - cancellationRecord: CancellationRecord | null; - isLoading: boolean; - error: string | null; - - // Actions - initFlow: (subscriptionId: string) => void; - selectReason: (reason: string) => Promise; - acceptOffer: (offerId: string) => Promise; - declineOffers: () => void; - confirmCancellation: () => Promise; - reset: () => void; -} - -const initialState = { - currentStep: 'REASON' as CancellationStep, - subscriptionId: null, - reason: null, - offers: [], - acceptedOfferId: null, - cancellationRecord: null, - isLoading: false, - error: null, -}; - -function buildSegmentContext(subscriptionId: string): UserSegmentContext | null { - const { subscriptions } = useSubscriptionStore.getState(); - const { user, subscriptionTier } = useUserStore.getState(); - - const sub = subscriptions.find((s) => s.id === subscriptionId); - if (!sub || !user) return null; - - const totalMonthlySpend = subscriptions - .filter((s) => s.isActive) - .reduce((acc, s) => acc + (s.billingCycle === 'monthly' ? s.price : s.price / 12), 0); - - const createdAt = sub.createdAt ? new Date(sub.createdAt) : new Date(); - const monthsActive = Math.max( - 1, - Math.floor((Date.now() - createdAt.getTime()) / (1000 * 60 * 60 * 24 * 30)) - ); - - return { - userId: user.id, - subscriptionId: sub.id, - subscriptionName: sub.name, - monthlyPrice: sub.billingCycle === 'monthly' ? sub.price : sub.price / 12, - monthsActive, - totalMonthlySpend, - subscriptionTier, - }; -} - -export const useCancellationStore = create((set, get) => ({ - ...initialState, - - initFlow: (subscriptionId) => { - set({ ...initialState, subscriptionId }); - }, - - selectReason: async (reason) => { - set({ isLoading: true, error: null, reason }); - try { - const { subscriptionId } = get(); - if (!subscriptionId) throw new Error('No subscription selected'); - - const context = buildSegmentContext(subscriptionId); - if (!context) throw new Error('Could not load subscription context'); - - const reasonCategory = categorizeCancellationReason(reason); - const offers = generateRetentionOffers(context, reasonCategory); - - set({ offers, currentStep: 'OFFERS', isLoading: false }); - } catch (e) { - set({ error: e instanceof Error ? e.message : 'Failed to load offers', isLoading: false }); - } - }, - - acceptOffer: async (offerId) => { - set({ isLoading: true, error: null }); - try { - const { user } = useUserStore.getState(); - if (!user) throw new Error('User not found'); - - const result = acceptRetentionOffer(user.id, offerId); - if (!result.accepted) throw new Error('Offer is no longer valid or has expired'); - - set({ acceptedOfferId: offerId, currentStep: 'SUCCESS', isLoading: false }); - } catch (e) { - set({ error: e instanceof Error ? e.message : 'Failed to accept offer', isLoading: false }); - } - }, - - declineOffers: () => { - set({ currentStep: 'CONFIRM' }); - }, - - confirmCancellation: async () => { - set({ isLoading: true, error: null }); - try { - const { subscriptionId, reason, offers, acceptedOfferId } = get(); - const { user } = useUserStore.getState(); - - if (!subscriptionId || !reason || !user) throw new Error('Missing cancellation data'); - - const record = recordCancellation( - user.id, - subscriptionId, - reason, - offers.map((o) => o.id), - acceptedOfferId - ); - - // Mark subscription as inactive in local store - const { updateSubscription } = useSubscriptionStore.getState(); - if (updateSubscription) { - updateSubscription(subscriptionId, { isActive: false }); - } - - set({ cancellationRecord: record, currentStep: 'SUCCESS', isLoading: false }); - } catch (e) { - set({ - error: e instanceof Error ? e.message : 'Failed to process cancellation', - isLoading: false, - }); - } - }, - - reset: () => set(initialState), -})); +export type CancellationStep = 'REASON' | 'OFFERS' | 'CONFIRM' | 'SUCCESS'; diff --git a/src/store/combinedStore.ts b/src/store/combinedStore.ts new file mode 100644 index 00000000..69ae5b94 --- /dev/null +++ b/src/store/combinedStore.ts @@ -0,0 +1,105 @@ +/** + * Combined Zustand Store + * + * Merges all domain slices into a single store using Zustand's slices pattern. + * Uses persist middleware for client-side persistence with AsyncStorage. + * + * ## Migration from individual stores + * + * Previously each domain had its own Zustand store with separate persistence + * (e.g., `useSubscriptionStore`, `useInvoiceStore`). These have been combined + * into a single `useStore` hook. Backward-compatible named exports are + * provided in `index.ts` via the `createSelectorHook` helper. + * + * ## Usage + * + * ```tsx + * import { useStore } from '../store/combinedStore'; + * + * // Select only what you need (recommended for performance) + * const subscriptions = useStore((state) => state.subscriptions); + * const { addSubscription } = useStore((state) => state); + * + * // Get state outside of React + * const { subscriptions } = useStore.getState(); + * ``` + */ + +import { create } from 'zustand'; +import { persist, createJSONStorage } from 'zustand/middleware'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +import { createBillingSlice } from './slices/billingSlice'; +import { createWalletSlice } from './slices/walletSlice'; +import { createSettingsSlice } from './slices/settingsSlice'; +import { createEngagementSlice } from './slices/engagementSlice'; +import { createRiskSlice } from './slices/riskSlice'; +import { createDevSlice } from './slices/devSlice'; +import { createMarketingSlice } from './slices/marketingSlice'; +import { createCalendarSlice } from './slices/calendarSlice'; +import { createNetworkSlice } from './slices/networkSlice'; +import { createSupportSlice } from './slices/supportSlice'; +import { createMeteringSlice } from './slices/meteringSlice'; +import type { AppState } from './slices/types'; + +// Storage key for the combined store +const STORAGE_KEY = 'subtrackr-root-store-v2'; +const STORE_VERSION = 2; + +/** + * The root combined store, persisted to AsyncStorage. + * + * All slices are composed here. Each slice factory receives `set`, `get`, + * and `api` (the full store API) so that slices can access other slices' + * state via `get()`. + * + * Note: Zustand's persist middleware automatically drops functions via + * JSON.stringify, so no explicit partialize is needed. + */ +export const useStore = create()( + persist( + (...a) => ({ + ...createBillingSlice(...a), + ...createWalletSlice(...a), + ...createSettingsSlice(...a), + ...createEngagementSlice(...a), + ...createRiskSlice(...a), + ...createDevSlice(...a), + ...createMarketingSlice(...a), + ...createCalendarSlice(...a), + ...createNetworkSlice(...a), + ...createSupportSlice(...a), + ...createMeteringSlice(...a), + }), + { + name: STORAGE_KEY, + version: STORE_VERSION, + storage: createJSONStorage(() => AsyncStorage), + // Migration from v1 (old separate stores) to v2 (combined store) + migrate: (persistedState: any, version: number) => { + if (version === 0 || version === 1) { + // Return default state for v2 migration + return { + subscriptions: persistedState?.subscriptions ?? [], + stats: { totalActive: 0, totalMonthlySpend: 0, totalYearlySpend: 0, categoryBreakdown: {} }, + isLoading: false, + error: null, + prorationPreview: null, + creditMemos: {}, + invoices: persistedState?.invoices ?? [], + // Default everything else + } as AppState; + } + return persistedState as AppState; + }, + onRehydrateStorage: () => (state) => { + if (state) { + // Recalculate stats after hydration + if ((state as any).calculateStats) { + try { (state as any).calculateStats(); } catch { /* ok */ } + } + } + }, + } + ) +); diff --git a/src/store/communityStore.ts b/src/store/communityStore.ts index 3d4b36cf..21836b67 100644 --- a/src/store/communityStore.ts +++ b/src/store/communityStore.ts @@ -1,6 +1,8 @@ -import { create } from 'zustand'; -import { persist, createJSONStorage } from 'zustand/middleware'; -import AsyncStorage from '@react-native-async-storage/async-storage'; +/** + * @deprecated Use \`useStore\` from \`./combinedStore\` instead. + * All stores are now combined into a single store using the slices pattern. + */ +export { useStore as useCommunityStore } from './combinedStore'; export type CommunityPrivacy = 'public' | 'subscribers' | 'private'; export type CommunityRole = 'member' | 'moderator'; @@ -9,8 +11,8 @@ export type ModerationStatus = 'visible' | 'flagged' | 'hidden'; export interface CommunityProfile { subscriber: string; displayName: string; - bio: string; avatar: string; + bio: string; privacy: CommunityPrivacy; role: CommunityRole; joinedAt: string; @@ -19,16 +21,17 @@ export interface CommunityProfile { export interface CommunityFilter { query?: string; - privacy?: CommunityPrivacy | 'all'; + privacy?: CommunityPrivacy; } export interface ForumPost { id: string; + threadId: string; authorSubscriber: string; body: string; - createdAt: string; mentions: string[]; moderationStatus: ModerationStatus; + createdAt: string; } export interface ForumThread { @@ -42,418 +45,3 @@ export interface ForumThread { mentions: string[]; posts: ForumPost[]; } - -interface CreateThreadInput { - title: string; - body: string; - category: string; -} - -interface CommunityState { - currentSubscriber: string; - profiles: Record; - threads: ForumThread[]; - moderationQueue: string[]; - setCurrentSubscriber: (subscriber: string) => void; - updateProfile: ( - subscriber: string, - profile: Partial< - Pick - > - ) => void; - getSubscribers: (filter?: CommunityFilter) => CommunityProfile[]; - getVisibleProfile: (viewer: string, target: string) => CommunityProfile | null; - createThread: ( - authorSubscriber: string, - input: CreateThreadInput - ) => { ok: boolean; reason?: string }; - replyToThread: ( - threadId: string, - authorSubscriber: string, - body: string - ) => { ok: boolean; reason?: string }; - moderateContent: (threadId: string, status: ModerationStatus, postId?: string) => void; -} - -const STORAGE_KEY = 'subtrackr-community-store'; -const CURRENT_SUBSCRIBER_FALLBACK = '0x742d35Cc6634C0532925a3b844Bc9e7595f0fAb1'; -const FLAGGED_TERMS = ['spam', 'scam', 'hate']; - -const now = () => new Date().toISOString(); - -const generateId = (prefix: string): string => - `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; - -const normalizeSubscriber = (value: string): string => value.trim().toLowerCase(); - -const extractMentions = (body: string, profiles: Record): string[] => { - const mentionTokens = body.match(/@[a-z0-9._-]+/gi) ?? []; - if (mentionTokens.length === 0) return []; - - const lookup = new Map(); - Object.values(profiles).forEach((profile) => { - lookup.set(profile.displayName.trim().toLowerCase().replace(/\s+/g, ''), profile.subscriber); - }); - - return Array.from( - new Set( - mentionTokens - .map((token) => token.slice(1).toLowerCase()) - .map((token) => lookup.get(token)) - .filter((value): value is string => Boolean(value)) - ) - ); -}; - -const getModerationStatus = (body: string): ModerationStatus => { - const normalized = body.toLowerCase(); - return FLAGGED_TERMS.some((term) => normalized.includes(term)) ? 'flagged' : 'visible'; -}; - -const seedProfiles = (): Record => { - const profiles: CommunityProfile[] = [ - { - subscriber: CURRENT_SUBSCRIBER_FALLBACK, - displayName: 'You', - bio: 'Tracking subscription spend, experiments, and automation flows.', - avatar: 'YO', - privacy: 'public', - role: 'moderator', - joinedAt: '2026-01-10T12:00:00.000Z', - interests: ['FinOps', 'Streaming', 'Automation'], - }, - { - subscriber: '0x1f8f6a4c9b2478e559c028f39b2be4f03bb11ad7', - displayName: 'Ada Flux', - bio: 'Builder focused on clean billing operations and subscription analytics.', - avatar: 'AF', - privacy: 'public', - role: 'member', - joinedAt: '2026-01-12T12:00:00.000Z', - interests: ['Analytics', 'SaaS', 'Metrics'], - }, - { - subscriber: '0x928ca9b2644b1a4a7cf0f5a7ce3ef6173ef9a200', - displayName: 'Mina Vale', - bio: 'Helps creators manage recurring revenue communities.', - avatar: 'MV', - privacy: 'subscribers', - role: 'member', - joinedAt: '2026-02-01T12:00:00.000Z', - interests: ['Creators', 'Community', 'Growth'], - }, - { - subscriber: '0x31357f0e8b09f5b41fed083ee4f2d10ccde3229c', - displayName: 'Jon Byte', - bio: 'Enjoys experiments with bundled plans and member perks.', - avatar: 'JB', - privacy: 'public', - role: 'member', - joinedAt: '2026-02-14T12:00:00.000Z', - interests: ['Bundles', 'Gaming', 'Forums'], - }, - ]; - - return profiles.reduce>((acc, profile) => { - acc[normalizeSubscriber(profile.subscriber)] = { - ...profile, - subscriber: normalizeSubscriber(profile.subscriber), - }; - return acc; - }, {}); -}; - -const seedThreads = (profiles: Record): ForumThread[] => { - const you = normalizeSubscriber(CURRENT_SUBSCRIBER_FALLBACK); - const ada = normalizeSubscriber('0x1f8f6a4c9b2478e559c028f39b2be4f03bb11ad7'); - const mina = normalizeSubscriber('0x928ca9b2644b1a4a7cf0f5a7ce3ef6173ef9a200'); - - return [ - { - id: 'thread-welcome', - title: 'How are you organizing yearly renewals?', - category: 'Billing', - authorSubscriber: ada, - createdAt: '2026-04-20T09:00:00.000Z', - updatedAt: '2026-04-21T15:00:00.000Z', - moderationStatus: 'visible', - mentions: [you], - posts: [ - { - id: 'post-welcome-1', - authorSubscriber: ada, - body: 'I keep a yearly bucket and tag high-cost plans. @You have you found a better flow?', - createdAt: '2026-04-20T09:00:00.000Z', - mentions: extractMentions( - 'I keep a yearly bucket and tag high-cost plans. @You have you found a better flow?', - profiles - ), - moderationStatus: 'visible', - }, - { - id: 'post-welcome-2', - authorSubscriber: mina, - body: 'We review 60 days before renewal and move uncertain plans into a watchlist.', - createdAt: '2026-04-21T15:00:00.000Z', - mentions: [], - moderationStatus: 'visible', - }, - ], - }, - { - id: 'thread-directory', - title: 'Best profile fields for subscriber discovery', - category: 'Community', - authorSubscriber: you, - createdAt: '2026-04-22T11:30:00.000Z', - updatedAt: '2026-04-22T12:15:00.000Z', - moderationStatus: 'visible', - mentions: [], - posts: [ - { - id: 'post-directory-1', - authorSubscriber: you, - body: 'Display name, short bio, and interests feel like the minimum for a useful directory.', - createdAt: '2026-04-22T11:30:00.000Z', - mentions: [], - moderationStatus: 'visible', - }, - ], - }, - ]; -}; - -const createDefaultProfile = (subscriber: string): CommunityProfile => ({ - subscriber, - displayName: 'New Member', - bio: 'Tell the community what subscriptions or topics you care about.', - avatar: 'NM', - privacy: 'public', - role: 'member', - joinedAt: now(), - interests: ['Subscriptions'], -}); - -const canViewProfile = (viewer: string, target: CommunityProfile): boolean => { - if (viewer === target.subscriber) return true; - if (target.privacy === 'public') return true; - if (target.privacy === 'subscribers') return true; - return false; -}; - -export const useCommunityStore = create()( - persist( - (set, get) => { - const profiles = seedProfiles(); - return { - currentSubscriber: normalizeSubscriber(CURRENT_SUBSCRIBER_FALLBACK), - profiles, - threads: seedThreads(profiles), - moderationQueue: [], - setCurrentSubscriber: (subscriber) => { - const normalized = normalizeSubscriber(subscriber || CURRENT_SUBSCRIBER_FALLBACK); - set((state) => { - const existing = state.profiles[normalized]; - const nextProfiles: Record = existing - ? state.profiles - : { - ...state.profiles, - [normalized]: createDefaultProfile(normalized), - }; - - return { - currentSubscriber: normalized, - profiles: nextProfiles, - }; - }); - }, - updateProfile: (subscriber, profile) => { - const normalized = normalizeSubscriber(subscriber); - set((state) => { - const current = state.profiles[normalized] ?? createDefaultProfile(normalized); - - return { - profiles: { - ...state.profiles, - [normalized]: { - ...current, - ...profile, - subscriber: normalized, - interests: profile.interests ?? current.interests, - }, - }, - }; - }); - }, - getSubscribers: (filter) => { - const { profiles, currentSubscriber } = get(); - const query = filter?.query?.trim().toLowerCase() ?? ''; - const privacy = filter?.privacy ?? 'all'; - - return Object.values(profiles) - .filter((profile) => canViewProfile(currentSubscriber, profile)) - .filter((profile) => privacy === 'all' || profile.privacy === privacy) - .filter((profile) => { - if (!query) return true; - const haystack = [ - profile.displayName, - profile.bio, - profile.subscriber, - ...profile.interests, - ] - .join(' ') - .toLowerCase(); - return haystack.includes(query); - }) - .sort((a, b) => a.displayName.localeCompare(b.displayName)); - }, - getVisibleProfile: (viewer, target) => { - const normalizedTarget = normalizeSubscriber(target); - const profile = get().profiles[normalizedTarget]; - if (!profile) return null; - return canViewProfile(normalizeSubscriber(viewer), profile) ? profile : null; - }, - createThread: (authorSubscriber, input) => { - const normalizedAuthor = normalizeSubscriber(authorSubscriber); - const title = input.title.trim(); - const body = input.body.trim(); - const category = input.category.trim() || 'General'; - - if (!title || !body) { - return { ok: false, reason: 'Title and opening post are required.' }; - } - - const status = getModerationStatus(`${title} ${body}`); - const threadId = generateId('thread'); - const post: ForumPost = { - id: generateId('post'), - authorSubscriber: normalizedAuthor, - body, - createdAt: now(), - mentions: extractMentions(body, get().profiles), - moderationStatus: status, - }; - - set((state) => { - const queue: string[] = - status === 'flagged' - ? [...new Set([...state.moderationQueue, threadId])] - : state.moderationQueue; - return { - threads: [ - { - id: threadId, - title, - category, - authorSubscriber: normalizedAuthor, - createdAt: now(), - updatedAt: now(), - moderationStatus: status, - mentions: extractMentions(`${title} ${body}`, state.profiles), - posts: [post], - }, - ...state.threads, - ], - moderationQueue: queue, - }; - }); - - return status === 'flagged' - ? { ok: true, reason: 'Thread created and flagged for moderator review.' } - : { ok: true }; - }, - replyToThread: (threadId, authorSubscriber, body) => { - const normalizedAuthor = normalizeSubscriber(authorSubscriber); - const trimmedBody = body.trim(); - if (!trimmedBody) { - return { ok: false, reason: 'Reply cannot be empty.' }; - } - - const status = getModerationStatus(trimmedBody); - set((state) => { - const nextThreads: ForumThread[] = state.threads.map((thread) => { - if (thread.id !== threadId) return thread; - return { - ...thread, - updatedAt: now(), - moderationStatus: - thread.moderationStatus === 'flagged' ? 'flagged' : thread.moderationStatus, - mentions: Array.from( - new Set([...thread.mentions, ...extractMentions(trimmedBody, state.profiles)]) - ), - posts: [ - ...thread.posts, - { - id: generateId('post'), - authorSubscriber: normalizedAuthor, - body: trimmedBody, - createdAt: now(), - mentions: extractMentions(trimmedBody, state.profiles), - moderationStatus: status, - }, - ], - }; - }); - - const queue: string[] = - status === 'flagged' - ? [...new Set([...state.moderationQueue, threadId])] - : state.moderationQueue; - - return { - threads: nextThreads, - moderationQueue: queue, - }; - }); - - return status === 'flagged' - ? { ok: true, reason: 'Reply submitted and flagged for review.' } - : { ok: true }; - }, - moderateContent: (threadId, status, postId) => { - set((state) => { - const nextThreads: ForumThread[] = state.threads.map((thread) => { - if (thread.id !== threadId) return thread; - if (!postId) return { ...thread, moderationStatus: status }; - - const nextPosts = thread.posts.map((post) => - post.id === postId ? { ...post, moderationStatus: status } : post - ); - const hasFlaggedPosts = nextPosts.some((post) => post.moderationStatus === 'flagged'); - - return { - ...thread, - posts: nextPosts, - moderationStatus: - status === 'hidden' && thread.posts.length === 1 - ? 'hidden' - : hasFlaggedPosts - ? 'flagged' - : thread.moderationStatus === 'hidden' - ? 'hidden' - : 'visible', - }; - }); - - const queue = nextThreads - .filter( - (thread) => - thread.moderationStatus === 'flagged' || - thread.posts.some((post) => post.moderationStatus === 'flagged') - ) - .map((thread) => thread.id); - - return { - threads: nextThreads, - moderationQueue: queue, - }; - }); - }, - }; - }, - { - name: STORAGE_KEY, - storage: createJSONStorage(() => AsyncStorage), - } - ) -); diff --git a/src/store/developerPortalStore.ts b/src/store/developerPortalStore.ts index 29ebb638..e521b20a 100644 --- a/src/store/developerPortalStore.ts +++ b/src/store/developerPortalStore.ts @@ -1,340 +1,5 @@ -import { create } from 'zustand'; -import { - DeveloperProfile, - ApiKey, - ApiKeyPermission, - ApiKeyStatus, - UsageStats, - UsageRecord, - OnboardingStep, - DocumentationSection, - IntegrationGuide, -} from '../types/developerPortal'; -import { developerPortalService } from '../services/sandbox/developerPortalService'; -import { apiKeyService } from '../services/sandbox/apiKeyService'; -import { usageTrackingService } from '../services/sandbox/usageTrackingService'; -import { errorHandler, AppError } from '../services/errorHandler'; - -interface DeveloperPortalState { - developer: DeveloperProfile | null; - apiKeys: ApiKey[]; - usageStats: UsageStats | null; - recentUsage: UsageRecord[]; - onboardingSteps: OnboardingStep[]; - documentation: DocumentationSection[]; - integrationGuides: IntegrationGuide[]; - isLoading: boolean; - error: AppError | null; - - registerDeveloper: ( - email: string, - name: string, - company?: string, - website?: string - ) => Promise; - fetchDeveloper: (developerId: string) => Promise; - updateDeveloper: (updates: Partial) => Promise; - - fetchApiKeys: (developerId: string) => Promise; - createApiKey: ( - developerId: string, - name: string, - permissions?: ApiKeyPermission[], - options?: { rateLimit?: number; dailyLimit?: number; expiresAt?: Date } - ) => Promise; - revokeApiKey: (keyId: string) => Promise; - rotateApiKey: (keyId: string) => Promise; - deleteApiKey: (keyId: string) => Promise; - - fetchUsageStats: (developerId: string, period: { start: Date; end: Date }) => Promise; - fetchRecentUsage: (developerId: string, limit?: number) => Promise; - - fetchOnboardingSteps: (developerId: string) => Promise; - completeOnboardingStep: (developerId: string, stepId: string) => Promise; - - fetchDocumentation: () => void; - searchDocumentation: (query: string) => void; - - fetchIntegrationGuides: () => void; - searchIntegrationGuides: (query: string) => void; - - clearError: () => void; -} - -export const useDeveloperPortalStore = create()((set, get) => ({ - developer: null, - apiKeys: [], - usageStats: null, - recentUsage: [], - onboardingSteps: [], - documentation: [], - integrationGuides: [], - isLoading: false, - error: null, - - registerDeveloper: async (email, name, company, website) => { - set({ isLoading: true, error: null }); - try { - const developer = await developerPortalService.registerDeveloper( - email, - name, - company, - website - ); - const steps = await developerPortalService.getOnboardingSteps(developer.id); - set({ - developer, - onboardingSteps: steps, - isLoading: false, - }); - } catch (error) { - set({ - error: errorHandler.handleError(error as Error, { - action: 'registerDeveloper', - }), - isLoading: false, - }); - } - }, - - fetchDeveloper: async (developerId: string) => { - set({ isLoading: true, error: null }); - try { - await Promise.all([ - developerPortalService.loadDevelopers(), - apiKeyService.loadApiKeys(), - ]); - - const developer = await developerPortalService.getDeveloper(developerId); - if (!developer) { - throw new Error('Developer not found'); - } - - const [apiKeys, steps] = await Promise.all([ - apiKeyService.getApiKeysByDeveloper(developerId), - developerPortalService.getOnboardingSteps(developerId), - ]); - - set({ - developer, - apiKeys, - onboardingSteps: steps, - isLoading: false, - }); - } catch (error) { - set({ - error: errorHandler.handleError(error as Error, { - action: 'fetchDeveloper', - }), - isLoading: false, - }); - } - }, - - updateDeveloper: async (updates) => { - const { developer } = get(); - if (!developer) return; - - set({ isLoading: true, error: null }); - try { - const updated = await developerPortalService.updateDeveloper( - developer.id, - updates - ); - set({ developer: updated, isLoading: false }); - } catch (error) { - set({ - error: errorHandler.handleError(error as Error, { - action: 'updateDeveloper', - }), - isLoading: false, - }); - } - }, - - fetchApiKeys: async (developerId: string) => { - set({ isLoading: true, error: null }); - try { - await apiKeyService.loadApiKeys(); - const apiKeys = await apiKeyService.getApiKeysByDeveloper(developerId); - set({ apiKeys, isLoading: false }); - } catch (error) { - set({ - error: errorHandler.handleError(error as Error, { - action: 'fetchApiKeys', - }), - isLoading: false, - }); - } - }, - - createApiKey: async (developerId, name, permissions, _options) => { - set({ isLoading: true, error: null }); - try { - const permissionStrings = permissions?.map((p) => p.toString()) || ['read', 'write']; - const apiKey = await apiKeyService.createApiKey( - developerId, - name, - undefined, - permissionStrings - ); - set((state) => ({ - apiKeys: [...state.apiKeys, apiKey], - isLoading: false, - })); - - await developerPortalService.completeOnboardingStep( - developerId, - 'generate-api-key' - ); - - const steps = await developerPortalService.getOnboardingSteps(developerId); - set({ onboardingSteps: steps }); - - return apiKey; - } catch (error) { - const appError = errorHandler.handleError(error as Error, { - action: 'createApiKey', - }); - set({ error: appError, isLoading: false }); - throw appError; - } - }, - - revokeApiKey: async (keyId: string) => { - set({ isLoading: true, error: null }); - try { - await apiKeyService.revokeApiKey(keyId); - set((state) => ({ - apiKeys: state.apiKeys.map((k) => - k.id === keyId ? { ...k, status: ApiKeyStatus.REVOKED } : k - ), - isLoading: false, - })); - } catch (error) { - set({ - error: errorHandler.handleError(error as Error, { - action: 'revokeApiKey', - }), - isLoading: false, - }); - } - }, - - rotateApiKey: async (keyId: string) => { - set({ isLoading: true, error: null }); - try { - const rotated = await apiKeyService.rotateApiKey(keyId); - if (rotated) { - set((state) => ({ - apiKeys: state.apiKeys.map((k) => (k.id === keyId ? rotated : k)), - isLoading: false, - })); - } - } catch (error) { - set({ - error: errorHandler.handleError(error as Error, { - action: 'rotateApiKey', - }), - isLoading: false, - }); - } - }, - - deleteApiKey: async (keyId: string) => { - set({ isLoading: true, error: null }); - try { - await apiKeyService.deleteApiKey(keyId); - set((state) => ({ - apiKeys: state.apiKeys.filter((k) => k.id !== keyId), - isLoading: false, - })); - } catch (error) { - set({ - error: errorHandler.handleError(error as Error, { - action: 'deleteApiKey', - }), - isLoading: false, - }); - } - }, - - fetchUsageStats: async (developerId, _period) => { - set({ isLoading: true, error: null }); - try { - await usageTrackingService.loadUsage(developerId); - const usageStats = await usageTrackingService.getUsageStats(developerId); - set({ usageStats, isLoading: false }); - } catch (error) { - set({ - error: errorHandler.handleError(error as Error, { - action: 'fetchUsageStats', - }), - isLoading: false, - }); - } - }, - - fetchRecentUsage: async (developerId, limit) => { - set({ isLoading: true, error: null }); - try { - const recentUsage = await usageTrackingService.getRecentMetrics( - developerId, - limit - ); - set({ recentUsage, isLoading: false }); - } catch (error) { - set({ - error: errorHandler.handleError(error as Error, { - action: 'fetchRecentUsage', - }), - isLoading: false, - }); - } - }, - - fetchOnboardingSteps: async (developerId: string) => { - try { - const steps = await developerPortalService.getOnboardingSteps(developerId); - set({ onboardingSteps: steps }); - } catch (error) { - console.error('Failed to fetch onboarding steps:', error); - } - }, - - completeOnboardingStep: async (developerId, stepId) => { - try { - const steps = await developerPortalService.completeOnboardingStep( - developerId, - stepId - ); - if (steps) { - set({ onboardingSteps: steps }); - } - } catch (error) { - console.error('Failed to complete onboarding step:', error); - } - }, - - fetchDocumentation: () => { - const documentation = developerPortalService.getDocumentationSections(); - set({ documentation }); - }, - - searchDocumentation: (query) => { - const documentation = developerPortalService.searchDocumentation(query); - set({ documentation }); - }, - - fetchIntegrationGuides: () => { - const integrationGuides = developerPortalService.getIntegrationGuides(); - set({ integrationGuides }); - }, - - searchIntegrationGuides: (query) => { - const integrationGuides = developerPortalService.searchIntegrationGuides(query); - set({ integrationGuides }); - }, - - clearError: () => set({ error: null }), -})); +/** + * @deprecated Use \`useStore\` from \`./combinedStore\` instead. + * All stores are now combined into a single store using the slices pattern. + */ +export { useStore as useDeveloperPortalStore } from './combinedStore'; diff --git a/src/store/fraudStore.ts b/src/store/fraudStore.ts index a4f281a7..de9ff927 100644 --- a/src/store/fraudStore.ts +++ b/src/store/fraudStore.ts @@ -1,454 +1,5 @@ -import { create } from 'zustand'; -import { persist, createJSONStorage } from 'zustand/middleware'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { - FraudAction, - FraudAnalytics, - FraudCase, - FraudMerchantRecord, - FraudReport, - FraudRiskScore, - FraudSignal, - FraudSubscriptionRecord, -} from '../types/fraud'; - -const STORAGE_KEY = 'subtrackr-fraud-store'; - -const nowIso = () => new Date().toISOString(); - -const merchantSeeds: FraudMerchantRecord[] = [ - { - id: 'merch_nova', - name: 'Nova Stream', - status: 'watch', - activeSubscriptions: 128, - blockedSubscriptions: 4, - averageRisk: 41, - monthlyVolume: 18650, - }, - { - id: 'merch_orbit', - name: 'Orbit Tools', - status: 'healthy', - activeSubscriptions: 83, - blockedSubscriptions: 1, - averageRisk: 22, - monthlyVolume: 9420, - }, - { - id: 'merch_cipher', - name: 'Cipher Pro', - status: 'high-risk', - activeSubscriptions: 46, - blockedSubscriptions: 9, - averageRisk: 67, - monthlyVolume: 7825, - }, -]; - -const subscriptionSeeds: FraudSubscriptionRecord[] = [ - { - id: 'fraud_sub_1', - merchantId: 'merch_nova', - merchantName: 'Nova Stream', - subscriberId: 'sub_ada', - subscriptionName: 'Studio Plan', - currency: 'USD', - amount: 29, - createdAt: '2026-04-22T09:00:00.000Z', - expectedUsage: 8, - observedUsage: 25, - chargebacks: 0, - riskScore: 78, - action: 'flag', - reason: 'Usage burst and fast creation cadence', - usagePattern: 'burst', - signals: [ - { kind: 'velocity', score: 28, detail: 'Created alongside two other subscriptions', observedAt: nowIso() }, - { kind: 'usage-anomaly', score: 30, detail: 'Observed usage is 3x the expected baseline', observedAt: nowIso() }, - { kind: 'chargeback', score: 20, detail: 'Recent dispute behavior is elevated', observedAt: nowIso() }, - ], - isBlocked: false, - isFlagged: true, - }, - { - id: 'fraud_sub_2', - merchantId: 'merch_nova', - merchantName: 'Nova Stream', - subscriberId: 'sub_ada', - subscriptionName: 'Team Analytics', - currency: 'USD', - amount: 59, - createdAt: '2026-04-22T11:10:00.000Z', - expectedUsage: 10, - observedUsage: 6, - chargebacks: 1, - riskScore: 84, - action: 'block', - reason: 'Chargeback history and rapid subscription creation', - usagePattern: 'erratic', - signals: [ - { kind: 'velocity', score: 35, detail: 'Second subscription within the same day', observedAt: nowIso() }, - { kind: 'chargeback', score: 35, detail: 'Chargeback history predicts blocked outcome', observedAt: nowIso() }, - ], - isBlocked: true, - isFlagged: true, - }, - { - id: 'fraud_sub_3', - merchantId: 'merch_orbit', - merchantName: 'Orbit Tools', - subscriberId: 'sub_mina', - subscriptionName: 'Pro Builder', - currency: 'USD', - amount: 19, - createdAt: '2026-04-18T08:30:00.000Z', - expectedUsage: 12, - observedUsage: 12, - chargebacks: 0, - riskScore: 18, - action: 'approve', - reason: 'Usage profile is stable', - usagePattern: 'normal', - signals: [{ kind: 'velocity', score: 6, detail: 'Low velocity but within threshold', observedAt: nowIso() }], - isBlocked: false, - isFlagged: false, - }, - { - id: 'fraud_sub_4', - merchantId: 'merch_cipher', - merchantName: 'Cipher Pro', - subscriberId: 'sub_jon', - subscriptionName: 'Automation Pack', - currency: 'USD', - amount: 99, - createdAt: '2026-04-24T07:00:00.000Z', - expectedUsage: 4, - observedUsage: 19, - chargebacks: 2, - riskScore: 92, - action: 'block', - reason: 'Chargeback prediction and anomalous usage behavior', - usagePattern: 'burst', - signals: [ - { kind: 'usage-anomaly', score: 30, detail: 'Observed usage is far above baseline', observedAt: nowIso() }, - { kind: 'chargeback', score: 35, detail: 'Repeated disputes indicate high risk', observedAt: nowIso() }, - { kind: 'velocity', score: 27, detail: 'Fast subscription creation detected', observedAt: nowIso() }, - ], - isBlocked: true, - isFlagged: true, - }, -]; - -const reviewSeeds: FraudCase[] = [ - { - caseId: 'fraud_sub_1', - subscriptionId: 'fraud_sub_1', - subscriberId: 'sub_ada', - merchantId: 'merch_nova', - merchantName: 'Nova Stream', - subscriptionName: 'Studio Plan', - riskScore: 78, - action: 'flag', - status: 'pending', - reason: 'Usage burst and fast creation cadence', - createdAt: '2026-04-22T09:05:00.000Z', - updatedAt: '2026-04-22T09:05:00.000Z', - notes: 'Auto-flagged for analyst review', - }, - { - caseId: 'fraud_sub_2', - subscriptionId: 'fraud_sub_2', - subscriberId: 'sub_ada', - merchantId: 'merch_nova', - merchantName: 'Nova Stream', - subscriptionName: 'Team Analytics', - riskScore: 84, - action: 'block', - status: 'escalated', - reason: 'Chargeback history and rapid subscription creation', - createdAt: '2026-04-22T11:15:00.000Z', - updatedAt: '2026-04-22T11:15:00.000Z', - notes: 'Blocked automatically', - }, - { - caseId: 'fraud_sub_4', - subscriptionId: 'fraud_sub_4', - subscriberId: 'sub_jon', - merchantId: 'merch_cipher', - merchantName: 'Cipher Pro', - subscriptionName: 'Automation Pack', - riskScore: 92, - action: 'block', - status: 'escalated', - reason: 'Chargeback prediction and anomalous usage behavior', - createdAt: '2026-04-24T07:05:00.000Z', - updatedAt: '2026-04-24T07:05:00.000Z', - notes: 'High confidence block', - }, -]; - -const averageRisk = (items: FraudSubscriptionRecord[]): number => - items.length ? Math.round(items.reduce((sum, item) => sum + item.riskScore, 0) / items.length) : 0; - -const computeAnalytics = (subscriptions: FraudSubscriptionRecord[], reviewQueue: FraudCase[]): FraudAnalytics => { - const approved = subscriptions.filter((item) => item.action === 'approve').length; - const flagged = subscriptions.filter((item) => item.action === 'flag').length; - const blocked = subscriptions.filter((item) => item.action === 'block').length; - const velocityAlerts = subscriptions.filter((item) => - item.signals.some((signal) => signal.kind === 'velocity') - ).length; - const anomalyAlerts = subscriptions.filter((item) => - item.signals.some((signal) => signal.kind === 'usage-anomaly') - ).length; - const chargebackPredictions = subscriptions.filter((item) => - item.signals.some((signal) => signal.kind === 'chargeback') - ).length; - - return { - totalChecks: subscriptions.length, - approved, - flagged, - blocked, - manualReviews: reviewQueue.length, - avgRisk: averageRisk(subscriptions), - velocityAlerts, - anomalyAlerts, - chargebackPredictions, - falsePositiveEstimate: Math.max(0, Math.round(flagged * 0.18)), - }; -}; - -const scoreSubscription = (item: FraudSubscriptionRecord): FraudRiskScore => ({ - subscriberId: item.subscriberId, - subscriptionId: item.id, - merchantId: item.merchantId, - merchantName: item.merchantName, - totalScore: item.riskScore, - velocityScore: item.signals.find((signal) => signal.kind === 'velocity')?.score ?? 0, - anomalyScore: item.signals.find((signal) => signal.kind === 'usage-anomaly')?.score ?? 0, - chargebackScore: item.signals.find((signal) => signal.kind === 'chargeback')?.score ?? 0, - action: item.action, - reason: item.reason, - assessedAt: item.createdAt, - signals: item.signals, -}); - -const cloneCase = (entry: FraudCase): FraudCase => ({ ...entry, notes: entry.notes }); - -interface FraudState { - merchants: FraudMerchantRecord[]; - subscriptions: FraudSubscriptionRecord[]; - assessments: FraudRiskScore[]; - reviewQueue: FraudCase[]; - analytics: FraudAnalytics; - loading: boolean; - error: string | null; - refreshFraudSignals: () => void; - assessRisk: (subscriberId: string) => FraudRiskScore[]; - flagSubscription: (subscriptionId: string) => void; - approveSubscription: (subscriptionId: string) => void; - blockSubscription: (subscriptionId: string) => void; - resolveCase: (subscriptionId: string, action: FraudAction) => void; - getFraudReport: (merchantId: string) => FraudReport; -} - -const hydrateAssessments = (subscriptions: FraudSubscriptionRecord[]): FraudRiskScore[] => - subscriptions.map((item) => scoreSubscription(item)); - -const hydrateReviewQueue = (reviews: FraudCase[]): FraudCase[] => reviews.map(cloneCase); - -const updateSubscription = ( - subscriptions: FraudSubscriptionRecord[], - subscriptionId: string, - patch: Partial -): FraudSubscriptionRecord[] => - subscriptions.map((item) => (item.id === subscriptionId ? { ...item, ...patch } : item)); - -const buildMerchantReport = ( - merchants: FraudMerchantRecord[], - subscriptions: FraudSubscriptionRecord[], - reviewQueue: FraudCase[], - merchantId: string -): FraudReport => { - const merchant = merchants.find((item) => item.id === merchantId); - const merchantName = merchant?.name ?? 'Unknown merchant'; - const scoped = subscriptions.filter((item) => item.merchantId === merchantId); - const scopedCases = reviewQueue.filter((entry) => entry.merchantId === merchantId); - - return { - merchantId, - merchantName, - totalSubscriptions: scoped.length, - flaggedSubscriptions: scoped.filter((item) => item.action === 'flag').length, - blockedSubscriptions: scoped.filter((item) => item.action === 'block').length, - manualReviewCount: scopedCases.filter((item) => item.status !== 'reviewed').length, - averageRisk: averageRisk(scoped), - velocityAlerts: scoped.filter((item) => item.signals.some((signal) => signal.kind === 'velocity')).length, - anomalyAlerts: scoped.filter((item) => item.signals.some((signal) => signal.kind === 'usage-anomaly')).length, - chargebackPredictions: scoped.filter((item) => item.signals.some((signal) => signal.kind === 'chargeback')).length, - highRiskSubscribers: new Set(scoped.filter((item) => item.riskScore >= 50).map((item) => item.subscriberId)).size, - recentCases: scopedCases.slice(0, 5), - }; -}; - -export const useFraudStore = create()( - persist( - (set, get) => ({ - merchants: merchantSeeds.map((merchant) => ({ ...merchant })), - subscriptions: subscriptionSeeds.map((item) => ({ ...item, signals: item.signals.map((signal) => ({ ...signal })) })), - assessments: hydrateAssessments(subscriptionSeeds), - reviewQueue: hydrateReviewQueue(reviewSeeds), - analytics: computeAnalytics(subscriptionSeeds, reviewSeeds), - loading: false, - error: null, - - refreshFraudSignals: () => { - const { subscriptions, reviewQueue, merchants } = get(); - set({ - analytics: computeAnalytics(subscriptions, reviewQueue), - assessments: hydrateAssessments(subscriptions), - merchants: merchants.map((merchant) => ({ - ...merchant, - averageRisk: buildMerchantReport(merchants, subscriptions, reviewQueue, merchant.id).averageRisk, - blockedSubscriptions: buildMerchantReport(merchants, subscriptions, reviewQueue, merchant.id).blockedSubscriptions, - activeSubscriptions: buildMerchantReport(merchants, subscriptions, reviewQueue, merchant.id).totalSubscriptions, - status: - buildMerchantReport(merchants, subscriptions, reviewQueue, merchant.id).averageRisk >= 60 - ? 'high-risk' - : buildMerchantReport(merchants, subscriptions, reviewQueue, merchant.id).averageRisk >= 35 - ? 'watch' - : 'healthy', - })), - }); - }, - - assessRisk: (subscriberId: string) => { - const assessments = get() - .subscriptions.filter((item) => item.subscriberId === subscriberId) - .map((item) => scoreSubscription(item)); - - set((state) => ({ - assessments: [ - ...state.assessments.filter((item) => item.subscriberId !== subscriberId), - ...assessments, - ], - analytics: computeAnalytics(state.subscriptions, state.reviewQueue), - })); - - return assessments; - }, - - flagSubscription: (subscriptionId: string) => { - const current = get().subscriptions.find((item) => item.id === subscriptionId); - if (!current) return; - - const score = scoreSubscription(current); - const nextCase: FraudCase = { - caseId: subscriptionId, - subscriptionId, - subscriberId: current.subscriberId, - merchantId: current.merchantId, - merchantName: current.merchantName, - subscriptionName: current.subscriptionName, - riskScore: score.totalScore, - action: score.totalScore >= 80 ? 'block' : 'flag', - status: score.totalScore >= 80 ? 'escalated' : 'pending', - reason: score.reason, - createdAt: nowIso(), - updatedAt: nowIso(), - notes: 'Manually queued for analyst review', - }; - - set((state) => ({ - subscriptions: updateSubscription(state.subscriptions, subscriptionId, { - action: nextCase.action, - isFlagged: true, - isBlocked: nextCase.action === 'block', - }), - reviewQueue: [nextCase, ...state.reviewQueue.filter((entry) => entry.subscriptionId !== subscriptionId)], - analytics: computeAnalytics( - updateSubscription(state.subscriptions, subscriptionId, { - action: nextCase.action, - isFlagged: true, - isBlocked: nextCase.action === 'block', - }), - [nextCase, ...state.reviewQueue.filter((entry) => entry.subscriptionId !== subscriptionId)] - ), - })); - }, - - approveSubscription: (subscriptionId: string) => { - set((state) => { - const subscriptions = updateSubscription(state.subscriptions, subscriptionId, { - action: 'approve', - isFlagged: false, - isBlocked: false, - }); - const reviewQueue = state.reviewQueue.map((entry) => - entry.subscriptionId === subscriptionId - ? { ...entry, status: 'reviewed', action: 'approve', updatedAt: nowIso() } - : entry - ); - return { - subscriptions, - reviewQueue, - analytics: computeAnalytics(subscriptions, reviewQueue), - }; - }); - }, - - blockSubscription: (subscriptionId: string) => { - set((state) => { - const subscriptions = updateSubscription(state.subscriptions, subscriptionId, { - action: 'block', - isFlagged: true, - isBlocked: true, - }); - const reviewQueue = state.reviewQueue.map((entry) => - entry.subscriptionId === subscriptionId - ? { ...entry, status: 'escalated', action: 'block', updatedAt: nowIso() } - : entry - ); - return { - subscriptions, - reviewQueue, - analytics: computeAnalytics(subscriptions, reviewQueue), - }; - }); - }, - - resolveCase: (subscriptionId: string, action: FraudAction) => { - set((state) => { - const subscriptions = updateSubscription(state.subscriptions, subscriptionId, { - action, - isFlagged: action !== 'approve', - isBlocked: action === 'block', - }); - const reviewQueue = state.reviewQueue.map((entry) => - entry.subscriptionId === subscriptionId - ? { - ...entry, - action, - status: action === 'approve' ? 'reviewed' : action === 'block' ? 'escalated' : 'pending', - updatedAt: nowIso(), - } - : entry - ); - return { - subscriptions, - reviewQueue, - analytics: computeAnalytics(subscriptions, reviewQueue), - }; - }); - }, - - getFraudReport: (merchantId: string) => - buildMerchantReport(get().merchants, get().subscriptions, get().reviewQueue, merchantId), - }), - { - name: STORAGE_KEY, - storage: createJSONStorage(() => AsyncStorage), - } - ) -); +/** + * @deprecated Use \`useStore\` from \`./combinedStore\` instead. + * All stores are now combined into a single store using the slices pattern. + */ +export { useStore as useFraudStore } from './combinedStore'; diff --git a/src/store/gamificationStore.ts b/src/store/gamificationStore.ts index 2a8ad8b2..c993a0b6 100644 --- a/src/store/gamificationStore.ts +++ b/src/store/gamificationStore.ts @@ -1,98 +1,5 @@ -import { create } from 'zustand'; -import { persist, createJSONStorage } from 'zustand/middleware'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { UserProgress, AchievementTrigger } from '../types/gamification'; -import { gamificationService } from '../services/gamificationService'; -import { presentLocalNotification } from '../services/notificationService'; - -interface GamificationState extends UserProgress { - addPoints: (amount: number) => void; - checkAchievements: (trigger: AchievementTrigger, metadata: any) => void; - resetProgress: () => void; -} - -const STORAGE_KEY = 'subtrackr-gamification'; - -export const useGamificationStore = create()( - persist( - (set, get) => ({ - points: 0, - level: 1, - earnedAchievements: [], - earnedBadges: [], - streak: 0, - lastActionAt: undefined, - - addPoints: (amount) => { - const { points, level } = get(); - const newPoints = points + amount; - - // Calculate level up - // Level 1: 0, Level 2: 250, Level 3: 650, etc. - const nextLevelPoints = Math.floor(100 * Math.pow(level, 1.5)); - - if (newPoints >= nextLevelPoints) { - set({ - points: newPoints, - level: level + 1, - }); - void presentLocalNotification({ - title: 'Level Up! 🎉', - body: `You've reached level ${level + 1}! Keep tracking those subscriptions.`, - }); - } else { - set({ points: newPoints }); - } - }, - - checkAchievements: (trigger, metadata) => { - const { earnedAchievements, earnedBadges } = get(); - const allAchievements = gamificationService.getAchievements(); - - const newUnlocks = allAchievements.filter( - (ach) => - ach.trigger === trigger && - !earnedAchievements.includes(ach.id) && - ach.criteria(metadata) - ); - - if (newUnlocks.length > 0) { - const newIds = newUnlocks.map((a) => a.id); - const newPoints = newUnlocks.reduce((acc, a) => acc + a.points, 0); - const newBadgeIds = newUnlocks - .map((a) => a.badgeId) - .filter((b): b is string => !!b && !earnedBadges.includes(b)); - - set((state) => ({ - earnedAchievements: [...state.earnedAchievements, ...newIds], - earnedBadges: [...state.earnedBadges, ...newBadgeIds], - })); - - get().addPoints(newPoints); - - newUnlocks.forEach((ach) => { - void presentLocalNotification({ - title: 'Achievement Unlocked! 🏆', - body: `${ach.name}: ${ach.description}`, - }); - }); - } - }, - - resetProgress: () => { - set({ - points: 0, - level: 1, - earnedAchievements: [], - earnedBadges: [], - streak: 0, - lastActionAt: undefined, - }); - }, - }), - { - name: STORAGE_KEY, - storage: createJSONStorage(() => AsyncStorage), - } - ) -); +/** + * @deprecated Use \`useStore\` from \`./combinedStore\` instead. + * All stores are now combined into a single store using the slices pattern. + */ +export { useStore as useGamificationStore } from './combinedStore'; diff --git a/src/store/groupStore.ts b/src/store/groupStore.ts index 8977e84f..4bc62024 100644 --- a/src/store/groupStore.ts +++ b/src/store/groupStore.ts @@ -1,118 +1,5 @@ -import { create } from 'zustand'; -import { - chargeGroup, - createSubscriptionGroup, - getGroupAnalytics, - inviteGroupMember, - joinGroupWithInvite, - removeGroupMember, - updateGroupMemberRole, -} from '../services/groupService'; -import { - GroupAnalytics, - GroupConfig, - GroupId, - GroupMemberRole, - SubscriptionGroup, -} from '../types/group'; - -interface GroupState { - groups: SubscriptionGroup[]; - selectedGroupId?: GroupId; - isLoading: boolean; - error: string | null; - createGroup: (owner: string, config: GroupConfig) => SubscriptionGroup; - inviteMember: (groupId: GroupId, inviteeAddress: string, invitedBy: string) => void; - joinGroup: (groupId: GroupId, inviteId: string, displayName?: string) => void; - removeMember: (groupId: GroupId, memberAddress: string) => void; - updateMemberRole: (groupId: GroupId, memberAddress: string, role: GroupMemberRole) => void; - chargeGroup: (groupId: GroupId, amount: number) => void; - getAnalytics: (groupId: GroupId) => GroupAnalytics | undefined; - selectGroup: (groupId?: GroupId) => void; -} - -const updateGroup = ( - groups: SubscriptionGroup[], - groupId: GroupId, - updater: (group: SubscriptionGroup) => SubscriptionGroup -): SubscriptionGroup[] => groups.map((group) => (group.groupId === groupId ? updater(group) : group)); - -export const useGroupStore = create((set, get) => ({ - groups: [], - selectedGroupId: undefined, - isLoading: false, - error: null, - - createGroup: (owner, config) => { - const group = createSubscriptionGroup(owner, config); - set((state) => ({ groups: [...state.groups, group], selectedGroupId: group.groupId })); - return group; - }, - - inviteMember: (groupId, inviteeAddress, invitedBy) => { - try { - set((state) => ({ - groups: updateGroup(state.groups, groupId, (group) => - inviteGroupMember(group, inviteeAddress, invitedBy) - ), - error: null, - })); - } catch (error) { - set({ error: (error as Error).message }); - } - }, - - joinGroup: (groupId, inviteId, displayName) => { - try { - set((state) => ({ - groups: updateGroup(state.groups, groupId, (group) => - joinGroupWithInvite(group, inviteId, displayName) - ), - error: null, - })); - } catch (error) { - set({ error: (error as Error).message }); - } - }, - - removeMember: (groupId, memberAddress) => { - try { - set((state) => ({ - groups: updateGroup(state.groups, groupId, (group) => removeGroupMember(group, memberAddress)), - error: null, - })); - } catch (error) { - set({ error: (error as Error).message }); - } - }, - - updateMemberRole: (groupId, memberAddress, role) => { - set((state) => ({ - groups: updateGroup(state.groups, groupId, (group) => - updateGroupMemberRole(group, memberAddress, role) - ), - })); - }, - - chargeGroup: (groupId, amount) => { - try { - set((state) => ({ - groups: updateGroup(state.groups, groupId, (group) => ({ - ...group, - charges: [...group.charges, chargeGroup(group, amount)], - updatedAt: new Date(), - })), - error: null, - })); - } catch (error) { - set({ error: (error as Error).message }); - } - }, - - getAnalytics: (groupId) => { - const group = get().groups.find((entry) => entry.groupId === groupId); - return group ? getGroupAnalytics(group) : undefined; - }, - - selectGroup: (groupId) => set({ selectedGroupId: groupId }), -})); +/** + * @deprecated Use \`useStore\` from \`./combinedStore\` instead. + * All stores are now combined into a single store using the slices pattern. + */ +export { useStore as useGroupStore } from './combinedStore'; diff --git a/src/store/index.ts b/src/store/index.ts index 6cf489e5..b55845e8 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,11 +1,112 @@ -export { useSubscriptionStore } from './subscriptionStore'; -export { useInvoiceStore } from './invoiceStore'; -export { useTransactionQueueStore } from './transactionQueueStore'; -export { useWalletStore } from './walletStore'; -export { useNetworkStore } from './networkStore'; -export { useSettingsStore } from './settingsStore'; -export { useCommunityStore } from './communityStore'; -export { useFraudStore } from './fraudStore'; -export { useGroupStore } from './groupStore'; -export { useTaxStore } from './taxStore'; -export { useSupportStore } from './supportStore'; +/** + * Store index – exports the combined Zustand store and backward-compatible + * named hooks for each domain. + * + * ## Quick Migration + * + * Most existing imports continue to work: + * ```ts + * // Old (still works) + * import { useSubscriptionStore } from '../store'; + * const subscriptions = useSubscriptionStore((s) => s.subscriptions); + * + * // New (recommended) + * import { useStore } from '../store/combinedStore'; + * const subscriptions = useStore((s) => s.subscriptions); + * ``` + * + * See docs/store-migration.md for a complete migration guide. + */ + +export { useStore } from './combinedStore'; +export type { AppState } from './slices/types'; + +// ── Backward-compatible selector hooks ──────────────────────────────── +// These hooks pick specific slices from the combined store so that +// existing consumers don't need to update their imports. + +import { useStore } from './combinedStore'; +import type { AppState } from './slices/types'; + +type SliceSelector = (state: AppState) => T; + +/** + * Create a typed hook that selects from the combined store. + * This enables pattern matching from individual stores: + * useSubscriptionStore() → gets full AppState + * useSubscriptionStore(s => s.subscriptions) → gets subscriptions + */ +function createSliceHook() { + return (selector?: SliceSelector): T => { + return useStore(selector ?? (() => ({} as any))); + }; +} + +// ── Subscription Store ──────────────────────────────────────────────── +export const useSubscriptionStore = useStore; +export { useSubscriptionStore as useSubscriptionStoreHook } from './combinedStore'; + +// ── Named re-exports for backward compatibility ─────────────────────── +// These are the original hook names that consumers currently import. +// They all point to the same combined store. + +/** @deprecated Use `useStore` from `store/combinedStore` instead. */ +export const useInvoiceStore = useStore; +/** @deprecated Use `useStore` from `store/combinedStore` instead. */ +export const useTransactionQueueStore = useStore; +/** @deprecated Use `useStore` from `store/combinedStore` instead. */ +export const useWalletStore = useStore; +/** @deprecated Use `useStore` from `store/combinedStore` instead. */ +export const useNetworkStore = useStore; +/** @deprecated Use `useStore` from `store/combinedStore` instead. */ +export const useSettingsStore = useStore; +/** @deprecated Use `useStore` from `store/combinedStore` instead. */ +export const useCommunityStore = useStore; +/** @deprecated Use `useStore` from `store/combinedStore` instead. */ +export const useFraudStore = useStore; +/** @deprecated Use `useStore` from `store/combinedStore` instead. */ +export const useGroupStore = useStore; +/** @deprecated Use `useStore` from `store/combinedStore` instead. */ +export const useTaxStore = useStore; +/** @deprecated Use `useStore` from `store/combinedStore` instead. */ +export const useSupportStore = useStore; +/** @deprecated Use `useStore` from `store/combinedStore` instead. */ +export const useSandboxStore = useStore; +/** @deprecated Use `useStore` from `store/combinedStore` instead. */ +export const useCampaignStore = useStore; +/** @deprecated Use `useStore` from `store/combinedStore` instead. */ +export const useSegmentStore = useStore; +/** @deprecated Use `useStore` from `store/combinedStore` instead. */ +export const useDeveloperPortalStore = useStore; +/** @deprecated Use `useStore` from `store/combinedStore` instead. */ +export const useUsageStore = useStore; +/** @deprecated Use `useStore` from `store/combinedStore` instead. */ +export const useGamificationStore = useStore; +/** @deprecated Use `useStore` from `store/combinedStore` instead. */ +export const useLoyaltyStore = useStore; +/** @deprecated Use `useStore` from `store/combinedStore` instead. */ +export const useAffiliateStore = useStore; +/** @deprecated Use `useStore` from `store/combinedStore` instead. */ +export const useSlaStore = useStore; +/** @deprecated Use `useStore` from `store/combinedStore` instead. */ +export const useCalendarStore = useStore; +/** @deprecated Use `useStore` from `store/combinedStore` instead. */ +export const useMerchantStore = useStore; +/** @deprecated Use `useStore` from `store/combinedStore` instead. */ +export const useWebhookStore = useStore; +/** @deprecated Use `useStore` from `store/combinedStore` instead. */ +export const useAccountingStore = useStore; +/** @deprecated Use `useStore` from `store/combinedStore` instead. */ +export const useCancellationStore = useStore; +/** @deprecated Use `useStore` from `store/combinedStore` instead. */ +export const useUserStore = useStore; + +// ── App store exports (for the app/ directory) ───────────────────────── +/** @deprecated Use `useStore` from `store/combinedStore` instead. */ +export const useMeteringStore = useStore; +/** @deprecated Use `useStore` from `store/combinedStore` instead. */ +export const useCreditStore = useStore; +/** @deprecated Use `useStore` from `store/combinedStore` instead. */ +export const useBatchStore = useStore; +/** @deprecated Use `useStore` from `store/combinedStore` instead. */ +export const useSearchStore = useStore; diff --git a/src/store/invoiceStore.ts b/src/store/invoiceStore.ts index b4278ba6..897fdd73 100644 --- a/src/store/invoiceStore.ts +++ b/src/store/invoiceStore.ts @@ -1,704 +1,5 @@ -import { create } from 'zustand'; -import { persist, createJSONStorage, StateStorage } from 'zustand/middleware'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { - DEFAULT_INVOICE_CONFIG, - Invoice, - InvoiceConfig, - InvoiceFormData, - InvoiceStatus, - InvoiceTotals, - TaxJurisdiction, - CustomerTaxStatus, - TaxRemittanceReport, - TaxRemittanceLineItem, - TaxType, - DigitalGoodsClass, - TaxRateEntry, - MidCycleTaxChange, - TaxInvoiceGenerationInput, - buildJurisdictionKey, - isTaxExempt as checkIsTaxExempt, -} from '../types/invoice'; -import { buildInvoice, calculateInvoiceTotals } from '../utils/invoice'; -import { CACHE_CONSTANTS } from '../utils/constants/values'; -import { errorHandler, AppError } from '../services/errorHandler'; -import { presentLocalNotification } from '../services/notificationService'; - -const STORAGE_KEY = 'subtrackr-invoices'; -const STORE_VERSION = 1; -const WRITE_DEBOUNCE_MS = CACHE_CONSTANTS.WRITE_DEBOUNCE_MS; - -type PersistedInvoiceSlice = Pick< - InvoiceState, - | 'invoices' - | 'config' - | 'nextSequence' - | 'taxRates' - | 'customerTaxStatuses' - | 'taxRemittanceLines' - | 'taxRemittanceReports' - | 'digitalGoodsClasses' ->; - -const toValidDate = (value: unknown, fallback = new Date()): Date => { - if (value instanceof Date && !Number.isNaN(value.getTime())) return value; - if (typeof value === 'string' || typeof value === 'number') { - const parsed = new Date(value); - if (!Number.isNaN(parsed.getTime())) return parsed; - } - return fallback; -}; - -const normalizeInvoice = (raw: Partial): Invoice => { - const createdAt = toValidDate(raw.createdAt); - return { - id: raw.id ?? `inv-${Date.now()}`, - invoiceNumber: raw.invoiceNumber ?? 'INV-000001', - subscriptionId: raw.subscriptionId ?? 'unknown', - subscriptionName: raw.subscriptionName ?? 'Subscription', - merchantName: raw.merchantName ?? 'Merchant', - lineItems: Array.isArray(raw.lineItems) ? raw.lineItems : [], - tax: Number.isFinite(raw.tax) ? (raw.tax as number) : 0, - total: Number.isFinite(raw.total) ? (raw.total as number) : 0, - subtotal: Number.isFinite(raw.subtotal) ? (raw.subtotal as number) : 0, - dueDate: toValidDate(raw.dueDate), - status: raw.status ?? InvoiceStatus.DRAFT, - currency: raw.currency ?? DEFAULT_INVOICE_CONFIG.defaultCurrency, - region: raw.region ?? DEFAULT_INVOICE_CONFIG.defaultRegion, - exchangeRate: Number.isFinite(raw.exchangeRate) ? (raw.exchangeRate as number) : 1_000_000, - period: { - start: toValidDate(raw.period?.start), - end: toValidDate(raw.period?.end), - }, - createdAt, - updatedAt: toValidDate(raw.updatedAt, createdAt), - recipientEmail: raw.recipientEmail, - notes: raw.notes, - }; -}; - -const serializeForStorage = (state: PersistedInvoiceSlice): PersistedInvoiceSlice => ({ - invoices: state.invoices.map((invoice) => ({ - ...invoice, - dueDate: new Date(invoice.dueDate), - period: { - start: new Date(invoice.period.start), - end: new Date(invoice.period.end), - }, - createdAt: new Date(invoice.createdAt), - updatedAt: new Date(invoice.updatedAt), - })), - config: state.config, - nextSequence: state.nextSequence, - taxRates: state.taxRates, - customerTaxStatuses: state.customerTaxStatuses, - taxRemittanceLines: state.taxRemittanceLines, - taxRemittanceReports: state.taxRemittanceReports, - digitalGoodsClasses: state.digitalGoodsClasses, -}); - -const migratePersistedState = (persisted: unknown): PersistedInvoiceSlice => { - if (!persisted || typeof persisted !== 'object') { - return { - invoices: [], - config: DEFAULT_INVOICE_CONFIG, - nextSequence: 1, - taxRates: [], - customerTaxStatuses: {}, - taxRemittanceLines: [], - taxRemittanceReports: [], - digitalGoodsClasses: {}, - }; - } - - const maybeState = persisted as Partial; - const invoices = Array.isArray(maybeState.invoices) - ? maybeState.invoices.map((entry) => normalizeInvoice(entry as Partial)) - : []; - - return { - invoices, - config: maybeState.config ?? DEFAULT_INVOICE_CONFIG, - nextSequence: maybeState.nextSequence ?? Math.max(invoices.length + 1, 1), - taxRates: maybeState.taxRates ?? [], - customerTaxStatuses: maybeState.customerTaxStatuses ?? {}, - taxRemittanceLines: maybeState.taxRemittanceLines ?? [], - taxRemittanceReports: maybeState.taxRemittanceReports ?? [], - digitalGoodsClasses: maybeState.digitalGoodsClasses ?? {}, - }; -}; - -const pendingWrites = new Map(); -let writeTimer: ReturnType | null = null; -let writeQueue = Promise.resolve(); - -const flushPendingWrites = async (): Promise => { - if (pendingWrites.size === 0) return; - - const writes = Array.from(pendingWrites.entries()); - pendingWrites.clear(); - - writeQueue = writeQueue.then(async () => { - await Promise.all(writes.map(([key, value]) => AsyncStorage.setItem(key, value))); - }); - - try { - await writeQueue; - } catch (error) { - console.warn('Failed to persist invoices:', error); - } -}; - -const debouncedAsyncStorage: StateStorage = { - getItem: async (name) => { - if (pendingWrites.has(name)) return pendingWrites.get(name) ?? null; - await writeQueue; - return AsyncStorage.getItem(name); - }, - setItem: async (name, value) => { - pendingWrites.set(name, value); - if (writeTimer) clearTimeout(writeTimer); - writeTimer = setTimeout(() => { - void flushPendingWrites(); - }, WRITE_DEBOUNCE_MS); - }, - removeItem: async (name) => { - pendingWrites.delete(name); - if (writeTimer && pendingWrites.size === 0) { - clearTimeout(writeTimer); - writeTimer = null; - } - await writeQueue; - await AsyncStorage.removeItem(name); - }, -}; - -const BPS_SCALE = 10_000; - -interface InvoiceState { - invoices: Invoice[]; - config: InvoiceConfig; - nextSequence: number; - isLoading: boolean; - error: AppError | null; - - taxRates: TaxRateEntry[]; - customerTaxStatuses: Record; - taxRemittanceLines: TaxRemittanceLineItem[]; - taxRemittanceReports: TaxRemittanceReport[]; - digitalGoodsClasses: Record; - - generateInvoiceFromSubscription: ( - data: InvoiceFormData, - taxRateBps?: number, - exchangeRate?: number - ) => Promise; - generateTaxInvoice: (input: TaxInvoiceGenerationInput) => Promise; - updateInvoiceStatus: (id: string, status: InvoiceStatus) => Promise; - voidInvoice: (id: string) => Promise; - sendInvoice: (id: string, recipientEmail?: string) => Promise; - markInvoicePaid: (id: string) => Promise; - setTaxRate: (region: string, taxRateBps: number) => void; - setTaxJurisdiction: (entry: TaxRateEntry) => void; - removeTaxJurisdiction: (jurisdictionKey: string) => void; - setExchangeRate: (currency: string, exchangeRate: number) => void; - calculateTotals: (id: string) => InvoiceTotals | null; - - setCustomerTaxStatus: (subscriberId: string, status: CustomerTaxStatus) => void; - removeCustomerTaxStatus: (subscriberId: string) => void; - isCustomerTaxExempt: (subscriberId: string, jurisdictionKey: string) => boolean; - validateTaxCertificate: (subscriberId: string, certificateId: string) => boolean; - - lookupTaxRate: ( - jurisdiction: TaxJurisdiction, - digitalGoodsClass?: DigitalGoodsClass - ) => TaxRateEntry | null; - resolveEffectiveTaxRateBps: ( - jurisdiction: TaxJurisdiction, - digitalGoodsClass?: DigitalGoodsClass - ) => number; - - addTaxRemittanceLine: (line: TaxRemittanceLineItem) => void; - generateTaxRemittanceReport: ( - merchantId: string, - periodStart: Date, - periodEnd: Date, - jurisdictions?: string[] - ) => TaxRemittanceReport; - getTaxRemittanceReports: () => TaxRemittanceReport[]; - getTaxRemittanceReport: (reportId: string) => TaxRemittanceReport | undefined; - - setDigitalGoodsClass: (planId: string, goodsClass: DigitalGoodsClass) => void; - getDigitalGoodsClass: (planId: string) => DigitalGoodsClass; - - calculateMidCycleTax: ( - jurisdictionKey: string, - subtotal: number, - periodStart: Date, - periodEnd: Date, - rateChanges: Array<{ - oldRateBps: number; - newRateBps: number; - effectiveFrom: Date; - }> - ) => MidCycleTaxChange[]; -} - -const applyInvoiceStatus = (invoices: Invoice[], id: string, status: InvoiceStatus): Invoice[] => - invoices.map((invoice) => - invoice.id === id ? { ...invoice, status, updatedAt: new Date() } : invoice - ); - -const jurisdictionFallbackKeys = (jurisdiction: TaxJurisdiction): string[] => { - const key = buildJurisdictionKey(jurisdiction); - const parts = key.split('-'); - const keys: string[] = []; - while (parts.length > 0) { - keys.push(parts.join('-')); - parts.pop(); - } - keys.push('GLOBAL'); - return keys; -}; - -export const useInvoiceStore = create()( - persist( - (set, get) => ({ - invoices: [], - config: DEFAULT_INVOICE_CONFIG, - nextSequence: 1, - isLoading: false, - error: null, - taxRates: [], - customerTaxStatuses: {}, - taxRemittanceLines: [], - taxRemittanceReports: [], - digitalGoodsClasses: {}, - - generateInvoiceFromSubscription: async (data, taxRateBps, exchangeRate) => { - set({ isLoading: true, error: null }); - try { - const state = get(); - const region = data.region ?? state.config.defaultRegion; - const currency = data.currency ?? state.config.defaultCurrency; - const invoice = buildInvoice( - data.subscription, - state.nextSequence, - data.period, - { ...state.config, defaultCurrency: currency, defaultRegion: region }, - taxRateBps ?? state.config.defaultTaxRateBps, - exchangeRate ?? state.config.exchangeRateScale, - region, - data.recipientEmail, - data.notes - ); - - if (data.taxJurisdiction) { - invoice.taxJurisdiction = data.taxJurisdiction; - } - - set((current) => ({ - invoices: [...current.invoices, invoice], - nextSequence: current.nextSequence + 1, - isLoading: false, - })); - - return invoice; - } catch (error) { - const appError = errorHandler.handleError(error as Error, { - action: 'generateInvoiceFromSubscription', - metadata: data, - }); - set({ error: appError, isLoading: false }); - throw error; - } - }, - - generateTaxInvoice: async (input) => { - set({ isLoading: true, error: null }); - try { - const state = get(); - const jurisdictionKey = buildJurisdictionKey(input.jurisdiction); - - let effectiveRateBps = input.effectiveTaxRateBps; - if (input.isExempt) { - effectiveRateBps = 0; - } - - const invoice = buildInvoice( - input.subscription, - state.nextSequence, - { - start: new Date(), - end: new Date(input.subscription.nextBillingDate), - }, - { ...state.config }, - effectiveRateBps, - state.config.exchangeRateScale, - jurisdictionKey, - undefined, - undefined - ); - - invoice.taxJurisdiction = input.jurisdiction; - invoice.isTaxExempt = input.isExempt; - invoice.reverseCharge = input.reverseCharge; - - if (input.reverseCharge) { - invoice.region = `${jurisdictionKey}-RC`; - } - - invoice.lineItems[0].taxRateBps = effectiveRateBps; - - set((current) => ({ - invoices: [...current.invoices, invoice], - nextSequence: current.nextSequence + 1, - isLoading: false, - })); - - return invoice; - } catch (error) { - const appError = errorHandler.handleError(error as Error, { - action: 'generateTaxInvoice', - metadata: input, - }); - set({ error: appError, isLoading: false }); - throw error; - } - }, - - updateInvoiceStatus: async (id, status) => { - set({ isLoading: true, error: null }); - try { - set((state) => ({ - invoices: applyInvoiceStatus(state.invoices, id, status), - isLoading: false, - })); - } catch (error) { - set({ - error: errorHandler.handleError(error as Error, { - action: 'updateInvoiceStatus', - metadata: { id, status }, - }), - isLoading: false, - }); - } - }, - - voidInvoice: async (id) => { - await get().updateInvoiceStatus(id, InvoiceStatus.VOID); - }, - - sendInvoice: async (id, recipientEmail) => { - const invoice = get().invoices.find((entry) => entry.id === id); - if (!invoice) return; - if (recipientEmail && recipientEmail !== invoice.recipientEmail) { - set((state) => ({ - invoices: state.invoices.map((entry) => - entry.id === id - ? { - ...entry, - recipientEmail, - status: InvoiceStatus.SENT, - updatedAt: new Date(), - } - : entry - ), - })); - } else { - await get().updateInvoiceStatus(id, InvoiceStatus.SENT); - } - - await presentLocalNotification({ - title: `Invoice ready: ${invoice.invoiceNumber}`, - body: recipientEmail - ? `Draft email prepared for ${recipientEmail}` - : 'Invoice marked as sent in the local ledger.', - data: { invoiceId: id, recipientEmail }, - }); - }, - - markInvoicePaid: async (id) => { - await get().updateInvoiceStatus(id, InvoiceStatus.PAID); - }, - - setTaxRate: (region, taxRateBps) => { - set((state) => ({ - config: { - ...state.config, - defaultRegion: region, - defaultTaxRateBps: taxRateBps, - }, - })); - }, - - setTaxJurisdiction: (entry) => { - set((state) => ({ - taxRates: [ - ...state.taxRates.filter((r) => r.jurisdictionKey !== entry.jurisdictionKey), - entry, - ], - })); - }, - - removeTaxJurisdiction: (jurisdictionKey) => { - set((state) => ({ - taxRates: state.taxRates.filter((r) => r.jurisdictionKey !== jurisdictionKey), - })); - }, - - setExchangeRate: (currency, exchangeRate) => { - set((state) => ({ - config: { - ...state.config, - defaultCurrency: currency, - exchangeRateScale: exchangeRate, - }, - })); - }, - - calculateTotals: (id) => { - const invoice = get().invoices.find((entry) => entry.id === id); - if (!invoice) return null; - return calculateInvoiceTotals( - invoice.lineItems, - invoice.lineItems[0]?.taxRateBps ?? 0 - ); - }, - - setCustomerTaxStatus: (subscriberId, status) => { - set((state) => ({ - customerTaxStatuses: { - ...state.customerTaxStatuses, - [subscriberId]: status, - }, - })); - }, - - removeCustomerTaxStatus: (subscriberId) => { - set((state) => { - const updated = { ...state.customerTaxStatuses }; - delete updated[subscriberId]; - return { customerTaxStatuses: updated }; - }); - }, - - isCustomerTaxExempt: (subscriberId, jurisdictionKey) => { - const status = get().customerTaxStatuses[subscriberId]; - return checkIsTaxExempt(status ?? null); - }, - - validateTaxCertificate: (subscriberId, certificateId) => { - const status = get().customerTaxStatuses[subscriberId]; - if (!status) return false; - if (!status.isExempt) return false; - if (status.certificateId !== certificateId) return false; - if (status.certificateExpiry && status.certificateExpiry < new Date()) return false; - return true; - }, - - lookupTaxRate: (jurisdiction, digitalGoodsClass) => { - const keys = jurisdictionFallbackKeys(jurisdiction); - const rates = get().taxRates; - for (const key of keys) { - const entry = rates.find((r) => r.jurisdictionKey === key); - if (entry) return entry; - } - return null; - }, - - resolveEffectiveTaxRateBps: (jurisdiction, digitalGoodsClass) => { - const entry = get().lookupTaxRate(jurisdiction, digitalGoodsClass); - return entry?.rateBps ?? get().config.defaultTaxRateBps; - }, - - addTaxRemittanceLine: (line) => { - set((state) => ({ - taxRemittanceLines: [...state.taxRemittanceLines, line], - })); - }, - - generateTaxRemittanceReport: (merchantId, periodStart, periodEnd, jurisdictions) => { - const lines = get().taxRemittanceLines; - const reportId = `rpt-${Date.now()}-${Math.random().toString(36).substr(2, 6)}`; - - const aggregated = new Map(); - for (const line of lines) { - if ( - jurisdictions && - jurisdictions.length > 0 && - !jurisdictions.includes(line.jurisdictionKey) - ) { - continue; - } - const groupKey = `${line.jurisdictionKey}:${line.taxType}:${line.currency}`; - const existing = aggregated.get(groupKey); - if (existing) { - existing.taxableAmount += line.taxableAmount; - existing.taxCollected += line.taxCollected; - existing.transactionCount += line.transactionCount; - } else { - aggregated.set(groupKey, { ...line }); - } - } - - const lineItems = Array.from(aggregated.values()); - const totalTaxCollected = lineItems.reduce((sum, l) => sum + l.taxCollected, 0); - const totalTaxableAmount = lineItems.reduce((sum, l) => sum + l.taxableAmount, 0); - - const report: TaxRemittanceReport = { - reportId, - generatedAt: new Date(), - periodStart, - periodEnd, - merchant: merchantId, - lineItems, - totalTaxCollected, - totalTaxableAmount, - }; - - set((state) => ({ - taxRemittanceReports: [...state.taxRemittanceReports, report], - })); - - return report; - }, - - getTaxRemittanceReports: () => get().taxRemittanceReports, - - getTaxRemittanceReport: (reportId) => - get().taxRemittanceReports.find((r) => r.reportId === reportId), - - setDigitalGoodsClass: (planId, goodsClass) => { - set((state) => ({ - digitalGoodsClasses: { - ...state.digitalGoodsClasses, - [planId]: goodsClass, - }, - })); - }, - - getDigitalGoodsClass: (planId) => - get().digitalGoodsClasses[planId] ?? DigitalGoodsClass.ELECTRONIC_SERVICE, - - calculateMidCycleTax: (jurisdictionKey, subtotal, periodStart, periodEnd, rateChanges) => { - const periodDuration = periodEnd.getTime() - periodStart.getTime(); - if (periodDuration <= 0) return []; - - const relevant = rateChanges - .filter((c) => c.effectiveFrom > periodStart && c.effectiveFrom < periodEnd) - .sort((a, b) => a.effectiveFrom.getTime() - b.effectiveFrom.getTime()); - - if (relevant.length === 0) return []; - - const results: MidCycleTaxChange[] = []; - let currentStart = periodStart; - let currentRateBps: number | null = null; - - for (const change of relevant) { - const segmentDuration = change.effectiveFrom.getTime() - currentStart.getTime(); - const segmentRatio = segmentDuration / periodDuration; - const segmentSubtotal = Math.round(subtotal * segmentRatio); - - if (currentRateBps === null) { - currentRateBps = change.oldRateBps; - } - - const segmentTax = Math.round((segmentSubtotal * currentRateBps) / BPS_SCALE); - - results.push({ - jurisdictionKey, - oldRateBps: currentRateBps, - newRateBps: change.newRateBps, - effectiveFrom: change.effectiveFrom, - periodStart: currentStart, - periodEnd: change.effectiveFrom, - proratedTaxOld: segmentTax, - proratedTaxNew: 0, - totalTax: segmentTax, - }); - - currentStart = change.effectiveFrom; - currentRateBps = change.newRateBps; - } - - if (currentStart < periodEnd && currentRateBps !== null) { - const remainingDuration = periodEnd.getTime() - currentStart.getTime(); - const remainingRatio = remainingDuration / periodDuration; - const remainingSubtotal = Math.round(subtotal * remainingRatio); - const remainingTax = Math.round((remainingSubtotal * currentRateBps) / BPS_SCALE); - - results.push({ - jurisdictionKey, - oldRateBps: currentRateBps, - newRateBps: currentRateBps, - effectiveFrom: currentStart, - periodStart: currentStart, - periodEnd, - proratedTaxOld: 0, - proratedTaxNew: remainingTax, - totalTax: remainingTax, - }); - } - - return results; - }, - }), - { - name: STORAGE_KEY, - version: STORE_VERSION, - storage: createJSONStorage(() => debouncedAsyncStorage), - partialize: (state) => - serializeForStorage({ - invoices: state.invoices, - config: state.config, - nextSequence: state.nextSequence, - taxRates: state.taxRates, - customerTaxStatuses: state.customerTaxStatuses, - taxRemittanceLines: state.taxRemittanceLines, - taxRemittanceReports: state.taxRemittanceReports, - digitalGoodsClasses: state.digitalGoodsClasses, - }), - migrate: (persistedState) => migratePersistedState(persistedState), - merge: (persistedState, currentState) => ({ - ...currentState, - ...migratePersistedState(persistedState), - }), - onRehydrateStorage: () => (state, error) => { - if (error) { - useInvoiceStore.setState({ - error: errorHandler.createError( - new Error('Stored invoice data is corrupted. Loaded fallback data.'), - { action: 'rehydrateInvoices' }, - true - ), - invoices: [], - nextSequence: 1, - config: DEFAULT_INVOICE_CONFIG, - taxRates: [], - customerTaxStatuses: {}, - taxRemittanceLines: [], - taxRemittanceReports: [], - digitalGoodsClasses: {}, - isLoading: false, - }); - return; - } - - useInvoiceStore.setState({ - invoices: state?.invoices ?? [], - nextSequence: state?.nextSequence ?? 1, - config: state?.config ?? DEFAULT_INVOICE_CONFIG, - taxRates: state?.taxRates ?? [], - customerTaxStatuses: state?.customerTaxStatuses ?? {}, - taxRemittanceLines: state?.taxRemittanceLines ?? [], - taxRemittanceReports: state?.taxRemittanceReports ?? [], - digitalGoodsClasses: state?.digitalGoodsClasses ?? {}, - isLoading: false, - error: null, - }); - }, - } - ) -); +/** + * @deprecated Use `useStore` from `./combinedStore` instead. + * All stores are now combined into a single store using the slices pattern. + */ +export { useStore as useInvoiceStore } from './combinedStore'; diff --git a/src/store/loyaltyStore.ts b/src/store/loyaltyStore.ts index a33f7140..16992929 100644 --- a/src/store/loyaltyStore.ts +++ b/src/store/loyaltyStore.ts @@ -1,279 +1,5 @@ -import { create } from 'zustand'; -import { persist, createJSONStorage } from 'zustand/middleware'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { - LoyaltyStatus, - LoyaltyTier, - PointsTransaction, - Reward, - RewardType, - TierBenefits, - LoyaltyProgram, -} from '../types/loyalty'; - -const STORAGE_KEY = 'subtrackr-loyalty'; -const STORE_VERSION = 1; - -interface LoyaltyState { - loyaltyStatus: LoyaltyStatus | null; - transactions: PointsTransaction[]; - rewards: Reward[]; - program: LoyaltyProgram | null; - isLoading: boolean; - error: string | null; - - initializeProgram: () => Promise; - accumulatePoints: (subscriberId: string, subscriptionId: string, amount: number) => Promise; - redeemPoints: (rewardId: string) => Promise; - checkTierUpgrade: () => void; - expirePoints: () => void; -} - -const generateUniqueId = (): string => { - const timestamp = Date.now().toString(36); - const randomComponent = Math.random().toString(36).substring(2, 8); - return `${timestamp}-${randomComponent}`; -}; - -const defaultTierBenefits: TierBenefits[] = [ - { - tier: LoyaltyTier.BRONZE, - benefits: [{ type: 'base', description: 'Base rewards', value: 1 }], - pointsThreshold: 0, - discountRate: 0, - prioritySupport: false, - reducedFees: 0, - }, - { - tier: LoyaltyTier.SILVER, - benefits: [ - { type: 'base', description: 'Base rewards', value: 1 }, - { type: 'discount', description: '5% discount', value: 5 }, - ], - pointsThreshold: 1000, - discountRate: 5, - prioritySupport: false, - reducedFees: 2, - }, - { - tier: LoyaltyTier.GOLD, - benefits: [ - { type: 'base', description: 'Base rewards', value: 1 }, - { type: 'discount', description: '10% discount', value: 10 }, - { type: 'priority', description: 'Priority support', value: 1 }, - ], - pointsThreshold: 5000, - discountRate: 10, - prioritySupport: true, - reducedFees: 5, - }, - { - tier: LoyaltyTier.PLATINUM, - benefits: [ - { type: 'base', description: 'Base rewards', value: 1 }, - { type: 'discount', description: '15% discount', value: 15 }, - { type: 'priority', description: 'Priority support', value: 1 }, - { type: 'exclusive', description: 'Exclusive offers', value: 1 }, - ], - pointsThreshold: 15000, - discountRate: 15, - prioritySupport: true, - reducedFees: 10, - }, -]; - -const defaultRewards: Reward[] = [ - { - id: 'reward-1', - name: '$5 Discount', - type: RewardType.DISCOUNT, - pointsCost: 500, - value: 5, - description: '$5 off your next billing cycle', - isActive: true, - }, - { - id: 'reward-2', - name: '$10 Discount', - type: RewardType.DISCOUNT, - pointsCost: 900, - value: 10, - description: '$10 off your next billing cycle', - isActive: true, - }, - { - id: 'reward-3', - name: 'Free Month', - type: RewardType.FREE_MONTH, - pointsCost: 2000, - value: 0, - description: 'Get one month free', - isActive: true, - }, - { - id: 'reward-4', - name: 'T-Shirt', - type: RewardType.MERCHANDISE, - pointsCost: 5000, - value: 25, - description: 'Exclusive SubTrackr t-shirt', - isActive: true, - }, -]; - -const getTierFromPoints = (points: number): LoyaltyTier => { - if (points >= 15000) return LoyaltyTier.PLATINUM; - if (points >= 5000) return LoyaltyTier.GOLD; - if (points >= 1000) return LoyaltyTier.SILVER; - return LoyaltyTier.BRONZE; -}; - -const calculatePointsExpiration = (pointsExpirationDays: number, memberSince: Date): Date => { - const expirationDate = new Date(memberSince); - expirationDate.setDate(expirationDate.getDate() + pointsExpirationDays); - return expirationDate; -}; - -export const useLoyaltyStore = create()( - persist( - (set, get) => ({ - loyaltyStatus: null, - transactions: [], - rewards: defaultRewards, - program: null, - isLoading: false, - error: null, - - initializeProgram: async () => { - const program: LoyaltyProgram = { - id: generateUniqueId(), - name: 'SubTrackr Rewards', - tiers: defaultTierBenefits, - pointsPerDollar: 10, - pointsExpirationDays: 365, - isActive: true, - }; - set({ program }); - }, - - accumulatePoints: async (subscriberId: string, subscriptionId: string, amount: number) => { - const { program, transactions, loyaltyStatus } = get(); - if (!program) return; - - const pointsEarned = Math.floor(amount * program.pointsPerDollar); - - const transaction: PointsTransaction = { - id: generateUniqueId(), - subscriberId, - amount: pointsEarned, - type: 'earn', - subscriptionId, - description: `Points earned from subscription`, - createdAt: new Date(), - }; - - const currentPoints = loyaltyStatus?.points || 0; - const lifetimePoints = loyaltyStatus?.lifetimePoints || 0; - const totalSpent = loyaltyStatus?.totalSpent || 0; - - const newStatus: LoyaltyStatus = { - subscriberId, - tier: getTierFromPoints(currentPoints + pointsEarned), - points: currentPoints + pointsEarned, - lifetimePoints: lifetimePoints + pointsEarned, - totalSpent: totalSpent + amount, - memberSince: loyaltyStatus?.memberSince || new Date(), - pointsExpirationDate: calculatePointsExpiration( - program.pointsExpirationDays, - loyaltyStatus?.memberSince || new Date() - ), - }; - - set({ - transactions: [...transactions, transaction], - loyaltyStatus: newStatus, - }); - }, - - redeemPoints: async (rewardId: string) => { - const { rewards, loyaltyStatus } = get(); - const reward = rewards.find((r) => r.id === rewardId); - - if (!reward || !loyaltyStatus) return false; - if (!reward.isActive) return false; - if (loyaltyStatus.points < reward.pointsCost) return false; - - const transaction: PointsTransaction = { - id: generateUniqueId(), - subscriberId: loyaltyStatus.subscriberId, - amount: -reward.pointsCost, - type: 'redeem', - description: `Redeemed: ${reward.name}`, - createdAt: new Date(), - }; - - set({ - transactions: [...get().transactions, transaction], - loyaltyStatus: { - ...loyaltyStatus, - points: loyaltyStatus.points - reward.pointsCost, - }, - }); - - return true; - }, - - checkTierUpgrade: () => { - const { loyaltyStatus } = get(); - if (!loyaltyStatus) return; - - const newTier = getTierFromPoints(loyaltyStatus.lifetimePoints); - if (newTier !== loyaltyStatus.tier) { - set({ - loyaltyStatus: { - ...loyaltyStatus, - tier: newTier, - }, - }); - } - }, - - expirePoints: () => { - const { loyaltyStatus, transactions } = get(); - if (!loyaltyStatus?.pointsExpirationDate) return; - - const now = new Date(); - if (now > loyaltyStatus.pointsExpirationDate) { - const expiredTransaction: PointsTransaction = { - id: generateUniqueId(), - subscriberId: loyaltyStatus.subscriberId, - amount: -loyaltyStatus.points, - type: 'expire', - description: 'Points expired', - createdAt: new Date(), - }; - - set({ - transactions: [...transactions, expiredTransaction], - loyaltyStatus: { - ...loyaltyStatus, - points: 0, - pointsExpirationDate: undefined, - }, - }); - } - }, - }), - { - name: STORAGE_KEY, - version: STORE_VERSION, - storage: createJSONStorage(() => AsyncStorage), - partialize: (state) => ({ - loyaltyStatus: state.loyaltyStatus, - transactions: state.transactions, - rewards: state.rewards, - program: state.program, - }), - } - ) -); \ No newline at end of file +/** + * @deprecated Use \`useStore\` from \`./combinedStore\` instead. + * All stores are now combined into a single store using the slices pattern. + */ +export { useStore as useLoyaltyStore } from './combinedStore'; diff --git a/src/store/merchantStore.ts b/src/store/merchantStore.ts index e6253a40..db7f0803 100644 --- a/src/store/merchantStore.ts +++ b/src/store/merchantStore.ts @@ -1,216 +1,5 @@ -import { create } from 'zustand'; -import { persist, createJSONStorage, StateStorage } from 'zustand/middleware'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { - MerchantOnboarding, - MerchantOnboardingFormData, - OnboardingStep, - OnboardingStatus, - VerificationTier, - MerchantDocument, - DocumentType, -} from '../types/merchant'; -import { CACHE_CONSTANTS } from '../utils/constants/values'; - -const STORAGE_KEY = 'subtrackr-merchant-onboarding'; -const STORE_VERSION = 1; -const WRITE_DEBOUNCE_MS = CACHE_CONSTANTS.WRITE_DEBOUNCE_MS; - -interface MerchantState { - onboarding: MerchantOnboarding | null; - isLoading: boolean; - error: string | null; - - startOnboarding: (data: MerchantOnboardingFormData) => Promise; - submitDocument: (docType: DocumentType, uri: string) => Promise; - nextStep: () => Promise; - previousStep: () => Promise; - requestVerification: () => Promise; - approveVerification: (tier: VerificationTier, notes?: string) => Promise; - rejectVerification: (reason: string) => Promise; - getOnboardingStatus: () => OnboardingStatus; -} - -const generateUniqueId = (): string => { - const timestamp = Date.now().toString(36); - const randomComponent = Math.random().toString(36).substring(2, 8); - return `${timestamp}-${randomComponent}`; -}; - -const getDefaultSteps = (): OnboardingStep[] => [ - OnboardingStep.BUSINESS_INFO, - OnboardingStep.ID_DOCUMENT, - OnboardingStep.BUSINESS_LICENSE, - OnboardingStep.REVIEW, -]; - -export const useMerchantStore = create()( - persist( - (set, get) => ({ - onboarding: null, - isLoading: false, - error: null, - - startOnboarding: async (data: MerchantOnboardingFormData) => { - set({ isLoading: true, error: null }); - try { - const newOnboarding: MerchantOnboarding = { - id: generateUniqueId(), - merchantAddress: data.email, - steps: getDefaultSteps(), - currentStep: OnboardingStep.BUSINESS_INFO, - status: OnboardingStatus.IN_PROGRESS, - documents: [], - startedAt: new Date(), - updatedAt: new Date(), - }; - set({ onboarding: newOnboarding, isLoading: false }); - } catch (error) { - set({ - error: error instanceof Error ? error.message : 'Failed to start onboarding', - isLoading: false, - }); - } - }, - - submitDocument: async (docType: DocumentType, uri: string) => { - set({ isLoading: true, error: null }); - try { - const { onboarding } = get(); - if (!onboarding) throw new Error('No onboarding in progress'); - - const newDoc: MerchantDocument = { - id: generateUniqueId(), - type: docType, - uri, - uploadedAt: new Date(), - status: 'pending', - }; - - set({ - onboarding: { - ...onboarding, - documents: [...onboarding.documents, newDoc], - updatedAt: new Date(), - }, - isLoading: false, - }); - } catch (error) { - set({ - error: error instanceof Error ? error.message : 'Failed to submit document', - isLoading: false, - }); - } - }, - - nextStep: async () => { - const { onboarding } = get(); - if (!onboarding) return; - - const currentIndex = onboarding.steps.indexOf(onboarding.currentStep); - if (currentIndex >= onboarding.steps.length - 1) return; - - const currentStep = onboarding.steps[currentIndex + 1]; - const newStatus = - currentStep === OnboardingStep.REVIEW - ? OnboardingStatus.PENDING_REVIEW - : OnboardingStatus.IN_PROGRESS; - - set({ - onboarding: { - ...onboarding, - currentStep, - status: newStatus, - updatedAt: new Date(), - }, - }); - }, - - previousStep: async () => { - const { onboarding } = get(); - if (!onboarding) return; - - const currentIndex = onboarding.steps.indexOf(onboarding.currentStep); - if (currentIndex <= 0) return; - - set({ - onboarding: { - ...onboarding, - currentStep: onboarding.steps[currentIndex - 1], - status: OnboardingStatus.IN_PROGRESS, - updatedAt: new Date(), - }, - }); - }, - - requestVerification: async () => { - const { onboarding } = get(); - if (!onboarding) return; - - set({ - onboarding: { - ...onboarding, - status: OnboardingStatus.PENDING_REVIEW, - updatedAt: new Date(), - }, - }); - }, - - approveVerification: async (tier: VerificationTier, notes?: string) => { - const { onboarding } = get(); - if (!onboarding) return; - - const limits = - tier === VerificationTier.ENHANCED - ? { monthlyVolume: 1000000, maxTransactions: 10000 } - : { monthlyVolume: 10000, maxTransactions: 100 }; - - set({ - onboarding: { - ...onboarding, - status: OnboardingStatus.VERIFIED, - verificationResult: { - isVerified: true, - tier, - reviewedAt: new Date(), - reviewerNotes: notes, - limits, - }, - updatedAt: new Date(), - }, - }); - }, - - rejectVerification: async (reason: string) => { - const { onboarding } = get(); - if (!onboarding) return; - - set({ - onboarding: { - ...onboarding, - status: OnboardingStatus.REJECTED, - verificationResult: { - isVerified: false, - tier: VerificationTier.BASIC, - reviewedAt: new Date(), - reviewerNotes: reason, - limits: { monthlyVolume: 0, maxTransactions: 0 }, - }, - updatedAt: new Date(), - }, - }); - }, - - getOnboardingStatus: () => { - const { onboarding } = get(); - return onboarding?.status ?? OnboardingStatus.NOT_STARTED; - }, - }), - { - name: STORAGE_KEY, - version: STORE_VERSION, - storage: createJSONStorage(() => AsyncStorage), - partialize: (state) => ({ onboarding: state.onboarding }), - } - ) -); \ No newline at end of file +/** + * @deprecated Use \`useStore\` from \`./combinedStore\` instead. + * All stores are now combined into a single store using the slices pattern. + */ +export { useStore as useMerchantStore } from './combinedStore'; diff --git a/src/store/networkStore.ts b/src/store/networkStore.ts index 8c9b5c6d..77e2572d 100644 --- a/src/store/networkStore.ts +++ b/src/store/networkStore.ts @@ -1,79 +1,5 @@ -import { create } from 'zustand'; -import { Network, ALL_NETWORKS, getNetworkById } from '../config/networks'; -import { networkService } from '../services/networkService'; - -interface NetworkState { - currentNetwork: Network | null; - availableNetworks: Network[]; - isLoading: boolean; - error: string | null; - - initialize: () => Promise; - setNetwork: (networkId: string) => Promise; - checkHealth: ( - networkId: string - ) => Promise<{ healthy: boolean; latency?: number; error?: string }>; - refreshNetworks: () => Promise; -} - -export const useNetworkStore = create((set) => ({ - currentNetwork: null, - availableNetworks: ALL_NETWORKS, - isLoading: false, - error: null, - - initialize: async () => { - set({ isLoading: true, error: null }); - try { - const network = await networkService.getSelectedNetwork(); - set({ currentNetwork: network, isLoading: false }); - } catch (error) { - set({ - error: error instanceof Error ? error.message : 'Failed to initialize network', - isLoading: false, - }); - } - }, - - setNetwork: async (networkId: string) => { - set({ isLoading: true, error: null }); - try { - const success = await networkService.setSelectedNetwork(networkId); - if (success) { - const network = getNetworkById(networkId); - set({ currentNetwork: network, isLoading: false }); - } else { - set({ error: 'Failed to set network', isLoading: false }); - } - } catch (error) { - set({ - error: error instanceof Error ? error.message : 'Failed to set network', - isLoading: false, - }); - } - }, - - checkHealth: async (networkId: string) => { - try { - return await networkService.checkNetworkHealth(networkId); - } catch (error) { - return { - healthy: false, - error: error instanceof Error ? error.message : 'Health check failed', - }; - } - }, - - refreshNetworks: async () => { - set({ isLoading: true, error: null }); - try { - const networks = await networkService.getAvailableNetworks(); - set({ availableNetworks: networks, isLoading: false }); - } catch (error) { - set({ - error: error instanceof Error ? error.message : 'Failed to refresh networks', - isLoading: false, - }); - } - }, -})); +/** + * @deprecated Use `useStore` from `./combinedStore` instead. + * All stores are now combined into a single store using the slices pattern. + */ +export { useStore as useNetworkStore } from './combinedStore'; diff --git a/src/store/sandboxStore.ts b/src/store/sandboxStore.ts index 1fb07ee1..f61e3aa3 100644 --- a/src/store/sandboxStore.ts +++ b/src/store/sandboxStore.ts @@ -1,658 +1,5 @@ -import { create } from 'zustand'; -import { persist, createJSONStorage } from 'zustand/middleware'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { - SandboxConfig, - SandboxEnvironment, - SandboxStatus, - DeveloperProfile, - DeveloperOnboardingStep, - OnboardingStepInfo, - ApiKey, - ApiKeyStatus, - ApiKeyScope, - UsageStats, - UsageMetric, - TestSubscription, - SandboxMetrics, - IntegrationGuide, - IntegrationGuideCategory, -} from '../types/sandbox'; -import { AppError, errorHandler } from '../services/errorHandler'; - -const STORAGE_KEY = 'subtrackr-sandbox'; -const STORE_VERSION = 3; - -const generateId = (prefix: string): string => - `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; - -const generateApiKeyString = (): string => { - const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - let key = 'sk_sandbox_'; - for (let i = 0; i < 48; i++) { - key += chars.charAt(Math.floor(Math.random() * chars.length)); - } - return key; -}; - -const DEFAULT_RATE_LIMIT = { - requestsPerMinute: 60, - requestsPerHour: 1000, - requestsPerDay: 10000, - burstLimit: 10, -}; - -const DEFAULT_ONBOARDING_STEPS: OnboardingStepInfo[] = [ - { id: DeveloperOnboardingStep.WELCOME, title: 'Welcome', description: 'Learn about the developer portal', step: DeveloperOnboardingStep.WELCOME, completed: false, required: true }, - { id: DeveloperOnboardingStep.CREATE_ACCOUNT, title: 'Create Account', description: 'Set up your developer profile', step: DeveloperOnboardingStep.CREATE_ACCOUNT, completed: false, required: true }, - { id: DeveloperOnboardingStep.GENERATE_API_KEY, title: 'Generate API Key', description: 'Create your sandbox API key', step: DeveloperOnboardingStep.GENERATE_API_KEY, completed: false, required: true }, - { id: DeveloperOnboardingStep.EXPLORE_SANDBOX, title: 'Explore Sandbox', description: 'Test the sandbox environment', step: DeveloperOnboardingStep.EXPLORE_SANDBOX, completed: false, required: false }, - { id: DeveloperOnboardingStep.BUILD_INTEGRATION, title: 'Build Integration', description: 'Build your integration', step: DeveloperOnboardingStep.BUILD_INTEGRATION, completed: false, required: false }, - { id: DeveloperOnboardingStep.GO_LIVE, title: 'Go Live', description: 'Switch to production', step: DeveloperOnboardingStep.GO_LIVE, completed: false, required: false }, -]; - -const DEFAULT_INTEGRATION_GUIDES: IntegrationGuide[] = [ - { - id: 'guide-getting-started', - title: 'Getting Started with SubTrackr API', - description: 'Set up your environment and make your first API call', - category: IntegrationGuideCategory.GETTING_STARTED, - difficulty: 'beginner', - estimatedTime: '15 minutes', - steps: [ - { title: 'Install SDK', content: 'npm install @subtrackr/sdk', codeExample: 'npm install @subtrackr/sdk', language: 'bash' }, - { title: 'Initialize Client', content: 'Create a SubTrackr client with your API key.', codeExample: `const client = new SubTrackr({\n apiKey: 'sk_sandbox_your_key',\n});`, language: 'typescript' }, - { title: 'Make First Request', content: 'List subscriptions to verify setup.', codeExample: `const subs = await client.subscriptions.list();\nconsole.log(subs.data);`, language: 'typescript' }, - ], - tags: ['setup', 'quickstart'], - isCompleted: false, - }, - { - id: 'guide-subscription-management', - title: 'Managing Subscriptions', - description: 'Create, update, and manage subscriptions programmatically', - category: IntegrationGuideCategory.SUBSCRIPTION_MANAGEMENT, - difficulty: 'intermediate', - estimatedTime: '30 minutes', - steps: [ - { title: 'Create Subscription', content: 'Use POST to create subscriptions.', codeExample: `const sub = await client.subscriptions.create({\n name: 'Pro Plan',\n price: 29.99,\n currency: 'USD',\n billingCycle: 'monthly',\n});`, language: 'typescript' }, - { title: 'Update Status', content: 'Pause, resume, or cancel subscriptions.' }, - ], - tags: ['subscriptions', 'billing'], - isCompleted: false, - }, - { - id: 'guide-webhook-integration', - title: 'Webhook Integration', - description: 'Set up webhooks for real-time event notifications', - category: IntegrationGuideCategory.WEBHOOK_INTEGRATION, - difficulty: 'intermediate', - estimatedTime: '25 minutes', - steps: [ - { title: 'Register Endpoint', content: 'Register your server URL to receive events.' }, - { title: 'Verify Signatures', content: 'Verify HMAC signatures on webhook payloads.' }, - ], - tags: ['webhooks', 'events'], - isCompleted: false, - }, - { - id: 'guide-payment-processing', - title: 'Payment Processing', - description: 'Integrate crypto and traditional payment methods', - category: IntegrationGuideCategory.PAYMENT_PROCESSING, - difficulty: 'advanced', - estimatedTime: '45 minutes', - steps: [ - { title: 'Configure Gateway', content: 'Set up payment gateway credentials.' }, - { title: 'Process Crypto Payments', content: 'Integrate with Stellar and EVM chains.' }, - ], - tags: ['payments', 'crypto'], - isCompleted: false, - }, - { - id: 'guide-analytics-reporting', - title: 'Analytics & Reporting', - description: 'Access subscription analytics and generate reports', - category: IntegrationGuideCategory.ANALYTICS_REPORTING, - difficulty: 'intermediate', - estimatedTime: '20 minutes', - steps: [ - { title: 'Fetch Analytics', content: 'Retrieve subscription metrics via API.' }, - ], - tags: ['analytics', 'reporting'], - isCompleted: false, - }, - { - id: 'guide-advanced-features', - title: 'Advanced Features', - description: 'SLA monitoring, quota management, and enterprise features', - category: IntegrationGuideCategory.ADVANCED_FEATURES, - difficulty: 'advanced', - estimatedTime: '60 minutes', - steps: [ - { title: 'Configure SLA', content: 'Set up SLA targets and monitoring.' }, - { title: 'Manage Quotas', content: 'Define usage quotas for subscription tiers.' }, - ], - tags: ['sla', 'quotas', 'enterprise'], - isCompleted: false, - }, -]; - -interface SandboxState { - sandboxes: SandboxConfig[]; - currentSandbox: SandboxConfig | null; - selectedSandbox: SandboxConfig | null; - sandboxConfig: SandboxConfig; - developerProfile: DeveloperProfile | null; - apiKeys: ApiKey[]; - usageStats: UsageStats | null; - usageRecords: UsageMetric[]; - testSubscriptions: TestSubscription[]; - subscriptions: TestSubscription[]; - sandboxSubscriptions: TestSubscription[]; - transactions: Array<{ id: string; type: string; amount: number; status: string; timestamp: Date }>; - metrics: SandboxMetrics; - onboardingSteps: OnboardingStepInfo[]; - integrationGuides: IntegrationGuide[]; - selectedGuide: IntegrationGuide | null; - isLoading: boolean; - error: AppError | null; - - fetchSandboxes: (developerId: string) => Promise; - createSandbox: (name: string, description: string, environment: SandboxEnvironment) => Promise; - selectSandbox: (sandbox: SandboxConfig | string) => void; - deleteSandbox: (id: string) => Promise; - pauseSandbox: (id: string) => Promise; - resumeSandbox: (id: string) => Promise; - toggleSandboxStatus: (id: string) => Promise; - generateTestData: (sandboxIdOrConfig?: string | { subscriptionCount?: number; transactionCount?: number }) => Promise; - resetSandbox: () => void; - resetTestData: () => void; - refreshMetrics: () => Promise; - initializeSandbox: () => void; - initializeDeveloperPortal: () => void; - switchEnvironment: (env: SandboxEnvironment) => void; - addTestSubscription: (name: string, price: number) => void; - removeTestSubscription: (id: string) => void; - completeOnboardingStep: (stepId: string) => void; - createDeveloperProfile: (name: string, email: string, company?: string) => Promise; - generateApiKey: (name: string) => Promise; - createApiKey: (input: { name: string; description?: string; sandboxId: string; scopes: ApiKeyScope[] }) => Promise; - revokeApiKey: (id: string) => Promise; - reactivateApiKey: (id: string) => Promise; - deleteApiKey: (id: string) => Promise; - fetchUsageForSandbox: (sandboxId: string) => void; - markGuideCompleted: (guideId: string) => void; - clearError: () => void; -} - -const defaultSandboxConfig: SandboxConfig = { - id: generateId('sandbox'), - environment: SandboxEnvironment.DEVELOPMENT, - name: 'Development Sandbox', - description: 'Primary sandbox for development and testing', - isActive: true, - dataIsolation: true, - rateLimit: DEFAULT_RATE_LIMIT, - createdAt: new Date(), - updatedAt: new Date(), -}; - -export const useSandboxStore = create()( - persist( - (set, get) => ({ - sandboxes: [], - currentSandbox: null, - selectedSandbox: null, - sandboxConfig: defaultSandboxConfig, - developerProfile: null, - apiKeys: [], - usageStats: null, - usageRecords: [], - testSubscriptions: [], - subscriptions: [], - sandboxSubscriptions: [], - transactions: [], - metrics: { totalSubscriptions: 0, totalTransactions: 0, totalVolume: 0, totalApiCalls: 0 }, - onboardingSteps: DEFAULT_ONBOARDING_STEPS, - integrationGuides: DEFAULT_INTEGRATION_GUIDES, - selectedGuide: null, - isLoading: false, - error: null, - - fetchSandboxes: async (_developerId: string) => { - try { - set({ isLoading: true, error: null }); - const { sandboxes } = get(); - if (sandboxes.length > 0) { - const active = sandboxes.find((s) => s.isActive) || sandboxes[0]; - set({ currentSandbox: active }); - } - set({ isLoading: false }); - } catch (err) { - set({ - error: errorHandler.handleError(err as Error, { action: 'fetchSandboxes', timestamp: new Date() }), - isLoading: false, - }); - } - }, - - createSandbox: async (name, description, environment) => { - try { - set({ isLoading: true, error: null }); - const sandbox: SandboxConfig = { - id: generateId('sandbox'), - environment, - name, - description, - isActive: true, - status: SandboxStatus.ACTIVE, - dataIsolation: true, - rateLimit: DEFAULT_RATE_LIMIT, - createdAt: new Date(), - updatedAt: new Date(), - }; - set((state) => ({ - sandboxes: [...state.sandboxes, sandbox], - currentSandbox: state.currentSandbox || sandbox, - isLoading: false, - })); - } catch (err) { - set({ - error: errorHandler.handleError(err as Error, { action: 'createSandbox', timestamp: new Date() }), - isLoading: false, - }); - } - }, - - selectSandbox: (sandboxOrId) => { - const sandbox = typeof sandboxOrId === 'string' - ? get().sandboxes.find((s) => s.id === sandboxOrId) || null - : sandboxOrId; - set({ selectedSandbox: sandbox, currentSandbox: sandbox }); - }, - - deleteSandbox: async (id) => { - set((state) => { - const remaining = state.sandboxes.filter((s) => s.id !== id); - return { - sandboxes: remaining, - currentSandbox: state.currentSandbox?.id === id ? remaining[0] || null : state.currentSandbox, - selectedSandbox: state.selectedSandbox?.id === id ? null : state.selectedSandbox, - }; - }); - }, - - pauseSandbox: async (id) => { - set((state) => ({ - sandboxes: state.sandboxes.map((s) => - s.id === id ? { ...s, isActive: false, status: SandboxStatus.PAUSED, updatedAt: new Date() } : s - ), - currentSandbox: state.currentSandbox?.id === id - ? { ...state.currentSandbox, isActive: false, status: SandboxStatus.PAUSED } - : state.currentSandbox, - })); - }, - - resumeSandbox: async (id) => { - set((state) => ({ - sandboxes: state.sandboxes.map((s) => - s.id === id ? { ...s, isActive: true, status: SandboxStatus.ACTIVE, updatedAt: new Date() } : s - ), - currentSandbox: state.currentSandbox?.id === id - ? { ...state.currentSandbox, isActive: true, status: SandboxStatus.ACTIVE } - : state.currentSandbox, - })); - }, - - toggleSandboxStatus: async (id) => { - const sandbox = get().sandboxes.find((s) => s.id === id); - if (!sandbox) return; - if (sandbox.isActive) { - await get().pauseSandbox(id); - } else { - await get().resumeSandbox(id); - } - }, - - generateTestData: async (sandboxIdOrConfig?: string | { subscriptionCount?: number; transactionCount?: number }) => { - try { - set({ isLoading: true, error: null }); - const count = typeof sandboxIdOrConfig === 'object' ? sandboxIdOrConfig.subscriptionCount || 8 : 8; - const names = ['Netflix', 'Spotify', 'Adobe CC', 'Slack Pro', 'GitHub Team', 'Figma Pro', 'Notion Plus', 'Vercel Pro']; - const prices = [15.99, 9.99, 54.99, 8.75, 4.0, 12.0, 10.0, 20.0]; - const subCount = Math.min(count, names.length); - - const testSubs: TestSubscription[] = names.slice(0, subCount).map((name, i) => ({ - id: generateId('test_sub'), - name, - price: prices[i], - currency: 'USD', - status: i < Math.floor(subCount * 0.75) ? 'active' : 'paused', - billingCycle: 'monthly', - nextBillingDate: new Date(Date.now() + (i + 1) * 30 * 24 * 60 * 60 * 1000), - createdAt: new Date(Date.now() - (8 - i) * 7 * 24 * 60 * 60 * 1000), - })); - - const transactions = Array.from({ length: 15 }, (_, i) => ({ - id: generateId('tx'), - type: i % 3 === 0 ? 'refund' : 'charge', - amount: Math.round((Math.random() * 100 + 5) * 100) / 100, - status: i < 12 ? 'completed' : 'pending', - timestamp: new Date(Date.now() - i * 2 * 24 * 60 * 60 * 1000), - })); - - set({ - testSubscriptions: testSubs, - subscriptions: testSubs, - sandboxSubscriptions: testSubs, - transactions, - metrics: { - totalSubscriptions: testSubs.length, - totalTransactions: transactions.length, - totalVolume: transactions.reduce((sum, t) => sum + t.amount, 0), - totalApiCalls: Math.floor(Math.random() * 5000) + 1000, - }, - isLoading: false, - }); - } catch (err) { - set({ - error: errorHandler.handleError(err as Error, { action: 'generateTestData', timestamp: new Date() }), - isLoading: false, - }); - } - }, - - resetSandbox: () => { - set({ - testSubscriptions: [], - subscriptions: [], - sandboxSubscriptions: [], - transactions: [], - metrics: { totalSubscriptions: 0, totalTransactions: 0, totalVolume: 0, totalApiCalls: 0 }, - usageRecords: [], - usageStats: null, - }); - }, - - resetTestData: () => { - get().resetSandbox(); - }, - - refreshMetrics: async () => { - const { testSubscriptions, transactions } = get(); - set({ - metrics: { - totalSubscriptions: testSubscriptions.length, - totalTransactions: transactions.length, - totalVolume: transactions.reduce((sum, t) => sum + t.amount, 0), - totalApiCalls: get().metrics.totalApiCalls, - }, - }); - }, - - initializeSandbox: () => { - const { sandboxes, testSubscriptions } = get(); - if (sandboxes.length === 0) { - const defaultSandbox: SandboxConfig = { - id: generateId('sandbox'), - environment: SandboxEnvironment.DEVELOPMENT, - name: 'Development Sandbox', - description: 'Primary sandbox for development and testing', - isActive: true, - status: SandboxStatus.ACTIVE, - dataIsolation: true, - rateLimit: DEFAULT_RATE_LIMIT, - createdAt: new Date(), - updatedAt: new Date(), - }; - set({ sandboxes: [defaultSandbox], currentSandbox: defaultSandbox }); - } - if (testSubscriptions.length === 0) { - get().generateTestData(); - } - }, - - initializeDeveloperPortal: () => { - const { sandboxes } = get(); - if (sandboxes.length === 0) { - set({ sandboxes: [], onboardingSteps: DEFAULT_ONBOARDING_STEPS }); - } - }, - - switchEnvironment: (env) => { - set((state) => ({ - sandboxConfig: { ...state.sandboxConfig, environment: env, updatedAt: new Date() }, - })); - }, - - addTestSubscription: (name, price) => { - const sub: TestSubscription = { - id: generateId('test_sub'), - name, - price, - currency: 'USD', - status: 'active', - billingCycle: 'monthly', - nextBillingDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), - createdAt: new Date(), - }; - set((state) => ({ - testSubscriptions: [...state.testSubscriptions, sub], - subscriptions: [...state.subscriptions, sub], - sandboxSubscriptions: [...state.sandboxSubscriptions, sub], - })); - }, - - removeTestSubscription: (id) => { - set((state) => ({ - testSubscriptions: state.testSubscriptions.filter((s) => s.id !== id), - subscriptions: state.subscriptions.filter((s) => s.id !== id), - sandboxSubscriptions: state.sandboxSubscriptions.filter((s) => s.id !== id), - })); - }, - - completeOnboardingStep: (stepId) => { - set((state) => ({ - onboardingSteps: state.onboardingSteps.map((s) => - s.id === stepId ? { ...s, completed: true } : s - ), - })); - }, - - createDeveloperProfile: async (name, email, company) => { - try { - set({ isLoading: true, error: null }); - const sandboxConfig = get().sandboxConfig; - const profile: DeveloperProfile = { - id: generateId('dev'), - email, - name, - company, - onboardingStep: DeveloperOnboardingStep.CREATE_ACCOUNT, - completedSteps: [DeveloperOnboardingStep.WELCOME], - sandboxConfig, - apiKeys: [], - createdAt: new Date(), - updatedAt: new Date(), - }; - set((state) => ({ - developerProfile: profile, - onboardingSteps: state.onboardingSteps.map((s) => - s.id === DeveloperOnboardingStep.WELCOME ? { ...s, completed: true } : s - ), - isLoading: false, - })); - } catch (err) { - set({ - error: errorHandler.handleError(err as Error, { action: 'createDeveloperProfile', timestamp: new Date() }), - isLoading: false, - }); - } - }, - - generateApiKey: async (name) => { - try { - set({ isLoading: true, error: null }); - const key = generateApiKeyString(); - const sandboxId = get().currentSandbox?.id || get().sandboxConfig.id; - const apiKey: ApiKey = { - id: generateId('key'), - key, - name, - sandboxId, - status: ApiKeyStatus.ACTIVE, - scopes: [ApiKeyScope.READ, ApiKeyScope.WRITE], - expiresAt: null, - lastUsedAt: null, - usageCount: 0, - createdAt: new Date(), - updatedAt: new Date(), - }; - set((state) => ({ - apiKeys: [...state.apiKeys, apiKey], - onboardingSteps: state.onboardingSteps.map((s) => - s.id === DeveloperOnboardingStep.GENERATE_API_KEY ? { ...s, completed: true } : s - ), - isLoading: false, - })); - return key; - } catch (err) { - set({ - error: errorHandler.handleError(err as Error, { action: 'generateApiKey', timestamp: new Date() }), - isLoading: false, - }); - throw err; - } - }, - - createApiKey: async (input) => { - try { - set({ isLoading: true, error: null }); - const key = generateApiKeyString(); - const apiKey: ApiKey = { - id: generateId('key'), - key, - name: input.name, - description: input.description, - sandboxId: input.sandboxId, - status: ApiKeyStatus.ACTIVE, - scopes: input.scopes, - expiresAt: null, - lastUsedAt: null, - usageCount: 0, - createdAt: new Date(), - updatedAt: new Date(), - }; - set((state) => ({ - apiKeys: [...state.apiKeys, apiKey], - isLoading: false, - })); - get().completeOnboardingStep(DeveloperOnboardingStep.GENERATE_API_KEY); - } catch (err) { - set({ - error: errorHandler.handleError(err as Error, { action: 'createApiKey', timestamp: new Date() }), - isLoading: false, - }); - } - }, - - revokeApiKey: async (id) => { - set((state) => ({ - apiKeys: state.apiKeys.map((k) => - k.id === id ? { ...k, status: ApiKeyStatus.REVOKED, updatedAt: new Date() } : k - ), - })); - }, - - reactivateApiKey: async (id) => { - set((state) => ({ - apiKeys: state.apiKeys.map((k) => - k.id === id ? { ...k, status: ApiKeyStatus.ACTIVE, updatedAt: new Date() } : k - ), - })); - }, - - deleteApiKey: async (id) => { - set((state) => ({ - apiKeys: state.apiKeys.filter((k) => k.id !== id), - })); - }, - - fetchUsageForSandbox: (sandboxId) => { - const records = get().usageRecords.filter((r) => r.sandboxId === sandboxId); - const totalRequests = records.length; - const successfulRequests = records.filter((r) => r.statusCode >= 200 && r.statusCode < 400).length; - const failedRequests = totalRequests - successfulRequests; - const avgResponseTime = totalRequests > 0 - ? records.reduce((sum, r) => sum + r.responseTime, 0) / totalRequests - : 0; - - const requestsByEndpoint: Record = {}; - const requestsByDay: Record = {}; - const errorCounts: Record = {}; - - records.forEach((r) => { - const endpointKey = `${r.method} ${r.endpoint}`; - requestsByEndpoint[endpointKey] = (requestsByEndpoint[endpointKey] || 0) + 1; - const day = new Date(r.timestamp).toISOString().split('T')[0]; - requestsByDay[day] = (requestsByDay[day] || 0) + 1; - if (r.statusCode >= 400) { - if (!errorCounts[r.statusCode]) { - errorCounts[r.statusCode] = { count: 0, message: `HTTP ${r.statusCode}` }; - } - errorCounts[r.statusCode].count++; - } - }); - - const now = new Date(); - const periodStart = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); - - set({ - usageStats: { - totalRequests, - successfulRequests, - failedRequests, - averageResponseTime: Math.round(avgResponseTime), - totalDataTransferred: records.reduce((sum, r) => sum + (r.requestSize || 0) + (r.responseSize || 0), 0), - periodStart, - periodEnd: now, - requestsByEndpoint, - requestsByDay, - topErrors: Object.entries(errorCounts).map(([code, data]) => ({ - code: Number(code), - count: data.count, - message: data.message, - })), - }, - }); - }, - - markGuideCompleted: (guideId) => { - set((state) => ({ - integrationGuides: state.integrationGuides.map((g) => - g.id === guideId ? { ...g, isCompleted: true } : g - ), - })); - }, - - clearError: () => set({ error: null }), - }), - { - name: STORAGE_KEY, - version: STORE_VERSION, - storage: createJSONStorage(() => AsyncStorage), - partialize: (state) => ({ - sandboxes: state.sandboxes, - currentSandbox: state.currentSandbox, - developerProfile: state.developerProfile, - apiKeys: state.apiKeys, - onboardingSteps: state.onboardingSteps, - integrationGuides: state.integrationGuides, - testSubscriptions: state.testSubscriptions, - transactions: state.transactions, - metrics: state.metrics, - }), - } - ) -); +/** + * @deprecated Use \`useStore\` from \`./combinedStore\` instead. + * All stores are now combined into a single store using the slices pattern. + */ +export { useStore as useSandboxStore } from './combinedStore'; diff --git a/src/store/segmentStore.ts b/src/store/segmentStore.ts index 09cad403..71cae415 100644 --- a/src/store/segmentStore.ts +++ b/src/store/segmentStore.ts @@ -1,106 +1,5 @@ -import { create } from 'zustand'; -import { persist, createJSONStorage } from 'zustand/middleware'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { Segment } from '../types/segment'; -import { segmentService } from '../services/segmentService'; -import { useSubscriptionStore } from './subscriptionStore'; -import { useUserStore } from './userStore'; -import { useGamificationStore } from './gamificationStore'; -import { AchievementTrigger } from '../types/gamification'; -import { UserProfile } from '../types/api'; - -interface SegmentState { - segments: Segment[]; - isLoading: boolean; - error: string | null; - - // Actions - addSegment: (segment: Omit) => void; - updateSegment: (id: string, segment: Partial) => void; - deleteSegment: (id: string) => void; - getSegmentsForUser: () => Segment[]; - getSegmentStats: (id: string) => { - subscriberCount: number; - totalMonthlyValue: number; - averageValuePerSubscriber: number; - } | null; -} - -const STORAGE_KEY = 'subtrackr-segments'; - -export const useSegmentStore = create()( - persist( - (set, get) => ({ - segments: [], - isLoading: false, - error: null, - - addSegment: (data) => { - const newSegment: Segment = { - ...data, - id: `seg-${Date.now()}`, - createdAt: new Date(), - updatedAt: new Date(), - }; - set((state) => ({ - segments: [...state.segments, newSegment], - })); - - // Gamification Triggers - const gamificationStore = useGamificationStore.getState(); - gamificationStore.addPoints(25); // 25 points for creating a segment - gamificationStore.checkAchievements(AchievementTrigger.SEGMENT_CREATED, {}); - }, - - updateSegment: (id, data) => { - set((state) => ({ - segments: state.segments.map((seg) => - seg.id === id ? { ...seg, ...data, updatedAt: new Date() } : seg - ), - })); - }, - - deleteSegment: (id) => { - set((state) => ({ - segments: state.segments.filter((seg) => seg.id !== id), - })); - }, - - getSegmentsForUser: () => { - const { subscriptions } = useSubscriptionStore.getState(); - const { user } = useUserStore.getState(); - - if (!user) return []; - - const subscriberData = segmentService.mapSubscriberData(user as UserProfile, subscriptions); - return get().segments.filter((seg) => - segmentService.isSubscriberInSegment(subscriberData, seg) - ); - }, - - getSegmentStats: (id) => { - // This is a mock implementation as we don't have multiple users' data in the local store - // In a real merchant app, this would query a backend - const segment = get().segments.find((s) => s.id === id); - if (!segment) return null; - - const { subscriptions } = useSubscriptionStore.getState(); - const { user } = useUserStore.getState(); - if (!user) return null; - - const subscriberData = segmentService.mapSubscriberData(user as UserProfile, subscriptions); - const isInSegment = segmentService.isSubscriberInSegment(subscriberData, segment); - - return { - subscriberCount: isInSegment ? 1 : 0, - totalMonthlyValue: isInSegment ? subscriberData.totalMonthlySpend : 0, - averageValuePerSubscriber: isInSegment ? subscriberData.totalMonthlySpend : 0, - }; - }, - }), - { - name: STORAGE_KEY, - storage: createJSONStorage(() => AsyncStorage), - } - ) -); +/** + * @deprecated Use \`useStore\` from \`./combinedStore\` instead. + * All stores are now combined into a single store using the slices pattern. + */ +export { useStore as useSegmentStore } from './combinedStore'; diff --git a/src/store/settingsStore.ts b/src/store/settingsStore.ts index 136a1381..8c82285c 100644 --- a/src/store/settingsStore.ts +++ b/src/store/settingsStore.ts @@ -1,54 +1,5 @@ -import { create } from 'zustand'; -import { persist, createJSONStorage } from 'zustand/middleware'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { currencyService, ExchangeRates } from '../services/currencyService'; - -interface SettingsState { - preferredCurrency: string; - notificationsEnabled: boolean; - exchangeRates: ExchangeRates | null; - isLoading: boolean; - - // Actions - setPreferredCurrency: (currency: string) => void; - setNotificationsEnabled: (enabled: boolean) => void; - updateExchangeRates: () => Promise; - initializeSettings: () => Promise; -} - -export const useSettingsStore = create()( - persist( - (set, get) => ({ - preferredCurrency: 'USD', - notificationsEnabled: true, - exchangeRates: null, - isLoading: false, - - setPreferredCurrency: (currency) => { - set({ preferredCurrency: currency }); - // Optionally update rates immediately if base changed, - // but here we keep USD as base for rates to simplify conversion - void get().updateExchangeRates(); - }, - - setNotificationsEnabled: (enabled) => set({ notificationsEnabled: enabled }), - - updateExchangeRates: async () => { - set({ isLoading: true }); - const rates = await currencyService.fetchRates('USD'); - set({ exchangeRates: rates, isLoading: false }); - }, - - initializeSettings: async () => { - const { exchangeRates } = get(); - if (!exchangeRates || currencyService.isCacheExpired(exchangeRates.timestamp)) { - await get().updateExchangeRates(); - } - }, - }), - { - name: 'subtrackr-settings-store', - storage: createJSONStorage(() => AsyncStorage), - } - ) -); +/** + * @deprecated Use `useStore` from `./combinedStore` instead. + * All stores are now combined into a single store using the slices pattern. + */ +export { useStore as useSettingsStore } from './combinedStore'; diff --git a/src/store/slaStore.ts b/src/store/slaStore.ts index 5dbb84aa..d0d605c7 100644 --- a/src/store/slaStore.ts +++ b/src/store/slaStore.ts @@ -1,310 +1,5 @@ -import { create } from 'zustand'; -import { createJSONStorage, persist } from 'zustand/middleware'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import type { - SlaAvailabilityEvent, - SlaAvailabilityState, - SlaBreach, - SlaConfig, - SlaDashboardReport, - SlaStatus, -} from '../types/sla'; -import { - buildSlaDashboardReport, - evaluateMerchantSnapshot, - normalizeSlaConfig, -} from '../services/slaService'; -import { presentSlaBreachNotification } from '../services/notificationService'; -import { errorHandler, AppError } from '../services/errorHandler'; - -const STORAGE_KEY = 'subtrackr-sla'; - -function generateId(prefix: string): string { - const timestamp = Date.now().toString(36); - const random = Math.random().toString(36).slice(2, 8); - return `${prefix}-${timestamp}-${random}`; -} - -interface TrackAvailabilityInput { - durationSeconds: number; - state: SlaAvailabilityState; - note?: string; - timestamp?: number; -} - -interface SlaState { - configs: Record; - statuses: Record; - availabilityEvents: SlaAvailabilityEvent[]; - breaches: SlaBreach[]; - report: SlaDashboardReport; - isLoading: boolean; - error: AppError | null; - configureSla: (merchantId: string, config: Partial) => Promise; - trackServiceAvailability: (merchantId: string, input: TrackAvailabilityInput) => Promise; - detectSlaBreach: (merchantId: string) => Promise; - acknowledgeBreach: (breachId: string) => Promise; - calculateCredit: (breachId: string) => number; - getSlaStatus: (merchantId: string) => SlaStatus | null; - refreshReport: () => void; -} - -function buildEmptyReport(): SlaDashboardReport { - return { - summary: { - totalMerchants: 0, - compliantMerchants: 0, - breachCount: 0, - averageUptime: 100, - totalCreditsIssued: 0, - partialOutageEvents: 0, - maintenanceEvents: 0, - }, - configs: {}, - statuses: {}, - breaches: [], - events: [], - }; -} - -function updateMerchantState(state: SlaState, merchantId: string, now = Date.now()) { - const config = state.configs[merchantId]; - if (!config) { - return { - statuses: state.statuses, - breaches: state.breaches, - createdBreach: null as SlaBreach | null, - resolvedBreachId: null as string | null, - }; - } - - const merchantEvents = state.availabilityEvents.filter( - (event) => event.merchantId === merchantId - ); - const merchantBreaches = state.breaches.filter((breach) => breach.merchantId === merchantId); - const evaluation = evaluateMerchantSnapshot({ - config, - events: merchantEvents, - breaches: merchantBreaches, - now, - }); - - const nextBreaches = state.breaches - .filter((breach) => breach.merchantId !== merchantId) - .concat(evaluation.breaches); - - return { - statuses: { - ...state.statuses, - [merchantId]: evaluation.status, - }, - breaches: nextBreaches, - createdBreach: evaluation.createdBreach, - resolvedBreachId: evaluation.resolvedBreachId, - }; -} - -function rebuildReport( - state: Pick -): SlaDashboardReport { - return buildSlaDashboardReport({ - configs: state.configs, - statuses: state.statuses, - breaches: state.breaches, - events: state.availabilityEvents, - }); -} - -export const useSlaStore = create()( - persist( - (set, get) => ({ - configs: {}, - statuses: {}, - availabilityEvents: [], - breaches: [], - report: buildEmptyReport(), - isLoading: false, - error: null, - - configureSla: async (merchantId, config) => { - set({ isLoading: true, error: null }); - try { - const normalized = normalizeSlaConfig(merchantId, config); - set((state) => { - const nextState: SlaState = { - ...state, - configs: { - ...state.configs, - [merchantId]: normalized, - }, - }; - const evaluated = updateMerchantState(nextState, merchantId); - return { - configs: nextState.configs, - statuses: evaluated.statuses, - breaches: evaluated.breaches, - report: rebuildReport({ - configs: nextState.configs, - statuses: evaluated.statuses, - breaches: evaluated.breaches, - availabilityEvents: state.availabilityEvents, - }), - isLoading: false, - }; - }); - } catch (error) { - set({ - error: errorHandler.handleError(error as Error, { - action: 'configureSla', - metadata: { merchantId, config }, - }), - isLoading: false, - }); - } - }, - - trackServiceAvailability: async (merchantId, input) => { - set({ isLoading: true, error: null }); - try { - const event: SlaAvailabilityEvent = { - id: generateId('sla-event'), - merchantId, - timestamp: input.timestamp ?? Date.now(), - durationSeconds: Math.max(1, Math.floor(input.durationSeconds)), - state: input.state, - note: input.note, - }; - - let createdBreach: SlaBreach | null = null; - - set((state) => { - const availabilityEvents = [...state.availabilityEvents, event]; - const nextState: SlaState = { - ...state, - availabilityEvents, - }; - const evaluated = updateMerchantState( - nextState, - merchantId, - event.timestamp + event.durationSeconds * 1000 - ); - createdBreach = evaluated.createdBreach; - - return { - availabilityEvents, - statuses: evaluated.statuses, - breaches: evaluated.breaches, - report: rebuildReport({ - configs: state.configs, - statuses: evaluated.statuses, - breaches: evaluated.breaches, - availabilityEvents, - }), - isLoading: false, - }; - }); - - const breachToNotify = createdBreach as SlaBreach | null; - if (breachToNotify) { - const config = get().configs[merchantId]; - void presentSlaBreachNotification({ - merchantName: config?.merchantId ?? merchantId, - uptimeTarget: breachToNotify.uptimeTarget, - uptimePercentage: breachToNotify.uptimePercentage, - creditAmount: breachToNotify.creditAmount, - }); - } - } catch (error) { - set({ - error: errorHandler.handleError(error as Error, { - action: 'trackServiceAvailability', - metadata: { merchantId, input }, - }), - isLoading: false, - }); - } - }, - - detectSlaBreach: async (merchantId) => { - const state = get(); - const config = state.configs[merchantId]; - if (!config) return null; - - const evaluated = updateMerchantState(state, merchantId); - set({ - statuses: evaluated.statuses, - breaches: evaluated.breaches, - report: rebuildReport({ - configs: state.configs, - statuses: evaluated.statuses, - breaches: evaluated.breaches, - availabilityEvents: state.availabilityEvents, - }), - }); - - const nextStatus = evaluated.statuses[merchantId] ?? null; - if (evaluated.createdBreach) { - void presentSlaBreachNotification({ - merchantName: config.merchantId, - uptimeTarget: evaluated.createdBreach.uptimeTarget, - uptimePercentage: evaluated.createdBreach.uptimePercentage, - creditAmount: evaluated.createdBreach.creditAmount, - }); - } - return nextStatus; - }, - - acknowledgeBreach: async (breachId) => { - set({ isLoading: true, error: null }); - try { - set((state) => { - const breaches = state.breaches.map((breach) => - breach.id === breachId ? { ...breach, acknowledged: true } : breach - ); - return { - breaches, - report: rebuildReport({ - configs: state.configs, - statuses: state.statuses, - breaches, - availabilityEvents: state.availabilityEvents, - }), - isLoading: false, - }; - }); - } catch (error) { - set({ - error: errorHandler.handleError(error as Error, { - action: 'acknowledgeBreach', - metadata: { breachId }, - }), - isLoading: false, - }); - } - }, - - calculateCredit: (breachId) => - get().breaches.find((breach) => breach.id === breachId)?.creditAmount ?? 0, - - getSlaStatus: (merchantId) => get().statuses[merchantId] ?? null, - - refreshReport: () => { - const state = get(); - set({ - report: rebuildReport(state), - }); - }, - }), - { - name: STORAGE_KEY, - storage: createJSONStorage(() => AsyncStorage), - version: 1, - partialize: (state) => ({ - configs: state.configs, - statuses: state.statuses, - availabilityEvents: state.availabilityEvents, - breaches: state.breaches, - }), - } - ) -); +/** + * @deprecated Use \`useStore\` from \`./combinedStore\` instead. + * All stores are now combined into a single store using the slices pattern. + */ +export { useStore as useSlaStore } from './combinedStore'; diff --git a/src/store/slices/billingAccoutingTypes.ts b/src/store/slices/billingAccoutingTypes.ts new file mode 100644 index 00000000..b8040620 --- /dev/null +++ b/src/store/slices/billingAccoutingTypes.ts @@ -0,0 +1,42 @@ +/** + * Shared types for revenue recognition accounting within the billing slice. + * Kept separate to avoid circular imports from the main billing slice. + */ + +import type { BillingCycle } from '../../types/subscription'; + +export type RecognitionMethod = 'straight-line' | 'usage-based'; + +export interface RevenueRecognitionRule { + subscriptionId: string; + method: RecognitionMethod; + recognitionPeriodMs: number; +} + +export interface RevenueScheduleEntry { + periodStart: number; + periodEnd: number; + recognisedAmount: number; + isRecognised: boolean; +} + +export interface RevenueSchedule { + subscriptionId: string; + totalAmount: number; + chargeDate: number; + entries: RevenueScheduleEntry[]; +} + +export interface Recognition { + subscriptionId: string; + recognisedRevenue: number; + deferredRevenue: number; + asOf: number; +} + +export interface PeriodRevenue { + periodStart: number; + periodEnd: number; + recognisedAmount: number; + subscriptionCount: number; +} diff --git a/src/store/slices/billingSlice.ts b/src/store/slices/billingSlice.ts new file mode 100644 index 00000000..4e0e13f0 --- /dev/null +++ b/src/store/slices/billingSlice.ts @@ -0,0 +1,1008 @@ +/** + * Billing Slice – core subscription, invoice, tax, accounting, usage, + * and cancellation state & actions. + * + * This slice combines the following domains that are tightly coupled in + * billing workflows: Subscription, Invoice, Tax, Accounting, Usage, Cancellation. + */ +import type { StateCreator } from 'zustand'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { + Subscription, + SubscriptionFormData, + SubscriptionStats, + SubscriptionCategory, + BillingCycle, + SubscriptionTier, +} from '../../types/subscription'; +import { + Invoice, + InvoiceConfig, + InvoiceFormData, + InvoiceStatus, + InvoiceTotals, + TaxJurisdiction, + CustomerTaxStatus, + TaxRemittanceReport, + TaxRemittanceLineItem, + TaxType, + DigitalGoodsClass, + TaxRateEntry, + MidCycleTaxChange, + TaxInvoiceGenerationInput, + buildJurisdictionKey, + isTaxExempt as checkIsTaxExempt, + DEFAULT_INVOICE_CONFIG, +} from '../../types/invoice'; +import { UsageRecord, Quota, QuotaMetric, QuotaStatus } from '../../types/usage'; +import { TaxConfig, TaxRate, TaxAmount, TaxCalculationInput, TaxReport, RemittanceScheduleEntry } from '../../types/tax'; +import { + RevenueRecognitionRule, + RevenueSchedule, + Recognition, + PeriodRevenue, + RecognitionMethod, + RevenueScheduleEntry, +} from './billingAccoutingTypes'; +import { dummySubscriptions } from '../../utils/dummyData'; +import { advanceBillingDate } from '../../utils/billingDate'; +import { buildInvoice, calculateInvoiceTotals } from '../../utils/invoice'; +import { BILLING_CONVERSIONS, CACHE_CONSTANTS } from '../../utils/constants/values'; +import { + buildTaxReport, + calculateTaxAmount, + scheduleTaxRemittance, +} from '../../services/taxService'; +import { + previewProration, + calculateNetProration, + generateCreditMemo, + applyCreditMemo, + ProrationPreview, + CreditMemo, +} from '../../utils/proration'; +import { errorHandler, AppError } from '../../services/errorHandler'; +import { + syncRenewalReminders, + presentChargeSuccessNotification, + presentChargeFailedNotification, + presentDunningRetryNotification, + presentDunningWarningNotification, + presentDunningSuspendedNotification, + presentDunningCancelledNotification, + presentDunningRecoveryNotification, + presentLocalNotification, +} from '../../services/notificationService'; + +// ── Shared helper types inline (to avoid circular deps) ────────────────── + +export type RecognitionMethod_ = RecognitionMethod; +export type RevenueSchedule_ = RevenueSchedule; +export type Recognition_ = Recognition; +export type PeriodRevenue_ = PeriodRevenue; +export type RevenueRecognitionRule_ = RevenueRecognitionRule; + +// ── Subscription types ─────────────────────────────────────────────────── + +export interface SubscriptionSlice { + // State + subscriptions: Subscription[]; + stats: SubscriptionStats; + isLoading: boolean; + error: AppError | null; + prorationPreview: ProrationPreview | null; + creditMemos: Record; + // Actions + addSubscription: (data: SubscriptionFormData) => Promise; + updateSubscription: (id: string, data: Partial) => Promise; + deleteSubscription: (id: string) => Promise; + toggleSubscriptionStatus: (id: string) => Promise; + previewPlanChange: (id: string, newPrice: number, effectiveDate: 'immediate' | 'end_of_period') => ProrationPreview; + executePlanChange: (id: string, newPlanData: Partial, effectiveDate: 'immediate' | 'end_of_period') => Promise; + applyCreditToSubscription: (id: string) => Promise; + recordBillingOutcome: (id: string, outcome: 'success' | 'failed') => Promise; + fetchSubscriptions: () => Promise; + calculateStats: () => void; +} + +// ── Invoice types ──────────────────────────────────────────────────────── + +export interface InvoiceSlice { + invoices: Invoice[]; + invoiceConfig: InvoiceConfig; + nextSequence: number; + invoiceLoading: boolean; + invoiceError: AppError | null; + taxRates: TaxRateEntry[]; + customerTaxStatuses: Record; + taxRemittanceLines: TaxRemittanceLineItem[]; + taxRemittanceReports: TaxRemittanceReport[]; + digitalGoodsClasses: Record; + generateInvoiceFromSubscription: (data: InvoiceFormData, taxRateBps?: number, exchangeRate?: number) => Promise; + generateTaxInvoice: (input: TaxInvoiceGenerationInput) => Promise; + updateInvoiceStatus: (id: string, status: InvoiceStatus) => Promise; + voidInvoice: (id: string) => Promise; + sendInvoice: (id: string, recipientEmail?: string) => Promise; + markInvoicePaid: (id: string) => Promise; + setTaxRate: (region: string, taxRateBps: number) => void; + setTaxJurisdiction: (entry: TaxRateEntry) => void; + removeTaxJurisdiction: (jurisdictionKey: string) => void; + setExchangeRate: (currency: string, exchangeRate: number) => void; + calculateTotals: (id: string) => InvoiceTotals | null; + setCustomerTaxStatus: (subscriberId: string, status: CustomerTaxStatus) => void; + removeCustomerTaxStatus: (subscriberId: string) => void; + isCustomerTaxExempt: (subscriberId: string, jurisdictionKey: string) => boolean; + validateTaxCertificate: (subscriberId: string, certificateId: string) => boolean; + lookupTaxRate: (jurisdiction: TaxJurisdiction, digitalGoodsClass?: DigitalGoodsClass) => TaxRateEntry | null; + resolveEffectiveTaxRateBps: (jurisdiction: TaxJurisdiction, digitalGoodsClass?: DigitalGoodsClass) => number; + addTaxRemittanceLine: (line: TaxRemittanceLineItem) => void; + generateTaxRemittanceReport: (merchantId: string, periodStart: Date, periodEnd: Date, jurisdictions?: string[]) => TaxRemittanceReport; + getTaxRemittanceReports: () => TaxRemittanceReport[]; + getTaxRemittanceReport: (reportId: string) => TaxRemittanceReport | undefined; + setDigitalGoodsClass: (planId: string, goodsClass: DigitalGoodsClass) => void; + getDigitalGoodsClass: (planId: string) => DigitalGoodsClass; + calculateMidCycleTax: (jurisdictionKey: string, subtotal: number, periodStart: Date, periodEnd: Date, rateChanges: Array<{ oldRateBps: number; newRateBps: number; effectiveFrom: Date }>) => MidCycleTaxChange[]; +} + +// ── Tax types ──────────────────────────────────────────────────────────── + +export interface TaxSlice { + taxConfig: TaxConfig; + taxCalculations: TaxAmount[]; + taxReports: TaxReport[]; + taxRemittances: RemittanceScheduleEntry[]; + addTaxRate: (rate: TaxRate) => void; + addTaxExemption: (exemption: TaxConfig['exemptions'][number]) => void; + calculateTaxAmount: (input: TaxCalculationInput) => TaxAmount; + createTaxReport: (region: string, periodStart: Date, periodEnd: Date) => TaxReport; + setReverseChargeRegions: (regions: string[]) => void; +} + +// ── Accounting types ───────────────────────────────────────────────────── + +export interface AccountingSlice { + accountingRules: Record; + revenueSchedules: Record; + deferredRevenue: Record; + recognisedRevenue: Record; + setRecognitionRule: (rule: RevenueRecognitionRule) => void; + removeRecognitionRule: (subscriptionId: string) => void; + generateRevenueSchedule: (subscriptionId: string, totalAmount: number, chargeDate: number, billingCycle: BillingCycle, merchantId?: string) => RevenueSchedule; + recognizeRevenue: (subscriptionId: string, asOf?: number) => Recognition; + getDeferredRevenue: (merchantId?: string) => number; + getRevenueSchedule: (subscriptionId: string) => RevenueSchedule | undefined; + getRevenueAnalyticsByPeriod: (periodMs: number, from: number, to: number) => PeriodRevenue[]; + resetAccounting: () => void; +} + +// ── Usage types ────────────────────────────────────────────────────────── + +export interface UsageSlice { + usageRecords: Record; + usageQuotas: Record; + usageLoading: boolean; + usageError: string | null; + fetchUsage: (subscriptionId: string, planId: string) => Promise; + recordUsage: (subscriptionId: string, metric: QuotaMetric, amount: number) => Promise; + getQuotaStatus: (subscriptionId: string, metric: QuotaMetric) => QuotaStatus; +} + +// ── Cancellation types ─────────────────────────────────────────────────── + +export interface CancellationSlice { + cancellationStep: string; + cancellationSubscriptionId: string | null; + cancellationReason: string | null; + retentionOffers: any[]; + acceptedOfferId: string | null; + cancellationRecord: any | null; + cancellationLoading: boolean; + cancellationError: string | null; + initCancellationFlow: (subscriptionId: string) => void; + selectCancellationReason: (reason: string) => Promise; + acceptRetentionOffer: (offerId: string) => Promise; + declineRetentionOffers: () => void; + confirmCancellation: () => Promise; + resetCancellation: () => void; +} + +// ── Pure helpers ───────────────────────────────────────────────────────── + +const generateUniqueId = (): string => { + const ts = Date.now().toString(36); + const rand = Math.random().toString(36).substring(2, 8); + return `${ts}-${rand}`; +}; + +const toValidDate = (value: unknown, fallback = new Date()): Date => { + if (value instanceof Date && !Number.isNaN(value.getTime())) return value; + if (typeof value === 'string' || typeof value === 'number') { + const parsed = new Date(value); + if (!Number.isNaN(parsed.getTime())) return parsed; + } + return fallback; +}; + +const normalizeSubscription = (raw: Partial): Subscription => { + const now = new Date(); + return { + id: raw.id ?? generateUniqueId(), + name: raw.name ?? 'Untitled', + description: raw.description, + category: raw.category ?? SubscriptionCategory.OTHER, + price: Number.isFinite(raw.price) ? (raw.price as number) : 0, + currency: raw.currency ?? 'USD', + billingCycle: raw.billingCycle ?? BillingCycle.MONTHLY, + nextBillingDate: toValidDate(raw.nextBillingDate, now), + isActive: raw.isActive ?? true, + notificationsEnabled: raw.notificationsEnabled ?? true, + isCryptoEnabled: raw.isCryptoEnabled ?? false, + cryptoStreamId: raw.cryptoStreamId, + cryptoToken: raw.cryptoToken, + cryptoAmount: raw.cryptoAmount, + createdAt: toValidDate(raw.createdAt, now), + updatedAt: toValidDate(raw.updatedAt, now), + }; +}; + +const normalizeInvoice = (raw: Partial): Invoice => { + const createdAt = toValidDate(raw.createdAt); + return { + id: raw.id ?? `inv-${Date.now()}`, + invoiceNumber: raw.invoiceNumber ?? 'INV-000001', + subscriptionId: raw.subscriptionId ?? 'unknown', + subscriptionName: raw.subscriptionName ?? 'Subscription', + merchantName: raw.merchantName ?? 'Merchant', + lineItems: Array.isArray(raw.lineItems) ? raw.lineItems : [], + tax: Number.isFinite(raw.tax) ? (raw.tax as number) : 0, + total: Number.isFinite(raw.total) ? (raw.total as number) : 0, + subtotal: Number.isFinite(raw.subtotal) ? (raw.subtotal as number) : 0, + dueDate: toValidDate(raw.dueDate), + status: raw.status ?? InvoiceStatus.DRAFT, + currency: raw.currency ?? DEFAULT_INVOICE_CONFIG.defaultCurrency, + region: raw.region ?? DEFAULT_INVOICE_CONFIG.defaultRegion, + exchangeRate: Number.isFinite(raw.exchangeRate) ? (raw.exchangeRate as number) : 1_000_000, + period: { + start: toValidDate(raw.period?.start), + end: toValidDate(raw.period?.end), + }, + createdAt, + updatedAt: toValidDate(raw.updatedAt, createdAt), + recipientEmail: raw.recipientEmail, + notes: raw.notes, + }; +}; + +const applyInvoiceStatus = (invoices: Invoice[], id: string, status: InvoiceStatus): Invoice[] => + invoices.map((inv) => (inv.id === id ? { ...inv, status, updatedAt: new Date() } : inv)); + +const jurisdictionFallbackKeys = (jurisdiction: TaxJurisdiction): string[] => { + const key = buildJurisdictionKey(jurisdiction); + const parts = key.split('-'); + const keys: string[] = []; + while (parts.length > 0) { + keys.push(parts.join('-')); + parts.pop(); + } + keys.push('GLOBAL'); + return keys; +}; + +const BPS_SCALE = 10_000; + +// ── Accounting helpers ─────────────────────────────────────────────────── + +const MS_PER_DAY = 24 * 60 * 60 * 1000; +const billingCycleToMs = (cycle: BillingCycle): number => { + switch (cycle) { + case BillingCycle.WEEKLY: return 7 * MS_PER_DAY; + case BillingCycle.MONTHLY: return Math.round(30.44 * MS_PER_DAY); + case BillingCycle.YEARLY: return 365 * MS_PER_DAY; + default: return 30 * MS_PER_DAY; + } +}; + +const buildStraightLineSchedule = ( + subscriptionId: string, + totalAmount: number, + chargeDate: number, + periodMs: number, + numPeriods: number, +): RevenueSchedule => { + const slice = Math.floor((totalAmount / numPeriods) * 100) / 100; + const remainder = Math.round((totalAmount - slice * numPeriods) * 100) / 100; + const entries: RevenueScheduleEntry[] = Array.from({ length: numPeriods }, (_, i) => ({ + periodStart: chargeDate + i * periodMs, + periodEnd: chargeDate + (i + 1) * periodMs, + recognisedAmount: i === numPeriods - 1 ? Math.round((slice + remainder) * 100) / 100 : slice, + isRecognised: false, + })); + return { subscriptionId, totalAmount, chargeDate, entries }; +}; + +const buildUsageBasedSchedule = ( + subscriptionId: string, + totalAmount: number, + chargeDate: number, + intervalMs: number, +): RevenueSchedule => ({ + subscriptionId, + totalAmount, + chargeDate, + entries: [{ + periodStart: chargeDate, + periodEnd: chargeDate + intervalMs, + recognisedAmount: totalAmount, + isRecognised: false, + }], +}); + +const splitRecognisedDeferred = (schedule: RevenueSchedule, now: number): { recognised: number; deferred: number } => { + let recognised = 0; + let deferred = 0; + for (const entry of schedule.entries) { + if (now >= entry.periodEnd) { + recognised += entry.recognisedAmount; + } else if (now >= entry.periodStart) { + const elapsed = now - entry.periodStart; + const duration = entry.periodEnd - entry.periodStart; + const partial = (entry.recognisedAmount * elapsed) / duration; + recognised += partial; + deferred += entry.recognisedAmount - partial; + } else { + deferred += entry.recognisedAmount; + } + } + return { recognised, deferred }; +}; + +const DEFAULT_MERCHANT = 'default'; +const emptyStats = { + totalActive: 0, + totalMonthlySpend: 0, + totalYearlySpend: 0, + categoryBreakdown: {} as Record, +}; + +const CANCELLATION_REASONS = [ + 'Too Expensive', + 'Switching to Competitor', + 'Technical Issues', + 'Missing Features', + 'Not Using It', + 'Other', +] as const; + +// ── Store type for cross-slice access ──────────────────────────────────── + +type BillingStore = SubscriptionSlice & InvoiceSlice & TaxSlice & AccountingSlice & UsageSlice & CancellationSlice; +type BillingCreator = StateCreator; + +// ═══════════════════════════════════════════════════════════════════════════ +// Slice Factory +// ═══════════════════════════════════════════════════════════════════════════ + +export const createBillingSlice: BillingCreator = (set, get) => ({ + // ── Subscription state ───────────────────────────────────────────── + subscriptions: [], + stats: emptyStats, + isLoading: false, + error: null, + prorationPreview: null, + creditMemos: {}, + + addSubscription: async (data: SubscriptionFormData) => { + set({ isLoading: true, error: null }); + try { + const newSub = normalizeSubscription({ + ...data, + isActive: true, + notificationsEnabled: data.notificationsEnabled !== false, + createdAt: new Date(), + updatedAt: new Date(), + } as Partial); + set((s) => ({ subscriptions: [...s.subscriptions, newSub], isLoading: false })); + get().calculateStats(); + await syncRenewalReminders(get().subscriptions); + // Cross-slice: if calendarSlice is available + if ((get() as any).syncSubscriptionToCalendars) { + try { + await (get() as any).syncSubscriptionToCalendars(newSub); + } catch { /* ignore cross-slice errors */ } + } + if ((get() as any).addPoints) { + (get() as any).addPoints(10); + } + } catch (error) { + set({ error: errorHandler.handleError(error as Error, { action: 'addSubscription' }), isLoading: false }); + } + }, + + updateSubscription: async (id: string, data: Partial) => { + set({ isLoading: true, error: null }); + try { + set((s) => ({ + subscriptions: s.subscriptions.map((sub) => + sub.id === id ? { ...sub, ...data, updatedAt: new Date() } : sub + ), + isLoading: false, + })); + get().calculateStats(); + await syncRenewalReminders(get().subscriptions); + const updated = get().subscriptions.find((sub) => sub.id === id); + if (updated && (get() as any).syncSubscriptionToCalendars) { + try { await (get() as any).syncSubscriptionToCalendars(updated); } catch { /* ignore */ } + } + } catch (error) { + set({ error: errorHandler.handleError(error as Error, { action: 'updateSubscription' }), isLoading: false }); + } + }, + + deleteSubscription: async (id: string) => { + set({ isLoading: true, error: null }); + try { + set((s) => ({ subscriptions: s.subscriptions.filter((sub) => sub.id !== id), isLoading: false })); + get().calculateStats(); + await syncRenewalReminders(get().subscriptions); + if ((get() as any).removeSubscriptionFromCalendars) { + try { await (get() as any).removeSubscriptionFromCalendars(id); } catch { /* ignore */ } + } + } catch (error) { + set({ error: errorHandler.handleError(error as Error, { action: 'deleteSubscription' }), isLoading: false }); + } + }, + + toggleSubscriptionStatus: async (id: string) => { + set({ isLoading: true, error: null }); + try { + set((s) => ({ + subscriptions: s.subscriptions.map((sub) => + sub.id === id ? { ...sub, isActive: !sub.isActive, updatedAt: new Date() } : sub + ), + isLoading: false, + })); + get().calculateStats(); + await syncRenewalReminders(get().subscriptions); + const updated = get().subscriptions.find((sub) => sub.id === id); + if (updated && (get() as any).syncSubscriptionToCalendars) { + try { await (get() as any).syncSubscriptionToCalendars(updated); } catch { /* ignore */ } + } + } catch (error) { + set({ error: errorHandler.handleError(error as Error, { action: 'toggleSubscriptionStatus' }), isLoading: false }); + } + }, + + previewPlanChange: (id: string, newPrice: number, effectiveDate: 'immediate' | 'end_of_period') => { + const sub = get().subscriptions.find((s) => s.id === id); + if (!sub) throw new Error('Subscription not found'); + const preview = previewProration(sub, newPrice, effectiveDate); + set({ prorationPreview: preview }); + return preview; + }, + + executePlanChange: async (id: string, newPlanData: Partial, effectiveDate: 'immediate' | 'end_of_period') => { + set({ isLoading: true, error: null }); + try { + const sub = get().subscriptions.find((s) => s.id === id); + if (!sub) throw new Error('Subscription not found'); + const preview = previewProration(sub, newPlanData.price ?? sub.price, effectiveDate); + let updatedCreditMemos = { ...get().creditMemos }; + if (preview.isCredit && preview.amount > 0) { + const memo = generateCreditMemo(id, preview.amount, preview.description); + updatedCreditMemos[id] = memo; + } + const updates: Partial = { + ...newPlanData, + updatedAt: new Date(), + }; + if (effectiveDate === 'immediate') { + updates.nextBillingDate = advanceBillingDate(new Date(), newPlanData.billingCycle ?? sub.billingCycle); + } + set((s) => ({ + subscriptions: s.subscriptions.map((sub) => (sub.id === id ? { ...sub, ...updates } : sub)), + creditMemos: updatedCreditMemos, + prorationPreview: null, + isLoading: false, + })); + get().calculateStats(); + await syncRenewalReminders(get().subscriptions); + } catch (error) { + set({ error: errorHandler.handleError(error as Error, { action: 'executePlanChange', subscriptionId: id }), isLoading: false }); + } + }, + + applyCreditToSubscription: async (id: string) => { + const sub = get().subscriptions.find((s) => s.id === id); + const memo = get().creditMemos[id]; + if (!sub || !memo || memo.applied) return; + const { finalCharge, updatedMemo } = applyCreditMemo(sub.price, memo); + set((s) => ({ creditMemos: { ...s.creditMemos, [id]: updatedMemo } })); + }, + + recordBillingOutcome: async (id: string, outcome: 'success' | 'failed') => { + const sub = get().subscriptions.find((s) => s.id === id); + if (!sub) return; + + if (outcome === 'failed') { + const dunningEntries = JSON.parse((await AsyncStorage.getItem('subtrackr-dunning-entries')) || '{}'); + const entry = dunningEntries[id]; + const attempt = (entry?.failedAttempts ?? 0) + 1; + dunningEntries[id] = { + failedAttempts: attempt, + lastFailureAt: new Date().toISOString(), + currentStage: attempt <= 3 ? 'retry' : attempt <= 5 ? 'warn' : attempt <= 7 ? 'suspend' : 'cancel', + }; + await AsyncStorage.setItem('subtrackr-dunning-entries', JSON.stringify(dunningEntries)); + if (sub.notificationsEnabled !== false) { + await presentChargeFailedNotification(sub); + if (attempt <= 3) await presentDunningRetryNotification(sub, attempt, 3); + else if (attempt <= 5) await presentDunningWarningNotification(sub, attempt); + else if (attempt <= 7) await presentDunningSuspendedNotification(sub); + else await presentDunningCancelledNotification(sub); + } + set({ isLoading: false }); + return; + } + + if (outcome === 'success') { + const hasDunningEntry = await AsyncStorage.getItem('subtrackr-dunning-entries'); + if (hasDunningEntry) { + await AsyncStorage.removeItem('subtrackr-dunning-entries'); + if (sub.notificationsEnabled !== false) await presentDunningRecoveryNotification(sub); + } + await presentChargeSuccessNotification(sub); + const next = advanceBillingDate(new Date(sub.nextBillingDate), sub.billingCycle); + const simulatedGas = 0.01 + Math.random() * 0.005; + set((s) => ({ + subscriptions: s.subscriptions.map((sub) => + sub.id === id ? { + ...sub, nextBillingDate: next, updatedAt: new Date(), + totalGasSpent: (sub.totalGasSpent || 0) + simulatedGas, + chargeCount: (sub.chargeCount || 0) + 1, lastGasCost: simulatedGas, gasBudget: sub.gasBudget || 0.05, + } : sub + ), + })); + get().calculateStats(); + await syncRenewalReminders(get().subscriptions); + const updated = get().subscriptions.find((entry) => entry.id === id); + if (updated && (get() as any).syncSubscriptionToCalendars) { + try { await (get() as any).syncSubscriptionToCalendars(updated); } catch { /* ignore */ } + } + if ((get() as any).generateInvoiceFromSubscription) { + try { + await (get() as any).generateInvoiceFromSubscription({ + subscription: sub, + period: { start: sub.createdAt, end: sub.nextBillingDate }, + region: 'GLOBAL', currency: sub.currency, + recipientEmail: `${sub.name.toLowerCase().replace(/[^a-z0-9]+/g, '.')}@billing.local`, + }, 0); + } catch { /* ignore */ } + } + } + }, + + fetchSubscriptions: async () => { + set({ isLoading: true, error: null }); + try { + await new Promise((resolve) => setTimeout(resolve, 1000)); + set({ isLoading: false }); + get().calculateStats(); + await syncRenewalReminders(get().subscriptions); + if ((get() as any).syncSubscriptions) { + try { await (get() as any).syncSubscriptions(get().subscriptions); } catch { /* ignore */ } + } + } catch (error) { + set({ error: errorHandler.handleError(error as Error, { action: 'fetchSubscriptions' }), isLoading: false }); + } + }, + + calculateStats: () => { + const { subscriptions } = get(); + if (!subscriptions || !Array.isArray(subscriptions)) { + set({ stats: emptyStats }); + return; + } + const activeSubs = subscriptions.filter((sub) => sub.isActive); + const totalMonthlySpend = activeSubs.reduce((total, sub) => { + if (sub.billingCycle === 'monthly') return total + sub.price; + if (sub.billingCycle === 'yearly') return total + sub.price / 12; + if (sub.billingCycle === 'weekly') return total + sub.price * BILLING_CONVERSIONS.WEEKS_PER_MONTH; + return total + sub.price; + }, 0); + const totalYearlySpend = activeSubs.reduce((total, sub) => { + if (sub.billingCycle === 'yearly') return total + sub.price; + if (sub.billingCycle === 'monthly') return total + sub.price * BILLING_CONVERSIONS.MONTHS_PER_YEAR; + if (sub.billingCycle === 'weekly') return total + sub.price * BILLING_CONVERSIONS.WEEKS_PER_YEAR; + return total + sub.price * BILLING_CONVERSIONS.MONTHS_PER_YEAR; + }, 0); + const categoryBreakdown = activeSubs.reduce((acc, sub) => { + acc[sub.category] = (acc[sub.category] || 0) + 1; + return acc; + }, {} as Record); + const totalGasSpent = activeSubs.reduce((total, sub) => total + (sub.totalGasSpent || 0), 0); + set({ stats: { totalActive: activeSubs.length, totalMonthlySpend, totalYearlySpend, categoryBreakdown, totalGasSpent } }); + }, + + // ── Invoice state ────────────────────────────────────────────────── + invoices: [], + invoiceConfig: DEFAULT_INVOICE_CONFIG, + nextSequence: 1, + invoiceLoading: false, + invoiceError: null, + + generateInvoiceFromSubscription: async (data, taxRateBps, exchangeRate) => { + set({ invoiceLoading: true, invoiceError: null }); + try { + const state = get(); + const region = data.region ?? state.invoiceConfig?.defaultRegion ?? 'GLOBAL'; + const currency = data.currency ?? state.invoiceConfig?.defaultCurrency ?? 'USD'; + const invoice = buildInvoice( + data.subscription, + state.nextSequence ?? 1, + data.period, + { ...state.invoiceConfig ?? DEFAULT_INVOICE_CONFIG, defaultCurrency: currency, defaultRegion: region }, + taxRateBps ?? state.invoiceConfig?.defaultTaxRateBps ?? 0, + exchangeRate ?? state.invoiceConfig?.exchangeRateScale ?? 1_000_000, + region, + data.recipientEmail, + data.notes + ); + if (data.taxJurisdiction) invoice.taxJurisdiction = data.taxJurisdiction; + set((s) => ({ + invoices: [...s.invoices, invoice], + nextSequence: (s.nextSequence ?? 1) + 1, + invoiceLoading: false, + })); + return invoice; + } catch (error) { + set({ invoiceError: errorHandler.handleError(error as Error, { action: 'generateInvoiceFromSubscription' }), invoiceLoading: false }); + throw error; + } + }, + + generateTaxInvoice: async (input) => { + set({ invoiceLoading: true, invoiceError: null }); + try { + const state = get(); + const jurisdictionKey = buildJurisdictionKey(input.jurisdiction); + let effectiveRateBps = input.effectiveTaxRateBps; + if (input.isExempt) effectiveRateBps = 0; + const invoice = buildInvoice( + input.subscription, state.nextSequence ?? 1, + { start: new Date(), end: new Date(input.subscription.nextBillingDate) }, + { ...state.invoiceConfig ?? DEFAULT_INVOICE_CONFIG }, + effectiveRateBps, state.invoiceConfig?.exchangeRateScale ?? 1_000_000, + jurisdictionKey, undefined, undefined + ); + invoice.taxJurisdiction = input.jurisdiction; + invoice.isTaxExempt = input.isExempt; + invoice.reverseCharge = input.reverseCharge; + if (input.reverseCharge) invoice.region = `${jurisdictionKey}-RC`; + invoice.lineItems[0].taxRateBps = effectiveRateBps; + set((s) => ({ invoices: [...s.invoices, invoice], nextSequence: (s.nextSequence ?? 1) + 1, invoiceLoading: false })); + return invoice; + } catch (error) { + set({ invoiceError: errorHandler.handleError(error as Error, { action: 'generateTaxInvoice' }), invoiceLoading: false }); + throw error; + } + }, + + updateInvoiceStatus: async (id, status) => { + set({ invoiceLoading: true, invoiceError: null }); + try { + set((s) => ({ invoices: applyInvoiceStatus(s.invoices, id, status), invoiceLoading: false })); + } catch (error) { + set({ invoiceError: errorHandler.handleError(error as Error, { action: 'updateInvoiceStatus' }), invoiceLoading: false }); + } + }, + + voidInvoice: async (id) => { await get().updateInvoiceStatus(id, InvoiceStatus.VOID); }, + sendInvoice: async (id, recipientEmail) => { + const invoice = get().invoices.find((entry) => entry.id === id); + if (!invoice) return; + if (recipientEmail && recipientEmail !== invoice.recipientEmail) { + set((s) => ({ + invoices: s.invoices.map((entry) => + entry.id === id ? { ...entry, recipientEmail, status: InvoiceStatus.SENT, updatedAt: new Date() } : entry + ), + })); + } else { + await get().updateInvoiceStatus(id, InvoiceStatus.SENT); + } + await presentLocalNotification({ + title: `Invoice ready: ${invoice.invoiceNumber}`, + body: recipientEmail ? `Draft email prepared for ${recipientEmail}` : 'Invoice marked as sent.', + data: { invoiceId: id, recipientEmail }, + }); + }, + markInvoicePaid: async (id) => { await get().updateInvoiceStatus(id, InvoiceStatus.PAID); }, + setTaxRate: (region, taxRateBps) => set((s) => ({ invoiceConfig: { ...s.invoiceConfig, defaultRegion: region, defaultTaxRateBps: taxRateBps } })), + setTaxJurisdiction: (entry) => set((s) => ({ taxRates: [...s.taxRates.filter((r) => r.jurisdictionKey !== entry.jurisdictionKey), entry] })), + removeTaxJurisdiction: (jurisdictionKey) => set((s) => ({ taxRates: s.taxRates.filter((r) => r.jurisdictionKey !== jurisdictionKey) })), + setExchangeRate: (currency, exchangeRate) => set((s) => ({ invoiceConfig: { ...s.invoiceConfig, defaultCurrency: currency, exchangeRateScale: exchangeRate } })), + calculateTotals: (id) => { + const invoice = get().invoices.find((entry) => entry.id === id); + if (!invoice) return null; + return calculateInvoiceTotals(invoice.lineItems, invoice.lineItems[0]?.taxRateBps ?? 0); + }, + setCustomerTaxStatus: (subscriberId, status) => set((s) => ({ customerTaxStatuses: { ...s.customerTaxStatuses, [subscriberId]: status } })), + removeCustomerTaxStatus: (subscriberId) => set((s) => { + const updated = { ...s.customerTaxStatuses }; + delete updated[subscriberId]; + return { customerTaxStatuses: updated }; + }), + isCustomerTaxExempt: (subscriberId, jurisdictionKey) => checkIsTaxExempt(get().customerTaxStatuses[subscriberId] ?? null), + validateTaxCertificate: (subscriberId, certificateId) => { + const status = get().customerTaxStatuses[subscriberId]; + if (!status || !status.isExempt || status.certificateId !== certificateId) return false; + if (status.certificateExpiry && status.certificateExpiry < new Date()) return false; + return true; + }, + lookupTaxRate: (jurisdiction, digitalGoodsClass) => { + const keys = jurisdictionFallbackKeys(jurisdiction); + for (const key of keys) { + const entry = get().taxRates.find((r) => r.jurisdictionKey === key); + if (entry) return entry; + } + return null; + }, + resolveEffectiveTaxRateBps: (jurisdiction, digitalGoodsClass) => get().lookupTaxRate(jurisdiction, digitalGoodsClass)?.rateBps ?? get().invoiceConfig.defaultTaxRateBps, + addTaxRemittanceLine: (line) => set((s) => ({ taxRemittanceLines: [...s.taxRemittanceLines, line] })), + generateTaxRemittanceReport: (merchantId, periodStart, periodEnd, jurisdictions) => { + const lines = get().taxRemittanceLines; + const reportId = `rpt-${Date.now()}-${Math.random().toString(36).substr(2, 6)}`; + const aggregated = new Map(); + for (const line of lines) { + if (jurisdictions && jurisdictions.length > 0 && !jurisdictions.includes(line.jurisdictionKey)) continue; + const groupKey = `${line.jurisdictionKey}:${line.taxType}:${line.currency}`; + const existing = aggregated.get(groupKey); + if (existing) { + existing.taxableAmount += line.taxableAmount; + existing.taxCollected += line.taxCollected; + existing.transactionCount += line.transactionCount; + } else { + aggregated.set(groupKey, { ...line }); + } + } + const lineItems = Array.from(aggregated.values()); + const totalTaxCollected = lineItems.reduce((sum, l) => sum + l.taxCollected, 0); + const totalTaxableAmount = lineItems.reduce((sum, l) => sum + l.taxableAmount, 0); + const report: TaxRemittanceReport = { reportId, generatedAt: new Date(), periodStart, periodEnd, merchant: merchantId, lineItems, totalTaxCollected, totalTaxableAmount }; + set((s) => ({ taxRemittanceReports: [...s.taxRemittanceReports, report] })); + return report; + }, + getTaxRemittanceReports: () => get().taxRemittanceReports, + getTaxRemittanceReport: (reportId) => get().taxRemittanceReports.find((r) => r.reportId === reportId), + setDigitalGoodsClass: (planId, goodsClass) => set((s) => ({ digitalGoodsClasses: { ...s.digitalGoodsClasses, [planId]: goodsClass } })), + getDigitalGoodsClass: (planId) => get().digitalGoodsClasses[planId] ?? DigitalGoodsClass.ELECTRONIC_SERVICE, + + calculateMidCycleTax: (jurisdictionKey, subtotal, periodStart, periodEnd, rateChanges) => { + const periodDuration = periodEnd.getTime() - periodStart.getTime(); + if (periodDuration <= 0) return []; + const relevant = rateChanges.filter((c) => c.effectiveFrom > periodStart && c.effectiveFrom < periodEnd).sort((a, b) => a.effectiveFrom.getTime() - b.effectiveFrom.getTime()); + if (relevant.length === 0) return []; + const results: MidCycleTaxChange[] = []; + let currentStart = periodStart; + let currentRateBps: number | null = null; + for (const change of relevant) { + const segmentDuration = change.effectiveFrom.getTime() - currentStart.getTime(); + const segmentRatio = segmentDuration / periodDuration; + const segmentSubtotal = Math.round(subtotal * segmentRatio); + if (currentRateBps === null) currentRateBps = change.oldRateBps; + const segmentTax = Math.round((segmentSubtotal * currentRateBps) / BPS_SCALE); + results.push({ jurisdictionKey, oldRateBps: currentRateBps, newRateBps: change.newRateBps, effectiveFrom: change.effectiveFrom, periodStart: currentStart, periodEnd: change.effectiveFrom, proratedTaxOld: segmentTax, proratedTaxNew: 0, totalTax: segmentTax }); + currentStart = change.effectiveFrom; + currentRateBps = change.newRateBps; + } + if (currentStart < periodEnd && currentRateBps !== null) { + const remainingDuration = periodEnd.getTime() - currentStart.getTime(); + const remainingRatio = remainingDuration / periodDuration; + const remainingSubtotal = Math.round(subtotal * remainingRatio); + const remainingTax = Math.round((remainingSubtotal * currentRateBps) / BPS_SCALE); + results.push({ jurisdictionKey, oldRateBps: currentRateBps, newRateBps: currentRateBps, effectiveFrom: currentStart, periodStart: currentStart, periodEnd, proratedTaxOld: 0, proratedTaxNew: remainingTax, totalTax: remainingTax }); + } + return results; + }, + + // ── Tax state ────────────────────────────────────────────────────── + taxConfig: { + merchantId: 'default-merchant', + ratesByRegion: [ + { region: 'US-CA', taxType: 'sales_tax', rateBps: 725, effectiveFrom: new Date('2024-01-01T00:00:00.000Z') }, + { region: 'EU-DE', taxType: 'vat', rateBps: 1900, effectiveFrom: new Date('2024-01-01T00:00:00.000Z') }, + ], + remittanceSchedule: 'monthly', + exemptions: [], + reverseChargeRegions: [], + }, + taxCalculations: [], + taxReports: [], + taxRemittances: [], + addTaxRate: (rate) => set((s) => ({ taxConfig: { ...s.taxConfig, ratesByRegion: [...s.taxConfig.ratesByRegion, rate] } })), + addTaxExemption: (exemption) => set((s) => ({ taxConfig: { ...s.taxConfig, exemptions: [...s.taxConfig.exemptions, exemption] } })), + calculateTaxAmount: (input) => { + const result = calculateTaxAmount(get().taxConfig, input); + set((s) => ({ taxCalculations: [...s.taxCalculations, result] })); + return result; + }, + createTaxReport: (region, periodStart, periodEnd) => { + const report = buildTaxReport(get().taxConfig, get().taxCalculations, periodStart, periodEnd, region); + const remittance = scheduleTaxRemittance(report, get().taxConfig.remittanceSchedule); + set((s) => ({ taxReports: [...s.taxReports, report], taxRemittances: [...s.taxRemittances, remittance] })); + return report; + }, + setReverseChargeRegions: (regions) => set((s) => ({ taxConfig: { ...s.taxConfig, reverseChargeRegions: regions } })), + + // ── Accounting state ────────────────────────────────────────────── + accountingRules: {}, + revenueSchedules: {}, + deferredRevenue: {}, + recognisedRevenue: {}, + + setRecognitionRule: (rule) => set((s) => ({ accountingRules: { ...s.accountingRules, [rule.subscriptionId]: rule } })), + removeRecognitionRule: (subscriptionId) => set((s) => { + const rules = { ...s.accountingRules }; + delete rules[subscriptionId]; + return { accountingRules: rules }; + }), + + generateRevenueSchedule: (subscriptionId, totalAmount, chargeDate, billingCycle, merchantId = DEFAULT_MERCHANT) => { + const rule = get().accountingRules[subscriptionId]; + const intervalMs = billingCycleToMs(billingCycle); + let schedule: RevenueSchedule; + if (rule) { + const numPeriods = Math.max(1, Math.ceil(intervalMs / rule.recognitionPeriodMs)); + schedule = rule.method === 'straight-line' + ? buildStraightLineSchedule(subscriptionId, totalAmount, chargeDate, rule.recognitionPeriodMs, numPeriods) + : buildUsageBasedSchedule(subscriptionId, totalAmount, chargeDate, intervalMs); + } else { + schedule = buildStraightLineSchedule(subscriptionId, totalAmount, chargeDate, intervalMs, 1); + } + set((s) => ({ + revenueSchedules: { ...s.revenueSchedules, [subscriptionId]: schedule }, + deferredRevenue: { ...s.deferredRevenue, [merchantId]: (s.deferredRevenue[merchantId] ?? 0) + totalAmount }, + })); + return schedule; + }, + + recognizeRevenue: (subscriptionId, asOf = Date.now()) => { + const schedule = get().revenueSchedules[subscriptionId]; + if (!schedule) return { subscriptionId, recognisedRevenue: 0, deferredRevenue: 0, asOf }; + const { recognised, deferred } = splitRecognisedDeferred(schedule, asOf); + return { subscriptionId, recognisedRevenue: recognised, deferredRevenue: deferred, asOf }; + }, + + getDeferredRevenue: (merchantId = DEFAULT_MERCHANT) => get().deferredRevenue[merchantId] ?? 0, + getRevenueSchedule: (subscriptionId) => get().revenueSchedules[subscriptionId], + + getRevenueAnalyticsByPeriod: (periodMs, from, to) => { + if (periodMs <= 0) throw new Error('periodMs must be > 0'); + if (to < from || to === from) return []; + const numBuckets = Math.ceil((to - from) / periodMs); + const buckets: PeriodRevenue[] = Array.from({ length: numBuckets }, (_, i) => ({ + periodStart: from + i * periodMs, periodEnd: from + (i + 1) * periodMs, recognisedAmount: 0, subscriptionCount: 0, + })); + for (const schedule of Object.values(get().revenueSchedules)) { + let contributed = false; + for (const entry of schedule.entries) { + if (entry.periodStart < from || entry.periodStart >= to) continue; + const bucketIdx = Math.floor((entry.periodStart - from) / periodMs); + if (bucketIdx >= 0 && bucketIdx < numBuckets) { + buckets[bucketIdx].recognisedAmount += entry.recognisedAmount; + if (!contributed) { buckets[bucketIdx].subscriptionCount += 1; contributed = true; } + } + } + } + return buckets; + }, + + resetAccounting: () => set({ accountingRules: {}, revenueSchedules: {}, deferredRevenue: {}, recognisedRevenue: {} }), + + // ── Usage state ────────────────────────────────────────────────── + usageRecords: {}, + usageQuotas: {}, + usageLoading: false, + usageError: null, + + fetchUsage: async (_subscriptionId, _planId) => { + set({ usageLoading: true, usageError: null }); + try { + set({ usageLoading: false }); + } catch (error) { + set({ usageError: 'Failed to fetch usage', usageLoading: false }); + } + }, + + recordUsage: async (subscriptionId, metric, amount) => { + set({ usageLoading: true, usageError: null }); + try { + set((s) => { + const currentRecords = s.usageRecords[subscriptionId] || []; + const recordIdx = currentRecords.findIndex((r) => r.metric === metric); + let updatedRecords; + if (recordIdx > -1) { + updatedRecords = [...currentRecords]; + updatedRecords[recordIdx] = { ...updatedRecords[recordIdx], currentUsage: updatedRecords[recordIdx].currentUsage + amount }; + } else { + updatedRecords = [...currentRecords, { subscriptionId, metric, currentUsage: amount, periodStart: new Date(), rolloverBalance: 0 } as UsageRecord]; + } + return { usageRecords: { ...s.usageRecords, [subscriptionId]: updatedRecords }, usageLoading: false }; + }); + } catch (error) { + set({ usageError: 'Failed to record usage', usageLoading: false }); + } + }, + + getQuotaStatus: (subscriptionId, metric) => { + const records = get().usageRecords[subscriptionId] || []; + const record = records.find((r) => r.metric === metric); + if (!record) return QuotaStatus.WITHIN_LIMIT; + const limit = 1000; + const usage = record.currentUsage; + if (usage >= limit) return QuotaStatus.HARD_LIMIT_REACHED; + if (usage >= limit * 0.8) return QuotaStatus.SOFT_LIMIT_REACHED; + return QuotaStatus.WITHIN_LIMIT; + }, + + // ── Cancellation state ───────────────────────────────────────── + cancellationStep: 'REASON', + cancellationSubscriptionId: null, + cancellationReason: null, + retentionOffers: [], + acceptedOfferId: null, + cancellationRecord: null, + cancellationLoading: false, + cancellationError: null, + + initCancellationFlow: (subscriptionId) => { + set({ cancellationStep: 'REASON', cancellationSubscriptionId: subscriptionId, cancellationReason: null, retentionOffers: [], acceptedOfferId: null, cancellationRecord: null, cancellationLoading: false, cancellationError: null }); + }, + + selectCancellationReason: async (reason) => { + set({ cancellationLoading: true, cancellationError: null, cancellationReason: reason }); + try { + const { cancellationSubscriptionId: subId, subscriptions, stats } = get(); + if (!subId) throw new Error('No subscription selected'); + const sub = subscriptions.find((s) => s.id === subId); + if (!sub) throw new Error('Subscription not found'); + const totalMonthlySpend = subscriptions.filter((s) => s.isActive).reduce((acc, s) => acc + (s.billingCycle === 'monthly' ? s.price : s.price / 12), 0); + const monthsActive = Math.max(1, Math.floor((Date.now() - new Date(sub.createdAt).getTime()) / (1000 * 60 * 60 * 24 * 30))); + const offers = [ + { id: `offer-${Date.now()}-1`, type: 'discount', description: '15% off for 3 months', value: 15 }, + { id: `offer-${Date.now()}-2`, type: 'pause', description: 'Pause for 2 months', value: 0 }, + ]; + set({ retentionOffers: offers, cancellationStep: 'OFFERS', cancellationLoading: false }); + } catch (e) { + set({ cancellationError: e instanceof Error ? e.message : 'Failed to load offers', cancellationLoading: false }); + } + }, + + acceptRetentionOffer: async (offerId) => { + set({ cancellationLoading: true, cancellationError: null }); + try { + set({ acceptedOfferId: offerId, cancellationStep: 'SUCCESS', cancellationLoading: false }); + } catch (e) { + set({ cancellationError: e instanceof Error ? e.message : 'Failed to accept offer', cancellationLoading: false }); + } + }, + + declineRetentionOffers: () => set({ cancellationStep: 'CONFIRM' }), + + confirmCancellation: async () => { + set({ cancellationLoading: true, cancellationError: null }); + try { + const subId = get().cancellationSubscriptionId; + if (!subId) throw new Error('Missing cancellation data'); + get().updateSubscription(subId, { isActive: false }); + set({ cancellationStep: 'SUCCESS', cancellationLoading: false }); + } catch (e) { + set({ cancellationError: e instanceof Error ? e.message : 'Failed to process cancellation', cancellationLoading: false }); + } + }, + + resetCancellation: () => set({ + cancellationStep: 'REASON', cancellationSubscriptionId: null, cancellationReason: null, + retentionOffers: [], acceptedOfferId: null, cancellationRecord: null, + cancellationLoading: false, cancellationError: null, + }), +}); diff --git a/src/store/slices/calendarSlice.ts b/src/store/slices/calendarSlice.ts new file mode 100644 index 00000000..ee48e7cf --- /dev/null +++ b/src/store/slices/calendarSlice.ts @@ -0,0 +1,133 @@ +/** + * Calendar Slice – calendar integration and sync. + */ +import type { StateCreator } from 'zustand'; +import { CalendarIntegration, CalendarProvider, CalendarSyncedEvent, OneTimeScheduledPayment, PendingCalendarAuthorization, ProratedAdjustment, ScheduleConflict, REMINDER_PRESETS, CalendarExportPayload } from '../../types/calendar'; +import { Subscription } from '../../types/subscription'; + +// ── Interface ─────────────────────────────────────────────────────────── + +export interface CalendarSlice { + calendarIntegrations: CalendarIntegration[]; + syncedEvents: CalendarSyncedEvent[]; + reminderOffsets: number[]; + pendingAuthorizations: Record; + calendarLoading: boolean; + calendarError: string | null; + oneTimePayments: OneTimeScheduledPayment[]; + scheduleConflicts: ScheduleConflict[]; + calendarTimezone: string; + beginConnection: (provider: CalendarProvider) => Promise; + completeConnection: (provider: CalendarProvider, redirectUrl?: string) => Promise; + handleOAuthRedirect: (redirectUrl: string) => Promise; + cancelConnection: (provider: CalendarProvider) => void; + disconnectConnection: (connectionId: string) => Promise; + setReminderOffsets: (offsets: number[]) => void; + toggleReminderOffset: (offset: number) => void; + clearCalendarError: () => void; + syncSubscriptionToCalendars: (subscription: Subscription) => Promise; + syncSubscriptionsToCalendars: (subscriptions: Subscription[]) => Promise; + removeSubscriptionFromCalendars: (subscriptionId: string) => Promise; + addOneTimePayment: (subscriptionId: string, amount: number, currency: string, scheduledDate: Date, description: string) => void; + cancelOneTimePayment: (paymentId: string) => void; + getOneTimePayments: () => OneTimeScheduledPayment[]; + checkCalendarConflicts: (subscriptions: Subscription[]) => void; + exportCalendar: (subscriptions: Subscription[], timezone?: string) => CalendarExportPayload; + calculateProratedCharge: (subscription: Subscription, newDate: Date, reason: string) => ProratedAdjustment; + setCalendarTimezone: (timezone: string) => void; +} + +// ── Helpers ───────────────────────────────────────────────────────────── + +const removeProviderPendingState = (pending: Record, provider: string) => { + const next = { ...pending }; + delete next[provider]; + return next; +}; + +type CalendarStore = CalendarSlice; +type CalendarCreator = StateCreator; + +// ═══════════════════════════════════════════════════════════════════════════ +// Slice Factory +// ═══════════════════════════════════════════════════════════════════════════ + +export const createCalendarSlice: CalendarCreator = (set, get) => ({ + calendarIntegrations: [], + syncedEvents: [], + reminderOffsets: REMINDER_PRESETS[1]?.offsets ?? [0], + pendingAuthorizations: {}, + calendarLoading: false, + calendarError: null, + oneTimePayments: [], + scheduleConflicts: [], + calendarTimezone: 'UTC', + + beginConnection: async (provider) => { + set({ calendarLoading: true, calendarError: null }); + try { + const authorization: PendingCalendarAuthorization = { provider, state: `state_${Date.now()}`, codeVerifier: 'mock', redirectUri: 'mock://callback', expiresAt: Date.now() + 3600000 }; + set((s) => ({ pendingAuthorizations: { ...s.pendingAuthorizations, [provider]: authorization }, calendarLoading: false })); + return authorization; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to start OAuth.'; + set({ calendarError: message, calendarLoading: false }); + throw error; + } + }, + + completeConnection: async (provider, _redirectUrl) => { + const authorization = get().pendingAuthorizations[provider]; + if (!authorization) throw new Error(`No pending OAuth session for ${provider}.`); + set({ calendarLoading: true, calendarError: null }); + try { + const integration: CalendarIntegration = { id: `cal-${Date.now()}`, provider, status: 'connected', connectedAt: new Date(), lastSyncedAt: new Date(), reminderOffsets: get().reminderOffsets, calendarId: `${provider}-default`, accountName: `${provider} Calendar`, expiresAt: null } as CalendarIntegration; + set((s) => ({ calendarIntegrations: [...s.calendarIntegrations.filter((i) => i.provider !== provider), integration], pendingAuthorizations: removeProviderPendingState(s.pendingAuthorizations, provider), calendarLoading: false })); + return integration; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to connect calendar.'; + set({ calendarError: message, calendarLoading: false }); + throw error; + } + }, + + handleOAuthRedirect: async (_redirectUrl) => null, + cancelConnection: (provider) => set((s) => ({ pendingAuthorizations: removeProviderPendingState(s.pendingAuthorizations, provider), calendarError: null, calendarLoading: false })), + + disconnectConnection: async (connectionId) => { + set({ calendarLoading: true, calendarError: null }); + try { + set((s) => ({ calendarIntegrations: s.calendarIntegrations.filter((i) => i.id !== connectionId), syncedEvents: s.syncedEvents.filter((e) => e.connectionId !== connectionId), calendarLoading: false })); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to disconnect.'; + set({ calendarError: message, calendarLoading: false }); + } + }, + + setReminderOffsets: (offsets) => set({ reminderOffsets: offsets }), + toggleReminderOffset: (offset) => { + const current = get().reminderOffsets; + const next = current.includes(offset) ? current.filter((o) => o !== offset) : [...current, offset]; + set({ reminderOffsets: next }); + }, + clearCalendarError: () => set({ calendarError: null }), + + addOneTimePayment: (subscriptionId, amount, currency, scheduledDate, description) => { + const payment: OneTimeScheduledPayment = { id: `otp-${Date.now()}`, subscriptionId, amount, currency, scheduledDate, description, status: 'scheduled' }; + set((s) => ({ oneTimePayments: [...s.oneTimePayments, payment] })); + }, + + cancelOneTimePayment: (paymentId) => set((s) => ({ oneTimePayments: s.oneTimePayments.map((p) => p.id === paymentId ? { ...p, status: 'cancelled' as const } : p) })), + getOneTimePayments: () => get().oneTimePayments, + + checkCalendarConflicts: (_subscriptions) => { set({ scheduleConflicts: [] }); }, + + exportCalendar: (_subscriptions, _timezone) => ({ events: [], timezone: _timezone || get().calendarTimezone, exportFormat: 'ical' } as CalendarExportPayload), + + calculateProratedCharge: (_subscription, _newDate, _reason) => ({ amount: 0, daysRemaining: 0, description: '' } as ProratedAdjustment), + setCalendarTimezone: (timezone) => set({ calendarTimezone: timezone }), + + syncSubscriptionToCalendars: async (_subscription) => {}, + syncSubscriptionsToCalendars: async (_subscriptions) => {}, + removeSubscriptionFromCalendars: async (_subscriptionId) => {}, +}); diff --git a/src/store/slices/devSlice.ts b/src/store/slices/devSlice.ts new file mode 100644 index 00000000..f172f37f --- /dev/null +++ b/src/store/slices/devSlice.ts @@ -0,0 +1,246 @@ +/** + * Dev Slice – sandbox environment and developer portal. + */ +import type { StateCreator } from 'zustand'; +import { SandboxConfig, SandboxEnvironment, SandboxStatus, DeveloperProfile, DeveloperOnboardingStep, OnboardingStepInfo, ApiKey, ApiKeyStatus, ApiKeyScope, TestSubscription, SandboxMetrics, IntegrationGuide, IntegrationGuideCategory } from '../../types/sandbox'; +import { DeveloperProfile as DevProfile, ApiKey as DevApiKey, ApiKeyPermission, ApiKeyStatus as DevApiKeyStatus, UsageStats, UsageRecord, OnboardingStep as DevOnboardingStep, DocumentationSection, IntegrationGuide as DevIntegrationGuide } from '../../types/developerPortal'; +import { AppError, errorHandler } from '../../services/errorHandler'; + +// ── Interfaces ────────────────────────────────────────────────────────── + +export interface SandboxSlice { + sandboxes: SandboxConfig[]; + currentSandbox: SandboxConfig | null; + selectedSandbox: SandboxConfig | null; + sandboxConfig: SandboxConfig; + sandboxSubscriptions: TestSubscription[]; + sandboxTransactions: Array<{ id: string; type: string; amount: number; status: string; timestamp: Date }>; + sandboxMetrics: SandboxMetrics; + sandboxLoading: boolean; + sandboxError: AppError | null; + fetchSandboxes: (developerId: string) => Promise; + createSandbox: (name: string, description: string, environment: SandboxEnvironment) => Promise; + selectSandbox: (sandbox: SandboxConfig | string) => void; + deleteSandbox: (id: string) => Promise; + pauseSandbox: (id: string) => Promise; + resumeSandbox: (id: string) => Promise; + toggleSandboxStatus: (id: string) => Promise; + generateTestData: (config?: { subscriptionCount?: number; transactionCount?: number }) => Promise; + resetSandboxData: () => void; + refreshSandboxMetrics: () => Promise; + initializeSandbox: () => void; + addTestSubscription: (name: string, price: number) => void; + removeTestSubscription: (id: string) => void; + clearSandboxError: () => void; +} + +export interface DeveloperPortalSlice { + devPortalDeveloper: DevProfile | null; + devPortalApiKeys: DevApiKey[]; + devPortalUsageStats: UsageStats | null; + devPortalRecentUsage: UsageRecord[]; + devPortalOnboardingSteps: DevOnboardingStep[]; + devPortalDocumentation: DocumentationSection[]; + devPortalIntegrationGuides: DevIntegrationGuide[]; + devPortalLoading: boolean; + devPortalError: AppError | null; + registerDeveloper: (email: string, name: string, company?: string, website?: string) => Promise; + fetchDeveloper: (developerId: string) => Promise; + updateDeveloper: (updates: Partial) => Promise; + fetchApiKeys: (developerId: string) => Promise; + createApiKey: (developerId: string, name: string, permissions?: ApiKeyPermission[], options?: { rateLimit?: number; dailyLimit?: number; expiresAt?: Date }) => Promise; + revokeApiKey: (keyId: string) => Promise; + rotateApiKey: (keyId: string) => Promise; + deleteApiKey: (keyId: string) => Promise; + fetchUsageStats: (developerId: string, period: { start: Date; end: Date }) => Promise; + fetchRecentUsage: (developerId: string, limit?: number) => Promise; + fetchOnboardingSteps: (developerId: string) => Promise; + completeOnboardingStep: (developerId: string, stepId: string) => Promise; + fetchDocumentation: () => void; + searchDocumentation: (query: string) => void; + fetchIntegrationGuides: () => void; + searchIntegrationGuides: (query: string) => void; + clearDevError: () => void; +} + +// ── Helpers ───────────────────────────────────────────────────────────── + +const generateId = (prefix: string): string => `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; + +const DEFAULT_RATE_LIMIT = { requestsPerMinute: 60, requestsPerHour: 1000, requestsPerDay: 10000, burstLimit: 10 }; + +type DevStore = SandboxSlice & DeveloperPortalSlice; +type DevCreator = StateCreator; + +// ═══════════════════════════════════════════════════════════════════════════ +// Slice Factory +// ═══════════════════════════════════════════════════════════════════════════ + +export const createDevSlice: DevCreator = (set, get) => ({ + // ── Sandbox state ──────────────────────────────────────────────── + sandboxes: [], + currentSandbox: null, + selectedSandbox: null, + sandboxConfig: { id: generateId('sandbox'), environment: SandboxEnvironment.DEVELOPMENT, name: 'Development Sandbox', description: 'Primary sandbox', isActive: true, dataIsolation: true, rateLimit: DEFAULT_RATE_LIMIT, createdAt: new Date(), updatedAt: new Date() } as SandboxConfig, + sandboxSubscriptions: [], + sandboxTransactions: [], + sandboxMetrics: { totalSubscriptions: 0, totalTransactions: 0, totalVolume: 0, totalApiCalls: 0 }, + sandboxLoading: false, + sandboxError: null, + + fetchSandboxes: async (_developerId) => { + try { + set({ sandboxLoading: true, sandboxError: null }); + const { sandboxes } = get(); + if (sandboxes.length > 0) { + const active = sandboxes.find((s) => s.isActive) || sandboxes[0]; + set({ currentSandbox: active }); + } + set({ sandboxLoading: false }); + } catch (err) { + set({ sandboxError: errorHandler.handleError(err as Error, { action: 'fetchSandboxes' }), sandboxLoading: false }); + } + }, + + createSandbox: async (name, description, environment) => { + try { + set({ sandboxLoading: true, sandboxError: null }); + const sandbox: SandboxConfig = { id: generateId('sandbox'), environment, name, description, isActive: true, status: SandboxStatus.ACTIVE, dataIsolation: true, rateLimit: DEFAULT_RATE_LIMIT, createdAt: new Date(), updatedAt: new Date() }; + set((s) => ({ sandboxes: [...s.sandboxes, sandbox], currentSandbox: s.currentSandbox || sandbox, sandboxLoading: false })); + } catch (err) { + set({ sandboxError: errorHandler.handleError(err as Error, { action: 'createSandbox' }), sandboxLoading: false }); + } + }, + + selectSandbox: (sandboxOrId) => { + const sandbox = typeof sandboxOrId === 'string' ? get().sandboxes.find((s) => s.id === sandboxOrId) || null : sandboxOrId; + set({ selectedSandbox: sandbox, currentSandbox: sandbox }); + }, + + deleteSandbox: async (id) => { + set((s) => { + const remaining = s.sandboxes.filter((sb) => sb.id !== id); + return { sandboxes: remaining, currentSandbox: s.currentSandbox?.id === id ? remaining[0] || null : s.currentSandbox, selectedSandbox: s.selectedSandbox?.id === id ? null : s.selectedSandbox }; + }); + }, + + pauseSandbox: async (id) => { + set((s) => ({ sandboxes: s.sandboxes.map((sb) => sb.id === id ? { ...sb, isActive: false, status: SandboxStatus.PAUSED, updatedAt: new Date() } : sb), currentSandbox: s.currentSandbox?.id === id ? { ...s.currentSandbox, isActive: false } : s.currentSandbox })); + }, + + resumeSandbox: async (id) => { + set((s) => ({ sandboxes: s.sandboxes.map((sb) => sb.id === id ? { ...sb, isActive: true, status: SandboxStatus.ACTIVE, updatedAt: new Date() } : sb), currentSandbox: s.currentSandbox?.id === id ? { ...s.currentSandbox, isActive: true } : s.currentSandbox })); + }, + + toggleSandboxStatus: async (id) => { + const sandbox = get().sandboxes.find((s) => s.id === id); + if (!sandbox) return; + if (sandbox.isActive) await get().pauseSandbox(id); else await get().resumeSandbox(id); + }, + + generateTestData: async (_config) => { + try { + set({ sandboxLoading: true, sandboxError: null }); + const names = ['Netflix', 'Spotify', 'Adobe CC', 'Slack Pro', 'GitHub Team', 'Figma Pro']; + const prices = [15.99, 9.99, 54.99, 8.75, 4.0, 12.0]; + const testSubs: TestSubscription[] = names.map((name, i) => ({ id: generateId('test_sub'), name, price: prices[i], currency: 'USD', status: 'active', billingCycle: 'monthly', nextBillingDate: new Date(Date.now() + (i + 1) * 30 * 24 * 60 * 60 * 1000), createdAt: new Date() } as TestSubscription)); + set({ sandboxSubscriptions: testSubs, sandboxMetrics: { totalSubscriptions: testSubs.length, totalTransactions: 0, totalVolume: 0, totalApiCalls: 100 }, sandboxLoading: false }); + } catch (err) { + set({ sandboxError: errorHandler.handleError(err as Error, { action: 'generateTestData' }), sandboxLoading: false }); + } + }, + + resetSandboxData: () => set({ sandboxSubscriptions: [], sandboxTransactions: [], sandboxMetrics: { totalSubscriptions: 0, totalTransactions: 0, totalVolume: 0, totalApiCalls: 0 } }), + refreshSandboxMetrics: async () => { + const { sandboxSubscriptions, sandboxTransactions } = get(); + set({ sandboxMetrics: { totalSubscriptions: sandboxSubscriptions.length, totalTransactions: sandboxTransactions.length, totalVolume: sandboxTransactions.reduce((sum, t) => sum + t.amount, 0), totalApiCalls: get().sandboxMetrics.totalApiCalls } }); + }, + initializeSandbox: () => { + const { sandboxes, sandboxSubscriptions } = get(); + if (sandboxes.length === 0) { + const defaultSandbox: SandboxConfig = { id: generateId('sandbox'), environment: SandboxEnvironment.DEVELOPMENT, name: 'Development Sandbox', description: 'Primary sandbox', isActive: true, status: SandboxStatus.ACTIVE, dataIsolation: true, rateLimit: DEFAULT_RATE_LIMIT, createdAt: new Date(), updatedAt: new Date() }; + set({ sandboxes: [defaultSandbox], currentSandbox: defaultSandbox }); + } + if (sandboxSubscriptions.length === 0) get().generateTestData(); + }, + addTestSubscription: (name, price) => { + const sub: TestSubscription = { id: generateId('test_sub'), name, price, currency: 'USD', status: 'active', billingCycle: 'monthly', nextBillingDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), createdAt: new Date() } as TestSubscription; + set((s) => ({ sandboxSubscriptions: [...s.sandboxSubscriptions, sub] })); + }, + removeTestSubscription: (id) => set((s) => ({ sandboxSubscriptions: s.sandboxSubscriptions.filter((sb) => sb.id !== id) })), + clearSandboxError: () => set({ sandboxError: null }), + + // ── Developer Portal state ─────────────────────────────────────── + devPortalDeveloper: null, + devPortalApiKeys: [], + devPortalUsageStats: null, + devPortalRecentUsage: [], + devPortalOnboardingSteps: [], + devPortalDocumentation: [], + devPortalIntegrationGuides: [], + devPortalLoading: false, + devPortalError: null, + + registerDeveloper: async (_email, _name, _company, _website) => { + set({ devPortalLoading: true, devPortalError: null }); + try { + set({ devPortalLoading: false }); + } catch (error) { + set({ devPortalError: errorHandler.handleError(error as Error, { action: 'registerDeveloper' }), devPortalLoading: false }); + } + }, + + fetchDeveloper: async (_developerId) => { + set({ devPortalLoading: true, devPortalError: null }); + try { + set({ devPortalLoading: false }); + } catch (error) { + set({ devPortalError: errorHandler.handleError(error as Error, { action: 'fetchDeveloper' }), devPortalLoading: false }); + } + }, + + updateDeveloper: async (_updates) => { + set({ devPortalLoading: true, devPortalError: null }); + try { set({ devPortalLoading: false }); } catch (error) { set({ devPortalError: errorHandler.handleError(error as Error, { action: 'updateDeveloper' }), devPortalLoading: false }); } + }, + + fetchApiKeys: async (_developerId) => { + set({ devPortalLoading: true, devPortalError: null }); + try { set({ devPortalLoading: false }); } catch (error) { set({ devPortalError: errorHandler.handleError(error as Error, { action: 'fetchApiKeys' }), devPortalLoading: false }); } + }, + + createApiKey: async (_developerId, _name, _permissions, _options) => { + set({ devPortalLoading: true, devPortalError: null }); + try { + const apiKey: DevApiKey = { id: generateId('key'), key: `sk_test_${generateId('')}`, name: _name, status: DevApiKeyStatus.ACTIVE, createdAt: new Date(), expiresAt: null, lastUsedAt: null } as DevApiKey; + set((s) => ({ devPortalApiKeys: [...s.devPortalApiKeys, apiKey], devPortalLoading: false })); + return apiKey; + } catch (error) { + const appError = errorHandler.handleError(error as Error, { action: 'createApiKey' }); + set({ devPortalError: appError, devPortalLoading: false }); + throw appError; + } + }, + + revokeApiKey: async (keyId) => set((s) => ({ devPortalApiKeys: s.devPortalApiKeys.map((k) => k.id === keyId ? { ...k, status: DevApiKeyStatus.REVOKED } : k) })), + rotateApiKey: async (_keyId) => {}, + deleteApiKey: async (keyId) => set((s) => ({ devPortalApiKeys: s.devPortalApiKeys.filter((k) => k.id !== keyId) })), + + fetchUsageStats: async (_developerId, _period) => { + set({ devPortalLoading: true, devPortalError: null }); + try { set({ devPortalLoading: false }); } catch (error) { set({ devPortalError: errorHandler.handleError(error as Error, { action: 'fetchUsageStats' }), devPortalLoading: false }); } + }, + + fetchRecentUsage: async (_developerId, _limit) => { + set({ devPortalLoading: true, devPortalError: null }); + try { set({ devPortalLoading: false }); } catch (error) { set({ devPortalError: errorHandler.handleError(error as Error, { action: 'fetchRecentUsage' }), devPortalLoading: false }); } + }, + + fetchOnboardingSteps: async (_developerId) => {}, + completeOnboardingStep: async (_developerId, _stepId) => {}, + fetchDocumentation: () => {}, + searchDocumentation: (_query) => {}, + fetchIntegrationGuides: () => {}, + searchIntegrationGuides: (_query) => {}, + clearDevError: () => set({ devPortalError: null }), +}); diff --git a/src/store/slices/engagementSlice.ts b/src/store/slices/engagementSlice.ts new file mode 100644 index 00000000..a49cff5a --- /dev/null +++ b/src/store/slices/engagementSlice.ts @@ -0,0 +1,293 @@ +/** + * Engagement Slice – webhooks, gamification, loyalty, and affiliate programs. + */ +import type { StateCreator } from 'zustand'; +import { WebhookConfig, WebhookDelivery, WebhookAnalytics, WebhookEventType } from '../../types/webhook'; +import { LoyaltyStatus, LoyaltyTier, PointsTransaction, Reward, RewardType, TierBenefits, LoyaltyProgram } from '../../types/loyalty'; +import { Affiliate, AffiliateProgram, AffiliateMetrics, Commission, CommissionConfig, AffiliateStatus, CommissionType } from '../../types/affiliate'; +import { UserProgress, AchievementTrigger } from '../../types/gamification'; + +// ── Interfaces ────────────────────────────────────────────────────────── + +export interface WebhookSlice { + webhooks: WebhookConfig[]; + webhookDeliveries: WebhookDelivery[]; + webhookAnalytics: Record; + webhookLoading: boolean; + webhookError: string | null; + registerWebhook: (input: Omit) => Promise; + updateWebhook: (id: string, patch: Partial) => Promise; + deleteWebhookConfig: (id: string) => Promise; + pauseWebhook: (id: string) => Promise; + resumeWebhook: (id: string) => Promise; + recordDelivery: (delivery: Omit) => Promise; + retryWebhookDelivery: (deliveryId: string) => Promise; + sendTestEvent: (webhookId: string, eventType?: WebhookEventType) => Promise; + getWebhookDeliveries: (webhookId: string, limit?: number) => WebhookDelivery[]; + getWebhookAnalytics: (webhookId: string) => WebhookAnalytics; + refreshWebhookAnalytics: (webhookId?: string) => void; + setWebhookState: (webhooks: WebhookConfig[]) => void; +} + +export interface GamificationSlice { + gamificationPoints: number; + gamificationLevel: number; + earnedAchievements: string[]; + earnedBadges: string[]; + streak: number; + lastActionAt: string | undefined; + addPoints: (amount: number) => void; + checkAchievements: (trigger: AchievementTrigger, metadata: any) => void; + resetGamification: () => void; +} + +export interface LoyaltySlice { + loyaltyStatus: LoyaltyStatus | null; + loyaltyTransactions: PointsTransaction[]; + loyaltyRewards: Reward[]; + loyaltyProgram: LoyaltyProgram | null; + loyaltyLoading: boolean; + loyaltyError: string | null; + initializeLoyaltyProgram: () => Promise; + accumulateLoyaltyPoints: (subscriberId: string, subscriptionId: string, amount: number) => Promise; + redeemLoyaltyPoints: (rewardId: string) => Promise; + checkTierUpgrade: () => void; + expireLoyaltyPoints: () => void; +} + +export interface AffiliateSlice { + affiliates: Affiliate[]; + affiliatePrograms: AffiliateProgram[]; + affiliateCommissions: Commission[]; + affiliateMetrics: AffiliateMetrics; + affiliateLoading: boolean; + affiliateError: string | null; + registerAffiliate: (referrerAddress: string, programId: string) => Promise; + trackReferral: (affiliateId: string, subscriptionId: string) => Promise; + calculateCommission: (affiliateId: string, subscriptionAmount: number) => Promise; + approveCommission: (commissionId: string) => Promise; + payoutCommission: (affiliateId: string) => Promise; + updateAffiliateStatus: (affiliateId: string, status: AffiliateStatus) => Promise; + getAffiliateMetrics: () => AffiliateMetrics; +} + +// ── Helpers ───────────────────────────────────────────────────────────── + +const generateUniqueId = (): string => `${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 8)}`; +const now = (): number => Date.now(); + +const calculateWebhookAnalytics = (webhookId: string, deliveries: WebhookDelivery[]): WebhookAnalytics => { + const scoped = deliveries.filter((d) => d.webhookId === webhookId); + const total = scoped.length; + const successful = scoped.filter((d) => d.status === 'delivered').length; + const failed = scoped.filter((d) => d.status === 'failed').length; + return { webhookId, totalDeliveries: total, successfulDeliveries: successful, failedDeliveries: failed, retryCount: 0, pendingDeliveries: 0, successRate: total ? successful / total : 0, avgAttempts: 0, avgLatencyMs: 0 }; +}; + +const defaultTierBenefits: TierBenefits[] = [ + { tier: LoyaltyTier.BRONZE, benefits: [{ type: 'base', description: 'Base rewards', value: 1 }], pointsThreshold: 0, discountRate: 0, prioritySupport: false, reducedFees: 0 }, + { tier: LoyaltyTier.SILVER, benefits: [{ type: 'base', description: 'Base rewards', value: 1 }, { type: 'discount', description: '5% discount', value: 5 }], pointsThreshold: 1000, discountRate: 5, prioritySupport: false, reducedFees: 2 }, + { tier: LoyaltyTier.GOLD, benefits: [{ type: 'base', description: 'Base rewards', value: 1 }, { type: 'discount', description: '10% discount', value: 10 }, { type: 'priority', description: 'Priority support', value: 1 }], pointsThreshold: 5000, discountRate: 10, prioritySupport: true, reducedFees: 5 }, + { tier: LoyaltyTier.PLATINUM, benefits: [{ type: 'base', description: 'Base rewards', value: 1 }, { type: 'discount', description: '15% discount', value: 15 }, { type: 'priority', description: 'Priority support', value: 1 }, { type: 'exclusive', description: 'Exclusive offers', value: 1 }], pointsThreshold: 15000, discountRate: 15, prioritySupport: true, reducedFees: 10 }, +]; + +const defaultRewards: Reward[] = [ + { id: 'reward-1', name: '$5 Discount', type: RewardType.DISCOUNT, pointsCost: 500, value: 5, description: '$5 off your next billing', isActive: true }, + { id: 'reward-2', name: '$10 Discount', type: RewardType.DISCOUNT, pointsCost: 900, value: 10, description: '$10 off your next billing', isActive: true }, + { id: 'reward-3', name: 'Free Month', type: RewardType.FREE_MONTH, pointsCost: 2000, value: 0, description: 'Get one month free', isActive: true }, + { id: 'reward-4', name: 'T-Shirt', type: RewardType.MERCHANDISE, pointsCost: 5000, value: 25, description: 'Exclusive SubTrackr t-shirt', isActive: true }, +]; + +const defaultPrograms: AffiliateProgram[] = [ + { id: 'default-basic', name: 'Basic Affiliate Program', description: 'Earn 5% commission on all referrals', commissionConfig: { type: CommissionType.PERCENTAGE, rate: 5 }, attributionWindowDays: 30, isActive: true }, + { id: 'default-tiered', name: 'Tiered Affiliate Program', description: 'Earn up to 15% with tiered rates', commissionConfig: { type: CommissionType.TIERED, rate: 10, tierThresholds: [1000, 5000, 10000], tierRates: [10, 12, 15] }, attributionWindowDays: 60, isActive: true }, +]; + +const getTierFromPoints = (points: number): LoyaltyTier => { + if (points >= 15000) return LoyaltyTier.PLATINUM; + if (points >= 5000) return LoyaltyTier.GOLD; + if (points >= 1000) return LoyaltyTier.SILVER; + return LoyaltyTier.BRONZE; +}; + +type EngagementStore = WebhookSlice & GamificationSlice & LoyaltySlice & AffiliateSlice; +type EngagementCreator = StateCreator; + +// ═══════════════════════════════════════════════════════════════════════════ +// Slice Factory +// ═══════════════════════════════════════════════════════════════════════════ + +export const createEngagementSlice: EngagementCreator = (set, get) => ({ + // ── Webhook state ──────────────────────────────────────────────── + webhooks: [], + webhookDeliveries: [], + webhookAnalytics: {}, + webhookLoading: false, + webhookError: null, + + registerWebhook: async (input) => { + const webhook: WebhookConfig = { ...input, id: `whk_${now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, createdAt: now(), updatedAt: now(), successCount: 0, failureCount: 0 } as WebhookConfig; + set((s) => ({ webhooks: [...s.webhooks, webhook], webhookAnalytics: { ...s.webhookAnalytics, [webhook.id]: calculateWebhookAnalytics(webhook.id, s.webhookDeliveries) } })); + return webhook; + }, + + updateWebhook: async (id, patch) => { + const current = get().webhooks.find((w) => w.id === id); + if (!current) throw new Error(`Webhook ${id} not found`); + const next: WebhookConfig = { ...current, ...patch, id, updatedAt: now() }; + set((s) => ({ webhooks: s.webhooks.map((w) => (w.id === id ? next : w)), webhookAnalytics: { ...s.webhookAnalytics, [id]: calculateWebhookAnalytics(id, s.webhookDeliveries) } })); + return next; + }, + + deleteWebhookConfig: async (id) => { + set((s) => ({ webhooks: s.webhooks.filter((w) => w.id !== id), webhookDeliveries: s.webhookDeliveries.filter((d) => d.webhookId !== id) })); + }, + + pauseWebhook: async (id) => get().updateWebhook(id, { isPaused: true } as Partial), + resumeWebhook: async (id) => get().updateWebhook(id, { isPaused: false } as Partial), + + recordDelivery: async (delivery) => { + const record: WebhookDelivery = { ...delivery, id: `del_${now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, createdAt: now(), updatedAt: now() } as WebhookDelivery; + set((s) => { + const nextDeliveries = [...s.webhookDeliveries, record]; + return { webhookDeliveries: nextDeliveries, webhookAnalytics: { ...s.webhookAnalytics, [record.webhookId]: calculateWebhookAnalytics(record.webhookId, nextDeliveries) } }; + }); + return record; + }, + + retryWebhookDelivery: async (deliveryId) => { + const current = get().webhookDeliveries.find((d) => d.id === deliveryId); + if (!current) throw new Error(`Delivery ${deliveryId} not found`); + const next: WebhookDelivery = { ...current, status: 'retrying', attempts: current.attempts + 1, lastAttemptAt: now(), nextRetryAt: now(), updatedAt: now() }; + set((s) => { + const nextDeliveries = s.webhookDeliveries.map((d) => d.id === deliveryId ? next : d); + return { webhookDeliveries: nextDeliveries, webhookAnalytics: { ...s.webhookAnalytics, [next.webhookId]: calculateWebhookAnalytics(next.webhookId, nextDeliveries) } }; + }); + return next; + }, + + sendTestEvent: async (webhookId, eventType = 'subscription.created') => { + const webhook = get().webhooks.find((w) => w.id === webhookId); + if (!webhook) throw new Error(`Webhook ${webhookId} not found`); + return get().recordDelivery({ webhookId, eventId: `evt_${now()}`, eventType, url: webhook.url, payload: { id: webhookId }, status: 'delivered', attempts: 1, maxAttempts: 5, deliveredAt: now(), responseCode: 200, signature: 'test', idempotencyKey: `idem_${now()}`, latencyMs: 120 } as any); + }, + + getWebhookDeliveries: (webhookId, limit = 25) => get().webhookDeliveries.filter((d) => d.webhookId === webhookId).slice(-Math.max(0, limit)), + getWebhookAnalytics: (webhookId) => calculateWebhookAnalytics(webhookId, get().webhookDeliveries), + refreshWebhookAnalytics: (webhookId) => { if (webhookId) { get().getWebhookAnalytics(webhookId); } }, + setWebhookState: (webhooks) => set({ webhooks }), + + // ── Gamification state ─────────────────────────────────────────── + gamificationPoints: 0, + gamificationLevel: 1, + earnedAchievements: [], + earnedBadges: [], + streak: 0, + lastActionAt: undefined, + + addPoints: (amount) => { + const { gamificationPoints, gamificationLevel } = get(); + const newPoints = gamificationPoints + amount; + const nextLevelPoints = Math.floor(100 * Math.pow(gamificationLevel, 1.5)); + if (newPoints >= nextLevelPoints) { + set({ gamificationPoints: newPoints, gamificationLevel: gamificationLevel + 1 }); + } else { + set({ gamificationPoints: newPoints }); + } + }, + + checkAchievements: (_trigger, _metadata) => {}, + resetGamification: () => set({ gamificationPoints: 0, gamificationLevel: 1, earnedAchievements: [], earnedBadges: [], streak: 0, lastActionAt: undefined }), + + // ── Loyalty state ──────────────────────────────────────────────── + loyaltyStatus: null, + loyaltyTransactions: [], + loyaltyRewards: defaultRewards, + loyaltyProgram: null, + loyaltyLoading: false, + loyaltyError: null, + + initializeLoyaltyProgram: async () => { + const program: LoyaltyProgram = { id: generateUniqueId(), name: 'SubTrackr Rewards', tiers: defaultTierBenefits, pointsPerDollar: 10, pointsExpirationDays: 365, isActive: true }; + set({ loyaltyProgram: program }); + }, + + accumulateLoyaltyPoints: async (subscriberId, subscriptionId, amount) => { + const program = get().loyaltyProgram; + if (!program) return; + const pointsEarned = Math.floor(amount * program.pointsPerDollar); + const transaction: PointsTransaction = { id: generateUniqueId(), subscriberId, amount: pointsEarned, type: 'earn', subscriptionId, description: 'Points earned', createdAt: new Date() }; + const currentPoints = get().loyaltyStatus?.points || 0; + const newStatus: LoyaltyStatus = { subscriberId, tier: getTierFromPoints(currentPoints + pointsEarned), points: currentPoints + pointsEarned, lifetimePoints: (get().loyaltyStatus?.lifetimePoints || 0) + pointsEarned, totalSpent: (get().loyaltyStatus?.totalSpent || 0) + amount, memberSince: get().loyaltyStatus?.memberSince || new Date() } as LoyaltyStatus; + set({ loyaltyTransactions: [...get().loyaltyTransactions, transaction], loyaltyStatus: newStatus }); + }, + + redeemLoyaltyPoints: async (rewardId) => { + const reward = get().loyaltyRewards.find((r) => r.id === rewardId); + const status = get().loyaltyStatus; + if (!reward || !status || !reward.isActive || status.points < reward.pointsCost) return false; + const tx: PointsTransaction = { id: generateUniqueId(), subscriberId: status.subscriberId, amount: -reward.pointsCost, type: 'redeem', description: `Redeemed: ${reward.name}`, createdAt: new Date() }; + set({ loyaltyTransactions: [...get().loyaltyTransactions, tx], loyaltyStatus: { ...status, points: status.points - reward.pointsCost } }); + return true; + }, + + checkTierUpgrade: () => { + const status = get().loyaltyStatus; + if (!status) return; + const newTier = getTierFromPoints(status.lifetimePoints); + if (newTier !== status.tier) set({ loyaltyStatus: { ...status, tier: newTier } }); + }, + + expireLoyaltyPoints: () => {}, + + // ── Affiliate state ───────────────────────────────────────────── + affiliates: [], + affiliatePrograms: defaultPrograms, + affiliateCommissions: [], + affiliateMetrics: { totalReferrals: 0, activeReferrals: 0, totalEarnings: 0, pendingPayout: 0, conversionRate: 0 }, + affiliateLoading: false, + affiliateError: null, + + registerAffiliate: async (referrerAddress, programId) => { + set({ affiliateLoading: true, affiliateError: null }); + try { + const program = get().affiliatePrograms.find((p) => p.id === programId); + if (!program) throw new Error('Program not found'); + const newAffiliate: Affiliate = { id: generateUniqueId(), referrerAddress, programId, commissionRate: program.commissionConfig.rate, paymentThreshold: 100, status: AffiliateStatus.ACTIVE, totalReferrals: 0, totalEarnings: 0, pendingPayout: 0, createdAt: new Date() }; + set((s) => ({ affiliates: [...s.affiliates, newAffiliate], affiliateLoading: false })); + } catch (error) { + set({ affiliateError: error instanceof Error ? error.message : 'Failed to register', affiliateLoading: false }); + } + }, + + trackReferral: async (affiliateId, subscriptionId) => { + set({ affiliateLoading: true, affiliateError: null }); + try { + set((s) => ({ affiliates: s.affiliates.map((a) => a.id === affiliateId ? { ...a, totalReferrals: a.totalReferrals + 1 } : a), affiliateCommissions: [...s.affiliateCommissions, { id: generateUniqueId(), affiliateId, subscriptionId, amount: 0, currency: 'USD', status: 'pending', createdAt: new Date() } as Commission], affiliateLoading: false })); + } catch (error) { + set({ affiliateError: error instanceof Error ? error.message : 'Failed to track referral', affiliateLoading: false }); + } + }, + + calculateCommission: async (affiliateId, subscriptionAmount) => { + const affiliate = get().affiliates.find((a) => a.id === affiliateId); + const program = affiliate ? get().affiliatePrograms.find((p) => p.id === affiliate.programId) : undefined; + if (!affiliate || !program) return 0; + const amount = subscriptionAmount * (program.commissionConfig.rate / 100); + return Math.round(amount * 100) / 100; + }, + + approveCommission: async (commissionId) => set((s) => ({ affiliateCommissions: s.affiliateCommissions.map((c) => c.id === commissionId ? { ...c, status: 'approved' as const } : c) })), + payoutCommission: async (affiliateId) => set((s) => ({ affiliateCommissions: s.affiliateCommissions.map((c) => c.affiliateId === affiliateId && c.status === 'approved' ? { ...c, status: 'paid' as const, paidAt: new Date() } : c) })), + + updateAffiliateStatus: async (affiliateId, status) => set((s) => ({ affiliates: s.affiliates.map((a) => a.id === affiliateId ? { ...a, status } : a) })), + + getAffiliateMetrics: () => { + const { affiliates, affiliateCommissions } = get(); + const totalEarnings = affiliates.reduce((sum, a) => sum + a.totalEarnings, 0); + const pendingPayout = affiliates.reduce((sum, a) => sum + a.pendingPayout, 0); + const totalReferrals = affiliates.reduce((sum, a) => sum + a.totalReferrals, 0); + const activeReferrals = affiliates.filter((a) => a.status === AffiliateStatus.ACTIVE).length; + return { totalReferrals, activeReferrals, totalEarnings, pendingPayout, conversionRate: totalReferrals > 0 ? (activeReferrals / totalReferrals) * 100 : 0 }; + }, +}); diff --git a/src/store/slices/marketingSlice.ts b/src/store/slices/marketingSlice.ts new file mode 100644 index 00000000..407c38fd --- /dev/null +++ b/src/store/slices/marketingSlice.ts @@ -0,0 +1,211 @@ +/** + * Marketing Slice – campaigns, segments, and group management. + */ +import type { StateCreator } from 'zustand'; +import { Campaign, CampaignStatus, CampaignAnalytics, CouponCode, CouponValidation, CampaignSchedule, CampaignOverlap, DiscountType } from '../../types/campaign'; +import { Segment } from '../../types/segment'; +import { SubscriptionGroup, GroupConfig, GroupId, GroupMemberRole, GroupAnalytics } from '../../types/group'; + +// ── Interfaces ────────────────────────────────────────────────────────── + +export interface CampaignSlice { + campaigns: Campaign[]; + campaignLoading: boolean; + campaignError: string | null; + activeCampaigns: Campaign[]; + redeemedCoupons: CouponCode[]; + campaignAnalytics: Record; + createCampaign: (data: Omit) => Promise; + updateCampaign: (id: string, updates: Partial) => Promise; + deleteCampaign: (id: string) => Promise; + launchCampaign: (id: string) => Promise; + pauseCampaign: (id: string) => Promise; + getCampaignAnalytics: (id: string) => CampaignAnalytics | null; + generateCoupons: (campaignId: string, count: number, pattern?: string) => Promise; + validateCoupon: (code: string, subscriptionId?: string) => Promise; + redeemCoupon: (code: string, subscriptionId: string) => Promise; + scheduleCampaign: (id: string, schedule: CampaignSchedule) => Promise; + activateCampaign: (id: string) => Promise; + expireCampaign: (id: string) => Promise; + getEligibleCampaigns: (userId: string) => Campaign[]; + checkCampaignEligibility: (campaignId: string, userId: string) => boolean; + calculateDiscountedPrice: (originalPrice: number, campaignIds: string[]) => number; + applyCampaignToPlan: (campaignId: string, planId: string) => Promise; + applyCampaignToSubscription: (campaignId: string, subscriptionId: string) => Promise; + getCampaignPerformance: (id: string) => CampaignAnalytics; + exportCampaignData: (id: string) => Promise; + detectOverlaps: (campaignId: string) => CampaignOverlap[]; +} + +export interface SegmentSlice { + segments: Segment[]; + segmentLoading: boolean; + segmentError: string | null; + addSegment: (data: Omit) => void; + updateSegment: (id: string, data: Partial) => void; + deleteSegment: (id: string) => void; + getSegmentsForUser: () => Segment[]; + getSegmentStats: (id: string) => { subscriberCount: number; totalMonthlyValue: number; averageValuePerSubscriber: number } | null; +} + +export interface GroupSlice { + groups: SubscriptionGroup[]; + selectedGroupId?: GroupId; + groupLoading: boolean; + groupError: string | null; + createGroup: (owner: string, config: GroupConfig) => SubscriptionGroup; + inviteGroupMember: (groupId: GroupId, inviteeAddress: string, invitedBy: string) => void; + joinGroup: (groupId: GroupId, inviteId: string, displayName?: string) => void; + removeGroupMember: (groupId: GroupId, memberAddress: string) => void; + updateGroupMemberRole: (groupId: GroupId, memberAddress: string, role: GroupMemberRole) => void; + chargeGroup: (groupId: GroupId, amount: number) => void; + getGroupAnalytics: (groupId: GroupId) => GroupAnalytics | undefined; + selectGroup: (groupId?: GroupId) => void; +} + +// ── Helpers ───────────────────────────────────────────────────────────── + +const generateUniqueId = (): string => `${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 8)}`; + +const initializeAnalytics = (): CampaignAnalytics => ({ campaignId: '', totalRecipients: 0, deliveredCount: 0, openedCount: 0, clickedCount: 0, convertedCount: 0, revenue: 0, startDate: new Date() }); + +type MarketingStore = CampaignSlice & SegmentSlice & GroupSlice; +type MarketingCreator = StateCreator; + +// ═══════════════════════════════════════════════════════════════════════════ +// Slice Factory +// ═══════════════════════════════════════════════════════════════════════════ + +export const createMarketingSlice: MarketingCreator = (set, get) => ({ + // ── Campaign state ──────────────────────────────────────────────── + campaigns: [], + campaignLoading: false, + campaignError: null, + activeCampaigns: [], + redeemedCoupons: [], + campaignAnalytics: {}, + + createCampaign: async (data) => { + set({ campaignLoading: true, campaignError: null }); + try { + const newCampaign: Campaign = { ...data, id: generateUniqueId(), analytics: initializeAnalytics(), createdAt: new Date(), updatedAt: new Date() }; + set((s) => ({ campaigns: [...s.campaigns, newCampaign], campaignLoading: false })); + } catch (error) { + set({ campaignError: error instanceof Error ? error.message : 'Failed to create campaign', campaignLoading: false }); + } + }, + + updateCampaign: async (id, updates) => set((s) => ({ campaigns: s.campaigns.map((c) => c.id === id ? { ...c, ...updates, updatedAt: new Date() } : c) })), + deleteCampaign: async (id) => set((s) => ({ campaigns: s.campaigns.filter((c) => c.id !== id) })), + + launchCampaign: async (id) => { + const campaign = get().campaigns.find((c) => c.id === id); + if (!campaign) return; + set((s) => ({ campaigns: s.campaigns.map((c) => c.id === id ? { ...c, status: CampaignStatus.ACTIVE, analytics: { ...initializeAnalytics(), campaignId: id, totalRecipients: Math.floor(Math.random() * 1000) + 100, startDate: new Date() }, updatedAt: new Date() } : c) })); + }, + + pauseCampaign: async (id) => { + set((s) => ({ campaigns: s.campaigns.map((c) => c.id === id ? { ...c, status: CampaignStatus.PAUSED, updatedAt: new Date() } : c) })); + }, + + getCampaignAnalytics: (id) => get().campaigns.find((c) => c.id === id)?.analytics || null, + + generateCoupons: async (campaignId, count, pattern) => { + const campaign = get().campaigns.find((c) => c.id === campaignId); + if (!campaign) return; + const coupons: CouponCode[] = Array.from({ length: count }, (_, i) => ({ id: generateUniqueId(), code: `${pattern || 'PROMO'}-${Math.random().toString(36).substring(2, 10).toUpperCase()}`, campaignId, maxUses: 100, usedCount: 0, maxUsesPerUser: 1, isActive: true, createdAt: new Date() })); + set((s) => ({ campaigns: s.campaigns.map((c) => c.id === campaignId ? { ...c, couponCodes: [...(c.couponCodes || []), ...coupons], updatedAt: new Date() } : c) })); + }, + + validateCoupon: async (_code, _subscriptionId) => ({ valid: true, code: _code }), + redeemCoupon: async (_code, _subscriptionId) => {}, + scheduleCampaign: async (id, schedule) => set((s) => ({ campaigns: s.campaigns.map((c) => c.id === id ? { ...c, status: CampaignStatus.SCHEDULED, schedule, updatedAt: new Date() } : c) })), + activateCampaign: async (id) => { const c = get().campaigns.find((x) => x.id === id); if (c) set((s) => ({ campaigns: s.campaigns.map((x) => x.id === id ? { ...x, status: CampaignStatus.ACTIVE, updatedAt: new Date() } : x), activeCampaigns: [...s.activeCampaigns, { ...c, status: CampaignStatus.ACTIVE }] })); }, + expireCampaign: async (id) => set((s) => ({ campaigns: s.campaigns.map((c) => c.id === id ? { ...c, status: CampaignStatus.COMPLETED, updatedAt: new Date() } : c), activeCampaigns: s.activeCampaigns.filter((c) => c.id !== id) })), + getEligibleCampaigns: (_userId) => get().campaigns.filter((c) => c.status === CampaignStatus.ACTIVE), + checkCampaignEligibility: (campaignId, _userId) => get().campaigns.find((c) => c.id === campaignId)?.status === CampaignStatus.ACTIVE, + + calculateDiscountedPrice: (originalPrice, campaignIds) => { + let price = originalPrice; + for (const cId of campaignIds) { + const c = get().campaigns.find((x) => x.id === cId); + if (c?.promotionRule?.discountType === DiscountType.PERCENTAGE) price -= price * (c.promotionRule.discountValue / 100); + else if (c?.promotionRule?.discountType === DiscountType.FIXED_AMOUNT) price -= c.promotionRule.discountValue; + } + return Math.max(0, price); + }, + + applyCampaignToPlan: async (campaignId, planId) => set((s) => ({ campaigns: s.campaigns.map((c) => c.id === campaignId ? { ...c, promotionRule: { ...c.promotionRule, planIds: [...(c.promotionRule?.planIds || []), planId] }, updatedAt: new Date() } : c) })), + applyCampaignToSubscription: async (_campaignId, _subscriptionId) => {}, + + getCampaignPerformance: (id) => { + const { campaigns, campaignAnalytics } = get(); + return campaignAnalytics[id] || campaigns.find((c) => c.id === id)?.analytics || initializeAnalytics(); + }, + + exportCampaignData: async (_id) => {}, + + detectOverlaps: (campaignId) => { + const campaign = get().campaigns.find((c) => c.id === campaignId); + if (!campaign) return []; + return []; + }, + + // ── Segment state ───────────────────────────────────────────────── + segments: [], + segmentLoading: false, + segmentError: null, + + addSegment: (data) => { + const newSegment: Segment = { ...data, id: `seg-${Date.now()}`, createdAt: new Date(), updatedAt: new Date() }; + set((s) => ({ segments: [...s.segments, newSegment] })); + }, + + updateSegment: (id, data) => set((s) => ({ segments: s.segments.map((seg) => seg.id === id ? { ...seg, ...data, updatedAt: new Date() } : seg) })), + deleteSegment: (id) => set((s) => ({ segments: s.segments.filter((seg) => seg.id !== id) })), + getSegmentsForUser: () => get().segments, + getSegmentStats: (id) => { + const segment = get().segments.find((s) => s.id === id); + return segment ? { subscriberCount: 0, totalMonthlyValue: 0, averageValuePerSubscriber: 0 } : null; + }, + + // ── Group state ─────────────────────────────────────────────────── + groups: [], + selectedGroupId: undefined, + groupLoading: false, + groupError: null, + + createGroup: (owner, config) => { + const group: SubscriptionGroup = { groupId: generateUniqueId(), owner, config, members: [], charges: [], createdAt: new Date(), updatedAt: new Date() } as SubscriptionGroup; + set((s) => ({ groups: [...s.groups, group], selectedGroupId: group.groupId })); + return group; + }, + + inviteGroupMember: (groupId, inviteeAddress, invitedBy) => { + try { + set((s) => ({ groups: s.groups.map((g) => g.groupId === groupId ? { ...g, members: [...(g.members || []), { address: inviteeAddress, role: 'member' as GroupMemberRole, invitedBy, joinedAt: new Date() }], updatedAt: new Date() } : g) })); + } catch (error) { set({ groupError: (error as Error).message }); } + }, + + joinGroup: (groupId, inviteId, displayName) => { + try { + set((s) => ({ groups: s.groups.map((g) => g.groupId === groupId ? { ...g, members: [...(g.members || []), { address: `user_${inviteId}`, role: 'member' as GroupMemberRole, invitedBy: inviteId, joinedAt: new Date(), displayName }], updatedAt: new Date() } : g) })); + } catch (error) { set({ groupError: (error as Error).message }); } + }, + + removeGroupMember: (groupId, memberAddress) => set((s) => ({ groups: s.groups.map((g) => g.groupId === groupId ? { ...g, members: (g.members || []).filter((m: any) => m.address !== memberAddress), updatedAt: new Date() } : g) })), + updateGroupMemberRole: (groupId, memberAddress, role) => set((s) => ({ groups: s.groups.map((g) => g.groupId === groupId ? { ...g, members: (g.members || []).map((m: any) => m.address === memberAddress ? { ...m, role } : m), updatedAt: new Date() } : g) })), + + chargeGroup: (groupId, amount) => { + try { + set((s) => ({ groups: s.groups.map((g) => g.groupId === groupId ? { ...g, charges: [...(g.charges || []), { id: `chg-${Date.now()}`, amount, timestamp: new Date() }], updatedAt: new Date() } : g), groupError: null })); + } catch (error) { set({ groupError: (error as Error).message }); } + }, + + getGroupAnalytics: (groupId) => { + const group = get().groups.find((g) => g.groupId === groupId); + return group ? { groupId, totalMembers: (group.members || []).length, totalCharges: (group.charges || []).length, totalAmount: (group.charges || []).reduce((sum: number, c: any) => sum + c.amount, 0) } as GroupAnalytics : undefined; + }, + + selectGroup: (groupId) => set({ selectedGroupId: groupId }), +}); diff --git a/src/store/slices/meteringSlice.ts b/src/store/slices/meteringSlice.ts new file mode 100644 index 00000000..2e2c2cea --- /dev/null +++ b/src/store/slices/meteringSlice.ts @@ -0,0 +1,285 @@ +/** + * Metering Slice – usage metering, credits, batch operations, and search. + */ +import type { StateCreator } from 'zustand'; +import { MeterState, MeteredUsage, UsageBucket, ChargeLine, Charge, TimeRange } from '../../types/metering'; +import { AccountCredit, CreditLot, CreditTransaction, CreditApplied, ExpirationPolicy, CreditTxKind } from '../../types/credit'; +import { BatchDraft, BatchExecutionResult, BatchHistoryEntry, BatchProgress, PerItemResult, BatchOperationType, PerItemStatus, CancelReason, BatchCreateInput, BatchUpdateParams, UpdateFilter } from '../../types/batch'; + +// ── Interfaces ────────────────────────────────────────────────────────── + +export interface MeteringSlice { + meters: Record>; + meteringAlerts: Array<{ subscriptionId: string; metric: string; total: number }>; + registerMeter: (subscriptionId: string, metric: string, config: { unitPrice: number; includedUnits: number; periodSecs?: number; alertThreshold?: number }) => void; + recordMeterUsage: (subscriptionId: string, metric: string, value: number) => MeteredUsage | null; + calculateUsageCharge: (subscriptionId: string, period: TimeRange) => Charge; + getMeters: (subscriptionId: string) => MeterState[]; + getUsageTotal: (subscriptionId: string, metric: string) => number; +} + +export interface CreditSlice { + creditAccounts: Record; + creditNextId: number; + issueCredit: (subscriber: string, amount: number, reason: string, expiresAt?: number) => void; + setCreditExpirationPolicy: (subscriber: string, policy: ExpirationPolicy) => void; + applyCredit: (subscriber: string, subscriptionId: string, amountDue: number) => CreditApplied; + transferCredit: (from: string, to: string, amount: number, reason: string) => boolean; + expireCredits: (subscriber: string) => number; + getCreditBalance: (subscriber: string) => number; + getCreditAccount: (subscriber: string) => AccountCredit; +} + +export interface BatchSlice { + batchDraft: any; + batchResult: BatchExecutionResult | null; + batchHistory: BatchHistoryEntry[]; + batchRunning: boolean; + batchProgress: BatchProgress | null; + setBatchDraft: (patch: Partial) => void; + setBatchOperationType: (op: string) => void; + toggleBatchAtomic: () => void; + setBatchChunkSize: (size: number) => void; + loadCreateCsv: (csv: string) => void; + loadCancelCsv: (csv: string) => void; + loadChargeCsv: (csv: string) => void; + loadUpdateCsv: (csv: string) => void; + executeBatch: () => Promise; + retryBatchFailed: () => Promise; + exportBatchResultJson: () => string | null; + exportBatchResultCsv: () => string | null; + loadBatchHistory: () => Promise; + resetBatchDraft: () => void; + clearBatchResult: () => void; +} + +export interface SearchSlice { + searchQuery: string; + searchFacets: Record; + searchResults: any[]; + savedSearches: Array<{ id: string; name: string; query: string; facets?: Record }>; + setSearchQuery: (q: string) => void; + setSearchFacets: (f: Record) => void; + updateSearchResults: (results: any[]) => void; + saveSearch: (name: string) => void; + loadSavedSearch: (id: string) => void; + clearSearch: () => void; +} + +// ── Helpers ───────────────────────────────────────────────────────────── + +const DEFAULT_PERIOD_SECS = 86_400; +const MAX_BUCKETS = 90; +const bucketStart = (now: number, periodSecs: number): number => periodSecs === 0 ? now : now - (now % periodSecs); +const newMeter = (metric: string, periodSecs: number): MeterState => ({ metric, total: 0, lastTimestamp: 0, periodSecs, includedUnits: 0, unitPrice: 0, alertThreshold: 0, alertFired: false, buckets: [] }); +const billableUnits = (used: number, included: number): number => Math.max(0, used - included); + +const blankAccount = (subscriber: string): AccountCredit => ({ subscriber, balance: 0, lots: [], transactions: [], expirationPolicy: { kind: 'never' } }); + +const isExpired = (lot: CreditLot, now: number): boolean => lot.expiresAt !== undefined && lot.expiresAt <= now; + +type MeteringStore = MeteringSlice & CreditSlice & BatchSlice & SearchSlice; +type MeteringCreator = StateCreator; + +// ═══════════════════════════════════════════════════════════════════════════ +// Slice Factory +// ═══════════════════════════════════════════════════════════════════════════ + +export const createMeteringSlice: MeteringCreator = (set, get) => { + const metersFor = (sub: string): Record => get().meters[sub] ?? {}; + const commitMeters = (sub: string, map: Record) => set((s) => ({ meters: { ...s.meters, [sub]: map } })); + + const account = (subscriber: string): AccountCredit => get().creditAccounts[subscriber] ?? blankAccount(subscriber); + const commitAccount = (acc: AccountCredit) => set((s) => ({ creditAccounts: { ...s.creditAccounts, [acc.subscriber]: acc } })); + + const addToBucket = (state: MeterState, now: number, value: number) => { + const start = bucketStart(now, state.periodSecs); + const last = state.buckets[state.buckets.length - 1]; + if (last && last.start === start) { + state.buckets = [...state.buckets.slice(0, -1), { start, units: last.units + value }]; + } else { + state.buckets = [...state.buckets, { start, units: value }]; + if (state.buckets.length > MAX_BUCKETS) state.buckets = state.buckets.slice(-MAX_BUCKETS); + } + }; + + const realizeExpiry = (acc: AccountCredit, now: number) => { + let expired = 0; + acc.lots = acc.lots.map((lot) => { + if (lot.remaining > 0 && isExpired(lot, now)) { expired += lot.remaining; return { ...lot, remaining: 0 }; } + return lot; + }); + if (expired > 0) { acc.balance -= expired; } + return expired; + }; + + const consume = (acc: AccountCredit, now: number, amount: number): number => { + let remaining = amount; + acc.lots = acc.lots.map((lot) => { + if (remaining <= 0 || lot.remaining <= 0 || isExpired(lot, now)) return lot; + const take = Math.min(lot.remaining, remaining); + remaining -= take; + return { ...lot, remaining: lot.remaining - take }; + }); + return amount - remaining; + }; + + const nowFn = () => Math.floor(Date.now() / 1000); + + return { + // ── Metering state ────────────────────────────────────────────── + meters: {}, + meteringAlerts: [], + + registerMeter: (sub, metric, config) => { + const map = { ...metersFor(sub) }; + const existing = map[metric] ?? newMeter(metric, config.periodSecs ?? DEFAULT_PERIOD_SECS); + const state: MeterState = { ...existing, periodSecs: config.periodSecs ?? existing.periodSecs ?? DEFAULT_PERIOD_SECS, includedUnits: config.includedUnits, unitPrice: config.unitPrice, alertThreshold: config.alertThreshold ?? 0, alertFired: (config.alertThreshold ?? 0) !== 0 && existing.total >= (config.alertThreshold ?? 0) }; + map[metric] = state; + commitMeters(sub, map); + }, + + recordMeterUsage: (sub, metric, value) => { + if (value <= 0) return null; + const now = nowFn(); + const map = { ...metersFor(sub) }; + const state: MeterState = { ...(map[metric] ?? newMeter(metric, DEFAULT_PERIOD_SECS)) }; + state.total += value; + state.lastTimestamp = now; + addToBucket(state, now, value); + if (state.alertThreshold !== 0 && !state.alertFired && state.total >= state.alertThreshold) { + state.alertFired = true; + set((s) => ({ meteringAlerts: [...s.meteringAlerts, { subscriptionId: sub, metric, total: state.total }] })); + } + map[metric] = state; + commitMeters(sub, map); + return { metric, value, timestamp: now }; + }, + + calculateUsageCharge: (sub, period) => { + const map = metersFor(sub); + const lines: ChargeLine[] = []; + let total = 0; + for (const metric of Object.keys(map)) { + const state = map[metric]; + const used = state.buckets.reduce((sum, b) => (b.start >= period.start && b.start <= period.end ? sum + b.units : sum), 0); + const billable = billableUnits(used, state.includedUnits); + const amount = billable * state.unitPrice; + total += amount; + lines.push({ metric, units: used, billableUnits: billable, unitPrice: state.unitPrice, amount }); + } + return { subscriptionId: sub, currency: 'USD', total, lines }; + }, + + getMeters: (sub) => Object.values(metersFor(sub)), + getUsageTotal: (sub, metric) => metersFor(sub)[metric]?.total ?? 0, + + // ── Credit state ─────────────────────────────────────────────── + creditAccounts: {}, + creditNextId: 0, + + issueCredit: (subscriber, amount, reason, expiresAt) => { + if (amount <= 0) return; + const n = nowFn(); + const acc = { ...account(subscriber), lots: [...account(subscriber).lots], transactions: [...account(subscriber).transactions] }; + realizeExpiry(acc, n); + const expiry = expiresAt ?? (acc.expirationPolicy.kind === 'after_secs' ? n + (acc.expirationPolicy as any).seconds : undefined); + acc.lots.push({ id: get().creditNextId, remaining: amount, issuedAt: n, expiresAt: expiry }); + acc.balance += amount; + acc.transactions = [...acc.transactions, { id: get().creditNextId, kind: 'issue', amount, timestamp: n, reason }]; + set((s) => ({ creditNextId: s.creditNextId + 1 })); + commitAccount(acc); + }, + + setCreditExpirationPolicy: (subscriber, policy) => { + const acc = { ...account(subscriber), expirationPolicy: policy }; + commitAccount(acc); + }, + + applyCredit: (subscriber, subscriptionId, amountDue) => { + const n = nowFn(); + const acc = { ...account(subscriber), lots: [...account(subscriber).lots], transactions: [...account(subscriber).transactions] }; + realizeExpiry(acc, n); + const applied = consume(acc, n, Math.max(0, amountDue)); + if (applied > 0) { acc.balance -= applied; acc.transactions = [...acc.transactions, { id: get().creditNextId, kind: 'apply', amount: -applied, timestamp: n, reason: 'charge_application' }]; set((s) => ({ creditNextId: s.creditNextId + 1 })); } + commitAccount(acc); + return { subscriptionId, applied, remainingDue: amountDue - applied, balanceAfter: acc.balance }; + }, + + transferCredit: (from, to, amount, reason) => { + if (amount <= 0 || from === to) return false; + const n = nowFn(); + const sender = { ...account(from), lots: [...account(from).lots], transactions: [...account(from).transactions] }; + realizeExpiry(sender, n); + if (sender.balance < amount) return false; + const moved = consume(sender, n, amount); + sender.balance -= moved; + sender.transactions = [...sender.transactions, { id: get().creditNextId, kind: 'transfer_out', amount: -moved, timestamp: n, reason, counterparty: to }]; + set((s) => ({ creditNextId: s.creditNextId + 1 })); + commitAccount(sender); + + const recipient = { ...account(to), lots: [...account(to).lots], transactions: [...account(to).transactions] }; + realizeExpiry(recipient, n); + const expiry = recipient.expirationPolicy.kind === 'after_secs' ? n + (recipient.expirationPolicy as any).seconds : undefined; + recipient.lots.push({ id: get().creditNextId, remaining: moved, issuedAt: n, expiresAt: expiry }); + recipient.balance += moved; + recipient.transactions = [...recipient.transactions, { id: get().creditNextId, kind: 'transfer_in', amount: moved, timestamp: n, reason, counterparty: from }]; + set((s) => ({ creditNextId: s.creditNextId + 1 })); + commitAccount(recipient); + return true; + }, + + expireCredits: (subscriber) => { + const n = nowFn(); + const acc = { ...account(subscriber), lots: [...account(subscriber).lots], transactions: [...account(subscriber).transactions] }; + const expired = realizeExpiry(acc, n); + if (expired > 0) { acc.transactions = [...acc.transactions, { id: get().creditNextId, kind: 'expire', amount: -expired, timestamp: n, reason: 'expired' }]; set((s) => ({ creditNextId: s.creditNextId + 1 })); } + commitAccount(acc); + return expired; + }, + + getCreditBalance: (subscriber) => account(subscriber).balance, + getCreditAccount: (subscriber) => account(subscriber), + + // ── Batch state ───────────────────────────────────────────────── + batchDraft: { operationType: 'create', atomic: false, createInputs: [], updateIds: [], updateParams: {}, cancelIds: [], cancelReasons: [], chargeItems: [], csvContent: '', chunkSize: 50 }, + batchResult: null, + batchHistory: [], + batchRunning: false, + batchProgress: null, + + setBatchDraft: (patch) => set((s) => ({ batchDraft: { ...s.batchDraft, ...patch } })), + setBatchOperationType: (op) => set((s) => ({ batchDraft: { ...s.batchDraft, operationType: op, csvContent: '' } })), + toggleBatchAtomic: () => set((s) => ({ batchDraft: { ...s.batchDraft, atomic: !s.batchDraft.atomic } })), + setBatchChunkSize: (size) => set((s) => ({ batchDraft: { ...s.batchDraft, chunkSize: Math.min(size, 500) } })), + + loadCreateCsv: (_csv) => {}, + loadCancelCsv: (_csv) => {}, + loadChargeCsv: (_csv) => {}, + loadUpdateCsv: (_csv) => {}, + + executeBatch: async () => null, + retryBatchFailed: async () => null, + exportBatchResultJson: () => null, + exportBatchResultCsv: () => null, + loadBatchHistory: async () => {}, + resetBatchDraft: () => set({ batchDraft: { operationType: 'create', atomic: false, createInputs: [], updateIds: [], updateParams: {}, cancelIds: [], cancelReasons: [], chargeItems: [], csvContent: '', chunkSize: 50 }, batchResult: null, batchProgress: null }), + clearBatchResult: () => set({ batchResult: null, batchProgress: null }), + + // ── Search state ──────────────────────────────────────────────── + searchQuery: '', + searchFacets: {}, + searchResults: [], + savedSearches: [], + + setSearchQuery: (q) => set({ searchQuery: q }), + setSearchFacets: (f) => set((s) => ({ searchFacets: { ...s.searchFacets, ...f } })), + updateSearchResults: (results) => set({ searchResults: results }), + saveSearch: (name) => set((s) => ({ savedSearches: [...s.savedSearches, { id: `ss_${Date.now()}`, name, query: s.searchQuery, facets: s.searchFacets }] })), + loadSavedSearch: (id) => { + const ss = get().savedSearches.find((s) => s.id === id); + if (ss) set({ searchQuery: ss.query, searchFacets: ss.facets || {} }); + }, + clearSearch: () => set({ searchQuery: '', searchFacets: {}, searchResults: [] }), + }; +}; diff --git a/src/store/slices/networkSlice.ts b/src/store/slices/networkSlice.ts new file mode 100644 index 00000000..055e33c2 --- /dev/null +++ b/src/store/slices/networkSlice.ts @@ -0,0 +1,62 @@ +/** + * Network Slice – blockchain network state. + */ +import type { StateCreator } from 'zustand'; +import { Network, ALL_NETWORKS, getNetworkById } from '../../config/networks'; + +// ── Interface ─────────────────────────────────────────────────────────── + +export interface NetworkSlice { + currentNetwork: Network | null; + availableNetworks: Network[]; + networkLoading: boolean; + networkError: string | null; + initializeNetwork: () => Promise; + setNetwork: (networkId: string) => Promise; + checkNetworkHealth: (networkId: string) => Promise<{ healthy: boolean; latency?: number; error?: string }>; + refreshNetworks: () => Promise; +} + +type NetworkStore = NetworkSlice; +type NetworkCreator = StateCreator; + +// ═══════════════════════════════════════════════════════════════════════════ +// Slice Factory +// ═══════════════════════════════════════════════════════════════════════════ + +export const createNetworkSlice: NetworkCreator = (set) => ({ + currentNetwork: null, + availableNetworks: ALL_NETWORKS, + networkLoading: false, + networkError: null, + + initializeNetwork: async () => { + set({ networkLoading: true, networkError: null }); + try { + set({ currentNetwork: ALL_NETWORKS[0] ?? null, networkLoading: false }); + } catch (error) { + set({ networkError: error instanceof Error ? error.message : 'Failed to initialize', networkLoading: false }); + } + }, + + setNetwork: async (networkId: string) => { + set({ networkLoading: true, networkError: null }); + try { + const network = getNetworkById(networkId); + set({ currentNetwork: network, networkLoading: false }); + } catch (error) { + set({ networkError: error instanceof Error ? error.message : 'Failed to set network', networkLoading: false }); + } + }, + + checkNetworkHealth: async (_networkId: string) => ({ healthy: true }), + + refreshNetworks: async () => { + set({ networkLoading: true, networkError: null }); + try { + set({ availableNetworks: ALL_NETWORKS, networkLoading: false }); + } catch (error) { + set({ networkError: error instanceof Error ? error.message : 'Failed to refresh', networkLoading: false }); + } + }, +}); diff --git a/src/store/slices/riskSlice.ts b/src/store/slices/riskSlice.ts new file mode 100644 index 00000000..febc5c8d --- /dev/null +++ b/src/store/slices/riskSlice.ts @@ -0,0 +1,146 @@ +/** + * Risk Slice – fraud detection and SLA management. + */ +import type { StateCreator } from 'zustand'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { FraudMerchantRecord, FraudSubscriptionRecord, FraudCase, FraudRiskScore, FraudAction, FraudReport, FraudAnalytics } from '../../types/fraud'; +import { SlaConfig, SlaStatus, SlaAvailabilityEvent, SlaBreach, SlaDashboardReport, SlaAvailabilityState } from '../../types/sla'; +import { errorHandler, AppError } from '../../services/errorHandler'; + +// ── Interfaces ────────────────────────────────────────────────────────── + +export interface FraudSlice { + fraudMerchants: FraudMerchantRecord[]; + fraudSubscriptions: FraudSubscriptionRecord[]; + fraudAssessments: FraudRiskScore[]; + fraudReviewQueue: FraudCase[]; + fraudAnalytics: FraudAnalytics; + fraudLoading: boolean; + fraudError: string | null; + refreshFraudSignals: () => void; + assessFraudRisk: (subscriberId: string) => FraudRiskScore[]; + flagFraudSubscription: (subscriptionId: string) => void; + approveFraudSubscription: (subscriptionId: string) => void; + blockFraudSubscription: (subscriptionId: string) => void; + resolveFraudCase: (subscriptionId: string, action: FraudAction) => void; + getFraudReport: (merchantId: string) => FraudReport; +} + +export interface SlaSlice { + slaConfigs: Record; + slaStatuses: Record; + slaAvailabilityEvents: SlaAvailabilityEvent[]; + slaBreaches: SlaBreach[]; + slaReport: SlaDashboardReport; + slaLoading: boolean; + slaError: AppError | null; + configureSla: (merchantId: string, config: Partial) => Promise; + trackServiceAvailability: (merchantId: string, input: { durationSeconds: number; state: SlaAvailabilityState; note?: string; timestamp?: number }) => Promise; + detectSlaBreach: (merchantId: string) => Promise; + acknowledgeSlaBreach: (breachId: string) => Promise; + calculateSlaCredit: (breachId: string) => number; + getSlaStatus: (merchantId: string) => SlaStatus | null; + refreshSlaReport: () => void; +} + +// ── Seed data ─────────────────────────────────────────────────────────── + +const merchantSeeds: FraudMerchantRecord[] = [ + { id: 'merch_nova', name: 'Nova Stream', status: 'watch', activeSubscriptions: 128, blockedSubscriptions: 4, averageRisk: 41, monthlyVolume: 18650 }, + { id: 'merch_orbit', name: 'Orbit Tools', status: 'healthy', activeSubscriptions: 83, blockedSubscriptions: 1, averageRisk: 22, monthlyVolume: 9420 }, + { id: 'merch_cipher', name: 'Cipher Pro', status: 'high-risk', activeSubscriptions: 46, blockedSubscriptions: 9, averageRisk: 67, monthlyVolume: 7825 }, +]; + +const generateId = (prefix: string) => `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + +type RiskStore = FraudSlice & SlaSlice; +type RiskCreator = StateCreator; + +// ═══════════════════════════════════════════════════════════════════════════ +// Slice Factory +// ═══════════════════════════════════════════════════════════════════════════ + +export const createRiskSlice: RiskCreator = (set, get) => ({ + // ── Fraud state ─────────────────────────────────────────────────── + fraudMerchants: merchantSeeds.map((m) => ({ ...m })), + fraudSubscriptions: [], + fraudAssessments: [], + fraudReviewQueue: [], + fraudAnalytics: { totalChecks: 0, approved: 0, flagged: 0, blocked: 0, manualReviews: 0, avgRisk: 0, velocityAlerts: 0, anomalyAlerts: 0, chargebackPredictions: 0, falsePositiveEstimate: 0 }, + fraudLoading: false, + fraudError: null, + + refreshFraudSignals: () => { + const { fraudSubscriptions, fraudReviewQueue, fraudMerchants } = get(); + set({ fraudAnalytics: { totalChecks: fraudSubscriptions.length, approved: 0, flagged: 0, blocked: 0, manualReviews: fraudReviewQueue.length, avgRisk: 0, velocityAlerts: 0, anomalyAlerts: 0, chargebackPredictions: 0, falsePositiveEstimate: 0 } }); + }, + + assessFraudRisk: (_subscriberId) => [], + flagFraudSubscription: (subscriptionId) => { + set((s) => { + const nextCase: FraudCase = { caseId: subscriptionId, subscriptionId, subscriberId: '', merchantId: '', merchantName: '', subscriptionName: '', riskScore: 0, action: 'flag', status: 'pending', reason: '', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), notes: 'Flagged' }; + return { fraudReviewQueue: [...s.fraudReviewQueue, nextCase] }; + }); + }, + + approveFraudSubscription: (subscriptionId) => { + set((s) => ({ fraudReviewQueue: s.fraudReviewQueue.map((c) => c.subscriptionId === subscriptionId ? { ...c, status: 'reviewed', action: 'approve' } : c) })); + }, + + blockFraudSubscription: (subscriptionId) => { + set((s) => ({ fraudReviewQueue: s.fraudReviewQueue.map((c) => c.subscriptionId === subscriptionId ? { ...c, status: 'escalated', action: 'block' } : c) })); + }, + + resolveFraudCase: (subscriptionId, action) => { + set((s) => ({ fraudReviewQueue: s.fraudReviewQueue.map((c) => c.subscriptionId === subscriptionId ? { ...c, action, status: action === 'approve' ? 'reviewed' : 'escalated' } : c) })); + }, + + getFraudReport: (merchantId) => { + const merchant = get().fraudMerchants.find((m) => m.id === merchantId); + return { merchantId, merchantName: merchant?.name ?? 'Unknown', totalSubscriptions: 0, flaggedSubscriptions: 0, blockedSubscriptions: 0, manualReviewCount: 0, averageRisk: 0, velocityAlerts: 0, anomalyAlerts: 0, chargebackPredictions: 0, highRiskSubscribers: 0, recentCases: [] }; + }, + + // ── SLA state ───────────────────────────────────────────────────── + slaConfigs: {}, + slaStatuses: {}, + slaAvailabilityEvents: [], + slaBreaches: [], + slaReport: { summary: { totalMerchants: 0, compliantMerchants: 0, breachCount: 0, averageUptime: 100, totalCreditsIssued: 0, partialOutageEvents: 0, maintenanceEvents: 0 }, configs: {}, statuses: {}, breaches: [], events: [] }, + slaLoading: false, + slaError: null, + + configureSla: async (merchantId, config) => { + set({ slaLoading: true, slaError: null }); + try { + const normalized: SlaConfig = { id: merchantId, merchantId, uptimeTarget: config.uptimeTarget ?? 99.9, measurementWindowDays: config.measurementWindowDays ?? 30, creditRateBps: config.creditRateBps ?? 1000, maxCreditPercentage: config.maxCreditPercentage ?? 100, excludesScheduledMaintenance: config.excludesScheduledMaintenance ?? true } as SlaConfig; + set((s) => { + const nextConfigs = { ...s.slaConfigs, [merchantId]: normalized }; + return { slaConfigs: nextConfigs, slaLoading: false }; + }); + } catch (error) { + set({ slaError: errorHandler.handleError(error as Error, { action: 'configureSla' }), slaLoading: false }); + } + }, + + trackServiceAvailability: async (merchantId, input) => { + set({ slaLoading: true, slaError: null }); + try { + const event: SlaAvailabilityEvent = { id: generateId('sla-event'), merchantId, timestamp: input.timestamp ?? Date.now(), durationSeconds: Math.max(1, Math.floor(input.durationSeconds)), state: input.state, note: input.note }; + set((s) => ({ slaAvailabilityEvents: [...s.slaAvailabilityEvents, event], slaLoading: false })); + } catch (error) { + set({ slaError: errorHandler.handleError(error as Error, { action: 'trackServiceAvailability' }), slaLoading: false }); + } + }, + + detectSlaBreach: async (merchantId) => { + return get().slaStatuses[merchantId] ?? null; + }, + + acknowledgeSlaBreach: async (breachId) => { + set((s) => ({ slaBreaches: s.slaBreaches.map((b) => b.id === breachId ? { ...b, acknowledged: true } : b) })); + }, + + calculateSlaCredit: (breachId) => get().slaBreaches.find((b) => b.id === breachId)?.creditAmount ?? 0, + getSlaStatus: (merchantId) => get().slaStatuses[merchantId] ?? null, + refreshSlaReport: () => { /* no-op for combined store */ }, +}); diff --git a/src/store/slices/settingsSlice.ts b/src/store/slices/settingsSlice.ts new file mode 100644 index 00000000..966ac9e4 --- /dev/null +++ b/src/store/slices/settingsSlice.ts @@ -0,0 +1,194 @@ +/** + * Settings Slice – user settings, profile, and community features. + */ +import type { StateCreator } from 'zustand'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { UserProfile } from '../../types/api'; +import { SubscriptionTier } from '../../types/subscription'; + +// ── Interfaces ────────────────────────────────────────────────────────── + +export interface SettingsSlice { + preferredCurrency: string; + notificationsEnabled: boolean; + exchangeRates: any | null; + settingsLoading: boolean; + setPreferredCurrency: (currency: string) => void; + setNotificationsEnabled: (enabled: boolean) => void; + updateExchangeRates: () => Promise; + initializeSettings: () => Promise; +} + +export interface UserSlice { + user: UserProfile | null; + subscriptionTier: SubscriptionTier; + consent: { analytics: boolean; marketing: boolean; notifications: boolean; hasAcceptedPolicy: boolean }; + setUser: (user: UserProfile | null) => void; + setSubscriptionTier: (tier: SubscriptionTier) => void; + setConsent: (consent: Partial<{ analytics: boolean; marketing: boolean; notifications: boolean; hasAcceptedPolicy: boolean }>) => void; + acceptAll: () => void; + resetConsent: () => void; +} + +export interface CommunitySlice { + communitySubscriber: string; + communityProfiles: Record; + communityThreads: any[]; + moderationQueue: string[]; + setCommunitySubscriber: (subscriber: string) => void; + updateCommunityProfile: (subscriber: string, profile: Partial) => void; + getCommunitySubscribers: (filter?: any) => any[]; + getVisibleProfile: (viewer: string, target: string) => any | null; + createForumThread: (author: string, input: { title: string; body: string; category: string }) => { ok: boolean; reason?: string }; + replyToForumThread: (threadId: string, author: string, body: string) => { ok: boolean; reason?: string }; + moderateContent: (threadId: string, status: string, postId?: string) => void; +} + +// ── Helpers ───────────────────────────────────────────────────────────── + +const CURRENT_SUBSCRIBER_FALLBACK = '0x742d35Cc6634C0532925a3b844Bc9e7595f0fAb1'; +const FLAGGED_TERMS = ['spam', 'scam', 'hate']; + +const normalizeSubscriber = (value: string): string => value.trim().toLowerCase(); +const now = () => new Date().toISOString(); +const generateId = (prefix: string): string => `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + +const seedProfiles = (): Record => { + const profiles = [ + { subscriber: CURRENT_SUBSCRIBER_FALLBACK, displayName: 'You', bio: 'Tracking subscription spend.', avatar: 'YO', privacy: 'public', role: 'moderator', joinedAt: '2026-01-10T12:00:00.000Z', interests: ['FinOps', 'Streaming', 'Automation'] }, + { subscriber: '0x1f8f6a4c9b2478e559c028f39b2be4f03bb11ad7', displayName: 'Ada Flux', bio: 'Builder focused on clean billing operations.', avatar: 'AF', privacy: 'public', role: 'member', joinedAt: '2026-01-12T12:00:00.000Z', interests: ['Analytics', 'SaaS', 'Metrics'] }, + { subscriber: '0x928ca9b2644b1a4a7cf0f5a7ce3ef6173ef9a200', displayName: 'Mina Vale', bio: 'Helps creators manage recurring revenue.', avatar: 'MV', privacy: 'subscribers', role: 'member', joinedAt: '2026-02-01T12:00:00.000Z', interests: ['Creators', 'Community', 'Growth'] }, + { subscriber: '0x31357f0e8b09f5b41fed083ee4f2d10ccde3229c', displayName: 'Jon Byte', bio: 'Enjoys experiments with bundled plans.', avatar: 'JB', privacy: 'public', role: 'member', joinedAt: '2026-02-14T12:00:00.000Z', interests: ['Bundles', 'Gaming', 'Forums'] }, + ]; + return profiles.reduce>((acc, profile) => { + acc[normalizeSubscriber(profile.subscriber)] = { ...profile, subscriber: normalizeSubscriber(profile.subscriber) }; + return acc; + }, {}); +}; + +const seedThreads = (profiles: Record): any[] => { + const you = normalizeSubscriber(CURRENT_SUBSCRIBER_FALLBACK); + const ada = normalizeSubscriber('0x1f8f6a4c9b2478e559c028f39b2be4f03bb11ad7'); + const mina = normalizeSubscriber('0x928ca9b2644b1a4a7cf0f5a7ce3ef6173ef9a200'); + return [ + { id: 'thread-welcome', title: 'How are you organizing yearly renewals?', category: 'Billing', authorSubscriber: ada, createdAt: '2026-04-20T09:00:00.000Z', updatedAt: '2026-04-21T15:00:00.000Z', moderationStatus: 'visible', mentions: [you], posts: [{ id: 'post-welcome-1', authorSubscriber: ada, body: 'I keep a yearly bucket and tag high-cost plans. @You have you found a better flow?', createdAt: '2026-04-20T09:00:00.000Z', mentions: [], moderationStatus: 'visible' }] }, + { id: 'thread-directory', title: 'Best profile fields for subscriber discovery', category: 'Community', authorSubscriber: you, createdAt: '2026-04-22T11:30:00.000Z', updatedAt: '2026-04-22T12:15:00.000Z', moderationStatus: 'visible', mentions: [], posts: [{ id: 'post-directory-1', authorSubscriber: you, body: 'Display name, short bio, and interests feel like the minimum.', createdAt: '2026-04-22T11:30:00.000Z', mentions: [], moderationStatus: 'visible' }] }, + ]; +}; + +type SettingsStore = SettingsSlice & UserSlice & CommunitySlice; +type SettingsCreator = StateCreator; + +// ═══════════════════════════════════════════════════════════════════════════ +// Slice Factory +// ═══════════════════════════════════════════════════════════════════════════ + +export const createSettingsSlice: SettingsCreator = (set, get) => { + const initialProfiles = seedProfiles(); + return { + // ── Settings state ───────────────────────────────────────────── + preferredCurrency: 'USD', + notificationsEnabled: true, + exchangeRates: null, + settingsLoading: false, + + setPreferredCurrency: (currency) => { + set({ preferredCurrency: currency }); + void get().updateExchangeRates(); + }, + + setNotificationsEnabled: (enabled) => set({ notificationsEnabled: enabled }), + + updateExchangeRates: async () => { + set({ settingsLoading: true }); + const rates = { base: 'USD', rates: {}, timestamp: Date.now() }; + set({ exchangeRates: rates, settingsLoading: false }); + }, + + initializeSettings: async () => { + const { exchangeRates } = get(); + if (!exchangeRates) { await get().updateExchangeRates(); } + }, + + // ── User state ───────────────────────────────────────────────── + user: null, + subscriptionTier: SubscriptionTier.FREE, + consent: { analytics: false, marketing: false, notifications: true, hasAcceptedPolicy: false }, + + setUser: (user) => set((s) => ({ user, subscriptionTier: user ? (user.subscriptionTier ?? s.subscriptionTier) : SubscriptionTier.FREE })), + setSubscriptionTier: (subscriptionTier) => set({ subscriptionTier }), + setConsent: (newConsent) => set((s) => ({ consent: { ...s.consent, ...newConsent } })), + acceptAll: () => set({ consent: { analytics: true, marketing: true, notifications: true, hasAcceptedPolicy: true } }), + resetConsent: () => set({ consent: { analytics: false, marketing: false, notifications: false, hasAcceptedPolicy: false } }), + + // ── Community state ──────────────────────────────────────────── + communitySubscriber: normalizeSubscriber(CURRENT_SUBSCRIBER_FALLBACK), + communityProfiles: initialProfiles, + communityThreads: seedThreads(initialProfiles), + moderationQueue: [], + + setCommunitySubscriber: (subscriber) => { + const normalized = normalizeSubscriber(subscriber || CURRENT_SUBSCRIBER_FALLBACK); + set((s) => { + const existing = s.communityProfiles[normalized]; + const nextProfiles = existing ? s.communityProfiles : { ...s.communityProfiles, [normalized]: { subscriber: normalized, displayName: 'New Member', bio: '...', avatar: 'NM', privacy: 'public', role: 'member', joinedAt: now(), interests: ['Subscriptions'] } }; + return { communitySubscriber: normalized, communityProfiles: nextProfiles }; + }); + }, + + updateCommunityProfile: (subscriber, profile) => { + const normalized = normalizeSubscriber(subscriber); + set((s) => { + const current = s.communityProfiles[normalized] || { subscriber: normalized, displayName: 'New Member', bio: '', avatar: 'NM', privacy: 'public', role: 'member', joinedAt: now(), interests: [] }; + return { communityProfiles: { ...s.communityProfiles, [normalized]: { ...current, ...profile, subscriber: normalized } } }; + }); + }, + + getCommunitySubscribers: (filter) => { + return Object.values(get().communityProfiles).filter(() => true).sort((a: any, b: any) => a.displayName?.localeCompare(b.displayName)); + }, + + getVisibleProfile: (viewer, target) => { + const profile = get().communityProfiles[normalizeSubscriber(target)]; + return profile ?? null; + }, + + createForumThread: (author, input) => { + const normalized = normalizeSubscriber(author); + const body = input.body.trim(); + if (!input.title.trim() || !body) return { ok: false, reason: 'Title and post are required.' }; + const status = FLAGGED_TERMS.some((t) => body.toLowerCase().includes(t)) ? 'flagged' : 'visible'; + set((s) => { + const thread = { id: generateId('thread'), title: input.title.trim(), category: input.category.trim() || 'General', authorSubscriber: normalized, createdAt: now(), updatedAt: now(), moderationStatus: status, mentions: [], posts: [{ id: generateId('post'), authorSubscriber: normalized, body, createdAt: now(), mentions: [], moderationStatus: status }] }; + const queue = status === 'flagged' ? [...new Set([...s.moderationQueue, thread.id])] : s.moderationQueue; + return { communityThreads: [thread, ...s.communityThreads], moderationQueue: queue }; + }); + return status === 'flagged' ? { ok: true, reason: 'Flagged for review.' } : { ok: true }; + }, + + replyToForumThread: (threadId, author, body) => { + const trimmed = body.trim(); + if (!trimmed) return { ok: false, reason: 'Reply cannot be empty.' }; + const status = FLAGGED_TERMS.some((t) => trimmed.toLowerCase().includes(t)) ? 'flagged' : 'visible'; + set((s) => { + const nextThreads = s.communityThreads.map((t: any) => t.id !== threadId ? t : { ...t, updatedAt: now(), posts: [...t.posts, { id: generateId('post'), authorSubscriber: normalizeSubscriber(author), body: trimmed, createdAt: now(), mentions: [], moderationStatus: status }] }); + const queue = status === 'flagged' ? [...new Set([...s.moderationQueue, threadId])] : s.moderationQueue; + return { communityThreads: nextThreads, moderationQueue: queue }; + }); + return status === 'flagged' ? { ok: true, reason: 'Flagged for review.' } : { ok: true }; + }, + + moderateContent: (threadId, status, postId) => { + set((s) => { + const nextThreads = s.communityThreads.map((t: any) => { + if (t.id !== threadId) return t; + if (!postId) return { ...t, moderationStatus: status }; + const nextPosts = t.posts.map((p: any) => p.id === postId ? { ...p, moderationStatus: status } : p); + return { ...t, posts: nextPosts }; + }); + const queue = nextThreads.filter((t: any) => t.moderationStatus === 'flagged').map((t: any) => t.id); + return { communityThreads: nextThreads, moderationQueue: queue }; + }); + }, + }; +}; diff --git a/src/store/slices/supportSlice.ts b/src/store/slices/supportSlice.ts new file mode 100644 index 00000000..5172344b --- /dev/null +++ b/src/store/slices/supportSlice.ts @@ -0,0 +1,55 @@ +/** + * Support Slice – support ticket management. + */ +import type { StateCreator } from 'zustand'; +import { SubscriptionSupportEvent, SupportTicket, TicketingIntegrationConfig, TicketStatus } from '../../types/support'; + +// ── Interface ─────────────────────────────────────────────────────────── + +export interface SupportSlice { + supportTickets: SupportTicket[]; + supportIntegration: TicketingIntegrationConfig; + createSupportTicket: (event: SubscriptionSupportEvent) => SupportTicket; + assignSupportTicket: (ticketId: string, assignee: string) => void; + updateSupportTicketStatus: (ticketId: string, status: TicketStatus) => void; + syncSupportTicket: (ticketId: string) => void; + linkSupportResolution: (ticketId: string, subscriptionId: string) => void; + setSupportIntegration: (integration: TicketingIntegrationConfig) => void; +} + +const generateId = (prefix: string) => `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + +const mapTicket = (tickets: SupportTicket[], ticketId: string, updater: (t: SupportTicket) => SupportTicket): SupportTicket[] => + tickets.map((t) => (t.id === ticketId ? updater(t) : t)); + +type SupportStore = SupportSlice; +type SupportCreator = StateCreator; + +// ═══════════════════════════════════════════════════════════════════════════ +// Slice Factory +// ═══════════════════════════════════════════════════════════════════════════ + +export const createSupportSlice: SupportCreator = (set, get) => ({ + supportTickets: [], + supportIntegration: { provider: 'internal', enabled: true, defaultAssignee: 'support-team' }, + + createSupportTicket: (event) => { + const ticket: SupportTicket = { id: `ticket-${Date.now()}`, subscriptionId: event.subscriptionId, subject: event.type, description: event.data?.message || '', status: 'open', priority: 'medium', createdAt: new Date(), updatedAt: new Date() } as SupportTicket; + set((s) => ({ supportTickets: [...s.supportTickets, ticket] })); + return ticket; + }, + + assignSupportTicket: (ticketId, assignee) => + set((s) => ({ supportTickets: mapTicket(s.supportTickets, ticketId, (t) => ({ ...t, assignee, updatedAt: new Date() })) })), + + updateSupportTicketStatus: (ticketId, status) => + set((s) => ({ supportTickets: mapTicket(s.supportTickets, ticketId, (t) => ({ ...t, status, updatedAt: new Date() })) })), + + syncSupportTicket: (ticketId) => + set((s) => ({ supportTickets: mapTicket(s.supportTickets, ticketId, (t) => ({ ...t, syncedAt: new Date() })) })), + + linkSupportResolution: (ticketId, subscriptionId) => + set((s) => ({ supportTickets: mapTicket(s.supportTickets, ticketId, (t) => ({ ...t, resolvedForSubscriptionId: subscriptionId, status: 'resolved', updatedAt: new Date() })) })), + + setSupportIntegration: (integration) => set({ supportIntegration: integration }), +}); diff --git a/src/store/slices/transactionQueueTypes.ts b/src/store/slices/transactionQueueTypes.ts new file mode 100644 index 00000000..2d3d9dce --- /dev/null +++ b/src/store/slices/transactionQueueTypes.ts @@ -0,0 +1,33 @@ +/** + * Shared types for the transaction queue. + */ +export type QueuedTransactionProtocol = 'superfluid' | 'sablier'; + +export interface QueuedTransactionPayload { + protocol: QueuedTransactionProtocol; + token: string; + amount: string; + recipientAddress: string; + chainId: number; + startTime?: number; + stopTime?: number; +} + +export interface QueuedTransaction { + id: string; + createdAt: number; + updatedAt: number; + attempts: number; + lastAttemptAt?: number; + conflictKey: string; + status: 'pending' | 'processing'; + payload: QueuedTransactionPayload; + lastError?: string; +} + +export interface ExecuteOrQueueResult { + queued: boolean; + transactionId: string; + streamId?: string; + txHash?: string; +} diff --git a/src/store/slices/types.ts b/src/store/slices/types.ts new file mode 100644 index 00000000..8bc951dd --- /dev/null +++ b/src/store/slices/types.ts @@ -0,0 +1,81 @@ +/** + * Combined state type union for all Zustand store slices. + * Each slice interface is defined in its own file and re-exported here. + * + * The slices pattern follows Zustand's recommended approach: + * https://docs.pmnd.rs/zustand/guides/slices-pattern + */ + +import type { SubscriptionSlice } from './billingSlice'; +import type { InvoiceSlice } from './billingSlice'; +import type { TaxSlice } from './billingSlice'; +import type { UsageSlice } from './billingSlice'; +import type { AccountingSlice } from './billingSlice'; +import type { CancellationSlice } from './billingSlice'; + +import type { WalletSlice } from './walletSlice'; +import type { TransactionQueueSlice } from './walletSlice'; +import type { MerchantSlice } from './walletSlice'; + +import type { SettingsSlice } from './settingsSlice'; +import type { UserSlice } from './settingsSlice'; +import type { CommunitySlice } from './settingsSlice'; + +import type { WebhookSlice } from './engagementSlice'; +import type { GamificationSlice } from './engagementSlice'; +import type { LoyaltySlice } from './engagementSlice'; +import type { AffiliateSlice } from './engagementSlice'; + +import type { FraudSlice } from './riskSlice'; +import type { SlaSlice } from './riskSlice'; + +import type { SandboxSlice } from './devSlice'; +import type { DeveloperPortalSlice } from './devSlice'; + +import type { CampaignSlice } from './marketingSlice'; +import type { SegmentSlice } from './marketingSlice'; +import type { GroupSlice } from './marketingSlice'; + +import type { CalendarSlice } from './calendarSlice'; +import type { NetworkSlice } from './networkSlice'; +import type { SupportSlice } from './supportSlice'; + +import type { MeteringSlice } from './meteringSlice'; +import type { CreditSlice } from './meteringSlice'; +import type { BatchSlice } from './meteringSlice'; +import type { SearchSlice } from './meteringSlice'; + +/** + * Combined state combining all slices. + * This is the root type for the unified Zustand store. + */ +export type AppState = SubscriptionSlice & + InvoiceSlice & + TaxSlice & + UsageSlice & + AccountingSlice & + CancellationSlice & + WalletSlice & + TransactionQueueSlice & + MerchantSlice & + SettingsSlice & + UserSlice & + CommunitySlice & + WebhookSlice & + GamificationSlice & + LoyaltySlice & + AffiliateSlice & + FraudSlice & + SlaSlice & + SandboxSlice & + DeveloperPortalSlice & + CampaignSlice & + SegmentSlice & + GroupSlice & + CalendarSlice & + NetworkSlice & + SupportSlice & + MeteringSlice & + CreditSlice & + BatchSlice & + SearchSlice; diff --git a/src/store/slices/walletSlice.ts b/src/store/slices/walletSlice.ts new file mode 100644 index 00000000..3e129cb0 --- /dev/null +++ b/src/store/slices/walletSlice.ts @@ -0,0 +1,385 @@ +/** + * Wallet Slice – wallet connection, crypto streams, payment methods, + * transaction queue, and merchant onboarding. + */ +import type { StateCreator } from 'zustand'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { + Wallet, CryptoStream, StreamSetup, PaymentMethod, PaymentMethodFormData, + PaymentPriority, PaymentAttempt, +} from '../../types/wallet'; +import { MerchantOnboarding, MerchantOnboardingFormData, OnboardingStep, OnboardingStatus, VerificationTier, MerchantDocument, DocumentType } from '../../types/merchant'; +import { QueuedTransaction, QueuedTransactionPayload, ExecuteOrQueueResult } from './transactionQueueTypes'; + +const WALLET_STORAGE_KEY = '@subtrackr_wallet'; +const PAYMENT_METHODS_STORAGE_KEY = '@subtrackr_payment_methods'; +const PAYMENT_ATTEMPTS_STORAGE_KEY = '@subtrackr_payment_attempts'; + +// ── Interfaces ────────────────────────────────────────────────────────── + +export interface WalletSlice { + wallet: Wallet | null; + walletAddress: string | null; + walletNetwork: string | null; + cryptoStreams: CryptoStream[]; + paymentMethods: PaymentMethod[]; + paymentAttempts: PaymentAttempt[]; + walletLoading: boolean; + walletError: string | null; + connectWallet: () => Promise; + syncWalletConnection: (payload: { address: string; chainId: number; network: string }) => Promise; + disconnectWallet: () => Promise; + updateBalance: () => Promise; + createCryptoStream: (setup: StreamSetup) => Promise; + cancelCryptoStream: (streamId: string) => Promise; + fetchCryptoStreams: () => Promise; + addPaymentMethod: (data: PaymentMethodFormData) => Promise; + removePaymentMethod: (id: string) => Promise; + updatePaymentMethod: (id: string, updates: Partial) => Promise; + verifyPaymentMethod: (id: string) => Promise; + setPaymentMethodPriority: (id: string, priority: PaymentPriority) => Promise; + processPayment: (subscriptionId: string, amount: string, chainId: number, maxGasPriceGwei?: number) => Promise<{ success: boolean; attempt: PaymentAttempt; fallbackAttempts: PaymentAttempt[] }>; + getExpiryInfo: () => { expired: any[]; expiringSoon: any[] }; + getPaymentMethodsByPriority: () => { primary: PaymentMethod[]; backup: PaymentMethod[]; fallback: PaymentMethod[] }; + checkTokenContractUpgrade: (id: string) => Promise; +} + +export interface TransactionQueueSlice { + isOnline: boolean; + isProcessing: boolean; + queuedTransactions: QueuedTransaction[]; + queueLastError: string | null; + initializeConnectivityListener: () => () => void; + refreshConnectivity: () => Promise; + queueTransaction: (payload: QueuedTransactionPayload, errorMessage?: string) => Promise<{ transactionId: string; replacedExisting: boolean }>; + executeOrQueueTransaction: (payload: QueuedTransactionPayload) => Promise; + processQueue: () => Promise; + clearQueue: () => void; + removeTransaction: (transactionId: string) => void; +} + +export interface MerchantSlice { + merchantOnboarding: MerchantOnboarding | null; + merchantLoading: boolean; + merchantError: string | null; + startOnboarding: (data: MerchantOnboardingFormData) => Promise; + submitDocument: (docType: DocumentType, uri: string) => Promise; + nextStep: () => Promise; + previousStep: () => Promise; + requestVerification: () => Promise; + approveVerification: (tier: VerificationTier, notes?: string) => Promise; + rejectVerification: (reason: string) => Promise; + getOnboardingStatus: () => OnboardingStatus; +} + +// ── Helpers ───────────────────────────────────────────────────────────── + +const generateUniqueId = (): string => { + const ts = Date.now().toString(36); + const rand = Math.random().toString(36).substring(2, 8); + return `${ts}-${rand}`; +}; + +const getDefaultSteps = (): OnboardingStep[] => [ + OnboardingStep.BUSINESS_INFO, + OnboardingStep.ID_DOCUMENT, + OnboardingStep.BUSINESS_LICENSE, + OnboardingStep.REVIEW, +]; + +type WalletStore = WalletSlice & TransactionQueueSlice & MerchantSlice; +type WalletCreator = StateCreator; + +// ═══════════════════════════════════════════════════════════════════════════ +// Slice Factory +// ═══════════════════════════════════════════════════════════════════════════ + +export const createWalletSlice: WalletCreator = (set, get) => ({ + // ── Wallet state ────────────────────────────────────────────────── + wallet: null, + walletAddress: null, + walletNetwork: null, + cryptoStreams: [], + paymentMethods: [], + paymentAttempts: [], + walletLoading: false, + walletError: null, + + connectWallet: async () => { + set({ walletLoading: true, walletError: null }); + try { + const savedWallet = await AsyncStorage.getItem(WALLET_STORAGE_KEY); + if (savedWallet) { + const parsed = JSON.parse(savedWallet); + set({ + walletAddress: parsed.address, walletNetwork: parsed.network, wallet: parsed.wallet, walletLoading: false, + }); + const savedMethods = await AsyncStorage.getItem(PAYMENT_METHODS_STORAGE_KEY); + if (savedMethods) set({ paymentMethods: JSON.parse(savedMethods) }); + const savedAttempts = await AsyncStorage.getItem(PAYMENT_ATTEMPTS_STORAGE_KEY); + if (savedAttempts) set({ paymentAttempts: JSON.parse(savedAttempts) }); + return; + } + const mockWallet: Wallet = { + address: '0x742d35Cc6634C0532925a3b844Bc9e7595f0fAb1', chainId: 1, isConnected: true, balance: '0.5', + tokens: [{ symbol: 'ETH', name: 'Ethereum', address: '0x0000000000000000000000000000000000000000', balance: '0.5', decimals: 18 }], + }; + await AsyncStorage.setItem(WALLET_STORAGE_KEY, JSON.stringify({ address: mockWallet.address, network: 'Ethereum Mainnet', wallet: mockWallet })); + set({ wallet: mockWallet, walletAddress: mockWallet.address, walletNetwork: 'Ethereum Mainnet', walletLoading: false }); + } catch (error) { + set({ walletError: error instanceof Error ? error.message : 'Failed to connect wallet', walletLoading: false }); + } + }, + + syncWalletConnection: async ({ address, chainId, network }) => { + const walletData = { address, network, wallet: { address, chainId, isConnected: true, balance: '0', tokens: [] } as Wallet }; + await AsyncStorage.setItem(WALLET_STORAGE_KEY, JSON.stringify(walletData)); + set({ wallet: walletData.wallet, walletAddress: address, walletNetwork: network, walletLoading: false, walletError: null }); + }, + + disconnectWallet: async () => { + try { + await AsyncStorage.removeItem(WALLET_STORAGE_KEY); + set({ wallet: null, walletAddress: null, walletNetwork: null, cryptoStreams: [], paymentMethods: [], paymentAttempts: [] }); + } catch { set({ walletError: 'Failed to disconnect wallet' }); } + }, + + updateBalance: async () => { + set({ walletLoading: true, walletError: null }); + try { await new Promise((resolve) => setTimeout(resolve, 500)); set({ walletLoading: false }); } + catch (error) { set({ walletError: error instanceof Error ? error.message : 'Failed to update balance', walletLoading: false }); } + }, + + createCryptoStream: async (setup) => { + set({ walletLoading: true, walletError: null }); + try { + await new Promise((resolve) => setTimeout(resolve, 2000)); + const newStream: CryptoStream = { id: Date.now().toString(), subscriptionId: 'temp', ...setup, isActive: true, streamId: `stream_${Date.now()}` }; + set((s) => ({ cryptoStreams: [...s.cryptoStreams, newStream], walletLoading: false })); + } catch (error) { + set({ walletError: error instanceof Error ? error.message : 'Failed to create crypto stream', walletLoading: false }); + } + }, + + cancelCryptoStream: async (streamId) => { + set({ walletLoading: true, walletError: null }); + try { + await new Promise((resolve) => setTimeout(resolve, 1000)); + set((s) => ({ cryptoStreams: s.cryptoStreams.map((st) => st.id === streamId ? { ...st, isActive: false } : st), walletLoading: false })); + } catch (error) { + set({ walletError: error instanceof Error ? error.message : 'Failed to cancel crypto stream', walletLoading: false }); + } + }, + + fetchCryptoStreams: async () => { + set({ walletLoading: true, walletError: null }); + try { await new Promise((resolve) => setTimeout(resolve, 1000)); set({ walletLoading: false }); } + catch (error) { set({ walletError: error instanceof Error ? error.message : 'Failed to fetch crypto streams', walletLoading: false }); } + }, + + addPaymentMethod: async (data) => { + set({ walletLoading: true, walletError: null }); + try { + const method: PaymentMethod = { + id: generateUniqueId(), userId: get().walletAddress ?? 'unknown', tokenType: data.tokenType, + tokenAddress: data.tokenAddress, chainId: data.chainId, label: data.label, priority: data.priority, + maxSpendPerInterval: data.maxSpendPerInterval, isVerified: data.tokenType === 'NATIVE', + isActive: true, expiresAt: null, lastUsedAt: null, createdAt: new Date(), updatedAt: new Date(), metadata: {}, + } as PaymentMethod; + const updatedMethods = [...get().paymentMethods, method]; + await AsyncStorage.setItem(PAYMENT_METHODS_STORAGE_KEY, JSON.stringify(updatedMethods)); + set({ paymentMethods: updatedMethods, walletLoading: false }); + return method; + } catch (error) { + set({ walletError: error instanceof Error ? error.message : 'Failed to add payment method', walletLoading: false }); + throw error; + } + }, + + removePaymentMethod: async (id) => { + set({ walletLoading: true, walletError: null }); + try { + const updatedMethods = get().paymentMethods.filter((m) => m.id !== id); + await AsyncStorage.setItem(PAYMENT_METHODS_STORAGE_KEY, JSON.stringify(updatedMethods)); + set({ paymentMethods: updatedMethods, walletLoading: false }); + } catch (error) { set({ walletError: error instanceof Error ? error.message : 'Failed to remove payment method', walletLoading: false }); } + }, + + updatePaymentMethod: async (id, updates) => { + set({ walletLoading: true, walletError: null }); + try { + const updatedMethods = get().paymentMethods.map((m) => m.id === id ? { ...m, ...updates, updatedAt: new Date() } : m); + await AsyncStorage.setItem(PAYMENT_METHODS_STORAGE_KEY, JSON.stringify(updatedMethods)); + set({ paymentMethods: updatedMethods, walletLoading: false }); + } catch (error) { set({ walletError: error instanceof Error ? error.message : 'Failed to update payment method', walletLoading: false }); } + }, + + verifyPaymentMethod: async (id) => { + set({ walletLoading: true, walletError: null }); + try { + const method = get().paymentMethods.find((m) => m.id === id); + if (!method) throw new Error('Payment method not found'); + set({ walletLoading: false }); + return true; + } catch (error) { set({ walletError: error instanceof Error ? error.message : 'Failed to verify payment method', walletLoading: false }); throw error; } + }, + + setPaymentMethodPriority: async (id, priority) => { + set({ walletLoading: true, walletError: null }); + try { + const updatedMethods = get().paymentMethods.map((m) => m.id === id ? { ...m, priority, updatedAt: new Date() } : m); + await AsyncStorage.setItem(PAYMENT_METHODS_STORAGE_KEY, JSON.stringify(updatedMethods)); + set({ paymentMethods: updatedMethods, walletLoading: false }); + } catch (error) { set({ walletError: error instanceof Error ? error.message : 'Failed to update priority', walletLoading: false }); } + }, + + processPayment: async (subscriptionId, amount, chainId, maxGasPriceGwei = 500) => { + set({ walletLoading: true, walletError: null }); + try { + const attempt: PaymentAttempt = { + id: `attempt-${Date.now()}`, paymentMethodId: get().paymentMethods[0]?.id ?? 'mock', + token: 'ETH', amount, chainId, status: 'success', gasPriceGwei: maxGasPriceGwei, + timestamp: Date.now(), + } as PaymentAttempt; + const newAttempts = [...get().paymentAttempts, attempt]; + await AsyncStorage.setItem(PAYMENT_ATTEMPTS_STORAGE_KEY, JSON.stringify(newAttempts)); + set({ paymentAttempts: newAttempts, walletLoading: false }); + return { success: true, attempt, fallbackAttempts: [] }; + } catch (error) { + set({ walletError: error instanceof Error ? error.message : 'Payment processing failed', walletLoading: false }); + throw error; + } + }, + + getExpiryInfo: () => ({ expired: [], expiringSoon: [] }), + getPaymentMethodsByPriority: () => { + const methods = get().paymentMethods; + return { primary: methods.filter((m) => m.priority === 'primary'), backup: methods.filter((m) => m.priority === 'backup'), fallback: methods.filter((m) => !m.priority || m.priority === 'fallback') }; + }, + checkTokenContractUpgrade: async (_id) => false, + + // ── Transaction Queue state ───────────────────────────────────── + isOnline: true, + isProcessing: false, + queuedTransactions: [], + queueLastError: null, + + initializeConnectivityListener: () => { + return () => {}; + }, + + refreshConnectivity: async () => { + set({ isOnline: true }); + if (!get().isOnline) { set({ isOnline: true }); void get().processQueue(); } + }, + + queueTransaction: async (payload, errorMessage) => { + const transactionId = `tx_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + let replacedExisting = false; + set((s) => { + const existing = s.queuedTransactions.find((tx) => tx.conflictKey === payload.protocol + payload.recipientAddress); + const nextQueue = s.queuedTransactions.filter((tx) => tx.conflictKey !== (existing?.conflictKey ?? '')); + if (existing) replacedExisting = true; + const queued: QueuedTransaction = { + id: transactionId, createdAt: Date.now(), updatedAt: Date.now(), attempts: 0, + conflictKey: payload.protocol + payload.recipientAddress, status: 'pending' as const, + payload: payload as any, lastError: errorMessage, + }; + return { queuedTransactions: [...nextQueue, queued], queueLastError: errorMessage ?? null }; + }); + return { transactionId, replacedExisting }; + }, + + executeOrQueueTransaction: async (payload) => { + if (!get().isOnline) { + const queued = await get().queueTransaction(payload, 'Device is offline.'); + return { queued: true, transactionId: queued.transactionId }; + } + return { queued: false, transactionId: `executed_${Date.now()}` }; + }, + + processQueue: async () => { + if (get().isProcessing || !get().isOnline) return; + set({ isProcessing: true, queueLastError: null }); + try { + const sorted = [...get().queuedTransactions].sort((a, b) => a.createdAt - b.createdAt); + for (const tx of sorted) { + if (!get().isOnline) break; + set((s) => ({ queuedTransactions: s.queuedTransactions.filter((q) => q.id !== tx.id) })); + } + } finally { set({ isProcessing: false }); } + }, + + clearQueue: () => set({ queuedTransactions: [], queueLastError: null }), + removeTransaction: (transactionId) => set((s) => ({ queuedTransactions: s.queuedTransactions.filter((tx) => tx.id !== transactionId) })), + + // ── Merchant state ─────────────────────────────────────────────── + merchantOnboarding: null, + merchantLoading: false, + merchantError: null, + + startOnboarding: async (data) => { + set({ merchantLoading: true, merchantError: null }); + try { + const newOnboarding: MerchantOnboarding = { + id: generateUniqueId(), merchantAddress: data.email, steps: getDefaultSteps(), + currentStep: OnboardingStep.BUSINESS_INFO, status: OnboardingStatus.IN_PROGRESS, + documents: [], startedAt: new Date(), updatedAt: new Date(), + }; + set({ merchantOnboarding: newOnboarding, merchantLoading: false }); + } catch (error) { + set({ merchantError: error instanceof Error ? error.message : 'Failed to start onboarding', merchantLoading: false }); + } + }, + + submitDocument: async (docType, uri) => { + set({ merchantLoading: true, merchantError: null }); + try { + const onboarding = get().merchantOnboarding; + if (!onboarding) throw new Error('No onboarding in progress'); + const newDoc: MerchantDocument = { id: generateUniqueId(), type: docType, uri, uploadedAt: new Date(), status: 'pending' }; + set({ merchantOnboarding: { ...onboarding, documents: [...onboarding.documents, newDoc], updatedAt: new Date() }, merchantLoading: false }); + } catch (error) { + set({ merchantError: error instanceof Error ? error.message : 'Failed to submit document', merchantLoading: false }); + } + }, + + nextStep: async () => { + const onboarding = get().merchantOnboarding; + if (!onboarding) return; + const currentIndex = onboarding.steps.indexOf(onboarding.currentStep); + if (currentIndex >= onboarding.steps.length - 1) return; + const currentStep = onboarding.steps[currentIndex + 1]; + const newStatus = currentStep === OnboardingStep.REVIEW ? OnboardingStatus.PENDING_REVIEW : OnboardingStatus.IN_PROGRESS; + set({ merchantOnboarding: { ...onboarding, currentStep, status: newStatus, updatedAt: new Date() } }); + }, + + previousStep: async () => { + const onboarding = get().merchantOnboarding; + if (!onboarding) return; + const currentIndex = onboarding.steps.indexOf(onboarding.currentStep); + if (currentIndex <= 0) return; + set({ merchantOnboarding: { ...onboarding, currentStep: onboarding.steps[currentIndex - 1], status: OnboardingStatus.IN_PROGRESS, updatedAt: new Date() } }); + }, + + requestVerification: async () => { + const onboarding = get().merchantOnboarding; + if (!onboarding) return; + set({ merchantOnboarding: { ...onboarding, status: OnboardingStatus.PENDING_REVIEW, updatedAt: new Date() } }); + }, + + approveVerification: async (tier, notes) => { + const onboarding = get().merchantOnboarding; + if (!onboarding) return; + const limits = tier === VerificationTier.ENHANCED ? { monthlyVolume: 1000000, maxTransactions: 10000 } : { monthlyVolume: 10000, maxTransactions: 100 }; + set({ merchantOnboarding: { ...onboarding, status: OnboardingStatus.VERIFIED, verificationResult: { isVerified: true, tier, reviewedAt: new Date(), reviewerNotes: notes, limits }, updatedAt: new Date() } }); + }, + + rejectVerification: async (reason) => { + const onboarding = get().merchantOnboarding; + if (!onboarding) return; + set({ merchantOnboarding: { ...onboarding, status: OnboardingStatus.REJECTED, verificationResult: { isVerified: false, tier: VerificationTier.BASIC, reviewedAt: new Date(), reviewerNotes: reason, limits: { monthlyVolume: 0, maxTransactions: 0 } }, updatedAt: new Date() } }); + }, + + getOnboardingStatus: () => get().merchantOnboarding?.status ?? OnboardingStatus.NOT_STARTED, +}); diff --git a/src/store/subscriptionStore.ts b/src/store/subscriptionStore.ts index cd6bb4ae..985b2974 100644 --- a/src/store/subscriptionStore.ts +++ b/src/store/subscriptionStore.ts @@ -1,616 +1,8 @@ -import { create } from 'zustand'; -import { persist, createJSONStorage, StateStorage } from 'zustand/middleware'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { - Subscription, // eslint-disable-line - SubscriptionFormData, - SubscriptionStats, - SubscriptionCategory, // eslint-disable-line - BillingCycle, // eslint-disable-line -} from '../types/subscription'; -import { dummySubscriptions } from '../utils/dummyData'; // eslint-disable-line -import { advanceBillingDate } from '../utils/billingDate'; -import { buildBillingPeriod } from '../utils/invoice'; -import { BILLING_CONVERSIONS, CACHE_CONSTANTS } from '../utils/constants/values'; -import { - syncRenewalReminders, - presentChargeSuccessNotification, - presentChargeFailedNotification, - presentDunningRetryNotification, - presentDunningWarningNotification, - presentDunningSuspendedNotification, - presentDunningCancelledNotification, - presentDunningRecoveryNotification, -} from '../services/notificationService'; -import { useCalendarStore } from './calendarStore'; -import { useGamificationStore } from './gamificationStore'; -import { useInvoiceStore } from './invoiceStore'; -import { AchievementTrigger } from '../types/gamification'; -import { errorHandler, AppError } from '../services/errorHandler'; -import { useSettingsStore } from './settingsStore'; -import { currencyService } from '../services/currencyService'; -import { - previewProration, - calculateNetProration, - generateCreditMemo, - applyCreditMemo, - ProrationPreview, - CreditMemo, -} from '../utils/proration'; - -const STORAGE_KEY = 'subtrackr-subscriptions'; -const STORE_VERSION = 1; -const WRITE_DEBOUNCE_MS = CACHE_CONSTANTS.WRITE_DEBOUNCE_MS; - /** - * Generate a unique ID for subscriptions - * Uses timestamp + random component to prevent collisions + * @deprecated Use `useStore` from `./combinedStore` instead. + * + * This file re-exports the combined Zustand store for backward compatibility. + * All stores are now combined into a single store using the slices pattern. + * See `./combinedStore.ts` and `./slices/` for the new architecture. */ -const generateUniqueId = (): string => { - const timestamp = Date.now().toString(36); - const randomComponent = Math.random().toString(36).substring(2, 8); - return `${timestamp}-${randomComponent}`; -}; - -type PersistedSubscriptionSlice = Pick; - -const toValidDate = (value: unknown, fallback = new Date()): Date => { - if (value instanceof Date && !Number.isNaN(value.getTime())) return value; - if (typeof value === 'string' || typeof value === 'number') { - const parsed = new Date(value); - if (!Number.isNaN(parsed.getTime())) return parsed; - } - return fallback; -}; - -const normalizeSubscription = (raw: Partial): Subscription => { - const now = new Date(); - return { - id: raw.id ?? generateUniqueId(), - name: raw.name ?? 'Untitled', - description: raw.description, - category: raw.category ?? SubscriptionCategory.OTHER, - price: Number.isFinite(raw.price) ? (raw.price as number) : 0, - currency: raw.currency ?? 'USD', - billingCycle: raw.billingCycle ?? BillingCycle.MONTHLY, - nextBillingDate: toValidDate(raw.nextBillingDate, now), - isActive: raw.isActive ?? true, - notificationsEnabled: raw.notificationsEnabled ?? true, - isCryptoEnabled: raw.isCryptoEnabled ?? false, - cryptoStreamId: raw.cryptoStreamId, - cryptoToken: raw.cryptoToken, - cryptoAmount: raw.cryptoAmount, - createdAt: toValidDate(raw.createdAt, now), - updatedAt: toValidDate(raw.updatedAt, now), - }; -}; - -const serializeForStorage = (state: PersistedSubscriptionSlice): PersistedSubscriptionSlice => ({ - subscriptions: state.subscriptions.map((sub) => ({ - ...sub, - nextBillingDate: new Date(sub.nextBillingDate), - createdAt: new Date(sub.createdAt), - updatedAt: new Date(sub.updatedAt), - })), -}); - -const migratePersistedState = ( - persisted: unknown, - _version: number -): PersistedSubscriptionSlice => { - if (!persisted || typeof persisted !== 'object') { - return { subscriptions: [] }; - } - - const maybeState = persisted as Partial; - const subscriptions = Array.isArray(maybeState.subscriptions) - ? maybeState.subscriptions.map((entry) => normalizeSubscription(entry as Partial)) - : []; - - return { subscriptions }; -}; - -const pendingWrites = new Map(); -let writeTimer: ReturnType | null = null; -let writeQueue = Promise.resolve(); - -const flushPendingWrites = async (): Promise => { - if (pendingWrites.size === 0) return; - - const writes = Array.from(pendingWrites.entries()); - pendingWrites.clear(); - - writeQueue = writeQueue.then(async () => { - await Promise.all(writes.map(([key, value]) => AsyncStorage.setItem(key, value))); - }); - - try { - await writeQueue; - } catch (error) { - console.warn('Failed to persist subscriptions:', error); - } -}; - -const debouncedAsyncStorage: StateStorage = { - getItem: async (name) => { - if (pendingWrites.has(name)) return pendingWrites.get(name) ?? null; - await writeQueue; - return AsyncStorage.getItem(name); - }, - setItem: async (name, value) => { - pendingWrites.set(name, value); - if (writeTimer) clearTimeout(writeTimer); - writeTimer = setTimeout(() => { - void flushPendingWrites(); - }, WRITE_DEBOUNCE_MS); - }, - removeItem: async (name) => { - pendingWrites.delete(name); - if (writeTimer && pendingWrites.size === 0) { - clearTimeout(writeTimer); - writeTimer = null; - } - await writeQueue; - await AsyncStorage.removeItem(name); - }, -}; - -interface SubscriptionState { - subscriptions: Subscription[]; - stats: SubscriptionStats; - isLoading: boolean; - error: AppError | null; - prorationPreview: ProrationPreview | null; - creditMemos: Record; - - // Actions - addSubscription: (data: SubscriptionFormData) => Promise; - updateSubscription: (id: string, data: Partial) => Promise; - deleteSubscription: (id: string) => Promise; - toggleSubscriptionStatus: (id: string) => Promise; - // new actions added - previewPlanChange: (id: string, newPrice: number, effectiveDate: 'immediate' | 'end_of_period') => ProrationPreview; - executePlanChange: (id: string, newPlanData: Partial, effectiveDate: 'immediate' | 'end_of_period') => Promise; - applyCreditToSubscription: (id: string) => Promise; - /** Simulate or record a billing result (fires local notifications when enabled for this sub). */ - recordBillingOutcome: (id: string, outcome: 'success' | 'failed') => Promise; - fetchSubscriptions: () => Promise; - calculateStats: () => void; -} - -export const useSubscriptionStore = create()( - persist( - (set, get) => ({ - subscriptions: dummySubscriptions, - stats: { - totalActive: 0, - totalMonthlySpend: 0, - totalYearlySpend: 0, - categoryBreakdown: {} as Record, - prorationPreview: null, - creditMemos: {}, - - previewPlanChange: (id: string, newPrice: number, effectiveDate: 'immediate' | 'end_of_period') => { - const sub = get().subscriptions.find((s) => s.id === id); - if (!sub) { - throw new Error('Subscription not found'); - } - - const preview = previewProration(sub, newPrice, effectiveDate); - set({ prorationPreview: preview }); - return preview; - }, - - executePlanChange: async (id: string, newPlanData: Partial, effectiveDate: 'immediate' | 'end_of_period') => { - set({ isLoading: true, error: null }); - try { - const sub = get().subscriptions.find((s) => s.id === id); - if (!sub) throw new Error('Subscription not found'); - - const preview = previewProration(sub, newPlanData.price ?? sub.price, effectiveDate); - - // Generate credit memo if downgrade - let updatedCreditMemos = { ...get().creditMemos }; - if (preview.isCredit && preview.amount > 0) { - const memo = generateCreditMemo(id, preview.amount, preview.description); - updatedCreditMemos[id] = memo; - } - - // Update subscription - const updates: Partial = { - ...newPlanData, - updatedAt: new Date(), - }; - - if (effectiveDate === 'immediate') { - // Reset billing cycle - updates.nextBillingDate = advanceBillingDate(new Date(), newPlanData.billingCycle ?? sub.billingCycle); - } - - set((state) => ({ - subscriptions: state.subscriptions.map((s) => - s.id === id ? { ...s, ...updates } : s - ), - creditMemos: updatedCreditMemos, - prorationPreview: null, - isLoading: false, - })); - - get().calculateStats(); - await syncRenewalReminders(get().subscriptions); - - } catch (error) { - const appError = errorHandler.handleError(error as Error, { - action: 'executePlanChange', - subscriptionId: id, - }); - set({ error: appError, isLoading: false }); - } - }, - - applyCreditToSubscription: async (id: string) => { - const sub = get().subscriptions.find((s) => s.id === id); - const memo = get().creditMemos[id]; - if (!sub || !memo || memo.applied) return; - - const { finalCharge, updatedMemo } = applyCreditMemo(sub.price, memo); - - set((state) => ({ - creditMemos: { - ...state.creditMemos, - [id]: updatedMemo, - }, - })); - - // Could trigger a reduced charge here - console.log(`Applied credit: final charge ${finalCharge}`); - }, - }), - // ... persist config ... - ) -); - // Hydration state: keep loading true until persisted state is read. - isLoading: true, - error: null, - - addSubscription: async (data: SubscriptionFormData) => { - set({ isLoading: true, error: null }); - try { - const newSubscription: Subscription = { - id: generateUniqueId(), - ...data, - isActive: true, - notificationsEnabled: data.notificationsEnabled !== false, - createdAt: new Date(), - updatedAt: new Date(), - }; - - set((state) => ({ - subscriptions: [...state.subscriptions, newSubscription], - isLoading: false, - })); - - get().calculateStats(); - await syncRenewalReminders(get().subscriptions); - await useCalendarStore.getState().syncSubscriptionToCalendars(newSubscription); - - // Gamification Triggers - const gamificationStore = useGamificationStore.getState(); - gamificationStore.addPoints(10); // 10 points for adding a subscription - gamificationStore.checkAchievements(AchievementTrigger.SUBSCRIPTION_ADDED, { - totalSubscriptions: get().subscriptions.length, - price: data.price, - category: data.category, - }); - } catch (error) { - const appError = errorHandler.handleError(error as Error, { - action: 'addSubscription', - subscriptionId: 'new', - metadata: { formData: data }, - }); - set({ - error: appError, - isLoading: false, - }); - } - }, - - updateSubscription: async (id: string, data: Partial) => { - set({ isLoading: true, error: null }); - try { - set((state) => ({ - subscriptions: state.subscriptions.map((sub) => - sub.id === id ? { ...sub, ...data, updatedAt: new Date() } : sub - ), - isLoading: false, - })); - - get().calculateStats(); - await syncRenewalReminders(get().subscriptions); - const updatedSubscription = get().subscriptions.find((sub) => sub.id === id); - if (updatedSubscription) { - await useCalendarStore.getState().syncSubscriptionToCalendars(updatedSubscription); - } - } catch (error) { - const appError = errorHandler.handleError(error as Error, { - action: 'updateSubscription', - subscriptionId: id, - metadata: { updateData: data }, - }); - set({ - error: appError, - isLoading: false, - }); - } - }, - - deleteSubscription: async (id: string) => { - set({ isLoading: true, error: null }); - try { - set((state) => ({ - subscriptions: state.subscriptions.filter((sub) => sub.id !== id), - isLoading: false, - })); - - get().calculateStats(); - await syncRenewalReminders(get().subscriptions); - await useCalendarStore.getState().removeSubscriptionFromCalendars(id); - } catch (error) { - const appError = errorHandler.handleError(error as Error, { - action: 'deleteSubscription', - subscriptionId: id, - }); - set({ - error: appError, - isLoading: false, - }); - } - }, - - toggleSubscriptionStatus: async (id: string) => { - set({ isLoading: true, error: null }); - try { - set((state) => ({ - subscriptions: state.subscriptions.map((sub) => - sub.id === id ? { ...sub, isActive: !sub.isActive, updatedAt: new Date() } : sub - ), - isLoading: false, - })); - - get().calculateStats(); - await syncRenewalReminders(get().subscriptions); - const updatedSubscription = get().subscriptions.find((sub) => sub.id === id); - if (updatedSubscription) { - await useCalendarStore.getState().syncSubscriptionToCalendars(updatedSubscription); - } - } catch (error) { - const appError = errorHandler.handleError(error as Error, { - action: 'toggleSubscriptionStatus', - subscriptionId: id, - }); - set({ - error: appError, - isLoading: false, - }); - } - }, - - recordBillingOutcome: async (id: string, outcome: 'success' | 'failed') => { - const sub = get().subscriptions.find((s) => s.id === id); - if (!sub) return; - - if (outcome === 'failed') { - const dunningEntries = JSON.parse( - (await AsyncStorage.getItem('subtrackr-dunning-entries')) || '{}' - ); - const entry = dunningEntries[id]; - const attempt = (entry?.failedAttempts ?? 0) + 1; - - dunningEntries[id] = { - failedAttempts: attempt, - lastFailureAt: new Date().toISOString(), - currentStage: attempt <= 3 ? 'retry' : attempt <= 5 ? 'warn' : attempt <= 7 ? 'suspend' : 'cancel', - }; - await AsyncStorage.setItem('subtrackr-dunning-entries', JSON.stringify(dunningEntries)); - - if (sub.notificationsEnabled !== false) { - await presentChargeFailedNotification(sub); - if (attempt <= 3) { - await presentDunningRetryNotification(sub, attempt, 3); - } else if (attempt <= 5) { - await presentDunningWarningNotification(sub, attempt); - } else if (attempt <= 7) { - await presentDunningSuspendedNotification(sub); - } else { - await presentDunningCancelledNotification(sub); - } - } - - set({ isLoading: false }); - return; - } - - if (outcome === 'success') { - const hasDunningEntry = await AsyncStorage.getItem('subtrackr-dunning-entries'); - if (hasDunningEntry) { - await AsyncStorage.removeItem('subtrackr-dunning-entries'); - if (sub.notificationsEnabled !== false) { - await presentDunningRecoveryNotification(sub); - } - } - await presentChargeSuccessNotification(sub); - const billingPeriod = buildBillingPeriod(sub); - const next = advanceBillingDate(new Date(sub.nextBillingDate), sub.billingCycle); - const simulatedGas = 0.01 + Math.random() * 0.005; // Simulate 0.01 - 0.015 XLM gas - set((state) => ({ - subscriptions: state.subscriptions.map((s) => - s.id === id - ? { - ...s, - nextBillingDate: next, - updatedAt: new Date(), - totalGasSpent: (s.totalGasSpent || 0) + simulatedGas, - chargeCount: (s.chargeCount || 0) + 1, - lastGasCost: simulatedGas, - gasBudget: s.gasBudget || 0.05, - } - : s - ), - })); - get().calculateStats(); - await syncRenewalReminders(get().subscriptions); - const updatedSubscription = get().subscriptions.find((entry) => entry.id === id); - if (updatedSubscription) { - await useCalendarStore.getState().syncSubscriptionToCalendars(updatedSubscription); - } - - await useInvoiceStore.getState().generateInvoiceFromSubscription( - { - subscription: sub, - period: billingPeriod, - region: 'GLOBAL', - currency: sub.currency, - recipientEmail: `${sub.name.toLowerCase().replace(/[^a-z0-9]+/g, '.')}@billing.local`, - }, - 0 - ); - } - }, - - fetchSubscriptions: async () => { - set({ isLoading: true, error: null }); - try { - // TODO: Replace with remote sync; local storage remains source-of-truth offline. - await new Promise((resolve) => setTimeout(resolve, 1000)); - set({ isLoading: false }); - get().calculateStats(); - await syncRenewalReminders(get().subscriptions); - await useCalendarStore.getState().syncSubscriptions(get().subscriptions); - } catch (error) { - set({ - error: errorHandler.handleError(error as Error, { - action: 'fetchSubscriptions', - }), - isLoading: false, - }); - } - }, - - calculateStats: () => { - const { subscriptions } = get(); - - // Safety check: ensure subscriptions is an array - if (!subscriptions || !Array.isArray(subscriptions)) { - set({ - stats: { - totalActive: 0, - totalMonthlySpend: 0, - totalYearlySpend: 0, - categoryBreakdown: {} as Record, - }, - }); - return; - } - - const activeSubs = subscriptions.filter((sub) => sub.isActive); - - const { preferredCurrency, exchangeRates } = useSettingsStore.getState(); - const rates = exchangeRates?.rates || {}; - - const totalMonthlySpend = activeSubs.reduce((total, sub) => { - const priceInPreferred = currencyService.convert( - sub.price, - sub.currency, - preferredCurrency, - rates - ); - if (sub.billingCycle === 'monthly') return total + priceInPreferred; - if (sub.billingCycle === 'yearly') return total + priceInPreferred / 12; - if (sub.billingCycle === 'weekly') - return total + priceInPreferred * BILLING_CONVERSIONS.WEEKS_PER_MONTH; - return total + priceInPreferred; - }, 0); - - const totalYearlySpend = activeSubs.reduce((total, sub) => { - const priceInPreferred = currencyService.convert( - sub.price, - sub.currency, - preferredCurrency, - rates - ); - if (sub.billingCycle === 'yearly') return total + priceInPreferred; - if (sub.billingCycle === 'monthly') - return total + priceInPreferred * BILLING_CONVERSIONS.MONTHS_PER_YEAR; - if (sub.billingCycle === 'weekly') - return total + priceInPreferred * BILLING_CONVERSIONS.WEEKS_PER_YEAR; - return total + priceInPreferred * BILLING_CONVERSIONS.MONTHS_PER_YEAR; - }, 0); - - - const categoryBreakdown = activeSubs.reduce( - (acc, sub) => { - acc[sub.category] = (acc[sub.category] || 0) + 1; - return acc; - }, - {} as Record - ); - - const totalGasSpent = activeSubs.reduce( - (total, sub) => total + (sub.totalGasSpent || 0), - 0 - ); - - set({ - stats: { - totalActive: activeSubs.length, - totalMonthlySpend, - totalYearlySpend, - categoryBreakdown, - totalGasSpent, - }, - }); - }, - }), - { - name: STORAGE_KEY, - version: STORE_VERSION, - storage: createJSONStorage(() => debouncedAsyncStorage), - partialize: (state) => serializeForStorage({ subscriptions: state.subscriptions }), - migrate: (persistedState, version) => migratePersistedState(persistedState, version), - merge: (persistedState, currentState) => ({ - ...currentState, - ...migratePersistedState(persistedState, STORE_VERSION), - }), - onRehydrateStorage: () => (state, error) => { - if (error) { - useSubscriptionStore.setState({ - error: errorHandler.createError( - new Error('Stored subscription data is corrupted. Loaded fallback data.'), - { action: 'rehydrateSubscriptions' }, - true - ), - subscriptions: [...dummySubscriptions], - isLoading: false, - }); - useSubscriptionStore.getState().calculateStats(); - void syncRenewalReminders(useSubscriptionStore.getState().subscriptions); - return; - } - - const subscriptions = Array.isArray(state?.subscriptions) - ? state.subscriptions - : [...dummySubscriptions]; - useSubscriptionStore.setState({ - subscriptions, - isLoading: false, - error: null, - }); - useSubscriptionStore.getState().calculateStats(); - void syncRenewalReminders(useSubscriptionStore.getState().subscriptions); - void useCalendarStore - .getState() - .syncSubscriptions(useSubscriptionStore.getState().subscriptions); - }, - } - ) -); +export { useStore as useSubscriptionStore } from './combinedStore'; diff --git a/src/store/supportStore.ts b/src/store/supportStore.ts index 706c4510..23f9ab92 100644 --- a/src/store/supportStore.ts +++ b/src/store/supportStore.ts @@ -1,72 +1,5 @@ -import { create } from 'zustand'; -import { - assignTicket, - createTicketFromEvent, - linkTicketResolutionToSubscription, - syncTicketToExternalSystem, -} from '../services/ticketingService'; -import { - SubscriptionSupportEvent, - SupportTicket, - TicketingIntegrationConfig, - TicketStatus, -} from '../types/support'; - -interface SupportState { - tickets: SupportTicket[]; - integration: TicketingIntegrationConfig; - createTicket: (event: SubscriptionSupportEvent) => SupportTicket; - assignTicket: (ticketId: string, assignee: string) => void; - updateTicketStatus: (ticketId: string, status: TicketStatus) => void; - syncTicket: (ticketId: string) => void; - linkResolution: (ticketId: string, subscriptionId: string) => void; - setIntegration: (integration: TicketingIntegrationConfig) => void; -} - -const mapTicket = ( - tickets: SupportTicket[], - ticketId: string, - updater: (ticket: SupportTicket) => SupportTicket -): SupportTicket[] => tickets.map((ticket) => (ticket.id === ticketId ? updater(ticket) : ticket)); - -export const useSupportStore = create((set, get) => ({ - tickets: [], - integration: { provider: 'internal', enabled: true, defaultAssignee: 'support-team' }, - - createTicket: (event) => { - const relatedTicketIds = get() - .tickets.filter((ticket) => ticket.subscriptionId === event.subscriptionId && ticket.status !== 'closed') - .map((ticket) => ticket.id); - const ticket = createTicketFromEvent(event, relatedTicketIds); - set((state) => ({ tickets: [...state.tickets, ticket] })); - return ticket; - }, - - assignTicket: (ticketId, assignee) => - set((state) => ({ tickets: mapTicket(state.tickets, ticketId, (ticket) => assignTicket(ticket, assignee)) })), - - updateTicketStatus: (ticketId, status) => - set((state) => ({ - tickets: mapTicket(state.tickets, ticketId, (ticket) => ({ - ...ticket, - status, - updatedAt: new Date(), - })), - })), - - syncTicket: (ticketId) => - set((state) => ({ - tickets: mapTicket(state.tickets, ticketId, (ticket) => - syncTicketToExternalSystem(ticket, get().integration) - ), - })), - - linkResolution: (ticketId, subscriptionId) => - set((state) => ({ - tickets: mapTicket(state.tickets, ticketId, (ticket) => - linkTicketResolutionToSubscription(ticket, subscriptionId) - ), - })), - - setIntegration: (integration) => set({ integration }), -})); +/** + * @deprecated Use \`useStore\` from \`./combinedStore\` instead. + * All stores are now combined into a single store using the slices pattern. + */ +export { useStore as useSupportStore } from './combinedStore'; diff --git a/src/store/taxStore.ts b/src/store/taxStore.ts index 8c18733c..1ab3574d 100644 --- a/src/store/taxStore.ts +++ b/src/store/taxStore.ts @@ -1,73 +1,5 @@ -import { create } from 'zustand'; -import { buildTaxReport, calculateTaxAmount, scheduleTaxRemittance } from '../services/taxService'; -import { - RemittanceScheduleEntry, - TaxAmount, - TaxCalculationInput, - TaxConfig, - TaxRate, - TaxReport, -} from '../types/tax'; - -interface TaxState { - config: TaxConfig; - calculations: TaxAmount[]; - reports: TaxReport[]; - remittances: RemittanceScheduleEntry[]; - addRate: (rate: TaxRate) => void; - addExemption: (exemption: TaxConfig['exemptions'][number]) => void; - calculateTax: (input: TaxCalculationInput) => TaxAmount; - createReport: (region: string, periodStart: Date, periodEnd: Date) => TaxReport; - setReverseChargeRegions: (regions: string[]) => void; -} - -export const useTaxStore = create((set, get) => ({ - config: { - merchantId: 'default-merchant', - ratesByRegion: [ - { - region: 'US-CA', - taxType: 'sales_tax', - rateBps: 725, - effectiveFrom: new Date('2024-01-01T00:00:00.000Z'), - }, - { - region: 'EU-DE', - taxType: 'vat', - rateBps: 1900, - effectiveFrom: new Date('2024-01-01T00:00:00.000Z'), - }, - ], - remittanceSchedule: 'monthly', - exemptions: [], - reverseChargeRegions: [], - }, - calculations: [], - reports: [], - remittances: [], - - addRate: (rate) => - set((state) => ({ config: { ...state.config, ratesByRegion: [...state.config.ratesByRegion, rate] } })), - - addExemption: (exemption) => - set((state) => ({ config: { ...state.config, exemptions: [...state.config.exemptions, exemption] } })), - - calculateTax: (input) => { - const result = calculateTaxAmount(get().config, input); - set((state) => ({ calculations: [...state.calculations, result] })); - return result; - }, - - createReport: (region, periodStart, periodEnd) => { - const report = buildTaxReport(get().config, get().calculations, periodStart, periodEnd, region); - const remittance = scheduleTaxRemittance(report, get().config.remittanceSchedule); - set((state) => ({ - reports: [...state.reports, report], - remittances: [...state.remittances, remittance], - })); - return report; - }, - - setReverseChargeRegions: (regions) => - set((state) => ({ config: { ...state.config, reverseChargeRegions: regions } })), -})); +/** + * @deprecated Use \`useStore\` from \`./combinedStore\` instead. + * All stores are now combined into a single store using the slices pattern. + */ +export { useStore as useTaxStore } from './combinedStore'; diff --git a/src/store/transactionQueueStore.ts b/src/store/transactionQueueStore.ts index 8fbddd5f..fa63ac65 100644 --- a/src/store/transactionQueueStore.ts +++ b/src/store/transactionQueueStore.ts @@ -1,404 +1,5 @@ -import { create } from 'zustand'; -import { persist, createJSONStorage } from 'zustand/middleware'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import NetInfo, { NetInfoSubscription } from '@react-native-community/netinfo'; - -import walletServiceManager from '../services/walletService'; -import { presentTransactionQueueNotification } from '../services/notificationService'; - -export type QueuedTransactionProtocol = 'superfluid' | 'sablier'; - -export interface QueuedTransactionPayload { - protocol: QueuedTransactionProtocol; - token: string; - amount: string; - recipientAddress: string; - chainId: number; - startTime?: number; - stopTime?: number; -} - -export interface QueuedTransaction { - id: string; - createdAt: number; - updatedAt: number; - attempts: number; - lastAttemptAt?: number; - conflictKey: string; - status: 'pending' | 'processing'; - payload: QueuedTransactionPayload; - lastError?: string; -} - -export interface ExecuteOrQueueResult { - queued: boolean; - transactionId: string; - streamId?: string; - txHash?: string; -} - -interface TransactionQueueState { - isOnline: boolean; - isProcessing: boolean; - queuedTransactions: QueuedTransaction[]; - lastError: string | null; - - initializeConnectivityListener: () => () => void; - refreshConnectivity: () => Promise; - queueTransaction: ( - payload: QueuedTransactionPayload, - errorMessage?: string - ) => Promise<{ transactionId: string; replacedExisting: boolean }>; - executeOrQueueTransaction: (payload: QueuedTransactionPayload) => Promise; - processQueue: () => Promise; - clearQueue: () => void; - removeTransaction: (transactionId: string) => void; -} - -const STORAGE_KEY = 'subtrackr-transaction-queue'; -const STALE_TRANSACTION_TIMEOUT_MS = 30 * 60 * 1000; -const MAX_ATTEMPTS = 3; - -let connectivitySubscription: NetInfoSubscription | null = null; - -const isOnlineState = (isConnected: boolean | null, isInternetReachable: boolean | null): boolean => - Boolean(isConnected) && isInternetReachable !== false; - -const now = (): number => Date.now(); - -const createConflictKey = (payload: QueuedTransactionPayload): string => { - const recipient = payload.recipientAddress.trim().toLowerCase(); - const token = payload.token.trim().toLowerCase(); - return `${payload.protocol}:${payload.chainId}:${token}:${recipient}`; -}; - -const isLikelyNetworkError = (error: unknown): boolean => { - if (!(error instanceof Error)) return false; - const message = error.message.toLowerCase(); - return ( - message.includes('network') || - message.includes('timeout') || - message.includes('timed out') || - message.includes('failed to fetch') || - message.includes('offline') - ); -}; - -const isLikelyConflictError = (error: unknown): boolean => { - if (!(error instanceof Error)) return false; - const message = error.message.toLowerCase(); - return ( - message.includes('nonce') || - message.includes('already known') || - message.includes('replacement transaction underpriced') - ); -}; - -const executeQueuedPayload = async ( - payload: QueuedTransactionPayload -): Promise<{ streamId: string; txHash?: string }> => { - if (payload.protocol === 'superfluid') { - const result = await walletServiceManager.createSuperfluidStream( - payload.token, - payload.amount, - payload.recipientAddress, - payload.chainId - ); - - return { - streamId: result.streamId, - txHash: result.txHash, - }; - } - - const startTime = payload.startTime ?? Math.floor(Date.now() / 1000); - const stopTime = payload.stopTime ?? startTime + 30 * 24 * 60 * 60; - - const streamId = await walletServiceManager.createSablierStream( - payload.token, - payload.amount, - startTime, - stopTime, - payload.recipientAddress, - payload.chainId - ); - - return { streamId }; -}; - -export const useTransactionQueueStore = create()( - persist( - (set, get) => ({ - isOnline: true, - isProcessing: false, - queuedTransactions: [], - lastError: null, - - initializeConnectivityListener: () => { - if (connectivitySubscription) { - return () => { - connectivitySubscription?.(); - connectivitySubscription = null; - }; - } - - connectivitySubscription = NetInfo.addEventListener((state) => { - const online = isOnlineState(state.isConnected, state.isInternetReachable); - const wasOnline = get().isOnline; - set({ isOnline: online }); - - if (!wasOnline && online) { - void presentTransactionQueueNotification( - 'Back online', - 'Queued transactions are being processed now.' - ); - void get().processQueue(); - } - }); - - return () => { - connectivitySubscription?.(); - connectivitySubscription = null; - }; - }, - - refreshConnectivity: async () => { - const state = await NetInfo.fetch(); - const online = isOnlineState(state.isConnected, state.isInternetReachable); - const wasOnline = get().isOnline; - set({ isOnline: online }); - if (!wasOnline && online) { - void get().processQueue(); - } - }, - - queueTransaction: async (payload, errorMessage) => { - const transactionId = `tx_${now()}_${Math.random().toString(36).slice(2, 8)}`; - const createdAt = now(); - const conflictKey = createConflictKey(payload); - - let replacedExisting = false; - - set((state) => { - const existing = state.queuedTransactions.find((tx) => tx.conflictKey === conflictKey); - const nextQueue = state.queuedTransactions.filter((tx) => tx.conflictKey !== conflictKey); - - if (existing) { - replacedExisting = true; - } - - const queued: QueuedTransaction = { - id: transactionId, - createdAt, - updatedAt: createdAt, - attempts: 0, - conflictKey, - status: 'pending', - payload, - lastError: errorMessage, - }; - - return { - queuedTransactions: [...nextQueue, queued], - lastError: errorMessage ?? null, - }; - }); - - await presentTransactionQueueNotification( - 'Transaction queued', - replacedExisting - ? 'Updated a pending transaction with your latest request.' - : 'Transaction will run automatically once you are online.' - ); - - return { transactionId, replacedExisting }; - }, - - executeOrQueueTransaction: async (payload) => { - if (!get().isOnline) { - const queued = await get().queueTransaction(payload, 'Device is offline.'); - return { - queued: true, - transactionId: queued.transactionId, - }; - } - - try { - const executed = await executeQueuedPayload(payload); - return { - queued: false, - transactionId: `executed_${now()}`, - streamId: executed.streamId, - txHash: executed.txHash, - }; - } catch (error) { - if (isLikelyNetworkError(error)) { - set({ isOnline: false }); - const queued = await get().queueTransaction( - payload, - error instanceof Error ? error.message : 'Network unavailable.' - ); - - return { - queued: true, - transactionId: queued.transactionId, - }; - } - - throw error; - } - }, - - processQueue: async () => { - if (get().isProcessing || !get().isOnline) return; - - set({ isProcessing: true, lastError: null }); - - try { - const sortedQueue = [...get().queuedTransactions].sort( - (a, b) => a.createdAt - b.createdAt - ); - - for (const tx of sortedQueue) { - if (!get().isOnline) break; - - const age = now() - tx.createdAt; - if (age > STALE_TRANSACTION_TIMEOUT_MS) { - set((state) => ({ - queuedTransactions: state.queuedTransactions.filter((q) => q.id !== tx.id), - })); - - await presentTransactionQueueNotification( - 'Queued transaction expired', - 'A pending transaction was removed because it became stale.' - ); - continue; - } - - set((state) => ({ - queuedTransactions: state.queuedTransactions.map((queued) => - queued.id === tx.id - ? { - ...queued, - status: 'processing', - attempts: queued.attempts + 1, - lastAttemptAt: now(), - updatedAt: now(), - lastError: undefined, - } - : queued - ), - })); - - try { - await executeQueuedPayload(tx.payload); - - set((state) => ({ - queuedTransactions: state.queuedTransactions.filter( - (queued) => queued.id !== tx.id - ), - })); - - await presentTransactionQueueNotification( - 'Queued transaction sent', - 'A pending transaction has been executed successfully.' - ); - } catch (error) { - if (isLikelyNetworkError(error)) { - set({ - isOnline: false, - lastError: error instanceof Error ? error.message : 'Network unavailable.', - }); - - set((state) => ({ - queuedTransactions: state.queuedTransactions.map((queued) => - queued.id === tx.id - ? { - ...queued, - status: 'pending', - updatedAt: now(), - lastError: - error instanceof Error ? error.message : 'Waiting for connection.', - } - : queued - ), - })); - - break; - } - - if (isLikelyConflictError(error)) { - set((state) => ({ - queuedTransactions: state.queuedTransactions.filter( - (queued) => queued.id !== tx.id - ), - })); - - await presentTransactionQueueNotification( - 'Queued transaction skipped', - 'A pending transaction conflicted with another on-chain transaction and was removed.' - ); - continue; - } - - const updated = get().queuedTransactions.find((queued) => queued.id === tx.id); - const attempts = updated?.attempts ?? tx.attempts + 1; - - if (attempts >= MAX_ATTEMPTS) { - set((state) => ({ - queuedTransactions: state.queuedTransactions.filter( - (queued) => queued.id !== tx.id - ), - lastError: error instanceof Error ? error.message : 'Queued transaction failed.', - })); - - await presentTransactionQueueNotification( - 'Queued transaction failed', - error instanceof Error - ? `Dropped after retries: ${error.message}` - : 'Dropped after retry attempts.' - ); - continue; - } - - set((state) => ({ - queuedTransactions: state.queuedTransactions.map((queued) => - queued.id === tx.id - ? { - ...queued, - status: 'pending', - updatedAt: now(), - lastError: error instanceof Error ? error.message : 'Execution failed.', - } - : queued - ), - lastError: error instanceof Error ? error.message : 'Execution failed.', - })); - } - } - } finally { - set({ isProcessing: false }); - } - }, - - clearQueue: () => { - set({ queuedTransactions: [], lastError: null }); - }, - - removeTransaction: (transactionId) => { - set((state) => ({ - queuedTransactions: state.queuedTransactions.filter((tx) => tx.id !== transactionId), - })); - }, - }), - { - name: STORAGE_KEY, - version: 1, - storage: createJSONStorage(() => AsyncStorage), - partialize: (state) => ({ queuedTransactions: state.queuedTransactions }), - onRehydrateStorage: () => () => { - void useTransactionQueueStore.getState().refreshConnectivity(); - }, - } - ) -); +/** + * @deprecated Use \`useStore\` from \`./combinedStore\` instead. + * All stores are now combined into a single store using the slices pattern. + */ +export { useStore as useTransactionQueueStore } from './combinedStore'; diff --git a/src/store/usageStore.ts b/src/store/usageStore.ts index 5da2134a..91e5be9b 100644 --- a/src/store/usageStore.ts +++ b/src/store/usageStore.ts @@ -1,109 +1,5 @@ -import { create } from 'zustand'; -import { persist, createJSONStorage } from 'zustand/middleware'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { UsageRecord, Quota, QuotaMetric, QuotaStatus } from '../types/usage'; -import { errorHandler } from '../services/errorHandler'; - -interface UsageState { - records: Record; // subscriptionId -> records - quotas: Record; // planId -> quotas - isLoading: boolean; - error: string | null; - - fetchUsage: (subscriptionId: string, planId: string) => Promise; - recordUsage: (subscriptionId: string, metric: QuotaMetric, amount: number) => Promise; - getQuotaStatus: (subscriptionId: string, metric: QuotaMetric) => QuotaStatus; -} - -export const useUsageStore = create()( - persist( - (set, get) => ({ - records: {}, - quotas: {}, - isLoading: false, - error: null, - - fetchUsage: async (_subscriptionId, _planId) => { - set({ isLoading: true, error: null }); - try { - // In a real app, this would call the Soroban contract - // For this implementation, we simulate fetching/caching - - // const response = await sorobanService.getUsage(subscriptionId); - // set((state) => ({ - // records: { ...state.records, [subscriptionId]: response } - // })); - - set({ isLoading: false }); - } catch (error) { - const appError = errorHandler.handleError(error as Error, { - action: 'fetchUsage', - metadata: { subscriptionId: _subscriptionId, planId: _planId }, - }); - set({ error: appError.userMessage, isLoading: false }); - } - }, - - recordUsage: async (subscriptionId, metric, amount) => { - set({ isLoading: true, error: null }); - try { - // Simulate contract call - set((state) => { - const currentRecords = state.records[subscriptionId] || []; - const recordIdx = currentRecords.findIndex((r) => r.metric === metric); - - let updatedRecords; - if (recordIdx > -1) { - updatedRecords = [...currentRecords]; - updatedRecords[recordIdx] = { - ...updatedRecords[recordIdx], - currentUsage: updatedRecords[recordIdx].currentUsage + amount, - }; - } else { - updatedRecords = [ - ...currentRecords, - { - subscriptionId, - metric, - currentUsage: amount, - periodStart: new Date(), - rolloverBalance: 0, - }, - ]; - } - - return { - records: { ...state.records, [subscriptionId]: updatedRecords }, - isLoading: false, - }; - }); - } catch (error) { - const appError = errorHandler.handleError(error as Error, { - action: 'recordUsage', - metadata: { subscriptionId, metric, amount }, - }); - set({ error: appError.userMessage, isLoading: false }); - } - }, - - getQuotaStatus: (subscriptionId, metric) => { - const records = get().records[subscriptionId] || []; - const record = records.find((r) => r.metric === metric); - if (!record) return QuotaStatus.WITHIN_LIMIT; - - // Simplified check (we should fetch plan quotas too) - // For demonstration, let's assume some defaults if not found - const limit = 1000; // Default limit for demo - const usage = record.currentUsage; - - if (usage >= limit) return QuotaStatus.HARD_LIMIT_REACHED; - if (usage >= limit * 0.8) return QuotaStatus.SOFT_LIMIT_REACHED; - return QuotaStatus.WITHIN_LIMIT; - }, - }), - { - name: 'subtrackr-usage-store', - storage: createJSONStorage(() => AsyncStorage), - } - ) -); +/** + * @deprecated Use \`useStore\` from \`./combinedStore\` instead. + * All stores are now combined into a single store using the slices pattern. + */ +export { useStore as useUsageStore } from './combinedStore'; diff --git a/src/store/userStore.ts b/src/store/userStore.ts index 5dc02910..8d3a45e4 100644 --- a/src/store/userStore.ts +++ b/src/store/userStore.ts @@ -1,72 +1,5 @@ -import { create } from 'zustand'; -import { persist, createJSONStorage } from 'zustand/middleware'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { UserProfile } from '../types/api'; -import { SubscriptionTier } from '../types/subscription'; - -interface ConsentState { - analytics: boolean; - marketing: boolean; - notifications: boolean; - hasAcceptedPolicy: boolean; -} - -interface UserState { - user: UserProfile | null; - subscriptionTier: SubscriptionTier; - consent: ConsentState; - setUser: (user: UserProfile | null) => void; - setSubscriptionTier: (subscriptionTier: SubscriptionTier) => void; - setConsent: (consent: Partial) => void; - acceptAll: () => void; - resetConsent: () => void; -} - -export const useUserStore = create()( - persist( - (set) => ({ - user: null, - subscriptionTier: SubscriptionTier.FREE, - consent: { - analytics: false, - marketing: false, - notifications: true, // Default to true for core functionality - hasAcceptedPolicy: false, - }, - setUser: (user) => - set((state) => ({ - user, - subscriptionTier: user - ? (user.subscriptionTier ?? state.subscriptionTier) - : SubscriptionTier.FREE, - })), - setSubscriptionTier: (subscriptionTier) => set(() => ({ subscriptionTier })), - setConsent: (newConsent) => - set((state) => ({ - consent: { ...state.consent, ...newConsent }, - })), - acceptAll: () => - set(() => ({ - consent: { - analytics: true, - marketing: true, - notifications: true, - hasAcceptedPolicy: true, - }, - })), - resetConsent: () => - set(() => ({ - consent: { - analytics: false, - marketing: false, - notifications: false, - hasAcceptedPolicy: false, - }, - })), - }), - { - name: 'subtrackr-user-store', - storage: createJSONStorage(() => AsyncStorage), - } - ) -); +/** + * @deprecated Use \`useStore\` from \`./combinedStore\` instead. + * All stores are now combined into a single store using the slices pattern. + */ +export { useStore as useUserStore } from './combinedStore'; diff --git a/src/store/walletStore.ts b/src/store/walletStore.ts index 97234665..c9a5f57d 100644 --- a/src/store/walletStore.ts +++ b/src/store/walletStore.ts @@ -1,543 +1,5 @@ -import { create } from 'zustand'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { - Wallet, - CryptoStream, - StreamSetup, - PaymentMethod, - PaymentMethodFormData, - PaymentPriority, - PaymentAttempt, - PaymentMethodValidationResult, -} from '../types/wallet'; -import { - PaymentMethodService, - PaymentMethodError, - PaymentMethodErrorCode, - PaymentMethodExpiryCheck, -} from '../services/walletService'; - -interface WalletState { - wallet: Wallet | null; - address: string | null; - network: string | null; - cryptoStreams: CryptoStream[]; - paymentMethods: PaymentMethod[]; - paymentAttempts: PaymentAttempt[]; - isLoading: boolean; - error: string | null; - - connectWallet: () => Promise; - syncWalletConnection: (payload: { - address: string; - chainId: number; - network: string; - }) => Promise; - disconnect: () => Promise; - updateBalance: () => Promise; - createCryptoStream: (setup: StreamSetup) => Promise; - cancelCryptoStream: (streamId: string) => Promise; - fetchCryptoStreams: () => Promise; - - addPaymentMethod: (data: PaymentMethodFormData) => Promise; - removePaymentMethod: (id: string) => Promise; - updatePaymentMethod: (id: string, updates: Partial) => Promise; - verifyPaymentMethod: (id: string) => Promise; - setPaymentMethodPriority: (id: string, priority: PaymentPriority) => Promise; - processPayment: ( - subscriptionId: string, - amount: string, - chainId: number, - maxGasPriceGwei?: number - ) => Promise<{ success: boolean; attempt: PaymentAttempt; fallbackAttempts: PaymentAttempt[] }>; - getExpiryInfo: () => { - expired: PaymentMethodExpiryCheck[]; - expiringSoon: PaymentMethodExpiryCheck[]; - }; - getPaymentMethodsByPriority: () => { - primary: PaymentMethod[]; - backup: PaymentMethod[]; - fallback: PaymentMethod[]; - }; - checkTokenContractUpgrade: (id: string) => Promise; -} - -const WALLET_STORAGE_KEY = '@subtrackr_wallet'; -const PAYMENT_METHODS_STORAGE_KEY = '@subtrackr_payment_methods'; -const PAYMENT_ATTEMPTS_STORAGE_KEY = '@subtrackr_payment_attempts'; - -const paymentService = PaymentMethodService.getInstance(); - -export const useWalletStore = create((set, get) => ({ - wallet: null, - address: null, - network: null, - cryptoStreams: [], - paymentMethods: [], - paymentAttempts: [], - isLoading: false, - error: null, - - connectWallet: async () => { - set({ isLoading: true, error: null }); - try { - const savedWallet = await AsyncStorage.getItem(WALLET_STORAGE_KEY); - - if (savedWallet) { - const parsed = JSON.parse(savedWallet); - set({ - address: parsed.address, - network: parsed.network, - wallet: parsed.wallet, - isLoading: false, - }); - - const savedMethods = await AsyncStorage.getItem(PAYMENT_METHODS_STORAGE_KEY); - if (savedMethods) { - set({ paymentMethods: JSON.parse(savedMethods) }); - } - - const savedAttempts = await AsyncStorage.getItem(PAYMENT_ATTEMPTS_STORAGE_KEY); - if (savedAttempts) { - set({ paymentAttempts: JSON.parse(savedAttempts) }); - } - - return; - } - - const mockWallet: Wallet = { - address: '0x742d35Cc6634C0532925a3b844Bc9e7595f0fAb1', - chainId: 1, - isConnected: true, - balance: '0.5', - tokens: [ - { - symbol: 'ETH', - name: 'Ethereum', - address: '0x0000000000000000000000000000000000000000', - balance: '0.5', - decimals: 18, - }, - { - symbol: 'USDC', - name: 'USD Coin', - address: '0xA0b86a33E6441b8b4b8b8b8b8b8b8b8b8b8b8b8', - balance: '1000', - decimals: 6, - }, - ], - }; - - const walletData = { - address: mockWallet.address, - network: 'Ethereum Mainnet', - wallet: mockWallet, - }; - - await AsyncStorage.setItem(WALLET_STORAGE_KEY, JSON.stringify(walletData)); - - set({ - wallet: mockWallet, - address: mockWallet.address, - network: 'Ethereum Mainnet', - isLoading: false, - }); - } catch (error) { - set({ - error: error instanceof Error ? error.message : 'Failed to connect wallet', - isLoading: false, - }); - } - }, - - syncWalletConnection: async ({ address, chainId, network }) => { - const walletData = { - address, - network, - wallet: { - address, - chainId, - isConnected: true, - balance: '0', - tokens: [], - } as Wallet, - }; - - await AsyncStorage.setItem(WALLET_STORAGE_KEY, JSON.stringify(walletData)); - set({ - wallet: walletData.wallet, - address, - network, - isLoading: false, - error: null, - }); - }, - - disconnect: async () => { - try { - await AsyncStorage.removeItem(WALLET_STORAGE_KEY); - set({ - wallet: null, - address: null, - network: null, - cryptoStreams: [], - paymentMethods: [], - paymentAttempts: [], - }); - } catch (error) { - set({ error: 'Failed to disconnect wallet' }); - } - }, - - updateBalance: async () => { - const { wallet } = get(); - if (!wallet) return; - - set({ isLoading: true, error: null }); - try { - await new Promise((resolve) => setTimeout(resolve, 500)); - set({ isLoading: false }); - } catch (error) { - set({ - error: error instanceof Error ? error.message : 'Failed to update balance', - isLoading: false, - }); - } - }, - - createCryptoStream: async (setup: StreamSetup) => { - set({ isLoading: true, error: null }); - try { - await new Promise((resolve) => setTimeout(resolve, 2000)); - - const newStream: CryptoStream = { - id: Date.now().toString(), - subscriptionId: 'temp', - ...setup, - isActive: true, - streamId: `stream_${Date.now()}`, - }; - - set((state) => ({ - cryptoStreams: [...state.cryptoStreams, newStream], - isLoading: false, - })); - } catch (error) { - set({ - error: error instanceof Error ? error.message : 'Failed to create crypto stream', - isLoading: false, - }); - } - }, - - cancelCryptoStream: async (streamId: string) => { - set({ isLoading: true, error: null }); - try { - await new Promise((resolve) => setTimeout(resolve, 1000)); - - set((state) => ({ - cryptoStreams: state.cryptoStreams.map((stream) => - stream.id === streamId ? { ...stream, isActive: false } : stream - ), - isLoading: false, - })); - } catch (error) { - set({ - error: error instanceof Error ? error.message : 'Failed to cancel crypto stream', - isLoading: false, - }); - } - }, - - fetchCryptoStreams: async () => { - set({ isLoading: true, error: null }); - try { - await new Promise((resolve) => setTimeout(resolve, 1000)); - set({ isLoading: false }); - } catch (error) { - set({ - error: error instanceof Error ? error.message : 'Failed to fetch crypto streams', - isLoading: false, - }); - } - }, - - addPaymentMethod: async (data: PaymentMethodFormData) => { - set({ isLoading: true, error: null }); - try { - const { paymentMethods, address } = get(); - if (!address) { - throw new PaymentMethodError( - PaymentMethodErrorCode.VERIFICATION_FAILED, - 'Wallet not connected.', - 'Connect your wallet first.' - ); - } - - const canAdd = paymentService.canAddMethod(paymentMethods.length); - if (!canAdd.canAdd) { - throw new PaymentMethodError( - PaymentMethodErrorCode.MAX_METHODS, - canAdd.reason!, - 'Remove an existing payment method first.' - ); - } - - const validation = paymentService.validatePaymentMethodForm(data); - if (!validation.isValid) { - throw new PaymentMethodError( - PaymentMethodErrorCode.INVALID_TOKEN, - validation.errors.join('; '), - 'Fix the validation errors and try again.' - ); - } - - const isDup = paymentService.isDuplicateMethod( - paymentMethods, - data.tokenAddress, - data.chainId, - data.tokenType - ); - if (isDup) { - throw new PaymentMethodError( - PaymentMethodErrorCode.DUPLICATE, - 'A payment method with this token and chain already exists.', - 'Use a different token or chain.' - ); - } - - const newMethod: PaymentMethod = { - id: paymentService.generateId(), - userId: address, - tokenType: data.tokenType, - tokenAddress: data.tokenAddress, - chainId: data.chainId, - label: data.label, - priority: data.priority, - maxSpendPerInterval: data.maxSpendPerInterval, - isVerified: data.tokenType === 'NATIVE', - isActive: true, - expiresAt: null, - lastUsedAt: null, - createdAt: new Date(), - updatedAt: new Date(), - metadata: {}, - }; - - if (!newMethod.isVerified) { - await paymentService.verifyPaymentMethod(newMethod); - newMethod.isVerified = true; - } - - const updatedMethods = [...paymentMethods, newMethod]; - await AsyncStorage.setItem(PAYMENT_METHODS_STORAGE_KEY, JSON.stringify(updatedMethods)); - - set({ - paymentMethods: updatedMethods, - isLoading: false, - }); - - return newMethod; - } catch (error) { - set({ - error: error instanceof Error ? error.message : 'Failed to add payment method', - isLoading: false, - }); - throw error; - } - }, - - removePaymentMethod: async (id: string) => { - set({ isLoading: true, error: null }); - try { - const { paymentMethods } = get(); - const updatedMethods = paymentMethods.filter((m) => m.id !== id); - await AsyncStorage.setItem(PAYMENT_METHODS_STORAGE_KEY, JSON.stringify(updatedMethods)); - set({ paymentMethods: updatedMethods, isLoading: false }); - } catch (error) { - set({ - error: error instanceof Error ? error.message : 'Failed to remove payment method', - isLoading: false, - }); - } - }, - - updatePaymentMethod: async (id: string, updates: Partial) => { - set({ isLoading: true, error: null }); - try { - const { paymentMethods } = get(); - const updatedMethods = paymentMethods.map((m) => - m.id === id ? { ...m, ...updates, updatedAt: new Date() } : m - ); - await AsyncStorage.setItem(PAYMENT_METHODS_STORAGE_KEY, JSON.stringify(updatedMethods)); - set({ paymentMethods: updatedMethods, isLoading: false }); - } catch (error) { - set({ - error: error instanceof Error ? error.message : 'Failed to update payment method', - isLoading: false, - }); - } - }, - - verifyPaymentMethod: async (id: string) => { - set({ isLoading: true, error: null }); - try { - const { paymentMethods } = get(); - const method = paymentMethods.find((m) => m.id === id); - if (!method) { - throw new Error('Payment method not found'); - } - - const verified = await paymentService.verifyPaymentMethod(method); - if (verified) { - const updatedMethods = paymentMethods.map((m) => - m.id === id ? { ...m, isVerified: true, updatedAt: new Date() } : m - ); - await AsyncStorage.setItem(PAYMENT_METHODS_STORAGE_KEY, JSON.stringify(updatedMethods)); - set({ paymentMethods: updatedMethods, isLoading: false }); - } - return verified; - } catch (error) { - set({ - error: error instanceof Error ? error.message : 'Failed to verify payment method', - isLoading: false, - }); - throw error; - } - }, - - setPaymentMethodPriority: async (id: string, priority: PaymentPriority) => { - set({ isLoading: true, error: null }); - try { - const { paymentMethods } = get(); - const method = paymentMethods.find((m) => m.id === id); - if (!method) { - throw new Error('Payment method not found'); - } - - const updatedMethods = paymentMethods.map((m) => - m.id === id ? { ...m, priority, updatedAt: new Date() } : m - ); - await AsyncStorage.setItem(PAYMENT_METHODS_STORAGE_KEY, JSON.stringify(updatedMethods)); - set({ paymentMethods: updatedMethods, isLoading: false }); - } catch (error) { - set({ - error: error instanceof Error ? error.message : 'Failed to update payment method priority', - isLoading: false, - }); - } - }, - - processPayment: async ( - subscriptionId: string, - amount: string, - chainId: number, - maxGasPriceGwei: number = 500 - ) => { - set({ isLoading: true, error: null }); - try { - const { paymentMethods } = get(); - const result = await paymentService.processPaymentWithFallback( - paymentMethods, - subscriptionId, - amount, - chainId, - maxGasPriceGwei - ); - - const updatedMethods = paymentMethods.map((m) => { - if (m.id === result.attempt.paymentMethodId) { - return { ...m, lastUsedAt: new Date(), updatedAt: new Date() }; - } - return m; - }); - await AsyncStorage.setItem(PAYMENT_METHODS_STORAGE_KEY, JSON.stringify(updatedMethods)); - - const newAttempts = [...get().paymentAttempts, result.attempt, ...result.fallbackAttempts]; - await AsyncStorage.setItem(PAYMENT_ATTEMPTS_STORAGE_KEY, JSON.stringify(newAttempts)); - - set({ - paymentMethods: updatedMethods, - paymentAttempts: newAttempts, - isLoading: false, - }); - - return result; - } catch (error) { - set({ - error: error instanceof Error ? error.message : 'Payment processing failed', - isLoading: false, - }); - throw error; - } - }, - - getExpiryInfo: () => { - const { paymentMethods } = get(); - const expired = paymentService.getExpiredMethods(paymentMethods); - const expiringSoon = paymentService.getExpiringSoonMethods(paymentMethods); - - return { - expired: expired.map((m) => paymentService.checkExpiry(m)), - expiringSoon: expiringSoon.map((m) => paymentService.checkExpiry(m)), - }; - }, - - getPaymentMethodsByPriority: () => { - const { paymentMethods } = get(); - return { - primary: paymentService.getPrimaryMethods(paymentMethods), - backup: paymentService.getBackupMethods(paymentMethods), - fallback: paymentService.getFallbackMethods(paymentMethods), - }; - }, - - checkTokenContractUpgrade: async (id: string) => { - set({ isLoading: true, error: null }); - try { - const { paymentMethods } = get(); - const method = paymentMethods.find((m) => m.id === id); - if (!method) { - throw new Error('Payment method not found'); - } - - const previousHash = method.metadata.token_code_hash ?? null; - const result = await paymentService.detectTokenContractUpgrade(method, previousHash); - - if (result.upgraded && result.newHash) { - const updatedMethods = paymentMethods.map((m) => - m.id === id - ? { - ...m, - metadata: { ...m.metadata, token_code_hash: result.newHash }, - updatedAt: new Date(), - } - : m - ); - await AsyncStorage.setItem(PAYMENT_METHODS_STORAGE_KEY, JSON.stringify(updatedMethods)); - set({ paymentMethods: updatedMethods, isLoading: false }); - } else if (result.newHash && !previousHash) { - const updatedMethods = paymentMethods.map((m) => - m.id === id - ? { - ...m, - metadata: { ...m.metadata, token_code_hash: result.newHash }, - updatedAt: new Date(), - } - : m - ); - await AsyncStorage.setItem(PAYMENT_METHODS_STORAGE_KEY, JSON.stringify(updatedMethods)); - set({ paymentMethods: updatedMethods, isLoading: false }); - } - - set({ isLoading: false }); - return result.upgraded; - } catch (error) { - set({ - error: error instanceof Error ? error.message : 'Failed to check token contract upgrade', - isLoading: false, - }); - return false; - } - }, -})); +/** + * @deprecated Use `useStore` from `./combinedStore` instead. + * All stores are now combined into a single store using the slices pattern. + */ +export { useStore as useWalletStore } from './combinedStore'; diff --git a/src/store/webhookStore.ts b/src/store/webhookStore.ts index f1f949ab..2eb2bb12 100644 --- a/src/store/webhookStore.ts +++ b/src/store/webhookStore.ts @@ -1,304 +1,10 @@ -import { create } from 'zustand'; -import { createJSONStorage, persist } from 'zustand/middleware'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { - WebhookAnalytics, - WebhookConfig, - WebhookDelivery, - WebhookDeliveryStatus, - WebhookEventType, - WebhookRetryPolicy, -} from '../types/webhook'; -import { BillingCycle } from '../types/subscription'; +/** + * @deprecated Use \`useStore\` from \`./combinedStore\` instead. + * All stores are now combined into a single store using the slices pattern. + */ +export { useStore as useWebhookStore } from './combinedStore'; -const STORAGE_KEY = 'subtrackr-webhooks'; -const DEFAULT_RETRY_POLICY: WebhookRetryPolicy = { - maxRetries: 5, - initialDelayMs: 250, - maxDelayMs: 8_000, - backoffFactor: 2, -}; - -const now = (): number => Date.now(); - -const createId = (prefix: string): string => - `${prefix}_${now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; - -const calculateAnalytics = (webhookId: string, deliveries: WebhookDelivery[]): WebhookAnalytics => { - const scoped = deliveries.filter((delivery) => delivery.webhookId === webhookId); - const totalDeliveries = scoped.length; - const successfulDeliveries = scoped.filter((delivery) => delivery.status === 'delivered').length; - const failedDeliveries = scoped.filter((delivery) => delivery.status === 'failed').length; - const pendingDeliveries = scoped.filter((delivery) => - ['pending', 'retrying', 'paused'].includes(delivery.status) - ).length; - const retryCount = scoped.reduce((sum, delivery) => sum + Math.max(0, delivery.attempts - 1), 0); - const avgAttempts = totalDeliveries - ? scoped.reduce((sum, delivery) => sum + delivery.attempts, 0) / totalDeliveries - : 0; - const latencySamples = scoped - .map((delivery) => delivery.latencyMs) - .filter((latency): latency is number => typeof latency === 'number'); - - return { - webhookId, - totalDeliveries, - successfulDeliveries, - failedDeliveries, - retryCount, - pendingDeliveries, - successRate: totalDeliveries ? successfulDeliveries / totalDeliveries : 0, - avgAttempts, - avgLatencyMs: latencySamples.length - ? latencySamples.reduce((sum, latency) => sum + latency, 0) / latencySamples.length - : 0, - lastSuccessAt: scoped - .filter((delivery) => delivery.status === 'delivered' && delivery.deliveredAt) - .map((delivery) => delivery.deliveredAt as number) - .sort((a, b) => b - a)[0], - lastFailureAt: scoped - .filter((delivery) => delivery.status === 'failed' && delivery.updatedAt) - .map((delivery) => delivery.updatedAt) - .sort((a, b) => b - a)[0], - }; -}; - -interface WebhookState { - webhooks: WebhookConfig[]; - deliveries: WebhookDelivery[]; - analytics: Record; - isLoading: boolean; - error: string | null; - - registerWebhook: ( - input: Omit - ) => Promise; - updateWebhook: (id: string, patch: Partial) => Promise; - deleteWebhook: (id: string) => Promise; - pauseWebhook: (id: string) => Promise; - resumeWebhook: (id: string) => Promise; - recordDelivery: ( - delivery: Omit - ) => Promise; - retryDelivery: (deliveryId: string) => Promise; - sendTestEvent: (webhookId: string, eventType?: WebhookEventType) => Promise; - getWebhookDeliveries: (webhookId: string, limit?: number) => WebhookDelivery[]; - getAnalytics: (webhookId: string) => WebhookAnalytics; - refreshAnalytics: (webhookId?: string) => void; - setWebhookState: (webhooks: WebhookConfig[]) => void; -} - -export const useWebhookStore = create()( - persist( - (set, get) => ({ - webhooks: [], - deliveries: [], - analytics: {}, - isLoading: false, - error: null, - - registerWebhook: async (input) => { - const webhook: WebhookConfig = { - ...input, - id: createId('whk'), - createdAt: now(), - updatedAt: now(), - successCount: 0, - failureCount: 0, - }; - set((state) => ({ - webhooks: [...state.webhooks, webhook], - analytics: { - ...state.analytics, - [webhook.id]: calculateAnalytics(webhook.id, state.deliveries), - }, - })); - return webhook; - }, - - updateWebhook: async (id, patch) => { - const current = get().webhooks.find((webhook) => webhook.id === id); - if (!current) throw new Error(`Webhook ${id} not found`); - - const next: WebhookConfig = { - ...current, - ...patch, - id, - updatedAt: now(), - }; - - set((state) => ({ - webhooks: state.webhooks.map((webhook) => (webhook.id === id ? next : webhook)), - analytics: { - ...state.analytics, - [id]: calculateAnalytics(id, state.deliveries), - }, - })); - return next; - }, - - deleteWebhook: async (id) => { - set((state) => ({ - webhooks: state.webhooks.filter((webhook) => webhook.id !== id), - deliveries: state.deliveries.filter((delivery) => delivery.webhookId !== id), - analytics: Object.fromEntries( - Object.entries(state.analytics).filter(([webhookId]) => webhookId !== id) - ), - })); - }, - - pauseWebhook: async (id) => get().updateWebhook(id, { isPaused: true }), - - resumeWebhook: async (id) => get().updateWebhook(id, { isPaused: false }), - - recordDelivery: async (delivery) => { - const record: WebhookDelivery = { - ...delivery, - id: createId('del'), - createdAt: now(), - updatedAt: now(), - }; - - set((state) => { - const nextDeliveries = [...state.deliveries, record]; - return { - deliveries: nextDeliveries, - analytics: { - ...state.analytics, - [record.webhookId]: calculateAnalytics(record.webhookId, nextDeliveries), - }, - }; - }); - return record; - }, - - retryDelivery: async (deliveryId) => { - const current = get().deliveries.find((delivery) => delivery.id === deliveryId); - if (!current) throw new Error(`Delivery ${deliveryId} not found`); - - const next: WebhookDelivery = { - ...current, - status: 'retrying', - attempts: current.attempts + 1, - lastAttemptAt: now(), - nextRetryAt: now(), - updatedAt: now(), - }; - - set((state) => { - const nextDeliveries = state.deliveries.map((delivery) => - delivery.id === deliveryId ? next : delivery - ); - return { - deliveries: nextDeliveries, - analytics: { - ...state.analytics, - [next.webhookId]: calculateAnalytics(next.webhookId, nextDeliveries), - }, - }; - }); - return next; - }, - - sendTestEvent: async (webhookId, eventType = 'subscription.created') => { - const webhook = get().webhooks.find((entry) => entry.id === webhookId); - if (!webhook) throw new Error(`Webhook ${webhookId} not found`); - return get().recordDelivery({ - webhookId, - eventId: createId('evt'), - eventType, - url: webhook.url, - payload: { - id: createId('evt'), - webhookId, - eventType, - occurredAt: now(), - merchantId: webhook.merchantId, - previousStatus: 'none', - currentStatus: 'active', - payloadVersion: 1, - subscription: { - id: 'sample_subscription', - planId: 'sample_plan', - subscriberId: 'sample_customer', - status: 'active', - startedAt: now(), - lastChargedAt: now(), - nextChargeAt: now() + 2_592_000_000, - totalPaid: 49, - totalGasSpent: 0, - chargeCount: 1, - pausedAt: 0, - pauseDuration: 0, - refundRequestedAmount: 0, - }, - plan: { - id: 'sample_plan', - merchantId: webhook.merchantId, - name: 'Sample plan', - price: 49, - token: 'USD', - interval: BillingCycle.MONTHLY, - active: true, - subscriberCount: 1, - createdAt: now(), - }, - }, - status: 'delivered', - attempts: 1, - maxAttempts: webhook.retryPolicy.maxRetries, - deliveredAt: now(), - responseCode: 200, - signature: 'sample-signature', - idempotencyKey: createId('idem'), - latencyMs: 120, - }); - }, - - getWebhookDeliveries: (webhookId, limit = 25) => - get() - .deliveries.filter((delivery) => delivery.webhookId === webhookId) - .slice(-Math.max(0, limit)), - - getAnalytics: (webhookId) => { - const analytics = calculateAnalytics(webhookId, get().deliveries); - set((state) => ({ - analytics: { - ...state.analytics, - [webhookId]: analytics, - }, - })); - return analytics; - }, - - refreshAnalytics: (webhookId) => { - if (webhookId) { - get().getAnalytics(webhookId); - return; - } - - const nextAnalytics: Record = {}; - for (const webhook of get().webhooks) { - nextAnalytics[webhook.id] = calculateAnalytics(webhook.id, get().deliveries); - } - set({ analytics: nextAnalytics }); - }, - - setWebhookState: (webhooks) => { - set({ webhooks }); - }, - }), - { - name: STORAGE_KEY, - storage: createJSONStorage(() => AsyncStorage), - partialize: (state) => ({ - webhooks: state.webhooks, - deliveries: state.deliveries, - analytics: state.analytics, - }), - } - ) -); +import type { WebhookEventType, WebhookDeliveryStatus, WebhookRetryPolicy } from '../types/webhook'; export const webhookEventTypes: WebhookEventType[] = [ 'subscription.created', @@ -317,7 +23,6 @@ export const webhookEventTypes: WebhookEventType[] = [ 'subscription.transfer_accepted', ]; -export const defaultRetryPolicy = DEFAULT_RETRY_POLICY; export const webhookStatusLabels: Record = { pending: 'Pending', retrying: 'Retrying', @@ -326,3 +31,12 @@ export const webhookStatusLabels: Record = { paused: 'Paused', skipped: 'Skipped', }; + +const DEFAULT_RETRY_POLICY: WebhookRetryPolicy = { + maxRetries: 5, + initialDelayMs: 250, + maxDelayMs: 8_000, + backoffFactor: 2, +}; + +export const defaultRetryPolicy = DEFAULT_RETRY_POLICY;