From 2738319c8de9867e87cc7d2fe44665a7b83a1f87 Mon Sep 17 00:00:00 2001 From: junman140 Date: Wed, 27 May 2026 11:46:18 +0100 Subject: [PATCH 1/3] feat: implement batch subscription operations for bulk management Add batch create from CSV/JSON, batch update with filtering, batch cancel with reason collection, and batch charge for manual billing runs. - contracts/batch: Added CancelReason enum, BatchFilter struct, enhanced result types with skipped_operations tracking - app/services/batchTransactionService.ts: Full rewrite with 4 operation types, CSV parsers, chunked processing, idempotent retry with backoff, result export, per-item status tracking - app/stores/batchStore.ts: Zustand store with draft management, CSV loading per operation type, execute/retry, export helpers - app/screens/BatchOperationsScreen.tsx: Full UI with operation selector, CSV input, update params/filter modals, cancel reason picker, progress bar, per-item results, export buttons, retry, history - src/screens/ImportScreen.tsx: Added batch operations shortcut banner - src/navigation: Added BatchOperations route to SettingsStack Edge cases: partial batch failure, idempotent retry of failed items, large batch memory management via chunked processing --- app/screens/BatchOperationsScreen.tsx | 1218 ++++++++++++++++++-- app/services/batchTransactionService.ts | 1124 +++++++++++++----- app/services/hooks/useBatchTransactions.ts | 214 ++-- app/stores/__tests__/batchStore.test.ts | 115 +- app/stores/batchStore.ts | 507 +++++--- contracts/batch/src/batch.rs | 27 + contracts/batch/src/lib.rs | 5 +- src/navigation/AppNavigator.tsx | 34 +- src/navigation/types.ts | 1 + src/screens/ImportScreen.tsx | 33 +- 10 files changed, 2622 insertions(+), 656 deletions(-) diff --git a/app/screens/BatchOperationsScreen.tsx b/app/screens/BatchOperationsScreen.tsx index feb321c..6267a85 100644 --- a/app/screens/BatchOperationsScreen.tsx +++ b/app/screens/BatchOperationsScreen.tsx @@ -1,121 +1,1161 @@ -import React, { useState } from 'react'; -import { View, Text, TextInput, Button, FlatList, StyleSheet } from 'react-native'; +import React, { useState, useCallback, useEffect } from 'react'; +import { + View, + Text, + TextInput, + FlatList, + StyleSheet, + SafeAreaView, + ScrollView, + TouchableOpacity, + Alert, + Share, + Switch, + Modal, + ActivityIndicator, +} from 'react-native'; import { useBatchStore, - OperationType, - estimateBatchGas, + BatchOperationType, + BatchState, + PerItemResult, + CancelReason, + BatchHistoryEntry, + exportBatchResultToJson as exportJson, + exportBatchResultToCsv as exportCsv, } from '../stores/batchStore'; +import { colors, spacing, typography, borderRadius } from '../../src/utils/constants'; -const styles = StyleSheet.create({ - container: { flex: 1, padding: 16 }, - header: { fontSize: 18, fontWeight: '700', marginVertical: 8 }, - label: { fontSize: 12, color: '#666', marginTop: 8 }, - input: { - minHeight: 40, - borderColor: '#ccc', - borderWidth: 1, - paddingHorizontal: 8, - borderRadius: 4, - marginTop: 4, - }, - row: { flexDirection: 'row', flexWrap: 'wrap', marginVertical: 8 }, - chip: { - paddingHorizontal: 10, - paddingVertical: 6, - borderRadius: 16, - borderWidth: 1, - borderColor: '#ccc', - marginRight: 6, - marginBottom: 6, - }, - chipActive: { backgroundColor: '#dbeafe', borderColor: '#2563eb' }, - meta: { fontSize: 12, color: '#444', marginVertical: 4 }, - item: { paddingVertical: 6, borderBottomWidth: 1, borderColor: '#eee' }, - success: { color: '#15803d' }, - failure: { color: '#b91c1c' }, -}); +// ════════════════════════════════════════════════════════════════ +// Constants +// ════════════════════════════════════════════════════════════════ + +const OPERATION_TYPES: Array<{ key: BatchOperationType; label: string; icon: string }> = [ + { key: 'create', label: 'Create', icon: '+' }, + { key: 'update', label: 'Update', icon: '-' }, + { key: 'charge', label: 'Charge', icon: '$' }, + { key: 'cancel', label: 'Cancel', icon: 'X' }, +]; + +const CANCEL_REASONS: Array<{ key: CancelReason['reason']; label: string }> = [ + { key: 'too_expensive', label: 'Too Expensive' }, + { key: 'no_longer_needed', label: 'No Longer Needed' }, + { key: 'found_alternative', label: 'Found Alternative' }, + { key: 'poor_service', label: 'Poor Service' }, + { key: 'other', label: 'Other' }, +]; -const OPERATIONS: OperationType[] = ['charge', 'pause', 'resume', 'cancel', 'update', 'create']; +const STATE_COLORS: Record = { + pending: colors.textSecondary, + running: colors.warning, + completed: colors.success, + partial: colors.warning, + failed: colors.error, +}; + +// ════════════════════════════════════════════════════════════════ +// Component +// ════════════════════════════════════════════════════════════════ export const BatchOperationsScreen: React.FC = () => { - const { draft, current, setDraft, loadFromCsv, createBatch, executeBatch, resetDraft } = - useBatchStore(); - const [csv, setCsv] = useState('subscriptionId,amount\nsub_1,1000\nsub_2,1000'); - const [busy, setBusy] = useState(false); + const { + draft, + currentResult, + history, + isRunning, + progress, + setOperationType, + toggleAtomic, + setChunkSize, + setCsvContent, + loadCreateCsv, + loadCancelCsv, + loadChargeCsv, + loadUpdateCsv, + setDraft, + executeBatch, + retryFailed, + exportResultJson, + exportResultCsv, + resetDraft, + clearResult, + loadHistory, + gasEstimate, + } = useBatchStore(); + + const [showHistory, setShowHistory] = useState(false); + const [showFilter, setShowFilter] = useState(false); + const [showCancelReasons, setShowCancelReasons] = useState(false); + const [selectedReason, setSelectedReason] = useState('other'); + const [cancelNotes, setCancelNotes] = useState(''); + const [updatePrice, setUpdatePrice] = useState(''); + const [updatePlan, setUpdatePlan] = useState(''); + const [updateCategory, setUpdateCategory] = useState(''); + const [updateCurrency, setUpdateCurrency] = useState(''); + const [filterMinPrice, setFilterMinPrice] = useState(''); + const [filterMaxPrice, setFilterMaxPrice] = useState(''); + const [filterPlanChange, setFilterPlanChange] = useState(false); - const onLoadCsv = () => loadFromCsv(csv, draft.operationType, draft.atomic); + useEffect(() => { + loadHistory(); + }, []); - const onRun = async () => { - const created = createBatch(); - if (!created) return; - setBusy(true); + const items = + draft.createInputs.length || + draft.updateIds.length || + draft.cancelIds.length || + draft.chargeItems.length || + 0; + + const canExecute = items > 0 && !isRunning; + + const onLoadCsv = useCallback(() => { + const csv = draft.csvContent; + if (!csv.trim()) { + Alert.alert('Error', 'Please enter CSV data'); + return; + } + switch (draft.operationType) { + case 'create': + loadCreateCsv(csv); + break; + case 'cancel': + loadCancelCsv(csv); + break; + case 'charge': + loadChargeCsv(csv); + break; + case 'update': + loadUpdateCsv(csv); + break; + } + }, [draft.csvContent, draft.operationType, loadCreateCsv, loadCancelCsv, loadChargeCsv, loadUpdateCsv]); + + const onExecute = useCallback(async () => { await executeBatch(); - setBusy(false); - }; + }, [executeBatch]); - const gas = estimateBatchGas(draft.subscriptionIds.length); + const onRetry = useCallback(async () => { + await retryFailed(); + }, [retryFailed]); - return ( - - Batch Operations + const onExportJson = useCallback(() => { + const json = exportResultJson(); + if (json) { + Share.share({ message: json, title: 'Batch Result' }).catch(() => {}); + } + }, [exportResultJson]); - Operation type - - {OPERATIONS.map((op) => ( - setDraft({ operationType: op })} - > - {op} - + const onExportCsv = useCallback(() => { + const csv = exportResultCsv(); + if (csv) { + Share.share({ message: csv, title: 'Batch Result CSV' }).catch(() => {}); + } + }, [exportResultCsv]); + + const onApplyFilter = useCallback(() => { + setDraft({ + updateFilter: { + planChange: filterPlanChange || undefined, + minPrice: filterMinPrice ? parseFloat(filterMinPrice) : undefined, + maxPrice: filterMaxPrice ? parseFloat(filterMaxPrice) : undefined, + }, + }); + setShowFilter(false); + }, [filterPlanChange, filterMinPrice, filterMaxPrice, setDraft]); + + const onApplyUpdateParams = useCallback(() => { + setDraft({ + updateParams: { + price: updatePrice ? parseFloat(updatePrice) : undefined, + plan: updatePlan || undefined, + category: updateCategory || undefined, + currency: updateCurrency || undefined, + }, + }); + }, [updatePrice, updatePlan, updateCategory, updateCurrency, setDraft]); + + const onAddCancelReason = useCallback(() => { + if (draft.cancelIds.length > 0) { + setDraft({ + cancelReasons: draft.cancelIds.map((id) => ({ + subscriptionId: id, + reason: selectedReason, + notes: cancelNotes || undefined, + })), + }); + Alert.alert('Applied', `Applied reason "${selectedReason}" to ${draft.cancelIds.length} subscription(s)`); + } + setShowCancelReasons(false); + }, [draft.cancelIds, selectedReason, cancelNotes, setDraft]); + + const gasEst = gasEstimate(); + const hasFailedItems = currentResult?.failedItems && currentResult.failedItems > 0; + + const getCsvPlaceholder = (): string => { + switch (draft.operationType) { + case 'create': + return 'name,description,category,price,currency,billingCycle\nNetflix,Streaming,streaming,15.99,USD,monthly\nSpotify,Music,streaming,9.99,USD,monthly'; + case 'update': + return 'subscriptionId\nsub_abc123\nsub_def456\nsub_ghi789'; + case 'cancel': + return 'subscriptionId,reason,notes\nsub_abc123,too_expensive,\nsub_def456,no_longer_needed,Switched to competitor'; + case 'charge': + return 'subscriptionId,amount\nsub_abc123,15.99\nsub_def456,9.99'; + default: + return ''; + } + }; + + const renderOperationSelector = () => ( + + Operation Type + + {OPERATION_TYPES.map((op) => ( + setOperationType(op.key)}> + + {op.icon} {op.label} + + ))} + + ); - CSV template (subscriptionId,param per row) + const renderCsvInput = () => ( + + Data Input (CSV) -