From 6380c840a3a44ce01628c5af6ad786a98759a351 Mon Sep 17 00:00:00 2001 From: dee-john Date: Thu, 28 May 2026 15:10:29 +0100 Subject: [PATCH 1/2] feat(import): CSV column mapping, progress/rollback, Stripe/Chargebee import helpers, tests --- backend/services/importService.ts | 89 ++++++++ backend/services/index.ts | 1 + src/screens/ImportScreen.tsx | 247 +++++++++++++++++++---- src/utils/__tests__/importExport.test.ts | 57 ++++++ 4 files changed, 352 insertions(+), 42 deletions(-) create mode 100644 backend/services/importService.ts 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..b349340 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, @@ -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,149 @@ 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] : ''; + (row as any)[target] = rawValue; + } + + // ensure required defaults + if (!row.name && values[0]) row.name = values[0]; + + // Cast numeric-ish fields + if (row.price !== undefined) row.price = Number(row.price as any) || 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'] 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 + // Apply the import with progress and rollback support 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 snapshot = useSubscriptionStore.getState().subscriptions.map((s) => ({ ...s })); + let processed = 0; + try { + for (const sub of parsedData) { + setProgress({ current: processed + 1, total: parsedData.length, message: `Processing ${sub.name}` }); + const existing = useSubscriptionStore.getState().subscriptions.find( + (s) => s.name.toLowerCase() === sub.name.toLowerCase() + ); + + if (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 { + 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, + }); + } + processed += 1; + setProgress({ current: processed, total: parsedData.length, message: `Processed ${processed}/${parsedData.length}` }); } + } catch (applyErr) { + // Rollback to snapshot on failure + useSubscriptionStore.setState({ subscriptions: snapshot }); + useSubscriptionStore.getState().calculateStats(); + throw applyErr; } } @@ -171,6 +284,7 @@ const ImportScreen: React.FC = () => { Alert.alert('Error', error instanceof Error ? error.message : 'Failed to complete import'); } finally { setIsProcessing(false); + setProgress(null); } }; @@ -373,9 +487,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 +541,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)')} + + )} + /> + +