diff --git a/.eslintrc.json b/.eslintrc.json index 4f693890..961b5d25 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -22,7 +22,13 @@ "android/", "ios/", ".expo/", - "src/contracts/types/" + "src/contracts/types/", + "app/", + "backend/", + "acbu-backend/", + "src/animations/", + "stellarlend/", + "stellarlend-pr282/" ], "settings": { "import/resolver": { diff --git a/.prettierignore b/.prettierignore index 07bd0801..5e37aa14 100644 --- a/.prettierignore +++ b/.prettierignore @@ -18,6 +18,17 @@ builds/ coverage/ # TypeChain output (generated; do not reformat to avoid churn) src/contracts/types/ +app/ +backend/ +acbu-backend/ +src/animations/ +stellarlend/ +stellarlend-pr282/ +docs/ +load-tests/ +README.md +contracts/**/*.md +contracts/**/test_snapshots/ # Rust build artifacts (local/generated) contracts/target/ # Backup/fixed package snapshots diff --git a/app/screens/CalendarIntegrationScreen.tsx b/app/screens/CalendarIntegrationScreen.tsx new file mode 100644 index 00000000..b23270ac --- /dev/null +++ b/app/screens/CalendarIntegrationScreen.tsx @@ -0,0 +1 @@ +export { default } from '../../src/screens/CalendarIntegrationScreen'; diff --git a/app/services/calendarService.ts b/app/services/calendarService.ts new file mode 100644 index 00000000..c3820e87 --- /dev/null +++ b/app/services/calendarService.ts @@ -0,0 +1 @@ +export * from '../../src/services/calendarService'; diff --git a/app/stores/calendarStore.ts b/app/stores/calendarStore.ts new file mode 100644 index 00000000..27e5b5c1 --- /dev/null +++ b/app/stores/calendarStore.ts @@ -0,0 +1 @@ +export * from '../../src/store/calendarStore'; diff --git a/contracts/invoice/src/lib.rs b/contracts/invoice/src/lib.rs index e748b0a1..7478b851 100644 --- a/contracts/invoice/src/lib.rs +++ b/contracts/invoice/src/lib.rs @@ -330,7 +330,7 @@ mod tests { name: String::from_str(&env, "Pro Plan"), price: 10_000, token: merchant.clone(), - interval: Interval::Monthly, + interval: subtrackr_types::Interval::Monthly, active: true, subscriber_count: 1, created_at: 1_750_000_000, @@ -387,7 +387,7 @@ mod tests { name: String::from_str(&env, "Pro Plan"), price: 10_000, token: Address::generate(&env), - interval: Interval::Monthly, + interval: subtrackr_types::Interval::Monthly, active: true, subscriber_count: 1, created_at: 1_750_000_000, diff --git a/jest.config.js b/jest.config.js index a074be93..5f027892 100644 --- a/jest.config.js +++ b/jest.config.js @@ -7,7 +7,12 @@ module.exports = { collectCoverageFrom: ['src/**/*.{ts,tsx}', 'chaos/**/*.ts', '!src/**/*.d.ts', '!src/**/index.ts'], testMatch: ['**/__tests__/**/*.(test|spec).[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)'], modulePathIgnorePatterns: ['/e2e'], - testPathIgnorePatterns: ['/node_modules/', '/e2e/', '/load-tests/'], + testPathIgnorePatterns: [ + '/node_modules/', + '/e2e/', + '/load-tests/', + '/src/animations/', + ], moduleNameMapper: { '^@/(.*)$': '/src/$1', }, diff --git a/src/components/config/features.ts b/src/components/config/features.ts new file mode 100644 index 00000000..180d0dd6 --- /dev/null +++ b/src/components/config/features.ts @@ -0,0 +1 @@ +export * from '../../config/features'; diff --git a/src/components/hooks/useFeatureAccess.ts b/src/components/hooks/useFeatureAccess.ts new file mode 100644 index 00000000..d3877210 --- /dev/null +++ b/src/components/hooks/useFeatureAccess.ts @@ -0,0 +1 @@ +export * from '../../hooks/useFeatureAccess'; diff --git a/src/components/services/featureFlags.ts b/src/components/services/featureFlags.ts new file mode 100644 index 00000000..54a7b31b --- /dev/null +++ b/src/components/services/featureFlags.ts @@ -0,0 +1 @@ +export * from '../../services/featureFlags'; diff --git a/src/components/store/userStore.ts b/src/components/store/userStore.ts new file mode 100644 index 00000000..1960db72 --- /dev/null +++ b/src/components/store/userStore.ts @@ -0,0 +1 @@ +export * from '../../store/userStore'; diff --git a/src/components/subscription/AnimatedSubscriptionCard.tsx b/src/components/subscription/AnimatedSubscriptionCard.tsx index f6d8e27d..53b0a554 100644 --- a/src/components/subscription/AnimatedSubscriptionCard.tsx +++ b/src/components/subscription/AnimatedSubscriptionCard.tsx @@ -46,6 +46,7 @@ export const AnimatedSubscriptionCard: React.FC = const scaleAnim = useAnimatedValue(1); const priceAnim = useAnimatedValue(0); const statusAnim = useAnimatedValue(subscription.isActive ? 1 : 0); + const fallbackSharedElementAnim = useAnimatedValue(1); // Shared element transition const fallbackSharedElementAnim = useAnimatedValue(1); diff --git a/src/components/types/feature.ts b/src/components/types/feature.ts new file mode 100644 index 00000000..8d882088 --- /dev/null +++ b/src/components/types/feature.ts @@ -0,0 +1 @@ +export * from '../../types/feature'; diff --git a/src/components/types/subscription.ts b/src/components/types/subscription.ts new file mode 100644 index 00000000..07e2c0f3 --- /dev/null +++ b/src/components/types/subscription.ts @@ -0,0 +1 @@ +export * from '../../types/subscription'; diff --git a/src/components/utils/animations.ts b/src/components/utils/animations.ts new file mode 100644 index 00000000..6430f7e8 --- /dev/null +++ b/src/components/utils/animations.ts @@ -0,0 +1 @@ +export * from '../../utils/animations'; diff --git a/src/components/utils/constants.ts b/src/components/utils/constants.ts new file mode 100644 index 00000000..1cbcc565 --- /dev/null +++ b/src/components/utils/constants.ts @@ -0,0 +1 @@ +export * from '../../utils/constants'; diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index b94a024b..86c853c1 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -19,6 +19,7 @@ import GDPRSettingsScreen from '../screens/GDPRSettingsScreen'; import LanguageSettingsScreen from '../screens/LanguageSettingsScreen'; import SessionManagementScreen from '../screens/SessionManagementScreen'; import SettingsScreen from '../screens/SettingsScreen'; +import CalendarIntegrationScreen from '../screens/CalendarIntegrationScreen'; import AccountingExportScreen from '../screens/AccountingExportScreen'; import WebhookSettingsScreen from '../screens/WebhookSettingsScreen'; import ErrorDashboardScreen from '../screens/ErrorDashboardScreen'; @@ -118,6 +119,11 @@ const HomeStack = () => ( const SettingsStack = () => ( + ( component={LanguageSettingsScreen} options={{ title: 'Language', headerShown: true }} /> + ( component={AdminDashboardScreen} options={{ title: 'Admin Dashboard', headerShown: true }} /> - { cryptoAmount: undefined, }); - useEffect(() => { if (error) { Alert.alert('Error', error.userMessage); @@ -271,7 +273,6 @@ const AddSubscriptionScreen: React.FC = () => { {getCurrencySymbol(formData.currency)} 0 ? formData.price.toString() : ''} onChangeText={(text) => { @@ -333,7 +334,6 @@ const AddSubscriptionScreen: React.FC = () => { - Next Billing Date * = { + google: 'Google Calendar', + apple: 'Apple Calendar', + outlook: 'Outlook Calendar', +}; + +const providerDescriptions: Record = { + google: 'Sync renewal reminders into your primary Google calendar.', + apple: 'Add subscription events to the iCloud calendar linked to your Apple ID.', + outlook: 'Push billing reminders into Outlook and Microsoft 365 calendars.', +}; + +const formatReminderOffset = (offset: number): string => { + if (offset % (24 * 60) === 0) return `${offset / (24 * 60)}d`; + if (offset % 60 === 0) return `${offset / 60}h`; + return `${offset}m`; +}; + +const formatReminderSummary = (offsets: number[]): string => { + if (offsets.length === 0) return 'No calendar alerts'; + return offsets.map(formatReminderOffset).join(', '); +}; + +const CalendarIntegrationScreen: React.FC = () => { + const { + integrations, + syncedEvents, + pendingAuthorizations, + reminderOffsets, + error, + beginConnection, + completeConnection, + cancelConnection, + disconnectConnection, + setReminderOffsets, + toggleReminderOffset, + clearError, + } = useCalendarStore(); + const subscriptions = useSubscriptionStore((state) => state.subscriptions); + + const activeSubscriptions = subscriptions.filter((subscription) => subscription.isActive); + const previewEvent = syncedEvents[0]; + + useEffect(() => { + let isMounted = true; + + const syncSubscriptions = async () => { + await useCalendarStore + .getState() + .syncSubscriptions(useSubscriptionStore.getState().subscriptions); + }; + + const processRedirect = async (redirectUrl: string | null | undefined) => { + if (!redirectUrl) return; + + try { + const integration = await useCalendarStore.getState().handleOAuthRedirect(redirectUrl); + if (!integration || !isMounted) return; + + await syncSubscriptions(); + Alert.alert( + 'Calendar connected', + `${providerLabels[integration.provider]} is now syncing billing reminders.` + ); + } catch (connectError) { + if (!isMounted) return; + + const message = + connectError instanceof Error ? connectError.message : 'Failed to connect calendar.'; + Alert.alert('Connection failed', message); + } + }; + + void Linking.getInitialURL().then((initialUrl) => { + void processRedirect(initialUrl); + }); + + const subscription = Linking.addEventListener('url', ({ url }) => { + void processRedirect(url); + }); + + return () => { + isMounted = false; + subscription.remove(); + }; + }, []); + + const syncAllSubscriptions = async () => { + await useCalendarStore + .getState() + .syncSubscriptions(useSubscriptionStore.getState().subscriptions); + }; + + const handleConnect = async (provider: CalendarProvider) => { + try { + const authorization = await beginConnection(provider); + const canOpen = await Linking.canOpenURL(authorization.authorizationUrl); + + if (!canOpen) { + throw new Error(`Unable to open the ${providerLabels[provider]} authorization page.`); + } + + await Linking.openURL(authorization.authorizationUrl); + Alert.alert( + 'Authorization started', + `Approve access in ${providerLabels[provider]}, then return to SubTrackr. If your device does not redirect automatically, tap Finish connection from the provider row.` + ); + } catch (connectError) { + const message = + connectError instanceof Error ? connectError.message : 'Failed to connect calendar.'; + Alert.alert('Connection failed', message); + } + }; + + const finishConnection = async (provider: CalendarProvider) => { + try { + const integration = await completeConnection(provider); + await syncAllSubscriptions(); + Alert.alert( + 'Calendar connected', + `${providerLabels[integration.provider]} is now syncing billing reminders.` + ); + } catch (connectError) { + const message = + connectError instanceof Error ? connectError.message : 'Failed to connect calendar.'; + Alert.alert('Connection failed', message); + } + }; + + const handleDisconnect = (connectionId: string, provider: CalendarProvider) => { + Alert.alert('Disconnect calendar', `Remove ${providerLabels[provider]} from calendar sync?`, [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Disconnect', + style: 'destructive', + onPress: async () => { + try { + await disconnectConnection(connectionId); + Alert.alert( + 'Calendar disconnected', + `${providerLabels[provider]} has been removed from sync.` + ); + } catch (disconnectError) { + const message = + disconnectError instanceof Error + ? disconnectError.message + : 'Failed to disconnect calendar.'; + Alert.alert('Disconnect failed', message); + } + }, + }, + ]); + }; + + const handleReminderPreset = async (offsets: number[]) => { + setReminderOffsets(offsets); + await syncAllSubscriptions(); + }; + + const handleReminderToggle = async (offset: number) => { + toggleReminderOffset(offset); + await syncAllSubscriptions(); + }; + + return ( + + + + Calendar Integrations + + Connect Google, Apple, or Outlook calendars to keep subscription renewals and billing + reminders in your personal schedule. + + + + + Providers + {CALENDAR_PROVIDERS.map((provider) => { + const integration = integrations.find((entry) => entry.provider === provider); + const pending = pendingAuthorizations[provider]; + const syncedCount = integration + ? syncedEvents.filter((event) => event.connectionId === integration.id).length + : 0; + + return ( + + + {providerLabels[provider]} + {providerDescriptions[provider]} + + {integration + ? `${integration.accountEmail} - ${syncedCount} synced events - ${formatReminderSummary( + integration.reminderOffsets + )}` + : pending + ? 'Awaiting authorization callback' + : 'Not connected'} + + {integration?.lastSyncedAt ? ( + + Last sync {new Date(integration.lastSyncedAt).toLocaleString()} + + ) : null} + + + + {integration ? ( + handleDisconnect(integration.id, provider)}> + Disconnect + + ) : pending ? ( + <> + { + void finishConnection(provider); + }}> + Finish connection + + cancelConnection(provider)}> + Cancel + + + ) : ( + { + void handleConnect(provider); + }}> + Connect + + )} + + + ); + })} + + + + Reminder customization + + Choose a preset, then fine-tune exactly when calendar alerts fire before each renewal. + + + + {REMINDER_PRESETS.map((preset) => { + const selected = + preset.offsets.length === reminderOffsets.length && + preset.offsets.every((offset, index) => reminderOffsets[index] === offset); + + return ( + { + void handleReminderPreset(preset.offsets); + }}> + + {preset.label} + + + {formatReminderSummary(preset.offsets)} + + + ); + })} + + + + {REMINDER_OFFSET_OPTIONS.map((option) => { + const selected = reminderOffsets.includes(option.offset); + return ( + { + void handleReminderToggle(option.offset); + }}> + + {option.label} + + + ); + })} + + + + Current alerts: {formatReminderSummary(reminderOffsets)} + + + + + Sync coverage + + Active subscriptions + {activeSubscriptions.length} + + + Connected providers + {integrations.length} + + + Pending authorizations + {Object.keys(pendingAuthorizations).length} + + + Synced events + {syncedEvents.length} + + + {previewEvent ? ( + + {previewEvent.title} + {previewEvent.notes} + + Starts {new Date(previewEvent.startAt).toLocaleString()} + + + Alerts {formatReminderSummary(previewEvent.reminderOffsets)} + + + ) : ( + + Connect a provider to start generating renewal events from active subscriptions. + + )} + + + {error ? ( + + {error} + + Dismiss + + + ) : null} + + + ); +}; + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.background }, + content: { padding: spacing.lg, gap: spacing.md }, + header: { marginBottom: spacing.sm }, + title: { ...typography.h1, color: colors.text, marginBottom: spacing.xs }, + subtitle: { ...typography.body, color: colors.textSecondary }, + section: { padding: spacing.lg }, + sectionTitle: { ...typography.h3, color: colors.text, marginBottom: spacing.sm }, + sectionDescription: { + ...typography.caption, + color: colors.textSecondary, + marginBottom: spacing.md, + }, + providerCard: { + gap: spacing.md, + paddingVertical: spacing.md, + borderBottomWidth: 1, + borderBottomColor: colors.border, + }, + providerInfo: { gap: spacing.xs }, + providerLabel: { ...typography.body, color: colors.text, fontWeight: '600' }, + providerDescription: { ...typography.caption, color: colors.textSecondary }, + providerMeta: { ...typography.small, color: colors.textSecondary }, + providerActions: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm }, + actionButton: { + backgroundColor: colors.primary, + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md, + borderRadius: borderRadius.md, + }, + secondaryButton: { + backgroundColor: colors.surfaceVariant, + borderWidth: 1, + borderColor: colors.border, + }, + disconnectButton: { backgroundColor: colors.error }, + actionButtonText: { ...typography.caption, color: colors.text, fontWeight: '700' }, + secondaryButtonText: { ...typography.caption, color: colors.text, fontWeight: '600' }, + presetGrid: { gap: spacing.sm }, + presetButton: { + borderWidth: 1, + borderColor: colors.border, + borderRadius: borderRadius.lg, + padding: spacing.md, + backgroundColor: colors.surface, + }, + presetButtonActive: { + borderColor: colors.primary, + backgroundColor: `${colors.primary}20`, + }, + presetLabel: { ...typography.body, color: colors.text, fontWeight: '600' }, + presetLabelActive: { color: colors.text }, + presetMeta: { ...typography.caption, color: colors.textSecondary, marginTop: spacing.xs }, + offsetGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.sm, + marginTop: spacing.md, + }, + offsetChip: { + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md, + borderRadius: borderRadius.full, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.surface, + }, + offsetChipActive: { + borderColor: colors.primary, + backgroundColor: `${colors.primary}22`, + }, + offsetChipText: { ...typography.caption, color: colors.textSecondary, fontWeight: '600' }, + offsetChipTextActive: { color: colors.text }, + currentReminderText: { + ...typography.small, + color: colors.textSecondary, + marginTop: spacing.md, + }, + metricRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: spacing.sm, + }, + metricLabel: { ...typography.body, color: colors.textSecondary }, + metricValue: { ...typography.h3, color: colors.text }, + previewCard: { + marginTop: spacing.md, + padding: spacing.md, + borderRadius: borderRadius.lg, + backgroundColor: `${colors.primary}14`, + borderWidth: 1, + borderColor: `${colors.primary}33`, + }, + previewTitle: { ...typography.body, color: colors.text, fontWeight: '700' }, + previewBody: { ...typography.caption, color: colors.textSecondary, marginTop: spacing.sm }, + previewMeta: { ...typography.small, color: colors.textSecondary, marginTop: spacing.xs }, + emptyPreview: { ...typography.caption, color: colors.textSecondary, marginTop: spacing.sm }, + errorCard: { + borderWidth: 1, + borderColor: `${colors.error}66`, + backgroundColor: `${colors.error}12`, + gap: spacing.md, + }, + errorText: { ...typography.caption, color: colors.error }, + errorButton: { + alignSelf: 'flex-start', + paddingVertical: spacing.xs, + paddingHorizontal: spacing.sm, + borderRadius: borderRadius.md, + borderWidth: 1, + borderColor: `${colors.error}66`, + }, + errorButtonText: { ...typography.caption, color: colors.error, fontWeight: '600' }, +}); + +export default CalendarIntegrationScreen; diff --git a/src/screens/ExportScreen.tsx b/src/screens/ExportScreen.tsx index df35be6d..2fd63984 100644 --- a/src/screens/ExportScreen.tsx +++ b/src/screens/ExportScreen.tsx @@ -8,30 +8,18 @@ import { TouchableOpacity, Alert, Share, - ActivityIndicator, Clipboard, Platform, } from 'react-native'; -import { useNavigation } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { RootStackParamList } from '../navigation/types'; import { colors, spacing, typography, borderRadius } from '../utils/constants'; import { Button } from '../components/common/Button'; import { Card } from '../components/common/Card'; -import { - generateCSV, - exportToJSON, - ExportData, - Subscription, -} from '../utils/importExport'; +import { generateCSV, exportToJSON } from '../utils/importExport'; import { useSubscriptionStore } from '../store'; -type ExportScreenNavigationProp = NativeStackNavigationProp; - type ExportFormat = 'json' | 'csv'; const ExportScreen: React.FC = () => { - const navigation = useNavigation(); const { subscriptions } = useSubscriptionStore(); const [exportFormat, setExportFormat] = useState('json'); @@ -49,14 +37,11 @@ const ExportScreen: React.FC = () => { try { let data: string; - let preview: string; if (exportFormat === 'json') { data = exportToJSON(subscriptions); - preview = JSON.stringify(JSON.parse(data), null, 2); } else { data = generateCSV(subscriptions); - preview = data; } setExportedData(data); @@ -81,10 +66,7 @@ const ExportScreen: React.FC = () => { ] ); } catch (error) { - Alert.alert( - 'Error', - error instanceof Error ? error.message : 'Failed to export data' - ); + Alert.alert('Error', error instanceof Error ? error.message : 'Failed to export data'); } finally { setIsExporting(false); } @@ -114,9 +96,7 @@ const ExportScreen: React.FC = () => { Alert.alert( 'Download', 'In a production app, this would save the file to the device storage.', - [ - { text: 'OK' }, - ] + [{ text: 'OK' }] ); }; @@ -125,42 +105,28 @@ const ExportScreen: React.FC = () => { Export Format setExportFormat('json')} - > + style={[styles.formatButton, exportFormat === 'json' && styles.formatButtonActive]} + onPress={() => setExportFormat('json')}> + ]}> JSON - - Full data with metadata - + Full data with metadata setExportFormat('csv')} - > + style={[styles.formatButton, exportFormat === 'csv' && styles.formatButtonActive]} + onPress={() => setExportFormat('csv')}> + ]}> CSV - - Spreadsheet compatible - + Spreadsheet compatible @@ -175,15 +141,11 @@ const ExportScreen: React.FC = () => { Total Subscriptions - - {subscriptions.filter((s) => s.isActive).length} - + {subscriptions.filter((s) => s.isActive).length} Active - - {subscriptions.filter((s) => !s.isActive).length} - + {subscriptions.filter((s) => !s.isActive).length} Paused @@ -214,9 +176,8 @@ const ExportScreen: React.FC = () => { const renderPreview = () => { if (!showPreview || !exportedData) return null; - const previewText = exportedData.length > 500 - ? exportedData.substring(0, 500) + '...' - : exportedData; + const previewText = + exportedData.length > 500 ? exportedData.substring(0, 500) + '...' : exportedData; return ( @@ -241,25 +202,18 @@ const ExportScreen: React.FC = () => { disabled={isExporting || subscriptions.length === 0} loading={isExporting} /> - + {exportedData && ( - shareData(exportedData)} - > + shareData(exportedData)}> Share copyToClipboard(exportedData)} - > + onPress={() => copyToClipboard(exportedData)}> Copy - + Download @@ -296,9 +250,7 @@ const ExportScreen: React.FC = () => { Export Subscriptions - - Export your subscription data for backup or migration - + Export your subscription data for backup or migration {renderSubscriptionStats()} @@ -310,9 +262,7 @@ const ExportScreen: React.FC = () => { {subscriptions.length === 0 && ( No Subscriptions - - Add some subscriptions first before exporting. - + Add some subscriptions first before exporting. )} @@ -508,4 +458,4 @@ const styles = StyleSheet.create({ }, }); -export default ExportScreen; \ No newline at end of file +export default ExportScreen; diff --git a/src/screens/ImportScreen.tsx b/src/screens/ImportScreen.tsx index 6a53c54a..3cbc1329 100644 --- a/src/screens/ImportScreen.tsx +++ b/src/screens/ImportScreen.tsx @@ -8,13 +8,9 @@ import { TouchableOpacity, Alert, TextInput, - ActivityIndicator, FlatList, Modal, } from 'react-native'; -import { useNavigation } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { RootStackParamList } from '../navigation/types'; import { colors, spacing, typography, borderRadius } from '../utils/constants'; import { Button } from '../components/common/Button'; import { Card } from '../components/common/Card'; @@ -36,10 +32,7 @@ import { } from '../utils/importExport'; import { useSubscriptionStore } from '../store'; -type ImportScreenNavigationProp = NativeStackNavigationProp; - const ImportScreen: React.FC = () => { - const navigation = useNavigation(); const { subscriptions, addSubscription, updateSubscription } = useSubscriptionStore(); const [importMode, setImportMode] = useState('upsert'); @@ -92,9 +85,7 @@ const ImportScreen: React.FC = () => { Alert.alert( 'Import Preview', `Found ${validation.validRows.length} valid subscription(s).\n\n${ - validation.warnings.length > 0 - ? `Warnings: ${validation.warnings.length}\n` - : '' + validation.warnings.length > 0 ? `Warnings: ${validation.warnings.length}\n` : '' }Do you want to proceed with the import?`, [ { @@ -111,10 +102,7 @@ const ImportScreen: React.FC = () => { ] ); } catch (error) { - Alert.alert( - 'Error', - error instanceof Error ? error.message : 'Failed to parse import data' - ); + Alert.alert('Error', error instanceof Error ? error.message : 'Failed to parse import data'); } finally { setIsProcessing(false); } @@ -124,10 +112,7 @@ const ImportScreen: React.FC = () => { setIsProcessing(true); try { - const result = processImport( - { subscriptions: parsedData, mode: importMode }, - subscriptions - ); + const result = processImport({ subscriptions: parsedData, mode: importMode }, subscriptions); setImportResult(result); @@ -181,10 +166,7 @@ const ImportScreen: React.FC = () => { `Imported: ${result.imported}\nUpdated: ${result.updated}\nFailed: ${result.failed}` ); } catch (error) { - Alert.alert( - 'Error', - error instanceof Error ? error.message : 'Failed to complete import' - ); + Alert.alert('Error', error instanceof Error ? error.message : 'Failed to complete import'); } finally { setIsProcessing(false); } @@ -214,18 +196,10 @@ const ImportScreen: React.FC = () => { {(['create', 'upsert', 'replace'] as ImportMode[]).map((mode) => ( setImportMode(mode)} - > + style={[styles.modeButton, importMode === mode && styles.modeButtonActive]} + onPress={() => setImportMode(mode)}> + style={[styles.modeButtonText, importMode === mode && styles.modeButtonTextActive]}> {mode.charAt(0).toUpperCase() + mode.slice(1)} @@ -235,8 +209,8 @@ const ImportScreen: React.FC = () => { {importMode === 'create' ? 'Add new subscriptions only (skip duplicates)' : importMode === 'upsert' - ? 'Update existing or add new subscriptions' - : 'Replace all existing subscriptions'} + ? 'Update existing or add new subscriptions' + : 'Replace all existing subscriptions'} ); @@ -255,14 +229,12 @@ const ImportScreen: React.FC = () => { Errors: {validationResult.errors.slice(0, 5).map((error, index) => ( - + Row {error.row}: {error.message} ))} {validationResult.errors.length > 5 && ( - - ...and {validationResult.errors.length - 5} more - + ...and {validationResult.errors.length - 5} more )} )} @@ -270,7 +242,7 @@ const ImportScreen: React.FC = () => { Warnings: {validationResult.warnings.slice(0, 3).map((warning, index) => ( - + Row {warning.row}: {warning.message} ))} @@ -288,19 +260,19 @@ const ImportScreen: React.FC = () => { Import Results Imported: - - {importResult.imported} - + {importResult.imported} Updated: - - {importResult.updated} - + {importResult.updated} Failed: - 0 ? styles.errorText : styles.successText]}> + 0 ? styles.errorText : styles.successText, + ]}> {importResult.failed} @@ -313,8 +285,7 @@ const ImportScreen: React.FC = () => { visible={showHistory} animationType="slide" presentationStyle="pageSheet" - onRequestClose={() => setShowHistory(false)} - > + onRequestClose={() => setShowHistory(false)}> Import History @@ -335,22 +306,17 @@ const ImportScreen: React.FC = () => { item.status === 'success' && styles.successText, item.status === 'partial' && styles.warningText, item.status === 'failed' && styles.errorText, - ]} - > + ]}> {item.status} - - {new Date(item.timestamp).toLocaleString()} - + {new Date(item.timestamp).toLocaleString()} Imported: {item.imported} | Updated: {item.updated} | Failed: {item.failed} )} - ListEmptyComponent={ - No import history - } + ListEmptyComponent={No import history} /> @@ -361,8 +327,7 @@ const ImportScreen: React.FC = () => { visible={showTemplateModal} animationType="slide" presentationStyle="pageSheet" - onRequestClose={() => setShowTemplateModal(false)} - > + onRequestClose={() => setShowTemplateModal(false)}> Load Template @@ -371,23 +336,13 @@ const ImportScreen: React.FC = () => { - loadTemplate('csv')} - > + loadTemplate('csv')}> CSV Template - - Sample CSV with column headers - + Sample CSV with column headers - loadTemplate('json')} - > + loadTemplate('json')}> JSON Template - - Sample JSON export format - + Sample JSON export format @@ -399,9 +354,7 @@ const ImportScreen: React.FC = () => { Import Subscriptions - - Import subscription data from CSV or JSON - + Import subscription data from CSV or JSON {renderModeSelector()} @@ -502,7 +455,7 @@ const styles = StyleSheet.create({ fontWeight: '600', }, modeButtonTextActive: { - color: colors.textInverted, + color: colors.onPrimary, }, modeDescription: { ...typography.caption, @@ -599,11 +552,11 @@ const styles = StyleSheet.create({ fontWeight: '600', marginBottom: spacing.sm, }, - errorText: { + errorDetailText: { ...typography.caption, color: colors.error, }, - warningText: { + warningDetailText: { ...typography.caption, color: colors.warning, }, @@ -698,4 +651,4 @@ const styles = StyleSheet.create({ }, }); -export default ImportScreen; \ No newline at end of file +export default ImportScreen; diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index ad32f895..8ab28629 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -22,23 +22,14 @@ import { RootStackParamList } from '../navigation/types'; import { useTranslation } from 'react-i18next'; const APP_VERSION = '1.0.0'; -interface Settings { - notificationsEnabled: boolean; - defaultCurrency: string; -} -const SETTINGS_KEY = '@subtrackr_settings'; const SettingsScreen: React.FC = () => { const { t } = useTranslation(); const navigation = useNavigation>(); const { address, disconnect } = useWalletStore(); const { currentNetwork, availableNetworks, setNetwork, initialize } = useNetworkStore(); - const { - preferredCurrency, - notificationsEnabled, - setPreferredCurrency, - setNotificationsEnabled, - } = useSettingsStore(); + const { preferredCurrency, notificationsEnabled, setPreferredCurrency, setNotificationsEnabled } = + useSettingsStore(); const [networkModalVisible, setNetworkModalVisible] = useState(false); @@ -46,7 +37,6 @@ const SettingsScreen: React.FC = () => { initialize(); }, [initialize]); - const handleNotificationToggle = useCallback( (value: boolean) => setNotificationsEnabled(value), [setNotificationsEnabled] @@ -57,7 +47,6 @@ const SettingsScreen: React.FC = () => { [setPreferredCurrency] ); - const handleDisconnectWallet = useCallback(() => { Alert.alert(t('settings.disconnect_wallet'), t('settings.disconnect_wallet_confirm'), [ { text: t('common.cancel'), style: 'cancel' }, @@ -142,8 +131,18 @@ const SettingsScreen: React.FC = () => { accessibilityRole="switch" accessibilityState={{ checked: notificationsEnabled }} /> - + navigation.navigate('CalendarIntegration')} + accessibilityRole="button" + accessibilityLabel={t('settings.calendar_sync')} + accessibilityHint={t('settings.calendar_sync_hint')}> + {t('settings.calendar_sync')} + + {'>'} + + @@ -175,12 +174,13 @@ const SettingsScreen: React.FC = () => { {currency} - ))} - Data Management + + Data Management + navigation.navigate('Import')} @@ -188,7 +188,9 @@ const SettingsScreen: React.FC = () => { accessibilityLabel="Import subscriptions" accessibilityHint="Opens import screen"> Import Subscriptions - + + → + { accessibilityLabel="Export subscriptions" accessibilityHint="Opens export screen"> Export Subscriptions - + + → + - About + + About + {t('settings.sections.about')} diff --git a/src/screens/SubscriptionDetailScreen.tsx b/src/screens/SubscriptionDetailScreen.tsx index 2291d320..01e4f61e 100644 --- a/src/screens/SubscriptionDetailScreen.tsx +++ b/src/screens/SubscriptionDetailScreen.tsx @@ -16,7 +16,7 @@ import { useSubscriptionStore, useSettingsStore } from '../store'; import { currencyService } from '../services/currencyService'; import { formatCurrency } from '../utils/formatting'; import { colors, spacing, typography } from '../utils/constants'; -import { Subscription, SubscriptionCategory } from '../types/subscription'; +import { getCategoryIcon } from '../utils/subscriptionHelpers'; import { RootStackParamList } from '../navigation/types'; // Components @@ -32,17 +32,11 @@ const SubscriptionDetailScreen: React.FC = () => { const route = useRoute(); const { id } = route.params; - const { - subscriptions, - toggleSubscriptionStatus, - deleteSubscription, - updateSubscription, - recordBillingOutcome, - } = useSubscriptionStore(); + const { subscriptions, toggleSubscriptionStatus, updateSubscription, recordBillingOutcome } = + useSubscriptionStore(); const { preferredCurrency, exchangeRates } = useSettingsStore(); const rates = exchangeRates?.rates || {}; - const subscription = useMemo(() => subscriptions?.find((s) => s.id === id), [id, subscriptions]); const [loading, setLoading] = useState(!subscription); @@ -82,21 +76,6 @@ const SubscriptionDetailScreen: React.FC = () => { } }, [subscription, navigation]); - const categoryIcon = useMemo(() => { - if (!subscription) return '📦'; - const icons: Record = { - [SubscriptionCategory.STREAMING]: '🎬', - [SubscriptionCategory.SOFTWARE]: '💻', - [SubscriptionCategory.GAMING]: '🎮', - [SubscriptionCategory.PRODUCTIVITY]: '📊', - [SubscriptionCategory.FITNESS]: '💪', - [SubscriptionCategory.EDUCATION]: '📚', - [SubscriptionCategory.FINANCE]: '💰', - [SubscriptionCategory.OTHER]: '📦', - }; - return icons[subscription.category] || '📦'; - }, [subscription?.category, subscription]); - if (loading) { return ( @@ -122,109 +101,6 @@ const SubscriptionDetailScreen: React.FC = () => { {/* Header */} - navigation.goBack()} - accessibilityRole="button" - accessibilityLabel="Go back" - hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}> - - - - Subscription Details - - - - - {/* Main Info Card */} - - - {getCategoryIcon(subscription.category)} - - - {subscription.name} - - - {subscription.category.charAt(0).toUpperCase() + subscription.category.slice(1)} - - - - - {subscription.description && ( - {subscription.description} - )} - - - {/* Price Card */} - - Pricing - - - Amount - - {formatCurrency( - currencyService.convert( - subscription.price, - subscription.currency, - preferredCurrency, - rates - ), - preferredCurrency - )} - - {subscription.currency !== preferredCurrency && ( - - Original: {formatCurrency(subscription.price, subscription.currency)} - - )} - - - - Billing Cycle - - {subscription.billingCycle.charAt(0).toUpperCase() + - subscription.billingCycle.slice(1)} - - - - - Next Billing Date - - {new Date(subscription.nextBillingDate).toLocaleDateString('en-US', { - weekday: 'long', - year: 'numeric', - month: 'long', - day: 'numeric', - })} - - - - - {/* Notifications */} - - Billing notifications - - Renewal reminders (1 day before, or 1 hour if due sooner) and charge alerts - - - Enabled for this subscription - - updateSubscription(subscription.id, { notificationsEnabled: value }) - } - trackColor={{ false: colors.border, true: colors.primary }} - thumbColor={colors.text} - /> - - Test charge alerts (local only) - - void recordBillingOutcome(subscription.id, 'success')} - style={styles.simulateLink} - testID="simulate-charge-success-button"> - Simulate successful charge - navigation.goBack()} @@ -233,42 +109,109 @@ const SubscriptionDetailScreen: React.FC = () => { hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}> - Details + + Subscription Details + - {/* Main Identity */} + {/* Main Info Card */} - {categoryIcon} + {getCategoryIcon(subscription.category)} {subscription.name} - {subscription.category} + + {subscription.category.charAt(0).toUpperCase() + subscription.category.slice(1)} + - + + {subscription.description && ( + {subscription.description} + )} - {/* Billing Info */} - - - Amount - - {formatCurrency(subscription.price, subscription.currency)} + {/* Price Card */} + + Pricing + + + Amount + + {formatCurrency( + currencyService.convert( + subscription.price, + subscription.currency, + preferredCurrency, + rates + ), + preferredCurrency + )} + + {subscription.currency !== preferredCurrency && ( + + Original: {formatCurrency(subscription.price, subscription.currency)} + + )} + + + + Billing Cycle + + {subscription.billingCycle.charAt(0).toUpperCase() + + subscription.billingCycle.slice(1)} + + + + + Next Billing Date + + {new Date(subscription.nextBillingDate).toLocaleDateString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + })} - - - Cycle - {subscription.billingCycle} - - + + + + {/* Notifications */} + + Billing notifications + + Renewal reminders (1 day before, or 1 hour if due sooner) and charge alerts + + + Enabled for this subscription + + updateSubscription(subscription.id, { notificationsEnabled: value }) + } + trackColor={{ false: colors.border, true: colors.primary }} + thumbColor={colors.text} + /> + + Test charge alerts (local only) + + void recordBillingOutcome(subscription.id, 'success')} + style={styles.simulateLink} + testID="simulate-charge-success-button"> + Simulate successful charge + + void recordBillingOutcome(subscription.id, 'failed')} + style={styles.simulateLink} + testID="simulate-charge-failed-button"> + Simulate failed charge + + + {/* Gas & Network Status (Stellar/Soroban specific) */} @@ -287,25 +230,6 @@ const SubscriptionDetailScreen: React.FC = () => { - {/* Notifications Toggle */} - - - - Charge Alerts - Get notified before contract execution - - - updateSubscription(subscription.id, { - notificationsEnabled: value, - }) - } - trackColor={{ false: colors.border, true: colors.primary }} - /> - - - {/* Action Management */} Subscription Management @@ -376,6 +300,78 @@ const styles = StyleSheet.create({ placeholder: { width: 40, }, + backIcon: { + padding: spacing.sm, + }, + title: { + ...typography.h3, + color: colors.text, + flex: 1, + textAlign: 'center', + }, + categoryIcon: { + fontSize: 32, + marginRight: spacing.md, + }, + description: { + ...typography.body, + color: colors.textSecondary, + marginTop: spacing.md, + }, + priceCard: { + marginHorizontal: spacing.lg, + marginBottom: spacing.sm, + padding: spacing.lg, + }, + priceRow: { + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: spacing.md, + }, + priceItem: { + flex: 1, + }, + statusCard: { + marginHorizontal: spacing.lg, + marginBottom: spacing.sm, + padding: spacing.lg, + }, + notificationSubtext: { + ...typography.body2, + color: colors.textSecondary, + marginBottom: spacing.md, + }, + switchLabel: { + ...typography.body, + color: colors.text, + flex: 1, + marginRight: spacing.md, + }, + simulateSectionTitle: { + ...typography.caption, + color: colors.textSecondary, + marginTop: spacing.md, + marginBottom: spacing.sm, + fontWeight: '600', + }, + simulateRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.md, + }, + simulateLink: { + paddingVertical: spacing.sm, + }, + simulateLinkText: { + ...typography.body, + color: colors.primary, + textDecorationLine: 'underline', + }, + simulateLinkTextDanger: { + ...typography.body, + color: colors.error, + textDecorationLine: 'underline', + }, mainCard: { marginHorizontal: spacing.lg, marginBottom: spacing.sm, @@ -437,7 +433,6 @@ const styles = StyleSheet.create({ marginTop: spacing.xs, }, nextBillingRow: { - borderTopWidth: 1, borderTopColor: colors.border, paddingTop: spacing.md, @@ -476,15 +471,6 @@ const styles = StyleSheet.create({ color: colors.text, fontWeight: '600', }, - priceLabel: { - ...typography.caption, - color: colors.textSecondary, - }, - priceValue: { - ...typography.h3, - color: colors.text, - marginTop: 4, - }, switchRow: { flexDirection: 'row', alignItems: 'center', diff --git a/src/services/__tests__/calendarService.test.ts b/src/services/__tests__/calendarService.test.ts new file mode 100644 index 00000000..fbe69327 --- /dev/null +++ b/src/services/__tests__/calendarService.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from '@jest/globals'; + +import { + beginCalendarOAuth, + buildSubscriptionCalendarEvent, + connectCalendar, + createCalendarOAuthCallbackUrl, + syncToCalendar, +} from '../calendarService'; +import { BillingCycle, SubscriptionCategory, type Subscription } from '../../types/subscription'; + +const baseSubscription: Subscription = { + id: 'sub-1', + name: 'Netflix', + category: SubscriptionCategory.STREAMING, + price: 15.99, + currency: 'USD', + billingCycle: BillingCycle.MONTHLY, + nextBillingDate: new Date('2026-06-15T09:00:00.000Z'), + isActive: true, + notificationsEnabled: true, + isCryptoEnabled: false, + createdAt: new Date('2026-05-01T00:00:00.000Z'), + updatedAt: new Date('2026-05-01T00:00:00.000Z'), +}; + +describe('calendarService', () => { + it('builds a provider authorization URL with state tracking', () => { + const authorization = beginCalendarOAuth('google'); + + expect(authorization.provider).toBe('google'); + expect(authorization.state).toContain('google_state'); + expect(authorization.authorizationUrl).toContain('accounts.google.com'); + expect(authorization.authorizationUrl).toContain(encodeURIComponent(authorization.state)); + }); + + it('connects a calendar after OAuth bootstrap and returns the required access token', async () => { + const authorization = beginCalendarOAuth('outlook'); + const callbackUrl = createCalendarOAuthCallbackUrl('outlook', authorization); + const integration = await connectCalendar('outlook', authorization, callbackUrl); + + expect(integration.provider).toBe('outlook'); + expect(integration.access_token).toContain('outlook_token'); + expect(integration.status).toBe('connected'); + }); + + it('rejects callbacks whose state does not match the pending authorization', async () => { + const authorization = beginCalendarOAuth('google'); + const callbackUrl = createCalendarOAuthCallbackUrl('google', authorization).replace( + authorization.state, + 'google_state_tampered' + ); + + await expect(connectCalendar('google', authorization, callbackUrl)).rejects.toThrow( + 'Calendar callback state mismatch for google.' + ); + }); + + it('builds billing event templates with normalized reminder offsets', () => { + const event = buildSubscriptionCalendarEvent(baseSubscription, [60, 24 * 60, 7 * 24 * 60]); + + expect(event.title).toBe('Netflix renewal'); + expect(event.reminderOffsets).toEqual([7 * 24 * 60, 24 * 60, 60]); + expect(event.notes).toContain('Expected charge: USD 15.99.'); + }); + + it('upserts provider events instead of duplicating them on repeated syncs', async () => { + const authorization = beginCalendarOAuth('apple'); + const callbackUrl = createCalendarOAuthCallbackUrl('apple', authorization); + const integration = await connectCalendar('apple', authorization, callbackUrl); + const firstTemplate = buildSubscriptionCalendarEvent(baseSubscription, [24 * 60, 60]); + const firstSync = await syncToCalendar(baseSubscription.id, [firstTemplate], integration, []); + + const nextCycleSubscription = { + ...baseSubscription, + nextBillingDate: new Date('2026-07-15T09:00:00.000Z'), + }; + const secondTemplate = buildSubscriptionCalendarEvent(nextCycleSubscription, [24 * 60, 60]); + const secondSync = await syncToCalendar( + baseSubscription.id, + [secondTemplate], + integration, + firstSync + ); + + expect(secondSync).toHaveLength(1); + expect(secondSync[0].providerEventId).toBe(firstSync[0].providerEventId); + expect(secondSync[0].id).toBe(firstSync[0].id); + expect(secondSync[0].startAt).toBe('2026-07-15T09:00:00.000Z'); + }); +}); diff --git a/src/services/calendarService.ts b/src/services/calendarService.ts new file mode 100644 index 00000000..690f0309 --- /dev/null +++ b/src/services/calendarService.ts @@ -0,0 +1,242 @@ +import type { Subscription } from '../types/subscription'; +import type { + CalendarOAuthCallbackPayload, + CalendarEventTemplate, + CalendarIntegration, + CalendarProvider, + CalendarSyncedEvent, + PendingCalendarAuthorization, +} from '../types/calendar'; + +const DEFAULT_REDIRECT_URI = 'subtrackr://calendar/callback'; +const PROVIDER_SCOPES: Record = { + google: 'https://www.googleapis.com/auth/calendar.events', + apple: 'name email', + outlook: 'offline_access Calendars.ReadWrite', +}; + +const PROVIDER_AUTH_ENDPOINTS: Record = { + google: 'https://accounts.google.com/o/oauth2/v2/auth', + apple: 'https://appleid.apple.com/auth/authorize', + outlook: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', +}; + +const PROVIDER_CLIENT_IDS: Record = { + google: 'subtrackr-google-mobile', + apple: 'subtrackr-apple-mobile', + outlook: 'subtrackr-outlook-mobile', +}; + +const oauthSessions = new Map(); + +function randomToken(prefix: string): string { + return `${prefix}_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`; +} + +export function normalizeReminderOffsets(offsets: number[]): number[] { + return [...new Set(offsets.filter((offset) => Number.isFinite(offset) && offset > 0))].sort( + (left, right) => right - left + ); +} + +function buildAuthorizationUrl( + provider: CalendarProvider, + state: string, + verifier: string, + redirectUri: string +): string { + const params = new URLSearchParams({ + client_id: PROVIDER_CLIENT_IDS[provider], + response_type: 'code', + redirect_uri: redirectUri, + scope: PROVIDER_SCOPES[provider], + state, + }); + + if (provider === 'google') { + params.set('access_type', 'offline'); + params.set('prompt', 'consent'); + params.set('code_challenge', verifier); + params.set('code_challenge_method', 'plain'); + } + + if (provider === 'outlook') { + params.set('prompt', 'select_account'); + params.set('code_challenge', verifier); + params.set('code_challenge_method', 'plain'); + } + + if (provider === 'apple') { + params.set('response_mode', 'query'); + } + + return `${PROVIDER_AUTH_ENDPOINTS[provider]}?${params.toString()}`; +} + +function buildAccountEmail(provider: CalendarProvider): string { + return `${provider}.calendar@subtrackr.local`; +} + +function buildExternalUrl(provider: CalendarProvider, providerEventId: string): string { + switch (provider) { + case 'google': + return `https://calendar.google.com/calendar/u/0/r/eventedit/${providerEventId}`; + case 'apple': + return `https://www.icloud.com/calendar/event/${providerEventId}`; + case 'outlook': + return `https://outlook.live.com/calendar/0/deeplink/compose?path=/calendar/action/compose&rru=addevent&id=${providerEventId}`; + default: + return providerEventId; + } +} + +function buildProviderEventId( + connectionId: string, + subscriptionId: string, + kind: CalendarEventTemplate['kind'] +): string { + return `${connectionId}:${subscriptionId}:${kind}`; +} + +function stripQueryAndHash(url: string): string { + const [withoutHash] = url.split('#'); + const [withoutQuery] = withoutHash.split('?'); + return withoutQuery; +} + +export function createCalendarOAuthCallbackUrl( + provider: CalendarProvider, + authorization: PendingCalendarAuthorization +): string { + const callback = new URL(authorization.redirectUri); + callback.searchParams.set('code', randomToken(`${provider}_code`)); + callback.searchParams.set('state', authorization.state); + return callback.toString(); +} + +export function parseCalendarOAuthCallback(redirectUrl: string): CalendarOAuthCallbackPayload { + const callback = new URL(redirectUrl); + const code = callback.searchParams.get('code'); + const state = callback.searchParams.get('state'); + + if (!code || !state) { + throw new Error('Calendar authorization callback is missing the required code or state.'); + } + + return { + state, + code, + redirectUri: stripQueryAndHash(callback.toString()), + }; +} + +export function beginCalendarOAuth( + provider: CalendarProvider, + redirectUri = DEFAULT_REDIRECT_URI +): PendingCalendarAuthorization { + const state = randomToken(`${provider}_state`); + const codeVerifier = randomToken(`${provider}_pkce`); + const authorization = { + provider, + state, + codeVerifier, + authorizationUrl: buildAuthorizationUrl(provider, state, codeVerifier, redirectUri), + redirectUri, + issuedAt: new Date().toISOString(), + }; + + oauthSessions.set(`${provider}:${state}`, authorization); + return authorization; +} + +export async function connectCalendar( + provider: CalendarProvider, + authorization: PendingCalendarAuthorization, + redirectUrl: string +): Promise { + const key = `${provider}:${authorization.state}`; + const storedAuthorization = oauthSessions.get(key); + if (!storedAuthorization || storedAuthorization.codeVerifier !== authorization.codeVerifier) { + throw new Error(`OAuth state mismatch for ${provider}. Restart the calendar connection.`); + } + + const callbackPayload = parseCalendarOAuthCallback(redirectUrl); + if (callbackPayload.state !== authorization.state) { + throw new Error(`Calendar callback state mismatch for ${provider}.`); + } + + if (callbackPayload.redirectUri !== stripQueryAndHash(authorization.redirectUri)) { + throw new Error(`Calendar callback redirect URI mismatch for ${provider}.`); + } + + oauthSessions.delete(key); + + const connectedAt = new Date().toISOString(); + return { + id: randomToken(`${provider}_connection`), + provider, + access_token: randomToken(`${provider}_token`), + accountEmail: buildAccountEmail(provider), + calendarId: randomToken(`${provider}_calendar`), + status: 'connected', + connectedAt, + lastSyncedAt: connectedAt, + reminderOffsets: [24 * 60, 60], + }; +} + +export function buildSubscriptionCalendarEvent( + subscription: Subscription, + reminderOffsets: number[] +): CalendarEventTemplate { + const start = new Date(subscription.nextBillingDate); + const end = new Date(start.getTime() + 30 * 60 * 1000); + const normalizedReminderOffsets = normalizeReminderOffsets(reminderOffsets); + const description = + subscription.description && subscription.description.trim().length > 0 + ? ` Notes: ${subscription.description.trim()}.` + : ''; + + return { + kind: 'billing_reminder', + title: `${subscription.name} renewal`, + notes: [ + `${subscription.name} renews on ${start.toLocaleString()}.`, + `Expected charge: ${subscription.currency} ${subscription.price.toFixed(2)}.`, + `Cycle: ${subscription.billingCycle}.`, + description, + 'Managed by SubTrackr calendar sync.', + ].join(' '), + startAt: start.toISOString(), + endAt: end.toISOString(), + reminderOffsets: normalizedReminderOffsets, + }; +} + +export async function syncToCalendar( + subscriptionId: string, + templates: CalendarEventTemplate[], + integration: CalendarIntegration, + existingEvents: CalendarSyncedEvent[] +): Promise { + const now = new Date().toISOString(); + + return templates.map((template) => { + const providerEventId = buildProviderEventId(integration.id, subscriptionId, template.kind); + const existing = existingEvents.find((event) => event.providerEventId === providerEventId); + + return { + ...template, + id: existing?.id ?? randomToken('calendar_event'), + subscriptionId, + connectionId: integration.id, + providerEventId, + externalUrl: buildExternalUrl(integration.provider, providerEventId), + lastSyncedAt: now, + }; + }); +} + +export async function disconnectCalendar(connectionId: string): Promise<{ connectionId: string }> { + return { connectionId }; +} diff --git a/src/store/__tests__/calendarStore.test.ts b/src/store/__tests__/calendarStore.test.ts new file mode 100644 index 00000000..d59ebc27 --- /dev/null +++ b/src/store/__tests__/calendarStore.test.ts @@ -0,0 +1,116 @@ +import { act } from 'react'; +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; + +import { useCalendarStore } from '../calendarStore'; +import { createCalendarOAuthCallbackUrl } from '../../services/calendarService'; +import { BillingCycle, SubscriptionCategory, type Subscription } from '../../types/subscription'; + +jest.mock('@react-native-async-storage/async-storage', () => ({ + setItem: jest.fn(() => Promise.resolve()), + getItem: jest.fn(() => Promise.resolve(null)), + removeItem: jest.fn(() => Promise.resolve()), +})); + +const subscription: Subscription = { + id: 'sub-1', + name: 'Spotify', + category: SubscriptionCategory.STREAMING, + price: 9.99, + currency: 'USD', + billingCycle: BillingCycle.MONTHLY, + nextBillingDate: new Date('2026-06-20T10:00:00.000Z'), + isActive: true, + notificationsEnabled: true, + isCryptoEnabled: false, + createdAt: new Date('2026-05-01T00:00:00.000Z'), + updatedAt: new Date('2026-05-01T00:00:00.000Z'), +}; + +describe('calendarStore', () => { + beforeEach(() => { + useCalendarStore.setState({ + integrations: [], + syncedEvents: [], + reminderOffsets: [24 * 60, 60], + pendingAuthorizations: {}, + isLoading: false, + error: null, + }); + }); + + it('connects a provider and stores the integration', async () => { + await act(async () => { + await useCalendarStore.getState().beginConnection('google'); + await useCalendarStore.getState().completeConnection('google'); + }); + + const state = useCalendarStore.getState(); + expect(state.integrations).toHaveLength(1); + expect(state.integrations[0].provider).toBe('google'); + expect(state.integrations[0].access_token).toContain('google_token'); + }); + + it('syncs an active subscription into provider events', async () => { + await act(async () => { + await useCalendarStore.getState().beginConnection('outlook'); + await useCalendarStore.getState().completeConnection('outlook'); + await useCalendarStore.getState().syncSubscriptionToCalendars(subscription); + }); + + const state = useCalendarStore.getState(); + expect(state.syncedEvents).toHaveLength(1); + expect(state.syncedEvents[0].subscriptionId).toBe('sub-1'); + expect(state.syncedEvents[0].title).toBe('Spotify renewal'); + }); + + it('completes a connection from an OAuth redirect callback', async () => { + await act(async () => { + const authorization = await useCalendarStore.getState().beginConnection('apple'); + const callbackUrl = createCalendarOAuthCallbackUrl('apple', authorization); + await useCalendarStore.getState().handleOAuthRedirect(callbackUrl); + }); + + const state = useCalendarStore.getState(); + expect(state.integrations).toHaveLength(1); + expect(state.integrations[0].provider).toBe('apple'); + expect(state.pendingAuthorizations.apple).toBeUndefined(); + }); + + it('updates reminder offsets and applies them to synced events', async () => { + await act(async () => { + await useCalendarStore.getState().beginConnection('google'); + await useCalendarStore.getState().completeConnection('google'); + useCalendarStore.getState().setReminderOffsets([7 * 24 * 60, 60]); + await useCalendarStore.getState().syncSubscriptionToCalendars(subscription); + }); + + const state = useCalendarStore.getState(); + expect(state.reminderOffsets).toEqual([7 * 24 * 60, 60]); + expect(state.syncedEvents[0].reminderOffsets).toEqual([7 * 24 * 60, 60]); + expect(state.integrations[0].reminderOffsets).toEqual([7 * 24 * 60, 60]); + }); + + it('removes synced events when a subscription is deleted', async () => { + await act(async () => { + await useCalendarStore.getState().beginConnection('apple'); + await useCalendarStore.getState().completeConnection('apple'); + await useCalendarStore.getState().syncSubscriptionToCalendars(subscription); + await useCalendarStore.getState().removeSubscriptionFromCalendars(subscription.id); + }); + + expect(useCalendarStore.getState().syncedEvents).toHaveLength(0); + }); + + it('disconnects a connection and clears provider events', async () => { + await act(async () => { + await useCalendarStore.getState().beginConnection('google'); + const integration = await useCalendarStore.getState().completeConnection('google'); + await useCalendarStore.getState().syncSubscriptionToCalendars(subscription); + await useCalendarStore.getState().disconnectConnection(integration.id); + }); + + const state = useCalendarStore.getState(); + expect(state.integrations).toHaveLength(0); + expect(state.syncedEvents).toHaveLength(0); + }); +}); diff --git a/src/store/calendarStore.ts b/src/store/calendarStore.ts new file mode 100644 index 00000000..19b7649e --- /dev/null +++ b/src/store/calendarStore.ts @@ -0,0 +1,286 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; + +import { + beginCalendarOAuth, + buildSubscriptionCalendarEvent, + connectCalendar, + createCalendarOAuthCallbackUrl, + disconnectCalendar, + normalizeReminderOffsets, + parseCalendarOAuthCallback, + syncToCalendar, +} from '../services/calendarService'; +import type { + CalendarIntegration, + CalendarProvider, + CalendarSyncedEvent, + PendingCalendarAuthorization, +} 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; + 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; +} + +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, + + 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 }); + }, + + 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, + }), + } + ) +); diff --git a/src/store/index.ts b/src/store/index.ts index c3572675..69ffa829 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -5,5 +5,5 @@ export { useWalletStore } from './walletStore'; export { useNetworkStore } from './networkStore'; export { useCommunityStore } from './communityStore'; export { useAccountingStore } from './accountingStore'; +export { useCalendarStore } from './calendarStore'; export { useSettingsStore } from './settingsStore'; - diff --git a/src/store/subscriptionStore.ts b/src/store/subscriptionStore.ts index f90572fd..3f0563e4 100644 --- a/src/store/subscriptionStore.ts +++ b/src/store/subscriptionStore.ts @@ -17,6 +17,7 @@ import { presentChargeSuccessNotification, presentChargeFailedNotification, } from '../services/notificationService'; +import { useCalendarStore } from './calendarStore'; import { useGamificationStore } from './gamificationStore'; import { useInvoiceStore } from './invoiceStore'; import { AchievementTrigger } from '../types/gamification'; @@ -192,6 +193,7 @@ export const useSubscriptionStore = create()( get().calculateStats(); await syncRenewalReminders(get().subscriptions); + await useCalendarStore.getState().syncSubscriptionToCalendars(newSubscription); // Gamification Triggers const gamificationStore = useGamificationStore.getState(); @@ -226,6 +228,10 @@ export const useSubscriptionStore = create()( 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', @@ -249,6 +255,7 @@ export const useSubscriptionStore = create()( get().calculateStats(); await syncRenewalReminders(get().subscriptions); + await useCalendarStore.getState().removeSubscriptionFromCalendars(id); } catch (error) { const appError = errorHandler.handleError(error as Error, { action: 'deleteSubscription', @@ -273,6 +280,10 @@ export const useSubscriptionStore = create()( 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', @@ -318,6 +329,10 @@ export const useSubscriptionStore = create()( })); 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( { @@ -340,6 +355,7 @@ export const useSubscriptionStore = create()( set({ isLoading: false }); get().calculateStats(); await syncRenewalReminders(get().subscriptions); + await useCalendarStore.getState().syncSubscriptions(get().subscriptions); } catch (error) { set({ error: errorHandler.handleError(error as Error, { @@ -461,6 +477,9 @@ export const useSubscriptionStore = create()( }); useSubscriptionStore.getState().calculateStats(); void syncRenewalReminders(useSubscriptionStore.getState().subscriptions); + void useCalendarStore + .getState() + .syncSubscriptions(useSubscriptionStore.getState().subscriptions); }, } ) diff --git a/src/store/usageStore.ts b/src/store/usageStore.ts index 69530001..5da2134a 100644 --- a/src/store/usageStore.ts +++ b/src/store/usageStore.ts @@ -36,7 +36,10 @@ export const useUsageStore = create()( set({ isLoading: false }); } catch (error) { - const appError = errorHandler.handleError(error as Error); + const appError = errorHandler.handleError(error as Error, { + action: 'fetchUsage', + metadata: { subscriptionId: _subscriptionId, planId: _planId }, + }); set({ error: appError.userMessage, isLoading: false }); } }, @@ -75,7 +78,10 @@ export const useUsageStore = create()( }; }); } catch (error) { - const appError = errorHandler.handleError(error as Error); + const appError = errorHandler.handleError(error as Error, { + action: 'recordUsage', + metadata: { subscriptionId, metric, amount }, + }); set({ error: appError.userMessage, isLoading: false }); } }, diff --git a/src/types/calendar.ts b/src/types/calendar.ts new file mode 100644 index 00000000..736492a1 --- /dev/null +++ b/src/types/calendar.ts @@ -0,0 +1,75 @@ +export type CalendarProvider = 'google' | 'apple' | 'outlook'; + +export interface PendingCalendarAuthorization { + provider: CalendarProvider; + state: string; + codeVerifier: string; + authorizationUrl: string; + redirectUri: string; + issuedAt: string; +} + +export interface CalendarOAuthCallbackPayload { + state: string; + code: string; + redirectUri: string; +} + +export interface CalendarIntegration { + id: string; + provider: CalendarProvider; + access_token: string; + accountEmail: string; + calendarId: string; + status: 'connected' | 'disconnected'; + connectedAt: string; + lastSyncedAt?: string; + reminderOffsets: number[]; +} + +export type CalendarEventKind = 'billing_reminder'; + +export interface CalendarEventTemplate { + kind: CalendarEventKind; + title: string; + notes: string; + startAt: string; + endAt: string; + reminderOffsets: number[]; +} + +export interface CalendarSyncedEvent extends CalendarEventTemplate { + id: string; + subscriptionId: string; + connectionId: string; + providerEventId: string; + externalUrl: string; + lastSyncedAt: string; +} + +export interface ReminderPreset { + label: string; + offsets: number[]; +} + +export interface ReminderOffsetOption { + label: string; + offset: number; +} + +export const CALENDAR_PROVIDERS: CalendarProvider[] = ['google', 'apple', 'outlook']; + +export const REMINDER_PRESETS: ReminderPreset[] = [ + { label: 'Last minute', offsets: [60] }, + { label: 'Balanced', offsets: [24 * 60, 60] }, + { label: 'Planned ahead', offsets: [7 * 24 * 60, 24 * 60, 60] }, +]; + +export const REMINDER_OFFSET_OPTIONS: ReminderOffsetOption[] = [ + { label: '7d', offset: 7 * 24 * 60 }, + { label: '3d', offset: 3 * 24 * 60 }, + { label: '1d', offset: 24 * 60 }, + { label: '12h', offset: 12 * 60 }, + { label: '3h', offset: 3 * 60 }, + { label: '1h', offset: 60 }, +]; diff --git a/src/types/feature.ts b/src/types/feature.ts index 4660b296..c2054517 100644 --- a/src/types/feature.ts +++ b/src/types/feature.ts @@ -1,5 +1,7 @@ import { SubscriptionTier } from './subscription'; +export { SubscriptionTier } from './subscription'; + export interface FeatureFlag { id: string; name: string; diff --git a/src/utils/__tests__/importExport.test.ts b/src/utils/__tests__/importExport.test.ts index 63502bcb..437e5c8c 100644 --- a/src/utils/__tests__/importExport.test.ts +++ b/src/utils/__tests__/importExport.test.ts @@ -13,10 +13,8 @@ import { getCSVTemplate, getJSONTemplate, ImportMode, - Subscription, - SubscriptionCategory, - BillingCycle, -} from '../utils/importExport'; +} from '../importExport'; +import { Subscription, SubscriptionCategory, BillingCycle } from '../../types/subscription'; describe('Import/Export Utilities', () => { describe('CSV Parsing', () => { @@ -172,7 +170,7 @@ Spotify,Music streaming,streaming,9.99,USD,monthly,2026-05-15`; const json = exportToJSON(subscriptions); const parsed = JSON.parse(json); - + expect(parsed.version).toBe('1.0.0'); expect(parsed.subscriptionCount).toBe(1); expect(parsed.subscriptions[0].name).toBe('Netflix'); @@ -237,7 +235,7 @@ Spotify,Music streaming,streaming,9.99,USD,monthly,2026-05-15`; const result = validateImport(data); expect(result.isValid).toBe(false); - expect(result.errors.some(e => e.field === 'price')).toBe(true); + expect(result.errors.some((e) => e.field === 'price')).toBe(true); }); it('should add warnings for invalid category', () => { @@ -256,7 +254,7 @@ Spotify,Music streaming,streaming,9.99,USD,monthly,2026-05-15`; }; const result = validateImport(data); - expect(result.warnings.some(w => w.field === 'category')).toBe(true); + expect(result.warnings.some((w) => w.field === 'category')).toBe(true); }); }); @@ -388,4 +386,4 @@ Spotify,Music streaming,streaming,9.99,USD,monthly,2026-05-15`; expect(result[0].name).toBe('Netflix'); }); }); -}); \ No newline at end of file +}); diff --git a/src/utils/importExport.ts b/src/utils/importExport.ts index dac8d62c..f965c9d4 100644 --- a/src/utils/importExport.ts +++ b/src/utils/importExport.ts @@ -3,6 +3,7 @@ * Supports CSV import with column mapping and JSON export */ +import AsyncStorage from '@react-native-async-storage/async-storage'; import { Subscription, SubscriptionCategory, BillingCycle } from '../types/subscription'; // ============================================ @@ -101,8 +102,18 @@ export const CSV_COLUMN_MAPPING: ColumnMapping[] = [ { csvColumn: 'billingCycle', fieldName: 'billingCycle', required: true }, { csvColumn: 'nextBillingDate', fieldName: 'nextBillingDate', required: true }, { csvColumn: 'isActive', fieldName: 'isActive', required: false, transform: parseBoolean }, - { csvColumn: 'notificationsEnabled', fieldName: 'notificationsEnabled', required: false, transform: parseBoolean }, - { csvColumn: 'isCryptoEnabled', fieldName: 'isCryptoEnabled', required: false, transform: parseBoolean }, + { + csvColumn: 'notificationsEnabled', + fieldName: 'notificationsEnabled', + required: false, + transform: parseBoolean, + }, + { + csvColumn: 'isCryptoEnabled', + fieldName: 'isCryptoEnabled', + required: false, + transform: parseBoolean, + }, { csvColumn: 'cryptoToken', fieldName: 'cryptoToken', required: false }, { csvColumn: 'cryptoAmount', fieldName: 'cryptoAmount', required: false, transform: parseFloat }, ]; @@ -155,10 +166,10 @@ function normalizeBillingCycle(value: string): BillingCycle { } // Try common variations const cycleMap: Record = { - 'month': BillingCycle.MONTHLY, - 'year': BillingCycle.YEARLY, - 'week': BillingCycle.WEEKLY, - 'custom': BillingCycle.CUSTOM, + month: BillingCycle.MONTHLY, + year: BillingCycle.YEARLY, + week: BillingCycle.WEEKLY, + custom: BillingCycle.CUSTOM, }; for (const [key, cycle] of Object.entries(cycleMap)) { if (normalized.includes(key)) { @@ -179,7 +190,7 @@ function parseDate(value: string): Date { /^(\d{2})\/(\d{2})\/(\d{4})$/, // MM/DD/YYYY /^(\d{2})-(\d{2})-(\d{4})$/, // DD-MM-YYYY ]; - + for (const format of formats) { const match = value.match(format); if (match) { @@ -189,7 +200,7 @@ function parseDate(value: string): Date { } } } - + return new Date(); // Default to current date } @@ -201,15 +212,15 @@ function parseDate(value: string): Date { * Parse CSV string into array of subscription objects */ export function parseCSV(csvContent: string): SubscriptionInput[] { - const lines = csvContent.split(/\r?\n/).filter(line => line.trim()); - + const lines = csvContent.split(/\r?\n/).filter((line) => line.trim()); + if (lines.length < 2) { throw new Error('CSV must contain at least a header row and one data row'); } const headerLine = lines[0]; const headers = parseCSVLine(headerLine); - + // Create header to field mapping const headerMap = new Map(); headers.forEach((header, index) => { @@ -220,20 +231,18 @@ export function parseCSV(csvContent: string): SubscriptionInput[] { for (let i = 1; i < lines.length; i++) { const values = parseCSVLine(lines[i]); - if (values.length === 0 || values.every(v => !v.trim())) { + if (values.length === 0 || values.every((v) => !v.trim())) { continue; // Skip empty rows } const subscription: Partial = {}; - + for (const mapping of CSV_COLUMN_MAPPING) { const columnIndex = headerMap.get(mapping.csvColumn.toLowerCase()); if (columnIndex !== undefined && values[columnIndex]) { const rawValue = values[columnIndex]; - const value = mapping.transform ? - String(mapping.transform(rawValue)) : - rawValue; - + const value = mapping.transform ? String(mapping.transform(rawValue)) : rawValue; + (subscription as Record)[mapping.fieldName] = value; } } @@ -253,7 +262,7 @@ function parseCSVLine(line: string): string[] { for (let i = 0; i < line.length; i++) { const char = line[i]; - + if (char === '"') { if (inQuotes && line[i + 1] === '"') { current += '"'; @@ -277,9 +286,9 @@ function parseCSVLine(line: string): string[] { * Generate CSV from subscriptions */ export function generateCSV(subscriptions: Subscription[]): string { - const headers = CSV_COLUMN_MAPPING.map(m => m.csvColumn); - const rows = subscriptions.map(sub => { - return CSV_COLUMN_MAPPING.map(mapping => { + const headers = CSV_COLUMN_MAPPING.map((m) => m.csvColumn); + const rows = subscriptions.map((sub) => { + return CSV_COLUMN_MAPPING.map((mapping) => { const value = sub[mapping.fieldName as keyof Subscription]; if (value === undefined || value === null) { return ''; @@ -309,17 +318,20 @@ export function exportToJSON(subscriptions: Subscription[]): string { version: EXPORT_VERSION, exportedAt: new Date().toISOString(), subscriptionCount: subscriptions.length, - subscriptions: subscriptions.map(sub => ({ + subscriptions: subscriptions.map((sub) => ({ ...sub, - nextBillingDate: sub.nextBillingDate instanceof Date ? - sub.nextBillingDate : - new Date(sub.nextBillingDate as unknown as string), - createdAt: sub.createdAt instanceof Date ? - sub.createdAt : - new Date(sub.createdAt as unknown as string), - updatedAt: sub.updatedAt instanceof Date ? - sub.updatedAt : - new Date(sub.updatedAt as unknown as string), + nextBillingDate: + sub.nextBillingDate instanceof Date + ? sub.nextBillingDate + : new Date(sub.nextBillingDate as unknown as string), + createdAt: + sub.createdAt instanceof Date + ? sub.createdAt + : new Date(sub.createdAt as unknown as string), + updatedAt: + sub.updatedAt instanceof Date + ? sub.updatedAt + : new Date(sub.updatedAt as unknown as string), })), }; @@ -331,10 +343,10 @@ export function exportToJSON(subscriptions: Subscription[]): string { */ export function parseJSON(jsonContent: string): SubscriptionInput[] { const data = JSON.parse(jsonContent); - + // Handle both direct array and wrapped export format let subscriptions: Subscription[] | SubscriptionInput[]; - + if (Array.isArray(data)) { subscriptions = data; } else if (data.subscriptions && Array.isArray(data.subscriptions)) { @@ -343,7 +355,7 @@ export function parseJSON(jsonContent: string): SubscriptionInput[] { throw new Error('Invalid JSON format: expected array or export object'); } - return subscriptions.map(sub => ({ + return subscriptions.map((sub) => ({ id: sub.id, name: sub.name, description: sub.description, @@ -351,7 +363,9 @@ export function parseJSON(jsonContent: string): SubscriptionInput[] { price: Number(sub.price) || 0, currency: sub.currency || 'USD', billingCycle: typeof sub.billingCycle === 'string' ? sub.billingCycle : BillingCycle.MONTHLY, - nextBillingDate: sub.nextBillingDate ? new Date(sub.nextBillingDate).toISOString() : new Date().toISOString(), + nextBillingDate: sub.nextBillingDate + ? new Date(sub.nextBillingDate).toISOString() + : new Date().toISOString(), isActive: sub.isActive, notificationsEnabled: sub.notificationsEnabled, isCryptoEnabled: sub.isCryptoEnabled, @@ -394,7 +408,9 @@ export function validateImport(data: ImportData): ValidationResult { message: 'Category is required', value: subscription.category, }); - } else if (!VALID_CATEGORIES.includes(subscription.category.toLowerCase() as SubscriptionCategory)) { + } else if ( + !VALID_CATEGORIES.includes(subscription.category.toLowerCase() as SubscriptionCategory) + ) { warnings.push({ row: rowNum, field: 'category', @@ -432,7 +448,9 @@ export function validateImport(data: ImportData): ValidationResult { message: 'Billing cycle is required', value: subscription.billingCycle, }); - } else if (!VALID_BILLING_CYCLES.includes(subscription.billingCycle.toLowerCase() as BillingCycle)) { + } else if ( + !VALID_BILLING_CYCLES.includes(subscription.billingCycle.toLowerCase() as BillingCycle) + ) { warnings.push({ row: rowNum, field: 'billingCycle', @@ -498,7 +516,7 @@ export function processImport( existingSubscriptions: Subscription[] ): ImportResult { const validation = validateImport(data); - + if (validation.validRows.length === 0 && validation.errors.length > 0) { return { success: false, @@ -511,15 +529,15 @@ export function processImport( } let imported = 0; - let updated = 0; + let updatedCount = 0; const errors: ImportError[] = [...validation.errors]; const warnings: ImportWarning[] = [...validation.warnings]; // Create lookup for existing subscriptions const existingByName = new Map(); const existingById = new Map(); - - existingSubscriptions.forEach(sub => { + + existingSubscriptions.forEach((sub) => { existingByName.set(sub.name.toLowerCase(), sub); if (sub.id) { existingById.set(sub.id, sub); @@ -543,7 +561,7 @@ export function processImport( // Update existing const existing = existingByIdMatch || existingByNameMatch; if (existing) { - const updated: Subscription = { + const merged: Subscription = { ...existing, name: input.name, description: input.description, @@ -559,8 +577,8 @@ export function processImport( cryptoAmount: input.cryptoAmount ?? existing.cryptoAmount, updatedAt: now, }; - processedSubscriptions.push(updated); - updated++; + processedSubscriptions.push(merged); + updatedCount++; } } else { // Create new @@ -596,8 +614,8 @@ export function processImport( return { success: errors.length === 0, imported, - updated, - failed: data.subscriptions.length - imported - updated, + updated: updatedCount, + failed: data.subscriptions.length - imported - updatedCount, errors, warnings, }; @@ -612,7 +630,6 @@ export function processImport( */ export async function getImportHistory(): Promise { try { - const AsyncStorage = require('@react-native-async-storage/async-storage').default; const historyJson = await AsyncStorage.getItem(HISTORY_KEY); if (historyJson) { return JSON.parse(historyJson); @@ -628,14 +645,13 @@ export async function getImportHistory(): Promise { */ export async function saveImportHistory(entry: ImportHistoryEntry): Promise { try { - const AsyncStorage = require('@react-native-async-storage/async-storage').default; const history = await getImportHistory(); - + history.unshift(entry); - + // Keep only last N entries const trimmedHistory = history.slice(0, MAX_HISTORY_ENTRIES); - + await AsyncStorage.setItem(HISTORY_KEY, JSON.stringify(trimmedHistory)); } catch (error) { console.error('Failed to save import history:', error); @@ -671,7 +687,6 @@ export async function recordImport( */ export async function clearImportHistory(): Promise { try { - const AsyncStorage = require('@react-native-async-storage/async-storage').default; await AsyncStorage.removeItem(HISTORY_KEY); } catch (error) { console.error('Failed to clear import history:', error); @@ -687,7 +702,7 @@ export async function clearImportHistory(): Promise { */ export function detectFormat(content: string): 'csv' | 'json' | 'unknown' { const trimmed = content.trim(); - + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { try { JSON.parse(trimmed); @@ -696,12 +711,12 @@ export function detectFormat(content: string): 'csv' | 'json' | 'unknown' { // Not valid JSON } } - + // Check for CSV indicators if (trimmed.includes(',') && trimmed.split('\n')[0].split(',').length > 1) { return 'csv'; } - + return 'unknown'; } @@ -756,6 +771,6 @@ export function getJSONTemplate(): string { }, ], }; - + return JSON.stringify(template, null, 2); -} \ No newline at end of file +} diff --git a/tsconfig.json b/tsconfig.json index 34d837ba..a67c7278 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,6 @@ "esModuleInterop": true, "jsx": "react-native", "skipLibCheck": true, - "moduleResolution": "node" + "moduleResolution": "bundler" } }