diff --git a/backend/services/importService.ts b/backend/services/importService.ts new file mode 100644 index 0000000..2d447ab --- /dev/null +++ b/backend/services/importService.ts @@ -0,0 +1,89 @@ +/** + * Lightweight import helpers for server-side import from external platforms + * Exposes functions to fetch subscriptions from Stripe and Chargebee and + * normalize them to the internal SubscriptionInput shape. + */ +import https from 'https'; + +export interface SubscriptionInput { + id?: string; + name: string; + description?: string; + category: string; + price: number; + currency: string; + billingCycle: string; + nextBillingDate: string; +} + +function fetchJson(url: string, headers: Record = {}): Promise { + return new Promise((resolve, reject) => { + const req = https.request(url, { headers }, (res) => { + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => { + try { + const parsed = JSON.parse(data); + resolve(parsed); + } catch (err) { + reject(err); + } + }); + }); + req.on('error', reject); + req.end(); + }); +} + +export async function fetchStripeSubscriptions(stripeApiKey: string): Promise { + if (!stripeApiKey) throw new Error('Missing Stripe API key'); + + // Stripe list subscriptions endpoint (simple, may need pagination in real use) + const url = 'https://api.stripe.com/v1/subscriptions?limit=100'; + const auth = 'Basic ' + Buffer.from(`${stripeApiKey}:`).toString('base64'); + + const data = await fetchJson(url, { Authorization: auth }); + + const items = data.data || []; + + return items.map((s: any) => ({ + id: s.id, + name: s.plan?.nickname || s.plan?.product || s.id, + description: s.description || '', + category: 'imported', + price: (s.plan?.amount || 0) / 100, + currency: (s.plan?.currency || 'USD').toUpperCase(), + billingCycle: s.plan?.interval || 'monthly', + nextBillingDate: s.current_period_end ? new Date(s.current_period_end * 1000).toISOString() : new Date().toISOString(), + })); +} + +export async function fetchChargebeeSubscriptions(site: string, apiKey: string): Promise { + if (!site || !apiKey) throw new Error('Missing Chargebee site or API key'); + + const url = `https://${site}.chargebee.com/api/v2/subscriptions`; + const auth = 'Basic ' + Buffer.from(`${apiKey}:`).toString('base64'); + + const data = await fetchJson(url + '?limit=100', { Authorization: auth }); + + const list = data.list || []; + + return list.map((entry: any) => { + const s = entry.subscription || entry; + return { + id: s.id, + name: s.plan_id || s.id, + description: s.plan_description || '', + category: 'imported', + price: Number(s.amount || 0) / 100, + currency: (s.currency_code || 'USD').toUpperCase(), + billingCycle: s.billing_period_unit || 'monthly', + nextBillingDate: s.next_billing_at ? new Date(s.next_billing_at * 1000).toISOString() : new Date().toISOString(), + }; + }); +} + +export default { + fetchStripeSubscriptions, + fetchChargebeeSubscriptions, +}; diff --git a/backend/services/index.ts b/backend/services/index.ts index a1919f0..ddee1e7 100644 --- a/backend/services/index.ts +++ b/backend/services/index.ts @@ -40,6 +40,7 @@ export { SubscriptionEventStore, subscriptionEventStore, } from './subscriptionEventStore'; +export { fetchStripeSubscriptions, fetchChargebeeSubscriptions } from './importService'; export type { SubscriptionEvent, SubscriptionEventPage, diff --git a/src/screens/ImportScreen.tsx b/src/screens/ImportScreen.tsx index c09b3ae..cf7d1c2 100644 --- a/src/screens/ImportScreen.tsx +++ b/src/screens/ImportScreen.tsx @@ -25,6 +25,7 @@ import { getCSVTemplate, getJSONTemplate, detectFormat, + CSV_COLUMN_MAPPING, ImportMode, ImportResult, ValidationResult, @@ -34,7 +35,7 @@ import { import { useSubscriptionStore } from '../store'; const ImportScreen: React.FC = () => { - const { subscriptions, addSubscription, updateSubscription } = useSubscriptionStore(); + const { subscriptions, addSubscription, updateSubscription, deleteSubscription } = useSubscriptionStore(); const navigation = useNavigation(); const [importMode, setImportMode] = useState('upsert'); @@ -45,6 +46,10 @@ const ImportScreen: React.FC = () => { const [showHistory, setShowHistory] = useState(false); const [importHistory, setImportHistory] = useState([]); const [showTemplateModal, setShowTemplateModal] = useState(false); + const [showMappingModal, setShowMappingModal] = useState(false); + const [detectedHeaders, setDetectedHeaders] = useState([]); + const [columnMapping, setColumnMapping] = useState>({}); + const [progress, setProgress] = useState<{ current: number; total: number; message?: string } | null>(null); const handleImport = useCallback(async () => { if (!importText.trim()) { @@ -61,7 +66,19 @@ const ImportScreen: React.FC = () => { let parsedData: SubscriptionInput[]; if (format === 'csv') { - parsedData = parseCSV(importText); + // Detect headers for optional mapping + const firstLine = importText.split(/\r?\n/).find((l) => l && l.trim()); + if (firstLine) { + const rawHeaders = firstLine.split(',').map((h) => h.replace(/^\"|\"$/g, '').trim()); + setDetectedHeaders(rawHeaders); + } + + // If a mapping exists, transform CSV using mapping, else use parseCSV fallback + if (Object.keys(columnMapping).length > 0) { + parsedData = parseCSVWithMapping(importText, columnMapping); + } else { + parsedData = parseCSV(importText); + } } else if (format === 'json') { parsedData = parseJSON(importText); } else { @@ -110,53 +127,178 @@ const ImportScreen: React.FC = () => { } }, [importText, importMode]); + // Simple CSV line parser (handles quoted fields) + function parseLine(line: string): string[] { + const result: string[] = []; + let current = ''; + let inQuotes = false; + + for (let i = 0; i < line.length; i++) { + const char = line[i]; + if (char === '"') { + if (inQuotes && line[i + 1] === '"') { + current += '"'; + i++; + } else { + inQuotes = !inQuotes; + } + } else if (char === ',' && !inQuotes) { + result.push(current.trim()); + current = ''; + } else { + current += char; + } + } + result.push(current.trim()); + return result; + } + + function parseCSVWithMapping(content: string, mapping: Record): SubscriptionInput[] { + const lines = content.split(/\r?\n/).filter((l) => l.trim()); + if (lines.length < 2) return []; + const rawHeaders = parseLine(lines[0]); + const headerIndex: Record = {}; + rawHeaders.forEach((h, i) => (headerIndex[h.toLowerCase()] = i)); + + const result: SubscriptionInput[] = []; + + for (let i = 1; i < lines.length; i++) { + const values = parseLine(lines[i]); + if (values.every((v) => !v)) continue; + + const row: Partial = {}; + + for (const raw of Object.keys(mapping)) { + const target = mapping[raw]; + if (!target) continue; + const idx = headerIndex[raw.toLowerCase()]; + const rawValue = typeof idx === 'number' && values[idx] ? values[idx] : ''; + let value: unknown = rawValue; + + if (target === 'price' || target === 'cryptoAmount') { + value = Number(rawValue) || 0; + } else if ( + target === 'isActive' || + target === 'notificationsEnabled' || + target === 'isCryptoEnabled' + ) { + const normalized = rawValue.toString().trim().toLowerCase(); + value = normalized === 'true' || normalized === '1' || normalized === 'yes'; + } + + (row as any)[target] = value; + } + + // ensure required defaults + if (!row.name && values[0]) row.name = values[0]; + + if (row.nextBillingDate && typeof row.nextBillingDate !== 'string') { + row.nextBillingDate = String(row.nextBillingDate); + } + + result.push(row as SubscriptionInput); + } + + return result; + } + + const openMappingModal = () => { + if (detectedHeaders.length === 0) { + Alert.alert('No headers detected', 'Please paste a CSV with headers first'); + return; + } + + const initial: Record = {}; + detectedHeaders.forEach((h) => { + initial[h] = columnMapping[h] ?? ''; + }); + setColumnMapping(initial); + setShowMappingModal(true); + }; + + const cycleMappingFor = (header: string) => { + const options = ['','name','description','category','price','currency','billingCycle','nextBillingDate','isActive','notificationsEnabled','isCryptoEnabled','cryptoToken','cryptoAmount','externalId','externalSource'] as (keyof SubscriptionInput | '')[]; + const current = columnMapping[header] ?? ''; + const idx = options.indexOf(current); + const next = options[(idx + 1) % options.length]; + setColumnMapping((m) => ({ ...m, [header]: next })); + }; + const executeImport = async (parsedData: SubscriptionInput[]) => { setIsProcessing(true); - try { const result = processImport({ subscriptions: parsedData, mode: importMode }, subscriptions); - setImportResult(result); - // Apply the import - if (result.imported > 0 || result.updated > 0) { - for (const sub of parsedData) { - const existing = subscriptions.find( - (s) => s.name.toLowerCase() === sub.name.toLowerCase() - ); - - if (existing) { - // Update existing - await updateSubscription(existing.id, { - name: sub.name, - description: sub.description, - category: sub.category as any, - price: sub.price, - currency: sub.currency, - billingCycle: sub.billingCycle as any, - nextBillingDate: new Date(sub.nextBillingDate), - isActive: sub.isActive ?? true, - notificationsEnabled: sub.notificationsEnabled ?? true, - isCryptoEnabled: sub.isCryptoEnabled ?? false, - cryptoToken: sub.cryptoToken, - cryptoAmount: sub.cryptoAmount, - }); - } else { - // Add new - await addSubscription({ - name: sub.name, - description: sub.description, - category: sub.category as any, - price: sub.price, - currency: sub.currency, - billingCycle: sub.billingCycle as any, - nextBillingDate: new Date(sub.nextBillingDate), - notificationsEnabled: sub.notificationsEnabled ?? true, - isCryptoEnabled: sub.isCryptoEnabled ?? false, - cryptoToken: sub.cryptoToken, - cryptoAmount: sub.cryptoAmount, - }); + const actions = result.actions ?? []; + + // Apply the import with progress and rollback support + if (actions.length > 0) { + const snapshot = useSubscriptionStore.getState().subscriptions.map((s) => ({ ...s })); + let processed = 0; + const appliedIds = new Set(); + + try { + for (const action of actions) { + const subscription = action.subscription; + if (!subscription) continue; + + setProgress({ current: processed + 1, total: actions.length, message: `Processing ${subscription.name}` }); + + if (action.type === 'update' && action.existingId) { + await updateSubscription(action.existingId, { + name: subscription.name, + description: subscription.description, + category: subscription.category as any, + price: subscription.price, + currency: subscription.currency, + billingCycle: subscription.billingCycle as any, + nextBillingDate: new Date(subscription.nextBillingDate), + isActive: subscription.isActive, + notificationsEnabled: subscription.notificationsEnabled, + isCryptoEnabled: subscription.isCryptoEnabled, + cryptoToken: subscription.cryptoToken, + cryptoAmount: subscription.cryptoAmount, + externalId: subscription.externalId, + externalSource: subscription.externalSource, + }); + appliedIds.add(action.existingId); + } else if (action.type === 'create') { + await addSubscription({ + id: subscription.id, + name: subscription.name, + description: subscription.description, + category: subscription.category as any, + price: subscription.price, + currency: subscription.currency, + billingCycle: subscription.billingCycle as any, + nextBillingDate: new Date(subscription.nextBillingDate), + notificationsEnabled: subscription.notificationsEnabled ?? true, + isCryptoEnabled: subscription.isCryptoEnabled ?? false, + cryptoToken: subscription.cryptoToken, + cryptoAmount: subscription.cryptoAmount, + externalId: subscription.externalId, + externalSource: subscription.externalSource, + }); + appliedIds.add(subscription.id); + } + + processed += 1; + setProgress({ current: processed, total: actions.length, message: `Processed ${processed}/${actions.length}` }); } + + if (importMode === 'replace') { + const currentSubscriptions = useSubscriptionStore.getState().subscriptions; + const toDelete = currentSubscriptions.filter((sub) => !appliedIds.has(sub.id)); + for (const sub of toDelete) { + await deleteSubscription(sub.id); + } + } + } catch (applyErr) { + // Rollback to snapshot on failure + useSubscriptionStore.setState({ subscriptions: snapshot }); + useSubscriptionStore.getState().calculateStats(); + throw applyErr; } } @@ -171,6 +313,7 @@ const ImportScreen: React.FC = () => { Alert.alert('Error', error instanceof Error ? error.message : 'Failed to complete import'); } finally { setIsProcessing(false); + setProgress(null); } }; @@ -373,9 +516,14 @@ const ImportScreen: React.FC = () => { Import Data - setShowTemplateModal(true)}> - Load Template - + + setShowTemplateModal(true)}> + Load Template + + openMappingModal()}> + Map Columns + + { /> + {progress && ( + + Import Progress + {progress.message} + {progress.current} / {progress.total} + + )} + View Import History @@ -414,6 +570,42 @@ const ImportScreen: React.FC = () => { {renderHistoryModal()} {renderTemplateModal()} + {/* Column mapping modal */} + setShowMappingModal(false)}> + + + Map CSV Columns + setShowMappingModal(false)}> + Close + + + + Detected Headers + item} + renderItem={({ item }) => ( + cycleMappingFor(item)}> + {item} + Mapping: {String(columnMapping[item] || '(none)')} + + )} + /> + +