From 8e868d13f186ed1f808a9facf66c5610fd1702cd Mon Sep 17 00:00:00 2001 From: distributed-nerd <267643428+distributed-nerd@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:57:22 +0100 Subject: [PATCH 1/6] feat(batch): add batch analytics, per-operation config, and rollback Batch subscription management was limited to create/charge with a single global configuration, no timing or success-rate reporting, and no way to undo a batch that had already committed. Contract (contracts/batch): - add Update and Cancel operation types with per-item cancel reason codes - add a Processing state and record start/finish ledger timestamps so status tracking covers pending -> processing -> completed/partial/failed - add per-operation-type BatchConfig (max items, default atomicity, rollback eligibility, gas rate) with an admin-only setter - add rollback_batch, which restores the pre-execution snapshot captured during execute; creates are removed, prior state is restored byte for byte, and a subscription touched twice restores to before the batch - add BatchAnalytics maintained globally and per operation type, with a basis-point success rate and execution timing; a rollback discounts its successful items - return per-item OperationResult codes and persist the batch result - delete the unused batch.rs module, whose types are now in lib.rs Client (app/): - collapse the four near-identical execute paths into one runner so atomicity, timing, idempotency and status handling are shared - add BatchOperationConfig per operation type, applied to chunking, atomicity defaults, retry budgets and size validation - add computeBatchAnalytics for success rates, p95/average duration, per-item timing and throughput, partitioned by operation type - add rollbackBatch driven by a caller-supplied RollbackHandler; a partial reversal leaves the batch partial rather than claiming success - track applied (operation, subscription) pairs so a re-submitted batch cannot double-charge, and forget successes an atomic rollback discarded - rename the in-flight state from running to processing to match the contract, and surface configuration, analytics and rollback in the UI Docs: add docs/BATCH_OPERATIONS.md and rewrite the contract's BATCHING_API.md, which documented an API that was never implemented. Tests: 24 new contract tests, 31 service tests, 16 store tests. --- app/screens/BatchOperationsScreen.tsx | 228 +++- .../__tests__/batchTransactionService.test.ts | 452 +++++++ app/services/batchTransactionService.ts | 1046 ++++++++++------- app/stores/__tests__/batchStore.test.ts | 157 +++ app/stores/batchStore.ts | 612 ++++++---- contracts/batch/BATCHING_API.md | 334 +++--- contracts/batch/src/batch.rs | 136 --- contracts/batch/src/lib.rs | 750 +++++++++++- contracts/batch/tests/batch_tests.rs | 512 +++++++- docs/BATCH_OPERATIONS.md | 210 ++++ 10 files changed, 3364 insertions(+), 1073 deletions(-) create mode 100644 app/services/__tests__/batchTransactionService.test.ts delete mode 100644 contracts/batch/src/batch.rs create mode 100644 docs/BATCH_OPERATIONS.md diff --git a/app/screens/BatchOperationsScreen.tsx b/app/screens/BatchOperationsScreen.tsx index 6267a853..77c5a78b 100644 --- a/app/screens/BatchOperationsScreen.tsx +++ b/app/screens/BatchOperationsScreen.tsx @@ -47,12 +47,18 @@ const CANCEL_REASONS: Array<{ key: CancelReason['reason']; label: string }> = [ const STATE_COLORS: Record = { pending: colors.textSecondary, - running: colors.warning, + processing: colors.warning, completed: colors.success, partial: colors.warning, failed: colors.error, + rolled_back: colors.textSecondary, }; +const formatPercent = (fraction: number): string => `${Math.round(fraction * 100)}%`; + +const formatDuration = (ms: number): string => + ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms)}ms`; + // ════════════════════════════════════════════════════════════════ // Component // ════════════════════════════════════════════════════════════════ @@ -75,12 +81,19 @@ export const BatchOperationsScreen: React.FC = () => { setDraft, executeBatch, retryFailed, + rollbackBatch, + canRollback, exportResultJson, exportResultCsv, resetDraft, - clearResult, loadHistory, gasEstimate, + itemCount, + validateDraft, + activeConfig, + setOperationConfig, + resetOperationConfig, + analytics, } = useBatchStore(); const [showHistory, setShowHistory] = useState(false); @@ -100,14 +113,11 @@ export const BatchOperationsScreen: React.FC = () => { loadHistory(); }, []); - const items = - draft.createInputs.length || - draft.updateIds.length || - draft.cancelIds.length || - draft.chargeItems.length || - 0; - - const canExecute = items > 0 && !isRunning; + const items = itemCount(); + const config = activeConfig(); + const sizeCheck = validateDraft(); + const canExecute = sizeCheck.valid && !isRunning; + const rollbackAvailable = canRollback(); const onLoadCsv = useCallback(() => { const csv = draft.csvContent; @@ -139,6 +149,36 @@ export const BatchOperationsScreen: React.FC = () => { await retryFailed(); }, [retryFailed]); + const onRollback = useCallback(() => { + Alert.alert( + 'Roll back batch', + 'Every committed item in this batch will be reversed. Continue?', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Roll back', + style: 'destructive', + onPress: async () => { + const rollback = await rollbackBatch(); + if (!rollback) { + Alert.alert( + 'Rollback unavailable', + 'This batch cannot be rolled back. Configure a rollback handler and check that the operation type allows it.', + ); + return; + } + Alert.alert( + 'Rollback complete', + `Reverted ${rollback.reverted} of ${rollback.attempted} item(s)${ + rollback.failed > 0 ? `, ${rollback.failed} could not be reverted` : '' + }.`, + ); + }, + }, + ], + ); + }, [rollbackBatch]); + const onExportJson = useCallback(() => { const json = exportResultJson(); if (json) { @@ -355,9 +395,132 @@ export const BatchOperationsScreen: React.FC = () => { Est. Gas: {gasEst.toLocaleString()} units | {draft.atomic ? 'Atomic' : 'Non-atomic'} mode + {!sizeCheck.valid && items > 0 && ( + {sizeCheck.reason} + )} ); + const renderConfig = () => ( + + + {draft.operationType.toUpperCase()} Configuration + resetOperationConfig(draft.operationType)}> + Reset + + + + Applies to every {draft.operationType} batch. Limits mirror the on-chain configuration for + this operation type. + + + Max items per batch + + setOperationConfig(draft.operationType, { + maxItems: Math.max(1, parseInt(v, 10) || 1), + }) + } + keyboardType="number-pad" + /> + + + Atomic by default + + setOperationConfig(draft.operationType, { atomicDefault }) + } + trackColor={{ false: colors.border, true: colors.primary }} + /> + + + Rollback allowed + + setOperationConfig(draft.operationType, { allowRollback }) + } + trackColor={{ false: colors.border, true: colors.primary }} + /> + + + Max retries per item + + setOperationConfig(draft.operationType, { + maxRetries: Math.max(0, parseInt(v, 10) || 0), + }) + } + keyboardType="number-pad" + /> + + + Skip already-applied items + + setOperationConfig(draft.operationType, { idempotent }) + } + trackColor={{ false: colors.border, true: colors.primary }} + /> + + + ); + + const renderAnalytics = () => { + const stats = analytics(); + if (stats.overall.batches === 0) return null; + const active = stats.byOperationType[draft.operationType]; + return ( + + Batch Analytics + + + {stats.overall.batches} + Batches + + + + {formatPercent(stats.overall.batchSuccessRate)} + + Clean runs + + + + {formatPercent(stats.overall.itemSuccessRate)} + + Items OK + + + {formatDuration(stats.overall.avgDurationMs)} + Avg time + + + + p95 {formatDuration(stats.overall.p95DurationMs)} ·{' '} + {stats.overall.avgItemDurationMs}ms per item · {stats.overall.throughputPerSecond}/s + throughput + + + {stats.overall.failedItems} failed item(s) · {stats.overall.skippedItems} skipped ·{' '} + {stats.overall.rolledBack} rolled back + + {active.batches > 0 && ( + + {draft.operationType}: {active.batches} batch(es),{' '} + {formatPercent(active.itemSuccessRate)} item success, avg{' '} + {formatDuration(active.avgDurationMs)} + + )} + + ); + }; + const renderActions = () => ( { disabled={!hasFailedItems || isRunning}> Retry Failed + + Roll Back + Reset @@ -454,10 +623,29 @@ export const BatchOperationsScreen: React.FC = () => { {currentResult.rolledBack && ( - Rolled back (atomic failure) + + {currentResult.state === 'rolled_back' + ? 'Rolled back — every committed item was reversed' + : 'Rolled back (atomic failure)'} + )} + {currentResult.rejectionReason && ( + + {currentResult.rejectionReason} + + )} + + {currentResult.durationMs !== undefined && ( + + Completed in {formatDuration(currentResult.durationMs)} + {currentResult.totalItems > 0 + ? ` · ${Math.round(currentResult.durationMs / currentResult.totalItems)}ms per item` + : ''} + + )} + Export JSON @@ -655,6 +843,7 @@ export const BatchOperationsScreen: React.FC = () => { {item.summary} {new Date(item.timestamp).toLocaleString()} + {item.durationMs !== undefined ? ` · ${formatDuration(item.durationMs)}` : ''} )} @@ -681,9 +870,11 @@ export const BatchOperationsScreen: React.FC = () => { {renderUpdateParams()} {renderCancelReasons()} {renderOptions()} + {renderConfig()} {renderActions()} {renderProgress()} {renderResults()} + {renderAnalytics()} @@ -853,6 +1044,21 @@ const styles = StyleSheet.create({ color: colors.textSecondary, marginTop: spacing.sm, }, + validationText: { + ...typography.caption, + color: colors.error, + marginTop: spacing.xs, + }, + configHint: { + ...typography.caption, + color: colors.textSecondary, + marginBottom: spacing.sm, + }, + analyticsDetail: { + ...typography.caption, + color: colors.textSecondary, + marginTop: spacing.xs, + }, primaryButton: { backgroundColor: colors.primary, paddingVertical: spacing.md, diff --git a/app/services/__tests__/batchTransactionService.test.ts b/app/services/__tests__/batchTransactionService.test.ts new file mode 100644 index 00000000..ad3789ed --- /dev/null +++ b/app/services/__tests__/batchTransactionService.test.ts @@ -0,0 +1,452 @@ +import { + BatchTransactionService, + DEFAULT_BATCH_CONFIGS, + computeBatchAnalytics, + getDefaultBatchConfig, + toHistoryEntry, + validateBatchSizeFor, + clearBatchHistory, + type BatchCreateInput, + type BatchHistoryEntry, + type CancelReason, + type PerItemResult, +} from '../batchTransactionService'; + +const createInput = (name: string): BatchCreateInput => ({ + name, + category: 'streaming', + price: 9.99, + currency: 'USD', + billingCycle: 'monthly', +}); + +const chargeItems = (count: number) => + Array.from({ length: count }, (_, i) => ({ subscriptionId: `sub_${i + 1}`, amount: 100 })); + +const historyEntry = (patch: Partial): BatchHistoryEntry => ({ + batchId: patch.batchId ?? 'batch_1', + operationType: patch.operationType ?? 'charge', + state: patch.state ?? 'completed', + totalItems: patch.totalItems ?? 10, + successfulItems: patch.successfulItems ?? 10, + failedItems: patch.failedItems ?? 0, + timestamp: patch.timestamp ?? '2026-01-01T00:00:00.000Z', + summary: patch.summary ?? 'charge: 10/10 succeeded', + skippedItems: patch.skippedItems, + durationMs: patch.durationMs, + rolledBack: patch.rolledBack, +}); + +beforeEach(async () => { + await clearBatchHistory(); +}); + +describe('per-operation configuration', () => { + it('applies conservative defaults to money movement', () => { + expect(DEFAULT_BATCH_CONFIGS.charge.atomicDefault).toBe(true); + expect(DEFAULT_BATCH_CONFIGS.charge.maxItems).toBe(50); + // Cancellation is terminal and customer-visible, so it is not reversible. + expect(DEFAULT_BATCH_CONFIGS.cancel.allowRollback).toBe(false); + expect(DEFAULT_BATCH_CONFIGS.create.allowRollback).toBe(true); + }); + + it('hands out copies so callers cannot mutate the shared defaults', () => { + const config = getDefaultBatchConfig('create'); + config.maxItems = 1; + expect(DEFAULT_BATCH_CONFIGS.create.maxItems).toBe(100); + }); + + it('merges instance overrides over the defaults', () => { + const service = new BatchTransactionService(); + service.setOperationConfig('charge', { maxItems: 5, atomicDefault: false }); + + const config = service.getOperationConfig('charge'); + expect(config.maxItems).toBe(5); + expect(config.atomicDefault).toBe(false); + // Untouched fields keep their defaults. + expect(config.retryDelayMs).toBe(DEFAULT_BATCH_CONFIGS.charge.retryDelayMs); + }); + + it('lets an explicit chunk size win over the configured one', () => { + const service = new BatchTransactionService(7); + service.setOperationConfig('charge', { chunkSize: 25 }); + expect(service.getOperationConfig('charge').chunkSize).toBe(7); + }); + + it('clamps chunk size into the supported range', () => { + const service = new BatchTransactionService(0); + expect(service.getOperationConfig('create').chunkSize).toBe(1); + service.setChunkSize(9999); + expect(service.getOperationConfig('create').chunkSize).toBe(200); + }); + + it('uses the configured atomicity when the caller states no preference', async () => { + const service = new BatchTransactionService(); + const result = await service.executeBatchCharge(chargeItems(2), async (id) => ({ + success: id === 'sub_1', + error: 'no such subscription', + })); + + // charge defaults to atomic, so the successful item is discarded too. + expect(result.atomic).toBe(true); + expect(result.rolledBack).toBe(true); + expect(result.successfulItems).toBe(0); + expect(result.state).toBe('failed'); + }); + + it('rejects a batch that exceeds the configured ceiling without applying anything', async () => { + const service = new BatchTransactionService(); + service.setOperationConfig('charge', { maxItems: 2 }); + const applied: string[] = []; + + const result = await service.executeBatchCharge(chargeItems(3), async (id) => { + applied.push(id); + return { success: true }; + }); + + expect(result.state).toBe('failed'); + expect(result.rejectionReason).toContain('limited to 2 items'); + expect(applied).toEqual([]); + }); + + it('validates item counts against a config', () => { + expect(validateBatchSizeFor('create', 0).valid).toBe(false); + expect(validateBatchSizeFor('create', 100).valid).toBe(true); + expect(validateBatchSizeFor('create', 101).valid).toBe(false); + expect(validateBatchSizeFor('charge', 51).reason).toContain('50 items'); + }); +}); + +describe('status tracking and timing', () => { + it('reports processing while items are in flight and completed at the end', async () => { + const service = new BatchTransactionService(); + const observed: string[] = []; + + const result = await service.executeBatchCreate( + [createInput('Netflix'), createInput('Spotify')], + async () => { + observed.push(service.getProgress()!.state); + return { success: true, id: 'sub_x' }; + }, + ); + + expect(observed).toEqual(['processing', 'processing']); + expect(result.state).toBe('completed'); + }); + + it('records batch and per-item durations', async () => { + const service = new BatchTransactionService(); + const result = await service.executeBatchCreate([createInput('Netflix')], async () => ({ + success: true, + })); + + expect(result.durationMs).toBeGreaterThanOrEqual(0); + expect(result.results[0].durationMs).toBeGreaterThanOrEqual(0); + expect(result.completedAt).toBeDefined(); + }); + + it('marks items after an atomic failure as skipped', async () => { + const service = new BatchTransactionService(); + const result = await service.executeBatchCharge( + chargeItems(4), + async (id) => ({ success: id !== 'sub_2', error: 'declined' }), + { atomic: true }, + ); + + const statuses = result.results.map((r) => r.status); + // sub_1 succeeded then was discarded, sub_2 failed, the rest never ran. + expect(statuses).toEqual(['skipped', 'failed', 'skipped', 'skipped']); + expect(result.successfulItems).toBe(0); + expect(result.skippedItems).toBe(3); + expect(result.failedItems).toBe(1); + }); +}); + +describe('idempotency', () => { + it('skips a charge that already succeeded on this service instance', async () => { + const service = new BatchTransactionService(); + const charged: string[] = []; + const charge = async (id: string) => { + charged.push(id); + return { success: true }; + }; + + await service.executeBatchCharge(chargeItems(2), charge, { atomic: false }); + const second = await service.executeBatchCharge(chargeItems(2), charge, { atomic: false }); + + expect(charged).toEqual(['sub_1', 'sub_2']); + expect(second.skippedItems).toBe(2); + expect(second.successfulItems).toBe(0); + expect(second.results[0].message).toContain('idempotency'); + }); + + it('forgets successes discarded by an atomic rollback so a re-run can apply them', async () => { + const service = new BatchTransactionService(); + let failFirstRun = true; + const charge = async (id: string) => ({ + success: !(failFirstRun && id === 'sub_2'), + error: 'declined', + }); + + await service.executeBatchCharge(chargeItems(2), charge, { atomic: true }); + failFirstRun = false; + const second = await service.executeBatchCharge(chargeItems(2), charge, { atomic: true }); + + expect(second.successfulItems).toBe(2); + expect(second.skippedItems).toBe(0); + }); + + it('does not deduplicate creates, which may legitimately share a name', async () => { + const service = new BatchTransactionService(); + const result = await service.executeBatchCreate( + [createInput('Netflix'), createInput('Netflix')], + async () => ({ success: true }), + ); + + expect(result.successfulItems).toBe(2); + expect(result.skippedItems).toBe(0); + }); + + it('clears the idempotency ledger on request', async () => { + const service = new BatchTransactionService(); + const charge = async () => ({ success: true }); + + await service.executeBatchCharge(chargeItems(1), charge, { atomic: false }); + service.clearIdempotencyKeys(); + const second = await service.executeBatchCharge(chargeItems(1), charge, { atomic: false }); + + expect(second.successfulItems).toBe(1); + }); +}); + +describe('rollback', () => { + const cancelReasons: CancelReason[] = [{ subscriptionId: 'sub_1', reason: 'too_expensive' }]; + + it('reverses every committed item and reports rolled_back', async () => { + const service = new BatchTransactionService(); + await service.executeBatchCharge(chargeItems(3), async () => ({ success: true }), { + atomic: false, + }); + + const reverted: string[] = []; + const rollback = await service.rollbackBatch(async (item: PerItemResult) => { + reverted.push(item.subscriptionId); + return { success: true }; + }); + + expect(reverted).toEqual(['sub_1', 'sub_2', 'sub_3']); + expect(rollback).toMatchObject({ attempted: 3, reverted: 3, failed: 0 }); + expect(service.getLastResult()!.state).toBe('rolled_back'); + expect(service.getLastResult()!.successfulItems).toBe(0); + }); + + it('stays partial when some items could not be reversed', async () => { + const service = new BatchTransactionService(); + await service.executeBatchCharge(chargeItems(2), async () => ({ success: true }), { + atomic: false, + }); + + const rollback = await service.rollbackBatch(async (item) => ({ + success: item.subscriptionId === 'sub_1', + error: 'refund window closed', + })); + + expect(rollback).toMatchObject({ reverted: 1, failed: 1 }); + const result = service.getLastResult()!; + expect(result.state).toBe('partial'); + expect(result.rolledBack).toBe(false); + expect(result.successfulItems).toBe(1); + expect(result.failedItems).toBe(1); + }); + + it('refuses to roll back an operation type configured against it', async () => { + const service = new BatchTransactionService(); + await service.executeBatchCancel( + ['sub_1'], + cancelReasons, + async () => ({ success: true }), + { atomic: false }, + ); + + expect(service.canRollback()).toBe(false); + expect(await service.rollbackBatch(async () => ({ success: true }))).toBeNull(); + }); + + it('refuses to roll back an atomic batch that committed nothing', async () => { + const service = new BatchTransactionService(); + await service.executeBatchCharge( + chargeItems(2), + async (id) => ({ success: id === 'sub_1', error: 'declined' }), + { atomic: true }, + ); + + expect(service.canRollback()).toBe(false); + }); + + it('refuses to roll back twice', async () => { + const service = new BatchTransactionService(); + await service.executeBatchCharge(chargeItems(1), async () => ({ success: true }), { + atomic: false, + }); + await service.rollbackBatch(async () => ({ success: true })); + + expect(service.canRollback()).toBe(false); + expect(await service.rollbackBatch(async () => ({ success: true }))).toBeNull(); + }); + + it('returns null when there is no batch to reverse', async () => { + const service = new BatchTransactionService(); + expect(await service.rollbackBatch(async () => ({ success: true }))).toBeNull(); + }); + + it('lets a reversed charge be applied again', async () => { + const service = new BatchTransactionService(); + const charged: string[] = []; + const charge = async (id: string) => { + charged.push(id); + return { success: true }; + }; + + await service.executeBatchCharge(chargeItems(1), charge, { atomic: false }); + await service.rollbackBatch(async () => ({ success: true })); + await service.executeBatchCharge(chargeItems(1), charge, { atomic: false }); + + expect(charged).toEqual(['sub_1', 'sub_1']); + }); +}); + +describe('retry', () => { + it('retries failed items up to the configured budget', async () => { + const service = new BatchTransactionService(); + service.setOperationConfig('update', { maxRetries: 2, retryDelayMs: 0 }); + + await service.executeBatchUpdate( + ['sub_1', 'sub_2'], + { price: 5 }, + async (id) => ({ success: id === 'sub_1', error: 'timeout' }), + { atomic: false }, + ); + + const result = await service.retryFailedItems(async () => ({ success: true })); + expect(result!.state).toBe('completed'); + expect(result!.successfulItems).toBe(2); + expect(result!.failedItems).toBe(0); + }); + + it('stops retrying once an item exhausts its budget', async () => { + const service = new BatchTransactionService(); + service.setOperationConfig('update', { maxRetries: 1, retryDelayMs: 0 }); + + await service.executeBatchUpdate( + ['sub_1'], + { price: 5 }, + async () => ({ success: false, error: 'timeout' }), + { atomic: false }, + ); + + await service.retryFailedItems(async () => ({ success: false, error: 'timeout' })); + let attempts = 0; + await service.retryFailedItems(async () => { + attempts++; + return { success: true }; + }); + + expect(attempts).toBe(0); + }); +}); + +describe('analytics', () => { + it('returns zeroed statistics for an empty history', () => { + const analytics = computeBatchAnalytics([]); + expect(analytics.overall.batches).toBe(0); + expect(analytics.overall.itemSuccessRate).toBe(0); + expect(analytics.overall.avgDurationMs).toBe(0); + expect(analytics.byOperationType.charge.batches).toBe(0); + }); + + it('computes success rates over all batches', () => { + const analytics = computeBatchAnalytics([ + historyEntry({ batchId: 'a', state: 'completed', totalItems: 4, successfulItems: 4 }), + historyEntry({ + batchId: 'b', + state: 'partial', + totalItems: 6, + successfulItems: 2, + failedItems: 4, + }), + ]); + + expect(analytics.overall.batches).toBe(2); + expect(analytics.overall.completed).toBe(1); + expect(analytics.overall.partial).toBe(1); + expect(analytics.overall.batchSuccessRate).toBe(0.5); + expect(analytics.overall.itemSuccessRate).toBe(0.6); + }); + + it('computes timing statistics only from batches that recorded a duration', () => { + const analytics = computeBatchAnalytics([ + historyEntry({ batchId: 'a', totalItems: 10, successfulItems: 10, durationMs: 1000 }), + historyEntry({ batchId: 'b', totalItems: 10, successfulItems: 10, durationMs: 3000 }), + // Persisted before timing was tracked, so it must not drag the average down. + historyEntry({ batchId: 'c', totalItems: 10, successfulItems: 10 }), + ]); + + expect(analytics.overall.avgDurationMs).toBe(2000); + expect(analytics.overall.p95DurationMs).toBe(3000); + expect(analytics.overall.avgItemDurationMs).toBe(200); + expect(analytics.overall.throughputPerSecond).toBe(5); + }); + + it('partitions statistics by operation type', () => { + const analytics = computeBatchAnalytics([ + historyEntry({ batchId: 'a', operationType: 'charge', totalItems: 2, successfulItems: 2 }), + historyEntry({ + batchId: 'b', + operationType: 'cancel', + state: 'failed', + totalItems: 2, + successfulItems: 0, + failedItems: 2, + }), + ]); + + expect(analytics.byOperationType.charge.itemSuccessRate).toBe(1); + expect(analytics.byOperationType.cancel.itemSuccessRate).toBe(0); + expect(analytics.byOperationType.cancel.failed).toBe(1); + expect(analytics.byOperationType.update.batches).toBe(0); + }); + + it('counts rolled-back batches', () => { + const analytics = computeBatchAnalytics([ + historyEntry({ batchId: 'a', state: 'rolled_back', successfulItems: 0, totalItems: 3 }), + historyEntry({ batchId: 'b', state: 'failed', rolledBack: true, totalItems: 3 }), + ]); + + expect(analytics.overall.rolledBack).toBe(2); + }); + + it('aggregates the service history it persisted', async () => { + const service = new BatchTransactionService(); + await service.executeBatchCreate([createInput('Netflix')], async () => ({ success: true })); + + const analytics = await service.getAnalytics(); + expect(analytics.overall.batches).toBe(1); + expect(analytics.byOperationType.create.itemSuccessRate).toBe(1); + }); +}); + +describe('history entries', () => { + it('carries timing and rollback state into the history record', async () => { + const service = new BatchTransactionService(); + const result = await service.executeBatchCreate([createInput('Netflix')], async () => ({ + success: true, + })); + + const entry = toHistoryEntry(result); + expect(entry.batchId).toBe(result.batchId); + expect(entry.durationMs).toBe(result.durationMs); + expect(entry.skippedItems).toBe(0); + expect(entry.rolledBack).toBe(false); + expect(entry.summary).toBe('create: 1/1 succeeded'); + }); +}); diff --git a/app/services/batchTransactionService.ts b/app/services/batchTransactionService.ts index 4f621ed9..df1f6ef4 100644 --- a/app/services/batchTransactionService.ts +++ b/app/services/batchTransactionService.ts @@ -4,15 +4,20 @@ // // Supports: batch create from CSV/JSON, batch update with filtering, // batch cancel with reason collection, batch charge for manual billing, -// per-item status tracking, idempotent retry of failed items, -// result export (CSV/JSON), and large batch memory management via chunking. +// per-item status tracking, atomic execution, post-commit rollback, +// idempotent retry of failed items, per-operation-type configuration, +// success/timing analytics, result export (CSV/JSON), and large batch +// memory management via chunking. +// +// The state machine mirrors the `subtrackr-batch` contract: +// pending -> processing -> completed | partial | failed +// with `rolled_back` reachable from any committed state via `rollbackBatch`. import AsyncStorage from '@react-native-async-storage/async-storage'; import type { Subscription } from '../../src/types/subscription'; const HISTORY_KEY = 'subtrackr-batch-history'; const MAX_HISTORY_ENTRIES = 50; -const DEFAULT_CHUNK_SIZE = 50; const MAX_CHUNK_SIZE = 200; // ════════════════════════════════════════════════════════════════ @@ -21,9 +26,15 @@ const MAX_CHUNK_SIZE = 200; export type BatchOperationType = 'create' | 'update' | 'charge' | 'cancel'; -export type BatchState = 'pending' | 'running' | 'completed' | 'partial' | 'failed'; +export type BatchState = + | 'pending' + | 'processing' + | 'completed' + | 'partial' + | 'failed' + | 'rolled_back'; -export type PerItemStatus = 'pending' | 'running' | 'success' | 'failed' | 'skipped'; +export type PerItemStatus = 'pending' | 'processing' | 'success' | 'failed' | 'skipped'; export interface CancelReason { subscriptionId: string; @@ -72,6 +83,8 @@ export interface PerItemResult { retryCount: number; completedAt?: string; message?: string; + /** Wall-clock milliseconds spent applying this item. */ + durationMs?: number; } export interface BatchExecutionResult { @@ -88,8 +101,12 @@ export interface BatchExecutionResult { gasEstimate: number; startedAt: string; completedAt?: string; + /** Wall-clock milliseconds from first item to last. */ + durationMs?: number; cancelReasons?: CancelReason[]; filter?: UpdateFilter; + /** Populated when the batch was rejected before any item ran. */ + rejectionReason?: string; } export interface BatchHistoryEntry { @@ -101,6 +118,9 @@ export interface BatchHistoryEntry { failedItems: number; timestamp: string; summary: string; + skippedItems?: number; + durationMs?: number; + rolledBack?: boolean; } export interface BatchExportData { @@ -126,6 +146,265 @@ export interface RetryConfig { onlyRetryFailed: boolean; } +// ════════════════════════════════════════════════════════════════ +// Per-operation configuration +// ════════════════════════════════════════════════════════════════ + +export interface BatchOperationConfig { + /** Ceiling on items in one batch, mirroring the contract's per-type limit. */ + maxItems: number; + /** Items applied per chunk, bounding peak memory for large batches. */ + chunkSize: number; + /** Atomicity used when the caller does not state a preference. */ + atomicDefault: boolean; + /** Whether a committed batch of this type may be rolled back afterwards. */ + allowRollback: boolean; + /** Retry budget applied by `retryFailedItems`. */ + maxRetries: number; + retryDelayMs: number; + backoffMultiplier: number; + /** + * Skip items whose (operation, subscription) pair already succeeded on this + * service instance, so a re-submitted batch cannot double-apply. + */ + idempotent: boolean; +} + +/** + * Defaults chosen per operation type. Money movement is atomic and conservative + * with retries; cancellation is deliberately not reversible, matching the + * contract's `allow_rollback` configuration. + */ +export const DEFAULT_BATCH_CONFIGS: Record = { + create: { + maxItems: 100, + chunkSize: 50, + atomicDefault: false, + allowRollback: true, + maxRetries: 3, + retryDelayMs: 300, + backoffMultiplier: 2, + // Two subscriptions may legitimately share a name, so creates are never + // deduplicated. + idempotent: false, + }, + update: { + maxItems: 100, + chunkSize: 50, + atomicDefault: false, + allowRollback: true, + maxRetries: 3, + retryDelayMs: 300, + backoffMultiplier: 2, + idempotent: true, + }, + charge: { + maxItems: 50, + chunkSize: 25, + atomicDefault: true, + allowRollback: true, + maxRetries: 2, + retryDelayMs: 500, + backoffMultiplier: 2, + idempotent: true, + }, + cancel: { + maxItems: 50, + chunkSize: 25, + atomicDefault: false, + allowRollback: false, + maxRetries: 1, + retryDelayMs: 300, + backoffMultiplier: 2, + idempotent: true, + }, +}; + +export function getDefaultBatchConfig(operationType: BatchOperationType): BatchOperationConfig { + return { ...DEFAULT_BATCH_CONFIGS[operationType] }; +} + +export interface BatchSizeValidation { + valid: boolean; + reason?: string; +} + +/** Validate an item count against an operation type's configured ceiling. */ +export function validateBatchSizeFor( + operationType: BatchOperationType, + count: number, + config: BatchOperationConfig = DEFAULT_BATCH_CONFIGS[operationType], +): BatchSizeValidation { + if (count <= 0) { + return { valid: false, reason: 'A batch must contain at least one item.' }; + } + if (count > config.maxItems) { + return { + valid: false, + reason: `A ${operationType} batch is limited to ${config.maxItems} items (got ${count}).`, + }; + } + return { valid: true }; +} + +// ════════════════════════════════════════════════════════════════ +// Analytics +// ════════════════════════════════════════════════════════════════ + +export interface BatchAnalyticsSummary { + batches: number; + completed: number; + partial: number; + failed: number; + rolledBack: number; + totalItems: number; + successfulItems: number; + failedItems: number; + skippedItems: number; + /** Fraction of batches that completed with no failures, 0-1. */ + batchSuccessRate: number; + /** Fraction of individual items that succeeded, 0-1. */ + itemSuccessRate: number; + totalDurationMs: number; + avgDurationMs: number; + p95DurationMs: number; + /** Mean milliseconds per item across all timed batches. */ + avgItemDurationMs: number; + /** Items applied per second across all timed batches. */ + throughputPerSecond: number; +} + +export interface BatchAnalytics { + overall: BatchAnalyticsSummary; + byOperationType: Record; +} + +const emptyAnalyticsSummary = (): BatchAnalyticsSummary => ({ + batches: 0, + completed: 0, + partial: 0, + failed: 0, + rolledBack: 0, + totalItems: 0, + successfulItems: 0, + failedItems: 0, + skippedItems: 0, + batchSuccessRate: 0, + itemSuccessRate: 0, + totalDurationMs: 0, + avgDurationMs: 0, + p95DurationMs: 0, + avgItemDurationMs: 0, + throughputPerSecond: 0, +}); + +const percentile = (sorted: number[], fraction: number): number => { + if (sorted.length === 0) return 0; + const index = Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1); + return sorted[Math.max(0, index)]; +}; + +function summarize(entries: BatchHistoryEntry[]): BatchAnalyticsSummary { + const summary = emptyAnalyticsSummary(); + if (entries.length === 0) return summary; + + // Only batches that recorded a duration contribute to timing statistics, so + // history persisted before timing was tracked cannot skew the averages. + const durations: number[] = []; + let timedItems = 0; + + for (const entry of entries) { + summary.batches++; + summary.totalItems += entry.totalItems; + summary.successfulItems += entry.successfulItems; + summary.failedItems += entry.failedItems; + summary.skippedItems += entry.skippedItems ?? 0; + + if (entry.rolledBack || entry.state === 'rolled_back') summary.rolledBack++; + if (entry.state === 'completed') summary.completed++; + else if (entry.state === 'partial') summary.partial++; + else if (entry.state === 'failed') summary.failed++; + + if (typeof entry.durationMs === 'number' && entry.durationMs >= 0) { + durations.push(entry.durationMs); + summary.totalDurationMs += entry.durationMs; + timedItems += entry.totalItems; + } + } + + summary.batchSuccessRate = summary.completed / summary.batches; + summary.itemSuccessRate = + summary.totalItems === 0 ? 0 : summary.successfulItems / summary.totalItems; + + if (durations.length > 0) { + summary.avgDurationMs = Math.round(summary.totalDurationMs / durations.length); + summary.p95DurationMs = percentile([...durations].sort((a, b) => a - b), 0.95); + summary.avgItemDurationMs = + timedItems === 0 ? 0 : Math.round(summary.totalDurationMs / timedItems); + summary.throughputPerSecond = + summary.totalDurationMs === 0 + ? 0 + : Math.round((timedItems / summary.totalDurationMs) * 1000 * 100) / 100; + } + + return summary; +} + +/** Aggregate success rates and timing over a batch history, overall and per type. */ +export function computeBatchAnalytics(history: BatchHistoryEntry[]): BatchAnalytics { + const operationTypes: BatchOperationType[] = ['create', 'update', 'charge', 'cancel']; + return { + overall: summarize(history), + byOperationType: operationTypes.reduce( + (acc, type) => { + acc[type] = summarize(history.filter((entry) => entry.operationType === type)); + return acc; + }, + {} as Record, + ), + }; +} + +export function toHistoryEntry(result: BatchExecutionResult): BatchHistoryEntry { + return { + batchId: result.batchId, + operationType: result.operationType, + state: result.state, + totalItems: result.totalItems, + successfulItems: result.successfulItems, + failedItems: result.failedItems, + skippedItems: result.skippedItems, + durationMs: result.durationMs, + rolledBack: result.rolledBack, + timestamp: result.completedAt ?? new Date().toISOString(), + summary: `${result.operationType}: ${result.successfulItems}/${result.totalItems} succeeded`, + }; +} + +// ════════════════════════════════════════════════════════════════ +// Rollback +// ════════════════════════════════════════════════════════════════ + +/** + * Reverses a single committed item. Implementations issue the compensating + * action for the operation type — deleting a created subscription, refunding a + * charge, restoring the previous plan. + */ +export type RollbackHandler = ( + item: PerItemResult, + operationType: BatchOperationType, +) => Promise<{ success: boolean; error?: string }>; + +export interface BatchRollbackResult { + batchId: string; + operationType: BatchOperationType; + attempted: number; + reverted: number; + failed: number; + items: PerItemResult[]; + completedAt: string; +} + // ════════════════════════════════════════════════════════════════ // Batch Csv Parsing // ════════════════════════════════════════════════════════════════ @@ -187,14 +466,19 @@ export function selectOverdueSubscriptions(subscriptions: Subscription[]): Subsc }); } -export function buildBatchChargeItems(subscriptions: Subscription[]): Array<{ subscriptionId: string; amount: number }> { +export function buildBatchChargeItems( + subscriptions: Subscription[], +): Array<{ subscriptionId: string; amount: number }> { return subscriptions.map((subscription) => ({ subscriptionId: subscription.id, amount: subscription.price, })); } -export function calculateBatchGasSavings(itemCount: number, singleTransactionGas = 150_000): { +export function calculateBatchGasSavings( + itemCount: number, + singleTransactionGas = 150_000, +): { singleTxGas: number; batchGas: number; saved: number; @@ -244,7 +528,9 @@ export function parseBatchCreateCsv(csvContent: string): BatchCreateInput[] { return results; } -export function parseBatchCancelCsv(csvContent: string): Array<{ subscriptionId: string; reason: string; notes?: string }> { +export function parseBatchCancelCsv( + csvContent: string, +): Array<{ subscriptionId: string; reason: string; notes?: string }> { const lines = csvContent.split(/\r?\n/).filter((l) => l.trim()); if (lines.length < 2) return []; @@ -272,7 +558,9 @@ export function parseBatchCancelCsv(csvContent: string): Array<{ subscriptionId: return results; } -export function parseBatchChargeCsv(csvContent: string): Array<{ subscriptionId: string; amount: number }> { +export function parseBatchChargeCsv( + csvContent: string, +): Array<{ subscriptionId: string; amount: number }> { const lines = csvContent.split(/\r?\n/).filter((l) => l.trim()); if (lines.length < 2) return []; @@ -311,7 +599,8 @@ export function exportBatchResultToJson(result: BatchExecutionResult): string { } export function exportBatchResultToCsv(result: BatchExecutionResult): string { - const headers = 'index,subscriptionId,subscriptionName,status,error,errorCode,retryCount,completedAt,message'; + const headers = + 'index,subscriptionId,subscriptionName,status,error,errorCode,retryCount,completedAt,durationMs,message'; const rows = result.results.map((r) => { const escape = (v: string | undefined) => { if (!v) return ''; @@ -326,6 +615,7 @@ export function exportBatchResultToCsv(result: BatchExecutionResult): string { r.errorCode ?? '', r.retryCount, r.completedAt ?? '', + r.durationMs ?? '', escape(r.message), ].join(','); }); @@ -369,36 +659,82 @@ export async function clearBatchHistory(): Promise { // Batch Transaction Service // ════════════════════════════════════════════════════════════════ +/** One unit of work queued for a batch run. */ +interface BatchItemPlan { + subscriptionId: string; + subscriptionName?: string; + cancelReason?: CancelReason; + /** Formats the `message` recorded on success. */ + successMessage?: string; +} + +interface RunOptions { + atomic?: boolean; + filter?: UpdateFilter; + cancelReasons?: CancelReason[]; +} + +type ApplyResult = { success: boolean; id?: string; error?: string; errorCode?: number }; + export class BatchTransactionService { - private chunkSize: number = DEFAULT_CHUNK_SIZE; + /** Explicit override; when null the per-operation config decides. */ + private chunkSizeOverride: number | null = null; private baseGasCost: number = 50_000; private gasPerOperation: number = 100_000; - private idempotencyKeys: Map = new Map(); + /** `${operationType}:${subscriptionId}` pairs already applied successfully. */ + private appliedItems: Set = new Set(); + private configOverrides: Partial>> = {}; private currentResult: BatchExecutionResult | null = null; - private retryConfig: RetryConfig = { - maxRetries: 3, - retryDelayMs: 300, - backoffMultiplier: 2, - onlyRetryFailed: true, - }; + private retryConfigOverride: Partial = {}; - constructor(chunkSize: number = DEFAULT_CHUNK_SIZE) { - this.setChunkSize(chunkSize); + constructor(chunkSize?: number) { + if (chunkSize !== undefined) { + this.setChunkSize(chunkSize); + } } setChunkSize(size: number): void { - if (size > MAX_CHUNK_SIZE) { - this.chunkSize = MAX_CHUNK_SIZE; - } else if (size < 1) { - this.chunkSize = 1; - } else { - this.chunkSize = size; - } + this.chunkSizeOverride = Math.min(MAX_CHUNK_SIZE, Math.max(1, size)); } setRetryConfig(config: Partial): void { - this.retryConfig = { ...this.retryConfig, ...config }; + this.retryConfigOverride = { ...this.retryConfigOverride, ...config }; + } + + /** Override part of an operation type's configuration for this instance. */ + setOperationConfig( + operationType: BatchOperationType, + patch: Partial, + ): void { + this.configOverrides[operationType] = { + ...this.configOverrides[operationType], + ...patch, + }; + } + + /** Effective configuration: defaults, then overrides, then chunk size override. */ + getOperationConfig(operationType: BatchOperationType): BatchOperationConfig { + const merged: BatchOperationConfig = { + ...DEFAULT_BATCH_CONFIGS[operationType], + ...this.configOverrides[operationType], + }; + if (this.chunkSizeOverride !== null) { + merged.chunkSize = this.chunkSizeOverride; + } + merged.chunkSize = Math.min(MAX_CHUNK_SIZE, Math.max(1, merged.chunkSize)); + return merged; + } + + getRetryConfig(operationType: BatchOperationType): RetryConfig { + const config = this.getOperationConfig(operationType); + return { + maxRetries: config.maxRetries, + retryDelayMs: config.retryDelayMs, + backoffMultiplier: config.backoffMultiplier, + onlyRetryFailed: true, + ...this.retryConfigOverride, + }; } getGasEstimate(itemCount: number): number { @@ -407,16 +743,12 @@ export class BatchTransactionService { private generateBatchId(): string { const timestamp = Date.now().toString(36); - const random = Math.random().toString(36).substr(2, 9); + const random = Math.random().toString(36).substring(2, 11); return `batch_${timestamp}_${random}`; } - private idempotencyKey(subscriptionId: string, operationType: BatchOperationType): string { - const key = `${operationType}_${subscriptionId}`; - if (!this.idempotencyKeys.has(key)) { - this.idempotencyKeys.set(key, this.generateBatchId()); - } - return this.idempotencyKeys.get(key)!; + private idempotencyKey(operationType: BatchOperationType, subscriptionId: string): string { + return `${operationType}:${subscriptionId}`; } getProgress(): BatchProgress | null { @@ -438,95 +770,133 @@ export class BatchTransactionService { return this.currentResult; } + /** True when the last result may still be reversed by `rollbackBatch`. */ + canRollback(): boolean { + const result = this.currentResult; + if (!result) return false; + if (result.rolledBack || result.state === 'rolled_back') return false; + if (result.successfulItems === 0) return false; + return this.getOperationConfig(result.operationType).allowRollback; + } + // ══════════════════════════════════════════════════════════════ - // Batch Create (from CSV/JSON input) + // Shared runner // ══════════════════════════════════════════════════════════════ - async executeBatchCreate( - inputs: BatchCreateInput[], - addFn: (input: BatchCreateInput) => Promise<{ success: boolean; id?: string; error?: string }>, - options?: { atomic?: boolean }, + /** + * Apply `plans` one at a time, chunked to bound memory, tracking per-item + * status. An atomic run stops at the first failure and reports every + * previously successful item as rolled back, so callers never observe a + * half-applied batch. + */ + private async run( + operationType: BatchOperationType, + plans: BatchItemPlan[], + apply: (plan: BatchItemPlan, index: number) => Promise, + options: RunOptions = {}, ): Promise { - const atomic = options?.atomic ?? false; - const batchId = this.generateBatchId(); + const config = this.getOperationConfig(operationType); + const atomic = options.atomic ?? config.atomicDefault; + const startedAtMs = Date.now(); const result: BatchExecutionResult = { - batchId, - operationType: 'create', - state: 'running', - totalItems: inputs.length, + batchId: this.generateBatchId(), + operationType, + state: 'processing', + totalItems: plans.length, successfulItems: 0, failedItems: 0, skippedItems: 0, results: [], atomic, rolledBack: false, - gasEstimate: this.getGasEstimate(inputs.length), - startedAt: new Date().toISOString(), + gasEstimate: this.getGasEstimate(plans.length), + startedAt: new Date(startedAtMs).toISOString(), + cancelReasons: options.cancelReasons, + filter: options.filter, }; + const sizeCheck = validateBatchSizeFor(operationType, plans.length, config); + if (!sizeCheck.valid) { + result.state = 'failed'; + result.rejectionReason = sizeCheck.reason; + result.completedAt = new Date().toISOString(); + result.durationMs = Date.now() - startedAtMs; + this.currentResult = result; + await this.recordBatchHistory(result); + return result; + } + this.currentResult = result; - let shouldStop = false; + let aborted = false; - for (let i = 0; i < inputs.length; i += this.chunkSize) { - if (shouldStop) break; - const chunk = inputs.slice(i, i + this.chunkSize); + for (let offset = 0; offset < plans.length; offset += config.chunkSize) { + const chunk = plans.slice(offset, offset + config.chunkSize); for (let j = 0; j < chunk.length; j++) { - const input = chunk[j]; - const index = i + j; - if (shouldStop && atomic) { + const plan = chunk[j]; + const index = offset + j; + + if (aborted) { result.results.push({ index, - subscriptionId: input.name, - subscriptionName: input.name, + subscriptionId: plan.subscriptionId, + subscriptionName: plan.subscriptionName, status: 'skipped', retryCount: 0, + cancelReason: plan.cancelReason, message: 'Skipped due to atomic failure', }); result.skippedItems++; continue; } - try { - const addResult = await addFn(input); - if (addResult.success) { - result.results.push({ - index, - subscriptionId: addResult.id || input.name, - subscriptionName: input.name, - status: 'success', - retryCount: 0, - completedAt: new Date().toISOString(), - }); - result.successfulItems++; - } else { - result.results.push({ - index, - subscriptionId: input.name, - subscriptionName: input.name, - status: 'failed', - error: addResult.error || 'Unknown error', - retryCount: 0, - completedAt: new Date().toISOString(), - }); - result.failedItems++; - if (atomic) shouldStop = true; - } - } catch (err) { + const key = this.idempotencyKey(operationType, plan.subscriptionId); + if (config.idempotent && this.appliedItems.has(key)) { result.results.push({ index, - subscriptionId: input.name, - subscriptionName: input.name, - status: 'failed', - error: String(err), + subscriptionId: plan.subscriptionId, + subscriptionName: plan.subscriptionName, + status: 'skipped', retryCount: 0, - completedAt: new Date().toISOString(), + cancelReason: plan.cancelReason, + message: 'Already applied — skipped for idempotency', }); + result.skippedItems++; + continue; + } + + const itemStartMs = Date.now(); + let outcome: ApplyResult; + try { + outcome = await apply(plan, index); + } catch (err) { + outcome = { success: false, error: String(err) }; + } + + const item: PerItemResult = { + index, + subscriptionId: outcome.id || plan.subscriptionId, + subscriptionName: plan.subscriptionName, + status: outcome.success ? 'success' : 'failed', + retryCount: 0, + cancelReason: plan.cancelReason, + completedAt: new Date().toISOString(), + durationMs: Date.now() - itemStartMs, + }; + + if (outcome.success) { + if (plan.successMessage) item.message = plan.successMessage; + if (config.idempotent) this.appliedItems.add(key); + result.successfulItems++; + } else { + item.error = outcome.error || 'Unknown error'; + item.errorCode = outcome.errorCode; result.failedItems++; - if (atomic) shouldStop = true; + if (atomic) aborted = true; } + result.results.push(item); this.currentResult = { ...result }; } } @@ -534,18 +904,20 @@ export class BatchTransactionService { const rolledBack = atomic && result.failedItems > 0; result.rolledBack = rolledBack; result.completedAt = new Date().toISOString(); - result.state = rolledBack - ? 'failed' - : result.failedItems === 0 - ? 'completed' - : 'partial'; + result.durationMs = Date.now() - startedAtMs; + result.state = rolledBack ? 'failed' : result.failedItems === 0 ? 'completed' : 'partial'; if (rolledBack) { + // The atomic call committed nothing, so successes are reported as skipped + // and the idempotency ledger must forget them. + for (const item of result.results) { + if (item.status !== 'success') continue; + this.appliedItems.delete(this.idempotencyKey(operationType, item.subscriptionId)); + item.status = 'skipped'; + item.message = 'Rolled back (atomic failure)'; + result.skippedItems++; + } result.successfulItems = 0; - result.results = result.results.map((r) => - r.status === 'success' ? { ...r, status: 'skipped', message: 'Rolled back (atomic failure)' } : r, - ); - result.skippedItems += inputs.length - result.failedItems; } this.currentResult = result; @@ -553,6 +925,24 @@ export class BatchTransactionService { return result; } + // ══════════════════════════════════════════════════════════════ + // Batch Create (from CSV/JSON input) + // ══════════════════════════════════════════════════════════════ + + async executeBatchCreate( + inputs: BatchCreateInput[], + addFn: (input: BatchCreateInput) => Promise<{ success: boolean; id?: string; error?: string }>, + options?: { atomic?: boolean }, + ): Promise { + const plans: BatchItemPlan[] = inputs.map((input) => ({ + subscriptionId: input.name, + subscriptionName: input.name, + })); + return this.run('create', plans, (_plan, index) => addFn(inputs[index]), { + atomic: options?.atomic, + }); + } + // ══════════════════════════════════════════════════════════════ // Batch Update (plan change, price change) with filtering // ══════════════════════════════════════════════════════════════ @@ -560,111 +950,17 @@ export class BatchTransactionService { async executeBatchUpdate( subscriptionIds: string[], updates: BatchUpdateParams, - updateFn: (id: string, updates: BatchUpdateParams) => Promise<{ success: boolean; error?: string }>, + updateFn: ( + id: string, + updates: BatchUpdateParams, + ) => Promise<{ success: boolean; error?: string }>, options?: { atomic?: boolean; filter?: UpdateFilter }, ): Promise { - const atomic = options?.atomic ?? false; - const filter = options?.filter; - const batchId = this.generateBatchId(); - - const result: BatchExecutionResult = { - batchId, - operationType: 'update', - state: 'running', - totalItems: subscriptionIds.length, - successfulItems: 0, - failedItems: 0, - skippedItems: 0, - results: [], - atomic, - rolledBack: false, - gasEstimate: this.getGasEstimate(subscriptionIds.length), - startedAt: new Date().toISOString(), - filter, - }; - - this.currentResult = result; - let shouldStop = false; - - for (let i = 0; i < subscriptionIds.length; i += this.chunkSize) { - if (shouldStop) break; - const chunk = subscriptionIds.slice(i, i + this.chunkSize); - - for (let j = 0; j < chunk.length; j++) { - const subId = chunk[j]; - const index = i + j; - if (shouldStop && atomic) { - result.results.push({ - index, - subscriptionId: subId, - status: 'skipped', - retryCount: 0, - message: 'Skipped due to atomic failure', - }); - result.skippedItems++; - continue; - } - - try { - const updateResult = await updateFn(subId, updates); - if (updateResult.success) { - result.results.push({ - index, - subscriptionId: subId, - status: 'success', - retryCount: 0, - completedAt: new Date().toISOString(), - }); - result.successfulItems++; - } else { - result.results.push({ - index, - subscriptionId: subId, - status: 'failed', - error: updateResult.error || 'Update failed', - retryCount: 0, - completedAt: new Date().toISOString(), - }); - result.failedItems++; - if (atomic) shouldStop = true; - } - } catch (err) { - result.results.push({ - index, - subscriptionId: subId, - status: 'failed', - error: String(err), - retryCount: 0, - completedAt: new Date().toISOString(), - }); - result.failedItems++; - if (atomic) shouldStop = true; - } - - this.currentResult = { ...result }; - } - } - - const rolledBack = atomic && result.failedItems > 0; - result.rolledBack = rolledBack; - result.completedAt = new Date().toISOString(); - result.state = rolledBack - ? 'failed' - : result.failedItems === 0 - ? 'completed' - : 'partial'; - - if (rolledBack) { - result.successfulItems = 0; - result.results = result.results.map((r) => - r.status === 'success' ? { ...r, status: 'skipped', message: 'Rolled back (atomic failure)' } : r, - ); - result.skippedItems += subscriptionIds.length - result.failedItems; - } - - this.currentResult = result; - await this.recordBatchHistory(result); - return result; + const plans: BatchItemPlan[] = subscriptionIds.map((id) => ({ subscriptionId: id })); + return this.run('update', plans, (plan) => updateFn(plan.subscriptionId, updates), { + atomic: options?.atomic, + filter: options?.filter, + }); } // ══════════════════════════════════════════════════════════════ @@ -677,115 +973,19 @@ export class BatchTransactionService { cancelFn: (id: string, reason: CancelReason) => Promise<{ success: boolean; error?: string }>, options?: { atomic?: boolean }, ): Promise { - const atomic = options?.atomic ?? false; - const batchId = this.generateBatchId(); - - const result: BatchExecutionResult = { - batchId, - operationType: 'cancel', - state: 'running', - totalItems: subscriptionIds.length, - successfulItems: 0, - failedItems: 0, - skippedItems: 0, - results: [], - atomic, - rolledBack: false, - gasEstimate: this.getGasEstimate(subscriptionIds.length), - startedAt: new Date().toISOString(), - cancelReasons, - }; - - this.currentResult = result; - let shouldStop = false; - - for (let i = 0; i < subscriptionIds.length; i += this.chunkSize) { - if (shouldStop) break; - const chunk = subscriptionIds.slice(i, i + this.chunkSize); - - for (let j = 0; j < chunk.length; j++) { - const subId = chunk[j]; - const index = i + j; - if (shouldStop && atomic) { - result.results.push({ - index, - subscriptionId: subId, - status: 'skipped', - retryCount: 0, - message: 'Skipped due to atomic failure', - }); - result.skippedItems++; - continue; - } - - const reason = cancelReasons.find((r) => r.subscriptionId === subId) || { - subscriptionId: subId, - reason: 'other' as const, - }; - - try { - const cancelResult = await cancelFn(subId, reason); - if (cancelResult.success) { - result.results.push({ - index, - subscriptionId: subId, - status: 'success', - retryCount: 0, - cancelReason: reason, - completedAt: new Date().toISOString(), - }); - result.successfulItems++; - } else { - result.results.push({ - index, - subscriptionId: subId, - status: 'failed', - error: cancelResult.error || 'Cancel failed', - retryCount: 0, - cancelReason: reason, - completedAt: new Date().toISOString(), - }); - result.failedItems++; - if (atomic) shouldStop = true; - } - } catch (err) { - result.results.push({ - index, - subscriptionId: subId, - status: 'failed', - error: String(err), - retryCount: 0, - cancelReason: reason, - completedAt: new Date().toISOString(), - }); - result.failedItems++; - if (atomic) shouldStop = true; - } - - this.currentResult = { ...result }; - } - } - - const rolledBack = atomic && result.failedItems > 0; - result.rolledBack = rolledBack; - result.completedAt = new Date().toISOString(); - result.state = rolledBack - ? 'failed' - : result.failedItems === 0 - ? 'completed' - : 'partial'; - - if (rolledBack) { - result.successfulItems = 0; - result.results = result.results.map((r) => - r.status === 'success' ? { ...r, status: 'skipped', message: 'Rolled back (atomic failure)' } : r, - ); - result.skippedItems += subscriptionIds.length - result.failedItems; - } - - this.currentResult = result; - await this.recordBatchHistory(result); - return result; + const plans: BatchItemPlan[] = subscriptionIds.map((id) => ({ + subscriptionId: id, + cancelReason: cancelReasons.find((r) => r.subscriptionId === id) ?? { + subscriptionId: id, + reason: 'other', + }, + })); + return this.run( + 'cancel', + plans, + (plan) => cancelFn(plan.subscriptionId, plan.cancelReason!), + { atomic: options?.atomic, cancelReasons }, + ); } // ══════════════════════════════════════════════════════════════ @@ -797,107 +997,16 @@ export class BatchTransactionService { chargeFn: (id: string, amount: number) => Promise<{ success: boolean; error?: string }>, options?: { atomic?: boolean }, ): Promise { - const atomic = options?.atomic ?? false; - const batchId = this.generateBatchId(); - - const result: BatchExecutionResult = { - batchId, - operationType: 'charge', - state: 'running', - totalItems: chargeItems.length, - successfulItems: 0, - failedItems: 0, - skippedItems: 0, - results: [], - atomic, - rolledBack: false, - gasEstimate: this.getGasEstimate(chargeItems.length), - startedAt: new Date().toISOString(), - }; - - this.currentResult = result; - let shouldStop = false; - - for (let i = 0; i < chargeItems.length; i += this.chunkSize) { - if (shouldStop) break; - const chunk = chargeItems.slice(i, i + this.chunkSize); - - for (let j = 0; j < chunk.length; j++) { - const item = chunk[j]; - const index = i + j; - if (shouldStop && atomic) { - result.results.push({ - index, - subscriptionId: item.subscriptionId, - status: 'skipped', - retryCount: 0, - message: 'Skipped due to atomic failure', - }); - result.skippedItems++; - continue; - } - - try { - const chargeResult = await chargeFn(item.subscriptionId, item.amount); - if (chargeResult.success) { - result.results.push({ - index, - subscriptionId: item.subscriptionId, - status: 'success', - retryCount: 0, - completedAt: new Date().toISOString(), - message: `Charged ${item.amount}`, - }); - result.successfulItems++; - } else { - result.results.push({ - index, - subscriptionId: item.subscriptionId, - status: 'failed', - error: chargeResult.error || 'Charge failed', - retryCount: 0, - completedAt: new Date().toISOString(), - }); - result.failedItems++; - if (atomic) shouldStop = true; - } - } catch (err) { - result.results.push({ - index, - subscriptionId: item.subscriptionId, - status: 'failed', - error: String(err), - retryCount: 0, - completedAt: new Date().toISOString(), - }); - result.failedItems++; - if (atomic) shouldStop = true; - } - - this.currentResult = { ...result }; - } - } - - const rolledBack = atomic && result.failedItems > 0; - result.rolledBack = rolledBack; - result.completedAt = new Date().toISOString(); - result.state = rolledBack - ? 'failed' - : result.failedItems === 0 - ? 'completed' - : 'partial'; - - if (rolledBack) { - result.successfulItems = 0; - result.results = result.results.map((r) => - r.status === 'success' ? { ...r, status: 'skipped', message: 'Rolled back (atomic failure)' } : r, - ); - result.skippedItems += chargeItems.length - result.failedItems; - } - - this.currentResult = result; - await this.recordBatchHistory(result); - return result; + const plans: BatchItemPlan[] = chargeItems.map((item) => ({ + subscriptionId: item.subscriptionId, + successMessage: `Charged ${item.amount}`, + })); + return this.run( + 'charge', + plans, + (plan, index) => chargeFn(plan.subscriptionId, chargeItems[index].amount), + { atomic: options?.atomic }, + ); } // ══════════════════════════════════════════════════════════════ @@ -914,17 +1023,18 @@ export class BatchTransactionService { if (failedItems.length === 0) return result; - result.state = 'running'; + const retryConfig = this.getRetryConfig(result.operationType); + const config = this.getOperationConfig(result.operationType); + result.state = 'processing'; this.currentResult = result; for (const item of failedItems) { - if (item.retryCount >= this.retryConfig.maxRetries) { + if (item.retryCount >= retryConfig.maxRetries) { continue; } const delay = - this.retryConfig.retryDelayMs * - Math.pow(this.retryConfig.backoffMultiplier, item.retryCount); + retryConfig.retryDelayMs * Math.pow(retryConfig.backoffMultiplier, item.retryCount); await new Promise((resolve) => setTimeout(resolve, delay)); try { @@ -934,6 +1044,11 @@ export class BatchTransactionService { item.retryCount++; item.error = undefined; item.completedAt = new Date().toISOString(); + if (config.idempotent) { + this.appliedItems.add( + this.idempotencyKey(result.operationType, item.subscriptionId), + ); + } result.successfulItems++; result.failedItems--; } else { @@ -949,29 +1064,104 @@ export class BatchTransactionService { } result.completedAt = new Date().toISOString(); - result.state = - result.failedItems === 0 ? 'completed' : 'partial'; + result.state = result.failedItems === 0 ? 'completed' : 'partial'; this.currentResult = result; return result; } // ══════════════════════════════════════════════════════════════ - // History + // Rollback of a committed batch // ══════════════════════════════════════════════════════════════ - private async recordBatchHistory(result: BatchExecutionResult): Promise { - const entry: BatchHistoryEntry = { + /** + * Reverse every committed item of the last batch by issuing the compensating + * action through `rollbackFn`. Only available for operation types whose + * configuration sets `allowRollback`; an atomic batch that already discarded + * its writes has nothing to reverse. + */ + async rollbackBatch(rollbackFn: RollbackHandler): Promise { + const result = this.currentResult; + if (!result) return null; + if (!this.canRollback()) return null; + + const committed = result.results.filter((r) => r.status === 'success'); + const items: PerItemResult[] = []; + let reverted = 0; + let failed = 0; + + for (const item of committed) { + let outcome: { success: boolean; error?: string }; + try { + outcome = await rollbackFn(item, result.operationType); + } catch (err) { + outcome = { success: false, error: String(err) }; + } + + if (outcome.success) { + reverted++; + this.appliedItems.delete( + this.idempotencyKey(result.operationType, item.subscriptionId), + ); + items.push({ + ...item, + status: 'skipped', + message: 'Reverted by rollback', + completedAt: new Date().toISOString(), + }); + } else { + failed++; + items.push({ + ...item, + status: 'failed', + error: outcome.error || 'Rollback failed', + completedAt: new Date().toISOString(), + }); + } + } + + const byIndex = new Map(items.map((item) => [item.index, item])); + const merged = result.results.map((r) => byIndex.get(r.index) ?? r); + + // A rollback that could not revert every item leaves the batch partially + // applied, so it stays `partial` rather than claiming a clean reversal. + const fullyReverted = failed === 0; + const rolledBackResult: BatchExecutionResult = { + ...result, + results: merged, + state: fullyReverted ? 'rolled_back' : 'partial', + rolledBack: fullyReverted, + successfulItems: result.successfulItems - reverted, + failedItems: result.failedItems + failed, + skippedItems: result.skippedItems + reverted, + completedAt: new Date().toISOString(), + }; + + this.currentResult = rolledBackResult; + await this.recordBatchHistory(rolledBackResult); + + return { batchId: result.batchId, operationType: result.operationType, - state: result.state, - totalItems: result.totalItems, - successfulItems: result.successfulItems, - failedItems: result.failedItems, - timestamp: new Date().toISOString(), - summary: `${result.operationType}: ${result.successfulItems}/${result.totalItems} succeeded`, + attempted: committed.length, + reverted, + failed, + items, + completedAt: rolledBackResult.completedAt!, }; - await saveBatchHistory(entry); + } + + // ══════════════════════════════════════════════════════════════ + // Analytics & History + // ══════════════════════════════════════════════════════════════ + + /** Success-rate and timing analytics over the persisted batch history. */ + async getAnalytics(): Promise { + return computeBatchAnalytics(await getBatchHistory()); + } + + private async recordBatchHistory(result: BatchExecutionResult): Promise { + await saveBatchHistory(toHistoryEntry(result)); } clearResult(): void { @@ -979,7 +1169,7 @@ export class BatchTransactionService { } clearIdempotencyKeys(): void { - this.idempotencyKeys.clear(); + this.appliedItems.clear(); } } diff --git a/app/stores/__tests__/batchStore.test.ts b/app/stores/__tests__/batchStore.test.ts index b420fc74..a7bc7afb 100644 --- a/app/stores/__tests__/batchStore.test.ts +++ b/app/stores/__tests__/batchStore.test.ts @@ -2,6 +2,7 @@ import { useBatchStore, estimateBatchGas, validateBatchSize, + getDefaultBatchConfig, BatchOperationType, BatchDraft, CancelReason, @@ -25,6 +26,14 @@ const reset = () => draft: emptyDraft(), currentResult: null, history: [], + configs: { + create: getDefaultBatchConfig('create'), + update: getDefaultBatchConfig('update'), + charge: getDefaultBatchConfig('charge'), + cancel: getDefaultBatchConfig('cancel'), + }, + service: null, + rollbackHandler: null, executor: async (_op, subscriptionId) => ({ subscriptionId, success: true }), isRunning: false, progress: null, @@ -124,3 +133,151 @@ describe('useBatchStore execution', () => { expect(useBatchStore.getState().history.length).toBeGreaterThan(0); }); }); + +describe('useBatchStore draft validation', () => { + it('counts items for the active operation type only', () => { + useBatchStore.getState().loadChargeCsv('subscriptionId,amount\nsub_1,100\nsub_2,100'); + expect(useBatchStore.getState().itemCount()).toBe(2); + + useBatchStore.getState().setOperationType('create'); + expect(useBatchStore.getState().itemCount()).toBe(0); + }); + + it('rejects an empty draft', () => { + expect(useBatchStore.getState().validateDraft().valid).toBe(false); + }); + + it('rejects a draft above the operation type ceiling', () => { + useBatchStore.getState().setOperationConfig('charge', { maxItems: 1 }); + useBatchStore.getState().loadChargeCsv('subscriptionId,amount\nsub_1,100\nsub_2,100'); + + const validation = useBatchStore.getState().validateDraft(); + expect(validation.valid).toBe(false); + expect(validation.reason).toContain('limited to 1 items'); + }); + + it('estimates gas from the active item count', () => { + useBatchStore.getState().loadChargeCsv('subscriptionId,amount\nsub_1,100\nsub_2,100'); + expect(useBatchStore.getState().gasEstimate()).toBe(estimateBatchGas(2)); + }); +}); + +describe('useBatchStore configuration', () => { + it('patches one operation type without touching the others', () => { + useBatchStore.getState().setOperationConfig('cancel', { allowRollback: true }); + + expect(useBatchStore.getState().configs.cancel.allowRollback).toBe(true); + expect(useBatchStore.getState().configs.charge.allowRollback).toBe(true); + expect(useBatchStore.getState().configs.cancel.maxItems).toBe( + getDefaultBatchConfig('cancel').maxItems, + ); + }); + + it('restores an operation type to its defaults', () => { + useBatchStore.getState().setOperationConfig('charge', { maxItems: 3 }); + useBatchStore.getState().resetOperationConfig('charge'); + expect(useBatchStore.getState().configs.charge).toEqual(getDefaultBatchConfig('charge')); + }); + + it('exposes the config for the drafted operation type', () => { + useBatchStore.getState().setOperationType('cancel'); + expect(useBatchStore.getState().activeConfig()).toEqual(getDefaultBatchConfig('cancel')); + }); + + it('applies the configured retry budget to a run', async () => { + useBatchStore.getState().setOperationConfig('charge', { maxRetries: 0, retryDelayMs: 0 }); + useBatchStore.getState().setExecutor(async (_op, id) => ({ + subscriptionId: id, + success: false, + error: 'declined', + })); + useBatchStore.getState().loadChargeCsv('subscriptionId,amount\nsub_1,100'); + await useBatchStore.getState().executeBatch(); + + const retried = await useBatchStore.getState().retryFailed(); + expect(retried?.failedItems).toBe(1); + expect(retried?.results[0].retryCount).toBe(0); + }); +}); + +describe('useBatchStore rollback', () => { + const loadCommittedCharge = async () => { + useBatchStore.getState().loadChargeCsv('subscriptionId,amount\nsub_1,100\nsub_2,100'); + return useBatchStore.getState().executeBatch(); + }; + + it('is unavailable before any batch runs', () => { + expect(useBatchStore.getState().canRollback()).toBe(false); + }); + + it('reverses a committed batch through the registered handler', async () => { + await loadCommittedCharge(); + const reverted: string[] = []; + useBatchStore.getState().setRollbackHandler(async (item) => { + reverted.push(item.subscriptionId); + return { success: true }; + }); + + expect(useBatchStore.getState().canRollback()).toBe(true); + const rollback = await useBatchStore.getState().rollbackBatch(); + + expect(reverted).toEqual(['sub_1', 'sub_2']); + expect(rollback?.reverted).toBe(2); + expect(useBatchStore.getState().currentResult?.state).toBe('rolled_back'); + }); + + it('returns null when no rollback handler is registered', async () => { + await loadCommittedCharge(); + expect(await useBatchStore.getState().rollbackBatch()).toBeNull(); + }); + + it('replaces the batch history entry rather than duplicating it', async () => { + const result = await loadCommittedCharge(); + useBatchStore.getState().setRollbackHandler(async () => ({ success: true })); + await useBatchStore.getState().rollbackBatch(); + + const entries = useBatchStore.getState().history.filter((e) => e.batchId === result?.batchId); + expect(entries.length).toBe(1); + expect(entries[0].state).toBe('rolled_back'); + }); +}); + +describe('useBatchStore analytics', () => { + it('is empty before any batch runs', () => { + expect(useBatchStore.getState().analytics().overall.batches).toBe(0); + }); + + it('summarizes executed batches by type', async () => { + useBatchStore.getState().loadChargeCsv('subscriptionId,amount\nsub_1,100\nsub_2,100'); + await useBatchStore.getState().executeBatch(); + + const analytics = useBatchStore.getState().analytics(); + expect(analytics.overall.batches).toBe(1); + expect(analytics.overall.itemSuccessRate).toBe(1); + expect(analytics.byOperationType.charge.batches).toBe(1); + expect(analytics.byOperationType.create.batches).toBe(0); + }); + + it('reflects a partial run in the item success rate', async () => { + useBatchStore.getState().setExecutor(async (_op, id) => ({ + subscriptionId: id, + success: id === 'sub_1', + })); + useBatchStore.getState().loadChargeCsv('subscriptionId,amount\nsub_1,100\nsub_2,100'); + await useBatchStore.getState().executeBatch(); + + const analytics = useBatchStore.getState().analytics(); + expect(analytics.overall.itemSuccessRate).toBe(0.5); + expect(analytics.overall.batchSuccessRate).toBe(0); + expect(analytics.overall.partial).toBe(1); + }); + + it('clears history and analytics together', async () => { + useBatchStore.getState().loadChargeCsv('subscriptionId,amount\nsub_1,100'); + await useBatchStore.getState().executeBatch(); + await useBatchStore.getState().clearHistory(); + + expect(useBatchStore.getState().history).toEqual([]); + expect(useBatchStore.getState().analytics().overall.batches).toBe(0); + }); +}); diff --git a/app/stores/batchStore.ts b/app/stores/batchStore.ts index ef35ac1e..b76f4919 100644 --- a/app/stores/batchStore.ts +++ b/app/stores/batchStore.ts @@ -4,8 +4,9 @@ // // Supports: batch create from CSV/JSON, batch update with filtering, // batch cancel with reason collection, batch charge for manual billing, -// per-item status tracking, idempotent retry, result export, and -// audit history of past batches. +// per-item status tracking, atomic execution, post-commit rollback, +// idempotent retry, per-operation-type configuration, success/timing +// analytics, result export, and audit history of past batches. import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; @@ -23,6 +24,17 @@ import { PerItemStatus, BatchProgress, BatchHistoryEntry, + BatchOperationConfig, + BatchAnalytics, + BatchAnalyticsSummary, + BatchRollbackResult, + BatchSizeValidation, + RollbackHandler, + DEFAULT_BATCH_CONFIGS, + computeBatchAnalytics, + getDefaultBatchConfig, + validateBatchSizeFor, + toHistoryEntry, parseBatchCreateCsv, parseBatchCancelCsv, parseBatchChargeCsv, @@ -40,8 +52,35 @@ const MAX_STORE_HISTORY = 100; // Types // ════════════════════════════════════════════════════════════════ -export type { BatchOperationType, BatchState, PerItemStatus, CancelReason, UpdateFilter, BatchUpdateParams, BatchCreateInput, PerItemResult, BatchProgress, BatchHistoryEntry }; -export { exportBatchResultToJson, exportBatchResultToCsv, getBatchHistory, saveBatchHistory, clearBatchHistory }; +export type { + BatchOperationType, + BatchState, + PerItemStatus, + CancelReason, + UpdateFilter, + BatchUpdateParams, + BatchCreateInput, + PerItemResult, + BatchProgress, + BatchHistoryEntry, + BatchOperationConfig, + BatchAnalytics, + BatchAnalyticsSummary, + BatchRollbackResult, + BatchSizeValidation, + RollbackHandler, +}; +export { + exportBatchResultToJson, + exportBatchResultToCsv, + getBatchHistory, + saveBatchHistory, + clearBatchHistory, + computeBatchAnalytics, + getDefaultBatchConfig, + validateBatchSizeFor, + DEFAULT_BATCH_CONFIGS, +}; export interface BatchDraft { operationType: BatchOperationType; @@ -69,8 +108,7 @@ const DEFAULT_CHUNK_SIZE = 50; export const estimateBatchGas = (count: number): number => 50_000 + count * 100_000; -export const validateBatchSize = (count: number): boolean => - count > 0 && count <= MAX_BATCH_SIZE; +export const validateBatchSize = (count: number): boolean => count > 0 && count <= MAX_BATCH_SIZE; // ════════════════════════════════════════════════════════════════ // Store @@ -80,18 +118,26 @@ interface BatchStoreState { draft: BatchDraft; currentResult: BatchExecutionResult | null; history: BatchHistoryEntry[]; + configs: Record; service: BatchTransactionService | null; executor: ItemExecutor; + rollbackHandler: RollbackHandler | null; isRunning: boolean; progress: BatchProgress | null; // Actions setExecutor: (executor: ItemExecutor) => void; + setRollbackHandler: (handler: RollbackHandler | null) => void; setDraft: (patch: Partial) => void; setOperationType: (op: BatchOperationType) => void; toggleAtomic: () => void; setChunkSize: (size: number) => void; + // Configuration + setOperationConfig: (op: BatchOperationType, patch: Partial) => void; + resetOperationConfig: (op: BatchOperationType) => void; + activeConfig: () => BatchOperationConfig; + // CSV loading loadCreateCsv: (csv: string) => void; loadCancelCsv: (csv: string) => void; @@ -102,17 +148,23 @@ interface BatchStoreState { // Execute executeBatch: () => Promise; retryFailed: () => Promise; + rollbackBatch: () => Promise; + canRollback: () => boolean; // Export exportResultJson: () => string | null; exportResultCsv: () => string | null; - // History + // History & analytics loadHistory: () => Promise; addHistoryEntry: (entry: BatchHistoryEntry) => Promise; + clearHistory: () => Promise; + analytics: () => BatchAnalytics; // Helpers gasEstimate: () => number; + itemCount: () => number; + validateDraft: () => BatchSizeValidation; resetDraft: () => void; clearResult: () => void; } @@ -131,6 +183,13 @@ const emptyDraft = (): BatchDraft => ({ chunkSize: DEFAULT_CHUNK_SIZE, }); +const defaultConfigs = (): Record => ({ + create: getDefaultBatchConfig('create'), + update: getDefaultBatchConfig('update'), + charge: getDefaultBatchConfig('charge'), + cancel: getDefaultBatchConfig('cancel'), +}); + const defaultExecutor: ItemExecutor = async (_op, subscriptionId) => ({ subscriptionId, success: true, @@ -142,275 +201,328 @@ export const useBatchStore = create()( draft: emptyDraft(), currentResult: null, history: [], + configs: defaultConfigs(), service: null, executor: defaultExecutor, + rollbackHandler: null, isRunning: false, progress: null, - setExecutor: (executor) => set({ executor }), + setExecutor: (executor) => set({ executor }), + + setRollbackHandler: (rollbackHandler) => set({ rollbackHandler }), + + setDraft: (patch) => set((s) => ({ draft: { ...s.draft, ...patch } })), - setDraft: (patch) => set((s) => ({ draft: { ...s.draft, ...patch } })), + setOperationType: (op) => + set((s) => ({ + draft: { ...s.draft, operationType: op, csvContent: '' }, + })), - setOperationType: (op) => - set((s) => ({ - draft: { ...s.draft, operationType: op, csvContent: '' }, - })), + toggleAtomic: () => set((s) => ({ draft: { ...s.draft, atomic: !s.draft.atomic } })), - toggleAtomic: () => - set((s) => ({ draft: { ...s.draft, atomic: !s.draft.atomic } })), + setChunkSize: (size) => + set((s) => ({ + draft: { ...s.draft, chunkSize: Math.min(size, MAX_BATCH_SIZE) }, + })), - setChunkSize: (size) => - set((s) => ({ - draft: { ...s.draft, chunkSize: Math.min(size, MAX_BATCH_SIZE) }, - })), + // ── Configuration ──────────────────────────────────────────── - // ── CSV Loading ────────────────────────────────────────────── + setOperationConfig: (op, patch) => + set((s) => ({ + configs: { ...s.configs, [op]: { ...s.configs[op], ...patch } }, + })), - setCsvContent: (csv) => set((s) => ({ draft: { ...s.draft, csvContent: csv } })), + resetOperationConfig: (op) => + set((s) => ({ + configs: { ...s.configs, [op]: getDefaultBatchConfig(op) }, + })), - loadCreateCsv: (csv) => { - const inputs = parseBatchCreateCsv(csv); - set((s) => ({ - draft: { - ...s.draft, - csvContent: csv, - operationType: 'create', - createInputs: inputs, + activeConfig: () => { + const { draft, configs } = get(); + return configs[draft.operationType] ?? getDefaultBatchConfig(draft.operationType); }, - })); - }, - - loadCancelCsv: (csv) => { - const parsed = parseBatchCancelCsv(csv); - const ids = parsed.map((r) => r.subscriptionId); - const reasons: CancelReason[] = parsed.map((r) => ({ - subscriptionId: r.subscriptionId, - reason: (['too_expensive', 'no_longer_needed', 'found_alternative', 'poor_service', 'other'].includes(r.reason) - ? r.reason - : 'other') as CancelReason['reason'], - notes: r.notes, - })); - set((s) => ({ - draft: { - ...s.draft, - csvContent: csv, - operationType: 'cancel', - cancelIds: ids, - cancelReasons: reasons, + + // ── CSV Loading ────────────────────────────────────────────── + + setCsvContent: (csv) => set((s) => ({ draft: { ...s.draft, csvContent: csv } })), + + loadCreateCsv: (csv) => { + const inputs = parseBatchCreateCsv(csv); + set((s) => ({ + draft: { + ...s.draft, + csvContent: csv, + operationType: 'create', + createInputs: inputs, + }, + })); }, - })); - }, - - loadChargeCsv: (csv) => { - const items = parseBatchChargeCsv(csv); - set((s) => ({ - draft: { - ...s.draft, - csvContent: csv, - operationType: 'charge', - chargeItems: items, + + loadCancelCsv: (csv) => { + const parsed = parseBatchCancelCsv(csv); + const ids = parsed.map((r) => r.subscriptionId); + const reasons: CancelReason[] = parsed.map((r) => ({ + subscriptionId: r.subscriptionId, + reason: ([ + 'too_expensive', + 'no_longer_needed', + 'found_alternative', + 'poor_service', + 'other', + ].includes(r.reason) + ? r.reason + : 'other') as CancelReason['reason'], + notes: r.notes, + })); + set((s) => ({ + draft: { + ...s.draft, + csvContent: csv, + operationType: 'cancel', + cancelIds: ids, + cancelReasons: reasons, + }, + })); }, - })); - }, - - loadUpdateCsv: (csv) => { - const lines = csv.split(/\r?\n/).filter((l) => l.trim()); - if (lines.length < 2) return; - const ids: string[] = []; - for (let i = 1; i < lines.length; i++) { - const id = lines[i].split(',')[0]?.trim(); - if (id) ids.push(id); - } - set((s) => ({ - draft: { - ...s.draft, - csvContent: csv, - operationType: 'update', - updateIds: ids, + + loadChargeCsv: (csv) => { + const items = parseBatchChargeCsv(csv); + set((s) => ({ + draft: { + ...s.draft, + csvContent: csv, + operationType: 'charge', + chargeItems: items, + }, + })); }, - })); - }, - - // ── Execute ────────────────────────────────────────────────── - - executeBatch: async () => { - const { draft, executor } = get(); - const service = new BatchTransactionService(draft.chunkSize); - set({ isRunning: true, currentResult: null, progress: null }); - - let result: BatchExecutionResult | null = null; - - try { - switch (draft.operationType) { - case 'create': { - if (draft.createInputs.length === 0) break; - result = await service.executeBatchCreate( - draft.createInputs, - async (input) => { - const r = await executor('create', input.name, input.price); - return r; - }, - { atomic: draft.atomic }, - ); - break; + + loadUpdateCsv: (csv) => { + const lines = csv.split(/\r?\n/).filter((l) => l.trim()); + if (lines.length < 2) return; + const ids: string[] = []; + for (let i = 1; i < lines.length; i++) { + const id = lines[i].split(',')[0]?.trim(); + if (id) ids.push(id); } + set((s) => ({ + draft: { + ...s.draft, + csvContent: csv, + operationType: 'update', + updateIds: ids, + }, + })); + }, - case 'update': { - if (draft.updateIds.length === 0) break; - result = await service.executeBatchUpdate( - draft.updateIds, - draft.updateParams, - async (id, updates) => { - const r = await executor('update', id, JSON.stringify(updates)); - return r; - }, - { atomic: draft.atomic, filter: draft.updateFilter }, - ); - break; + // ── Execute ────────────────────────────────────────────────── + + executeBatch: async () => { + const { draft, executor, configs } = get(); + const service = new BatchTransactionService(); + // The draft's chunk size is the operator's live choice, so it wins over + // the stored per-operation default. + service.setOperationConfig(draft.operationType, { + ...(configs[draft.operationType] ?? {}), + chunkSize: draft.chunkSize, + }); + set({ isRunning: true, currentResult: null, progress: null }); + + let result: BatchExecutionResult | null = null; + + try { + switch (draft.operationType) { + case 'create': { + if (draft.createInputs.length === 0) break; + result = await service.executeBatchCreate( + draft.createInputs, + (input) => executor('create', input.name, input.price), + { atomic: draft.atomic }, + ); + break; + } + + case 'update': { + if (draft.updateIds.length === 0) break; + result = await service.executeBatchUpdate( + draft.updateIds, + draft.updateParams, + (id, updates) => executor('update', id, JSON.stringify(updates)), + { atomic: draft.atomic, filter: draft.updateFilter }, + ); + break; + } + + case 'cancel': { + if (draft.cancelIds.length === 0) break; + result = await service.executeBatchCancel( + draft.cancelIds, + draft.cancelReasons, + (id, reason) => executor('cancel', id, reason.reason, reason), + { atomic: draft.atomic }, + ); + break; + } + + case 'charge': { + if (draft.chargeItems.length === 0) break; + result = await service.executeBatchCharge( + draft.chargeItems, + (id, amount) => executor('charge', id, amount), + { atomic: draft.atomic }, + ); + break; + } + } + } catch (err) { + console.error('Batch execution error:', err); } - case 'cancel': { - if (draft.cancelIds.length === 0) break; - result = await service.executeBatchCancel( - draft.cancelIds, - draft.cancelReasons, - async (id, reason) => { - const r = await executor('cancel', id, reason.reason, reason); - return r; - }, - { atomic: draft.atomic }, - ); - break; + set({ + currentResult: result, + isRunning: false, + progress: service.getProgress(), + service, + }); + + if (result) { + await get().addHistoryEntry(toHistoryEntry(result)); } - case 'charge': { - if (draft.chargeItems.length === 0) break; - result = await service.executeBatchCharge( - draft.chargeItems, - async (id, amount) => { - const r = await executor('charge', id, amount); - return r; - }, - { atomic: draft.atomic }, - ); - break; + return result; + }, + + // ── Retry ──────────────────────────────────────────────────── + + retryFailed: async () => { + const { service, currentResult, executor } = get(); + if (!service || !currentResult) return null; + + set({ isRunning: true }); + + const result = await service.retryFailedItems((item) => + executor(currentResult.operationType, item.subscriptionId, 0, item.cancelReason), + ); + + set({ + currentResult: result, + isRunning: false, + progress: service.getProgress(), + }); + + return result; + }, + + // ── Rollback ───────────────────────────────────────────────── + + canRollback: () => { + const { service } = get(); + return service?.canRollback() ?? false; + }, + + rollbackBatch: async () => { + const { service, rollbackHandler } = get(); + if (!service || !rollbackHandler) return null; + + set({ isRunning: true }); + const rollback = await service.rollbackBatch(rollbackHandler); + const result = service.getLastResult(); + + set({ + currentResult: result, + isRunning: false, + progress: service.getProgress(), + }); + + if (rollback && result) { + await get().addHistoryEntry(toHistoryEntry(result)); } - } - } catch (err) { - console.error('Batch execution error:', err); - } - set((s) => ({ - currentResult: result, - isRunning: false, - progress: service.getProgress(), - service, - })); - - if (result) { - const entry: BatchHistoryEntry = { - batchId: result.batchId, - operationType: result.operationType, - state: result.state, - totalItems: result.totalItems, - successfulItems: result.successfulItems, - failedItems: result.failedItems, - timestamp: new Date().toISOString(), - summary: `${result.operationType}: ${result.successfulItems}/${result.totalItems} succeeded`, - }; - await get().addHistoryEntry(entry); - } - - return result; - }, - - // ── Retry ──────────────────────────────────────────────────── - - retryFailed: async () => { - const { service, currentResult, executor } = get(); - if (!service || !currentResult) return null; - - set({ isRunning: true }); - - const result = await service.retryFailedItems(async (item) => { - const r = await executor( - currentResult.operationType, - item.subscriptionId, - 0, - item.cancelReason, - ); - return r; - }); - - set((s) => ({ - currentResult: result, - isRunning: false, - progress: service.getProgress(), - })); - - return result; - }, - - // ── Export ─────────────────────────────────────────────────── - - exportResultJson: () => { - const { currentResult } = get(); - if (!currentResult) return null; - return exportBatchResultToJson(currentResult); - }, - - exportResultCsv: () => { - const { currentResult } = get(); - if (!currentResult) return null; - return exportBatchResultToCsv(currentResult); - }, - - // ── History ────────────────────────────────────────────────── - - loadHistory: async () => { - // Automatically handled by persist middleware - }, - - addHistoryEntry: async (entry) => { - set((s) => { - const next = [entry, ...s.history].slice(0, MAX_STORE_HISTORY); - return { history: next }; - }); - }, - - // ── Helpers ───────────────────────────────────────────────── - - gasEstimate: () => { - const { draft } = get(); - const count = - draft.createInputs.length || - draft.updateIds.length || - draft.cancelIds.length || - draft.chargeItems.length || - 0; - return estimateBatchGas(count); - }, - - resetDraft: () => - set({ - draft: emptyDraft(), - currentResult: null, - progress: null, - }), + return rollback; + }, - clearResult: () => - set({ - currentResult: null, - progress: null, - }), + // ── Export ─────────────────────────────────────────────────── + + exportResultJson: () => { + const { currentResult } = get(); + if (!currentResult) return null; + return exportBatchResultToJson(currentResult); + }, + + exportResultCsv: () => { + const { currentResult } = get(); + if (!currentResult) return null; + return exportBatchResultToCsv(currentResult); + }, + + // ── History & analytics ────────────────────────────────────── + + loadHistory: async () => { + // Automatically handled by persist middleware + }, + + addHistoryEntry: async (entry) => { + set((s) => { + // Rollback re-records the same batch, so replace rather than duplicate. + const withoutBatch = s.history.filter((e) => e.batchId !== entry.batchId); + return { history: [entry, ...withoutBatch].slice(0, MAX_STORE_HISTORY) }; + }); + }, + + clearHistory: async () => { + set({ history: [] }); + await clearBatchHistory(); + }, + + analytics: () => computeBatchAnalytics(get().history), + + // ── Helpers ───────────────────────────────────────────────── + + itemCount: () => { + const { draft } = get(); + switch (draft.operationType) { + case 'create': + return draft.createInputs.length; + case 'update': + return draft.updateIds.length; + case 'cancel': + return draft.cancelIds.length; + case 'charge': + return draft.chargeItems.length; + default: + return 0; + } + }, + + validateDraft: () => { + const { draft } = get(); + return validateBatchSizeFor(draft.operationType, get().itemCount(), get().activeConfig()); + }, + + gasEstimate: () => estimateBatchGas(get().itemCount()), + + resetDraft: () => + set({ + draft: emptyDraft(), + currentResult: null, + progress: null, + }), + + clearResult: () => + set({ + currentResult: null, + progress: null, + }), }), { name: HISTORY_STORE_KEY, storage: createJSONStorage(() => asyncStorageAdapter), partialize: (state) => ({ history: state.history, + configs: state.configs, }), - merge: (persistedState: any, currentState) => { + merge: (persistedState: unknown, currentState) => { if (Array.isArray(persistedState)) { return { ...currentState, @@ -418,13 +530,19 @@ export const useBatchStore = create()( }; } if (persistedState && typeof persistedState === 'object') { + const persisted = persistedState as Partial; return { ...currentState, - ...persistedState, + ...persisted, + // Persisted configs may predate a new field, so merge over defaults. + configs: { + ...currentState.configs, + ...(persisted.configs ?? {}), + }, }; } return currentState; }, - } - ) + }, + ), ); diff --git a/contracts/batch/BATCHING_API.md b/contracts/batch/BATCHING_API.md index cbcf3759..202c723a 100644 --- a/contracts/batch/BATCHING_API.md +++ b/contracts/batch/BATCHING_API.md @@ -1,253 +1,197 @@ -# SubTrackr Transaction Batching API +# SubTrackr Batch Contract API + +Reference for the `subtrackr-batch` contract. For the end-to-end feature — client service, +store, UI, CSV import and export — see [`docs/BATCH_OPERATIONS.md`](../../docs/BATCH_OPERATIONS.md). ## Overview -The batching system allows you to combine multiple subscription operations into a single transaction, reducing gas costs and improving efficiency. - -## Key Benefits - -✅ **70% Gas Savings** - Combine operations -✅ **Atomicity** - All or nothing execution -✅ **Dependencies** - Control operation order -✅ **Simulation** - Test before execution - -## Batch Operations Supported - -| Operation | Function | Example | -| --------- | --------------------- | -------------------------- | -| Subscribe | `subscribe` | Subscribe to a plan | -| Pause | `pause_subscription` | Pause a subscription | -| Resume | `resume_subscription` | Resume paused subscription | -| Cancel | `cancel_subscription` | Cancel subscription | -| Charge | `charge_subscription` | Process payment | -| Refund | `request_refund` | Request refund | -| Transfer | `request_transfer` | Transfer ownership | - -## Usage Examples - -### React Component Example - -```typescript -import { useBatchTransactions } from '@/hooks/useBatchTransactions'; - -export function SubscriptionBatcher() { - const { - addTransaction, - executeBatch, - pending, - isBatchReady - } = useBatchTransactions({ maxBatchSize: 10 }); - - const handleAddSubscription = (planId: string) => { - addTransaction("subscribe", [planId], true); - }; - - const handleBatchExecute = async () => { - const result = await executeBatch(true); // atomic - console.log(`✅ ${result.successfulOperations} operations completed`); - }; - - return ( -
- - -
- ); -} -``` +The batch contract applies one operation kind across many subscriptions in a single +transaction, so a merchant pays one base fee instead of `n`. It provides an atomic +execution guarantee, an explicit status machine, post-commit rollback, per-operation-type +configuration, and success/timing analytics. -### Gas Estimation +## Types -```typescript -const { getGasEstimate, getGasSavings } = useBatchTransactions(); +```rust +pub enum OperationType { Create, Charge, Update, Cancel } -// Individual transactions: 5 × 150,000 = 750,000 gas -// Batched: 50,000 + (5 × 100,000) = 550,000 gas -// Savings: 200,000 gas (26.7%) +pub struct BatchOperation { + pub operation_type: OperationType, + pub subscription_ids: Vec, + /// params[i] is the scalar argument for subscription_ids[i]: + /// initial price (Create), charge amount (Charge), new price (Update), + /// cancel reason code (Cancel). + pub params: Vec, +} -const estimate = getGasEstimate(); -const savings = getGasSavings(); +pub enum BatchState { Pending, Processing, Completed, PartiallyCompleted, Failed, RolledBack } -console.log(`Estimated gas: ${estimate}`); -console.log(`Gas savings: ${savings.percentSavings}%`); +pub enum CancelReason { TooExpensive, NoLongerNeeded, FoundAlternative, PoorService, Other } ``` -### Batch with Dependencies +`Create` ignores a missing `params`. `Charge` and `Update` require one entry per +subscription. `Cancel` accepts either no entries (recording `Other` for every item) or one +per subscription. -```typescript -const { addTransactionWithDependency, executeBatch } = useBatchTransactions(); +## Functions -// Op 0: Subscribe to plan -addTransaction('subscribe', [planId], true); +### Lifecycle -// Op 1: Pause subscription (depends on op 0) -// Only runs if op 0 succeeds -addTransactionWithDependency( - 'pause_subscription', - [subscriptionId, duration], - 0, // depends on operation 0 - true -); +```rust +initialize(admin: Address) -// Op 2: Another operation (independent) -addTransaction('request_refund', [amount], false); - -const result = await executeBatch(false); // non-atomic (continue on error) +create_batch_operation(owner, operation, atomic) -> Result +create_batch_operation_default(owner, operation) -> Result +execute_batch(batch_id) -> Result +rollback_batch(caller, batch_id) -> Result ``` -## API Reference +`create_batch_operation` validates the batch against the operation type's configured +`max_items` and returns a monotonic batch id. `create_batch_operation_default` uses the +operation type's `atomic_default` instead of an explicit flag. -### BatchTransactionService +`execute_batch` runs exactly once per batch; a second call returns +`BatchError::AlreadyExecuted`. -```typescript -// Create instance -const service = new BatchTransactionService(maxBatchSize: 10); +### Reads -// Add operations -service.addTransaction(functionName, params, required); -service.addTransactionWithDependency(functionName, params, dependsOn, required); +```rust +get_batch_status(batch_id) -> BatchStatus // state, counts, started_at, completed_at, duration +get_batch_result(batch_id) -> Option +get_batch_history() -> Vec +get_batch_analytics() -> BatchAnalytics +get_batch_analytics_for(operation_type) -> BatchAnalytics +get_subscription(subscription_id) -> Option +``` -// Check status -service.getPendingCount(): number; -service.isBatchReady(): boolean; -service.getGasEstimate(): number; -service.getBatchSummary(): Summary; +### Configuration -// Execute -await service.simulateBatch(): Promise; -await service.executeBatch(atomic: boolean): Promise; +```rust +set_batch_config(caller, operation_type, config) -> Result<(), BatchError> // admin only +get_batch_config(operation_type) -> BatchConfig -// Manage -service.clearBatch(): void; -service.calculateGasSavings(): Savings; -``` - -### Batch Result - -```typescript -interface BatchExecutionResult { - batchId: string; - totalOperations: number; - successfulOperations: number; - failedOperations: number; - results: OperationResult[]; - atomic: boolean; - gasEstimate: number; +pub struct BatchConfig { + pub max_items: u32, // <= MAX_BATCH_ITEMS (100) + pub atomic_default: bool, + pub allow_rollback: bool, + pub gas_per_item: u64, } ``` -## Cost Comparison - -### Without Batching +Defaults (`default_config`): -``` -5 subscription operations -× 150,000 gas each -= 750,000 total gas -``` +| Operation | `max_items` | `atomic_default` | `allow_rollback` | +| --------- | ----------- | ---------------- | ---------------- | +| `Create` | 100 | false | true | +| `Charge` | 50 | **true** | true | +| `Update` | 100 | false | true | +| `Cancel` | 50 | false | **false** | -### With Batching +## Status machine ``` -Base cost: 50,000 gas -+ 5 operations × 100,000 each -= 550,000 total gas +Pending ──▶ Processing ──▶ Completed (no item failed) + ├▶ PartiallyCompleted (some items failed, non-atomic) + └▶ Failed (atomic batch discarded its writes) -💰 Savings: 200,000 gas (26.7%) +Completed / PartiallyCompleted ──▶ RolledBack ``` -## Best Practices +## Atomicity -✅ **DO:** +An atomic batch stages writes in memory and flushes them only after the last item +succeeds. The first failure aborts the loop, the staging area is dropped, and the result +reports `rolled_back: true` with `successful_operations: 0`. A non-atomic batch commits +each success as it happens and finishes `PartiallyCompleted`. -- Batch similar operations together -- Use dependencies when operations must run in order -- Test with simulation first -- Monitor gas usage -- Use atomic mode for critical operations +## Rollback -❌ **DON'T:** +`execute_batch` records a `SnapshotEntry { id, existed, prior }` for each subscription it +touched, capturing the state from before the batch. `rollback_batch` replays that snapshot: +entries with `existed: false` are removed, the rest are restored. -- Create batches with > 100 operations -- Ignore error results -- Skip simulation for large batches -- Use without understanding dependencies -- Assume all operations will succeed +Rejections: -## Atomic vs Non-Atomic +| Condition | Error | +| ---------------------------------------- | --------------------- | +| Caller is neither the owner nor the admin | `Unauthorized` | +| Batch has not executed | `NotExecuted` | +| Batch already rolled back | `AlreadyRolledBack` | +| Operation type sets `allow_rollback: false` | `RollbackNotAllowed` | +| Atomic batch that committed nothing | `RollbackNotAllowed` | +| Unknown batch id | `BatchNotFound` | -### Atomic Mode (All or Nothing) +Rollback also discounts the batch's successful items from analytics. -``` -Operation 1: Subscribe ✓ -Operation 2: Charge ✓ -Operation 3: Pause ✗ FAILED +## Analytics -Result: ALL THREE OPERATIONS ROLLED BACK -Batch Status: FAILED +```rust +pub struct BatchAnalytics { + pub total_batches: u32, + pub completed_batches: u32, + pub partial_batches: u32, + pub failed_batches: u32, + pub rolled_back_batches: u32, + pub total_items: u32, + pub successful_items: u32, + pub failed_items: u32, + pub success_rate_bps: u32, // 10_000 == 100% + pub total_duration: u64, // ledger seconds + pub avg_duration: u64, +} ``` -### Non-Atomic Mode (Continue on Error) +Maintained incrementally on every execution, globally and partitioned by operation type. -``` -Operation 1: Subscribe ✓ -Operation 2: Charge ✓ -Operation 3: Pause ✗ FAILED +## Per-item results -Result: Operations 1&2 succeed, 3 fails -Batch Status: COMPLETED (with partial success) +```rust +pub struct OperationResult { + pub subscription_id: u64, + pub success: bool, + pub code: u32, // 0 on success, otherwise a CoreError discriminant +} ``` -## Performance Metrics - -| Metric | Value | -| -------------------- | ------- | -| Max operations/batch | 100 | -| Base gas cost | 50,000 | -| Gas per operation | 100,000 | -| Simulation cost | 50,000 | -| Average savings | ~25-30% | +Common codes: `302` subscription not found, `311` already exists, `305` invalid amount, +`308` invalid price, `500` subscription not active, `501` already cancelled. -## Troubleshooting +## Gas model -### Batch Too Large - -``` -Error: "Too many operations (max 100)" -Solution: Split into multiple batches ``` +estimate = 50_000 + item_count * gas_per_item // gas_per_item defaults to 100_000 -### Invalid Dependency - -``` -Error: "Invalid dependency" -Solution: Ensure dependency index < current index +5 items batched : 50_000 + 5 × 100_000 = 550_000 +5 items separate: 5 × 150_000 = 750_000 +saving : 200_000 (26.7%) ``` -### Atomic Failure +## Events -``` -Error: "Batch failed (atomic)" -Solution: Check individual operation results -``` +| Topics | Data | +| ----------------------- | ----------------------------------- | +| `("batch", "created")` | `batch_id` | +| `("batch", "executed")` | `(batch_id, successful, failed)` | +| `("batch", "rolledbk")` | `batch_id` | -## FAQ +## Errors -**Q: How much gas do I save?** -A: Typically 25-30% savings, depending on operation complexity. +```rust +pub enum BatchError { + InvalidBatch = 1, + AlreadyExecuted = 2, + NotExecuted = 3, + RollbackNotAllowed = 4, + AlreadyRolledBack = 5, + Unauthorized = 6, + BatchNotFound = 7, +} +``` -**Q: Can I batch different operations?** -A: Yes! You can mix subscribe, pause, resume, cancel, etc. +Each maps onto a `subtrackr_types::CoreError` for cross-contract propagation. -**Q: What if one operation fails?** -A: In atomic mode, entire batch fails. In non-atomic, others continue. +## Testing -**Q: Can operations depend on each other?** -A: Yes, use `addTransactionWithDependency()` to create dependencies. +```bash +cd contracts && cargo test -p subtrackr-batch +``` diff --git a/contracts/batch/src/batch.rs b/contracts/batch/src/batch.rs deleted file mode 100644 index 7cfe8e4b..00000000 --- a/contracts/batch/src/batch.rs +++ /dev/null @@ -1,136 +0,0 @@ -//! Batch value types and pure (storage-free) helpers. - -use crate::MAX_BATCH_SIZE; -use soroban_sdk::{contracttype, Vec}; -use subtrackr_types::SubscriptionId; - -/// Reason supplied when a batch cancellation is requested. -#[contracttype] -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum CancelReason { - TooExpensive, - NoLongerNeeded, - FoundAlternative, - PoorService, - Custom, -} - -/// Filtering criteria for batch update operations. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct BatchFilter { - pub plan_change: Option, - pub min_price: Option, - pub max_price: Option, - pub category: Option>, -} - -/// The kind of operation applied across every subscription in a batch. -#[contracttype] -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum OperationType { - Create, - Update, - Charge, - Pause, - Resume, - Cancel, -} - -/// One operation applied to many subscriptions. -/// -/// `params[i]` is the scalar argument (e.g. charge amount) for -/// `subscription_ids[i]`; a shorter `params` vector defaults missing entries -/// to `0`. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct BatchOperation { - pub operation_type: OperationType, - pub subscription_ids: Vec, - pub params: Vec, - /// Optional per-subscription cancel reasons for cancel batches. - pub cancel_reasons: Vec, - /// Optional filtering criteria for update batches. - pub filter: Option, -} - -/// Outcome for a single subscription within a batch. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct OperationResult { - pub subscription_id: SubscriptionId, - pub success: bool, - /// `0` on success, otherwise an operation-specific failure code. - pub code: u32, - pub reason: Option, -} - -/// Aggregate result of executing a batch. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct BatchResult { - pub batch_id: u64, - pub total_operations: u32, - pub successful_operations: u32, - pub failed_operations: u32, - pub skipped_operations: u32, - pub results: Vec, - pub atomic: bool, - /// True when an atomic batch failed and all writes were discarded. - pub rolled_back: bool, - pub gas_estimate: u64, -} - -/// Lifecycle state of a batch. -#[contracttype] -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum BatchState { - Pending, - Completed, - PartiallyCompleted, - Failed, -} - -/// Progress snapshot returned by `get_batch_status`. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct BatchStatus { - pub batch_id: u64, - pub state: BatchState, - pub total: u32, - pub succeeded: u32, - pub failed: u32, -} - -/// Internal subscription status tracked by the batch registry. -#[contracttype] -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum SubStatus { - Active, - Paused, - Cancelled, -} - -/// Internal subscription record used to make batch outcomes real. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct SubRecord { - pub exists: bool, - pub status: SubStatus, - pub charged: i128, -} - -/// A batch is valid when it targets at least one subscription and stays within -/// [`MAX_BATCH_SIZE`]. -pub fn validate_batch_operation(op: &BatchOperation) -> bool { - let n = op.subscription_ids.len(); - n > 0 && n <= MAX_BATCH_SIZE -} - -/// Gas estimate: a fixed base plus a per-operation cost. Mirrors the documented -/// `50_000 + n * 100_000` model used by the client batching service. -pub fn estimate_batch_gas(op: &BatchOperation) -> u64 { - const BASE_GAS: u64 = 50_000; - const GAS_PER_OP: u64 = 100_000; - BASE_GAS + (op.subscription_ids.len() as u64) * GAS_PER_OP -} diff --git a/contracts/batch/src/lib.rs b/contracts/batch/src/lib.rs index b5ba28ab..5343cbae 100644 --- a/contracts/batch/src/lib.rs +++ b/contracts/batch/src/lib.rs @@ -1,12 +1,39 @@ #![no_std] #![allow(clippy::too_many_arguments)] -use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, Env, Vec}; +//! Batch subscription operations. +//! +//! A batch bundles one operation kind (create / update / charge / cancel) over +//! many subscriptions so merchants pay one transaction fee instead of `n`. +//! +//! Guarantees offered here: +//! +//! * **Atomic execution** — an atomic batch stages every write and discards the +//! whole staging area if any item fails, so callers never observe a partial +//! apply. +//! * **Status tracking** — every batch moves `Pending -> Processing -> +//! {Completed, PartiallyCompleted, Failed}`, with start/finish timestamps. +//! * **Rollback** — a batch that already committed can still be undone with +//! [`SubTrackrBatch::rollback_batch`], which restores the pre-execution +//! snapshot captured during execute. +//! * **Analytics** — aggregate counts, success rate (basis points) and +//! execution timing are maintained per operation type and in total. +//! * **Per-operation configuration** — batch size ceiling, default atomicity +//! and rollback eligibility are configured independently for each +//! [`OperationType`]. + +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, symbol_short, Address, Env, Map, Vec, +}; use subtrackr_types::CoreError; -const MAX_BATCH_ITEMS: u32 = 100; +/// Default ceiling on items in one batch, used when an operation type has no +/// explicit configuration. +pub const MAX_BATCH_ITEMS: u32 = 100; const GAS_BASE: u64 = 50_000; const GAS_PER_ITEM: u64 = 100_000; +/// Denominator for the basis-point success rate reported by analytics. +pub const BPS_DENOMINATOR: u32 = 10_000; #[contracterror] #[derive(Clone, Debug, Copy, PartialEq, Eq)] @@ -14,13 +41,23 @@ const GAS_PER_ITEM: u64 = 100_000; pub enum BatchError { InvalidBatch = 1, AlreadyExecuted = 2, + NotExecuted = 3, + RollbackNotAllowed = 4, + AlreadyRolledBack = 5, + Unauthorized = 6, + BatchNotFound = 7, } impl From for CoreError { fn from(err: BatchError) -> Self { match err { BatchError::InvalidBatch => CoreError::InvalidConfig, - BatchError::AlreadyExecuted => CoreError::InvalidStateTransition, + BatchError::AlreadyExecuted + | BatchError::NotExecuted + | BatchError::RollbackNotAllowed + | BatchError::AlreadyRolledBack => CoreError::InvalidStateTransition, + BatchError::Unauthorized => CoreError::Unauthorized, + BatchError::BatchNotFound => CoreError::NotFound, } } } @@ -30,18 +67,62 @@ impl From for BatchError { match err { CoreError::InvalidConfig => BatchError::InvalidBatch, CoreError::InvalidStateTransition => BatchError::AlreadyExecuted, + CoreError::Unauthorized | CoreError::OwnerMismatch => BatchError::Unauthorized, + CoreError::NotFound => BatchError::BatchNotFound, _ => BatchError::InvalidBatch, } } } +/// The kind of operation applied to every subscription in a batch. #[contracttype] #[derive(Clone, Debug, PartialEq, Eq)] pub enum OperationType { Create, Charge, + Update, + Cancel, } +/// Reason recorded against each subscription in a cancel batch. Encoded in +/// [`BatchOperation::params`] as the code returned by [`cancel_reason_code`]. +#[contracttype] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CancelReason { + TooExpensive, + NoLongerNeeded, + FoundAlternative, + PoorService, + Other, +} + +/// Maps a cancel reason onto the integer stored in `params`. +pub fn cancel_reason_code(reason: &CancelReason) -> i128 { + match reason { + CancelReason::TooExpensive => 0, + CancelReason::NoLongerNeeded => 1, + CancelReason::FoundAlternative => 2, + CancelReason::PoorService => 3, + CancelReason::Other => 4, + } +} + +/// Inverse of [`cancel_reason_code`]; unknown codes fall back to `Other`. +pub fn cancel_reason_from_code(code: i128) -> CancelReason { + match code { + 0 => CancelReason::TooExpensive, + 1 => CancelReason::NoLongerNeeded, + 2 => CancelReason::FoundAlternative, + 3 => CancelReason::PoorService, + _ => CancelReason::Other, + } +} + +/// One operation applied to many subscriptions. +/// +/// `params[i]` is the scalar argument for `subscription_ids[i]`: the charge +/// amount for `Charge`, the new price for `Update`, and the cancel reason code +/// for `Cancel`. `Create` reads it as the initial price. #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct BatchOperation { @@ -50,36 +131,148 @@ pub struct BatchOperation { pub params: Vec, } +/// Lifecycle state of a batch. #[contracttype] #[derive(Clone, Debug, PartialEq, Eq)] pub enum BatchState { + /// Created but not yet executed. Pending, + /// Execution has begun; items are being applied. + Processing, + /// Every item succeeded. Completed, + /// Some items succeeded, some failed (non-atomic batches only). PartiallyCompleted, + /// Execution failed; an atomic batch discarded all writes. Failed, + /// A committed batch was explicitly undone via `rollback_batch`. + RolledBack, } +/// Tunables applied to a single [`OperationType`]. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct BatchConfig { + /// Hard ceiling on items in one batch of this type. + pub max_items: u32, + /// Atomicity used when the caller does not state a preference. + pub atomic_default: bool, + /// Whether committed batches of this type may be rolled back afterwards. + pub allow_rollback: bool, + /// Marginal gas charged per item, used by the gas estimate. + pub gas_per_item: u64, +} + +/// Progress snapshot returned by `get_batch_status`. #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct BatchStatus { pub state: BatchState, + pub total: u32, + pub succeeded: u32, + pub failed: u32, + /// Ledger timestamp at which execution started, `0` while pending. + pub started_at: u64, + /// Ledger timestamp at which execution finished, `0` while unfinished. + pub completed_at: u64, + /// `completed_at - started_at`, in seconds. + pub duration: u64, } +/// Outcome for a single subscription in a batch. #[contracttype] #[derive(Clone, Debug, PartialEq)] -pub struct SubscriptionRecord { - pub id: u64, - pub charged: i128, +pub struct OperationResult { + pub subscription_id: u64, + pub success: bool, + /// `0` on success, otherwise a [`CoreError`] discriminant. + pub code: u32, } +/// Aggregate result of executing a batch. #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct BatchResult { + pub batch_id: u64, pub total_operations: u32, pub successful_operations: u32, pub failed_operations: u32, pub gas_estimate: u64, pub rolled_back: bool, + pub results: Vec, + /// Wall-clock seconds spent executing, from the ledger timestamp. + pub duration: u64, +} + +/// Aggregate execution statistics, kept globally and per operation type. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct BatchAnalytics { + pub total_batches: u32, + pub completed_batches: u32, + pub partial_batches: u32, + pub failed_batches: u32, + pub rolled_back_batches: u32, + pub total_items: u32, + pub successful_items: u32, + pub failed_items: u32, + /// `successful_items / total_items` in basis points (10_000 == 100%). + pub success_rate_bps: u32, + pub total_duration: u64, + /// `total_duration / total_batches`, in seconds. + pub avg_duration: u64, +} + +impl BatchAnalytics { + fn empty() -> Self { + BatchAnalytics { + total_batches: 0, + completed_batches: 0, + partial_batches: 0, + failed_batches: 0, + rolled_back_batches: 0, + total_items: 0, + successful_items: 0, + failed_items: 0, + success_rate_bps: 0, + total_duration: 0, + avg_duration: 0, + } + } + + fn recompute_derived(&mut self) { + self.success_rate_bps = if self.total_items == 0 { + 0 + } else { + ((self.successful_items as u64 * BPS_DENOMINATOR as u64) / self.total_items as u64) + as u32 + }; + self.avg_duration = if self.total_batches == 0 { + 0 + } else { + self.total_duration / self.total_batches as u64 + }; + } +} + +/// Subscription state owned by the batch registry. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct SubscriptionRecord { + pub id: u64, + pub charged: i128, + pub price: i128, + pub active: bool, +} + +/// Pre-execution snapshot of one subscription, used to undo a committed batch. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct SnapshotEntry { + pub id: u64, + /// False when the batch created this subscription, so rollback deletes it. + pub existed: bool, + pub prior: SubscriptionRecord, } #[contracttype] @@ -91,24 +284,84 @@ enum DataKey { BatchOwner(u64), BatchAtomic(u64), BatchExecuted(u64), + BatchRolledBack(u64), BatchStatus(u64), + BatchResult(u64), + BatchSnapshot(u64), + Config(OperationType), + Analytics, + AnalyticsFor(OperationType), Subscription(u64), History, } -pub fn validate_batch_operation(op: &BatchOperation) -> bool { +/// Configuration applied to an operation type that has never been configured. +pub fn default_config(operation_type: &OperationType) -> BatchConfig { + match operation_type { + // Creates are cheap, so they get the largest batches; a mistaken create + // is undone by rolling the batch back. + OperationType::Create => BatchConfig { + max_items: MAX_BATCH_ITEMS, + atomic_default: false, + allow_rollback: true, + gas_per_item: GAS_PER_ITEM, + }, + // Money movement defaults to atomic so a merchant never half-bills a + // cohort, and stays reversible for a mistaken billing run. + OperationType::Charge => BatchConfig { + max_items: 50, + atomic_default: true, + allow_rollback: true, + gas_per_item: GAS_PER_ITEM, + }, + OperationType::Update => BatchConfig { + max_items: MAX_BATCH_ITEMS, + atomic_default: false, + allow_rollback: true, + gas_per_item: GAS_PER_ITEM, + }, + // Cancellation is customer-visible and terminal: keep batches small and + // require an explicit re-subscribe rather than a silent undo. + OperationType::Cancel => BatchConfig { + max_items: 50, + atomic_default: false, + allow_rollback: false, + gas_per_item: GAS_PER_ITEM, + }, + } +} + +/// A batch is valid when it targets at least one subscription, stays within +/// `max_items`, and supplies one `params` entry per subscription for the +/// operations that need one. +pub fn validate_batch_operation_with_limit(op: &BatchOperation, max_items: u32) -> bool { let n = op.subscription_ids.len(); - if n == 0 || n > MAX_BATCH_ITEMS { + if n == 0 || n > max_items { return false; } match op.operation_type { + // The initial price is optional on create. OperationType::Create => true, - OperationType::Charge => op.params.len() == n, + OperationType::Charge | OperationType::Update => op.params.len() == n, + // A cancel reason per subscription is optional; omitting all of them + // records `TooExpensive`'s neighbour `Other` for every item. + OperationType::Cancel => op.params.is_empty() || op.params.len() == n, } } +/// [`validate_batch_operation_with_limit`] against the default ceiling. +pub fn validate_batch_operation(op: &BatchOperation) -> bool { + validate_batch_operation_with_limit(op, MAX_BATCH_ITEMS) +} + +/// Gas estimate: a fixed base plus a per-item cost. +pub fn estimate_batch_gas_with_rate(op: &BatchOperation, gas_per_item: u64) -> u64 { + GAS_BASE + (op.subscription_ids.len() as u64) * gas_per_item +} + +/// [`estimate_batch_gas_with_rate`] at the default per-item rate. pub fn estimate_batch_gas(op: &BatchOperation) -> u64 { - GAS_BASE + (op.subscription_ids.len() as u64 * GAS_PER_ITEM) + estimate_batch_gas_with_rate(op, GAS_PER_ITEM) } #[contract] @@ -125,12 +378,55 @@ impl SubTrackrBatch { env.storage() .instance() .set(&DataKey::History, &Vec::::new(&env)); + env.storage() + .instance() + .set(&DataKey::Analytics, &BatchAnalytics::empty()); } + // ── Configuration ──────────────────────────────────────────────────── + + /// Replace the configuration for one operation type. Admin only. + pub fn set_batch_config( + env: Env, + caller: Address, + operation_type: OperationType, + config: BatchConfig, + ) -> Result<(), BatchError> { + caller.require_auth(); + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(BatchError::Unauthorized)?; + if admin != caller { + return Err(BatchError::Unauthorized); + } + if config.max_items == 0 || config.max_items > MAX_BATCH_ITEMS { + return Err(BatchError::InvalidBatch); + } + env.storage() + .persistent() + .set(&DataKey::Config(operation_type), &config); + Ok(()) + } + + /// Effective configuration for an operation type, falling back to + /// [`default_config`]. + pub fn get_batch_config(env: Env, operation_type: OperationType) -> BatchConfig { + env.storage() + .persistent() + .get(&DataKey::Config(operation_type.clone())) + .unwrap_or_else(|| default_config(&operation_type)) + } + + // ── Subscription registry ──────────────────────────────────────────── + pub fn seed_subscription(env: Env, subscription_id: u64) { let sub = SubscriptionRecord { id: subscription_id, charged: 0, + price: 0, + active: true, }; env.storage() .persistent() @@ -143,6 +439,11 @@ impl SubTrackrBatch { .get(&DataKey::Subscription(subscription_id)) } + // ── Batch lifecycle ────────────────────────────────────────────────── + + /// Register a batch for later execution. `atomic` overrides the operation + /// type's `atomic_default`; use [`Self::create_batch_operation_default`] to + /// accept the configured default instead. pub fn create_batch_operation( env: Env, owner: Address, @@ -150,7 +451,8 @@ impl SubTrackrBatch { atomic: bool, ) -> Result { owner.require_auth(); - if !validate_batch_operation(&operation) { + let config = Self::get_batch_config(env.clone(), operation.operation_type.clone()); + if !validate_batch_operation_with_limit(&operation, config.max_items) { return Err(BatchError::InvalidBatch); } @@ -162,6 +464,7 @@ impl SubTrackrBatch { count += 1; env.storage().instance().set(&DataKey::BatchCount, &count); + let total = operation.subscription_ids.len(); env.storage() .persistent() .set(&DataKey::Batch(count), &operation); @@ -174,20 +477,46 @@ impl SubTrackrBatch { env.storage() .persistent() .set(&DataKey::BatchExecuted(count), &false); + env.storage() + .persistent() + .set(&DataKey::BatchRolledBack(count), &false); env.storage().persistent().set( &DataKey::BatchStatus(count), &BatchStatus { state: BatchState::Pending, + total, + succeeded: 0, + failed: 0, + started_at: 0, + completed_at: 0, + duration: 0, }, ); - let mut history: Vec = env.storage().instance().get(&DataKey::History).unwrap(); + let mut history: Vec = env + .storage() + .instance() + .get(&DataKey::History) + .unwrap_or(Vec::new(&env)); history.push_back(count); env.storage().instance().set(&DataKey::History, &history); + env.events() + .publish((symbol_short!("batch"), symbol_short!("created")), count); + Ok(count) } + /// Register a batch using the operation type's configured default atomicity. + pub fn create_batch_operation_default( + env: Env, + owner: Address, + operation: BatchOperation, + ) -> Result { + let config = Self::get_batch_config(env.clone(), operation.operation_type.clone()); + Self::create_batch_operation(env, owner, operation, config.atomic_default) + } + pub fn get_batch_history(env: Env) -> Vec { env.storage() .instance() @@ -201,9 +530,22 @@ impl SubTrackrBatch { .get(&DataKey::BatchStatus(batch_id)) .unwrap_or(BatchStatus { state: BatchState::Pending, + total: 0, + succeeded: 0, + failed: 0, + started_at: 0, + completed_at: 0, + duration: 0, }) } + /// Result recorded by the execution of `batch_id`, if it has run. + pub fn get_batch_result(env: Env, batch_id: u64) -> Option { + env.storage() + .persistent() + .get(&DataKey::BatchResult(batch_id)) + } + pub fn execute_batch(env: Env, batch_id: u64) -> Result { let executed: bool = env .storage() @@ -224,58 +566,87 @@ impl SubTrackrBatch { .persistent() .get(&DataKey::BatchAtomic(batch_id)) .unwrap_or(false); + let config = Self::get_batch_config(env.clone(), op.operation_type.clone()); let total = op.subscription_ids.len(); - let gas_estimate = estimate_batch_gas(&op); + let gas_estimate = estimate_batch_gas_with_rate(&op, config.gas_per_item); + let started_at = env.ledger().timestamp(); + + Self::write_status( + &env, + batch_id, + BatchStatus { + state: BatchState::Processing, + total, + succeeded: 0, + failed: 0, + started_at, + completed_at: 0, + duration: 0, + }, + ); let mut successful: u32 = 0; let mut failed: u32 = 0; - - // Minimal rollback model used by tests: if atomic and any failure occurs, - // we do not persist any successful effects. - let mut staged: Vec = Vec::new(&env); + let mut results: Vec = Vec::new(&env); + let mut snapshot: Vec = Vec::new(&env); + // Writes are staged for atomic batches and flushed only once every item + // has succeeded, so a failure leaves no partial effects behind. + let mut staged: Map = Map::new(&env); + // Subscriptions already snapshotted, so a batch that touches the same id + // twice still restores the state from before the whole batch. + let mut snapshotted: Map = Map::new(&env); for (i, sub_id) in op.subscription_ids.iter().enumerate() { let idx: u32 = i as u32; - match op.operation_type { - OperationType::Create => { - let sub = SubscriptionRecord { - id: sub_id, - charged: 0, - }; + let param = op.params.get(idx).unwrap_or(0); + let existing: Option = staged.get(sub_id).or_else(|| { + env.storage() + .persistent() + .get(&DataKey::Subscription(sub_id)) + }); + + match Self::apply_item(&op.operation_type, sub_id, param, existing.clone()) { + Ok(updated) => { + if !snapshotted.contains_key(sub_id) { + snapshotted.set(sub_id, true); + snapshot.push_back(SnapshotEntry { + id: sub_id, + existed: existing.is_some(), + prior: existing.unwrap_or(SubscriptionRecord { + id: sub_id, + charged: 0, + price: 0, + active: false, + }), + }); + } if atomic { - staged.push_back(sub); + staged.set(sub_id, updated); } else { env.storage() .persistent() - .set(&DataKey::Subscription(sub_id), &sub); + .set(&DataKey::Subscription(sub_id), &updated); } successful += 1; + results.push_back(OperationResult { + subscription_id: sub_id, + success: true, + code: 0, + }); } - OperationType::Charge => { - let existing: Option = env - .storage() - .persistent() - .get(&DataKey::Subscription(sub_id)); - if existing.is_none() { - failed += 1; - if atomic { - // Any failure aborts for atomic batches. - break; - } - continue; - } - let mut sub = existing.unwrap(); - let amount = op.params.get(idx).unwrap_or(0); - sub.charged += amount; + Err(code) => { + failed += 1; + results.push_back(OperationResult { + subscription_id: sub_id, + success: false, + code, + }); if atomic { - staged.push_back(sub); - } else { - env.storage() - .persistent() - .set(&DataKey::Subscription(sub_id), &sub); + // Any failure aborts an atomic batch; nothing staged is + // ever written. + break; } - successful += 1; } } } @@ -284,10 +655,10 @@ impl SubTrackrBatch { if rolled_back { successful = 0; } else if atomic { - for sub in staged.iter() { + for (id, record) in staged.iter() { env.storage() .persistent() - .set(&DataKey::Subscription(sub.id), &sub); + .set(&DataKey::Subscription(id), &record); } } @@ -299,19 +670,290 @@ impl SubTrackrBatch { BatchState::PartiallyCompleted }; + let completed_at = env.ledger().timestamp(); + let duration = completed_at.saturating_sub(started_at); + env.storage() .persistent() .set(&DataKey::BatchExecuted(batch_id), &true); - env.storage() - .persistent() - .set(&DataKey::BatchStatus(batch_id), &BatchStatus { state }); + // An atomic batch that rolled back committed nothing, so there is + // nothing left for `rollback_batch` to undo. + if !rolled_back { + env.storage() + .persistent() + .set(&DataKey::BatchSnapshot(batch_id), &snapshot); + } + Self::write_status( + &env, + batch_id, + BatchStatus { + state: state.clone(), + total, + succeeded: successful, + failed, + started_at, + completed_at, + duration, + }, + ); - Ok(BatchResult { + let result = BatchResult { + batch_id, total_operations: total, successful_operations: successful, failed_operations: failed, gas_estimate, rolled_back, - }) + results, + duration, + }; + env.storage() + .persistent() + .set(&DataKey::BatchResult(batch_id), &result); + + Self::record_analytics( + &env, + &op.operation_type, + &state, + total, + successful, + failed, + duration, + ); + + env.events().publish( + (symbol_short!("batch"), symbol_short!("executed")), + (batch_id, successful, failed), + ); + + Ok(result) + } + + /// Undo a committed batch by restoring the snapshot captured during + /// execution. Only the batch owner or the admin may call this, and only for + /// operation types whose configuration sets `allow_rollback`. + pub fn rollback_batch( + env: Env, + caller: Address, + batch_id: u64, + ) -> Result { + caller.require_auth(); + + let op: BatchOperation = env + .storage() + .persistent() + .get(&DataKey::Batch(batch_id)) + .ok_or(BatchError::BatchNotFound)?; + let owner: Address = env + .storage() + .persistent() + .get(&DataKey::BatchOwner(batch_id)) + .ok_or(BatchError::BatchNotFound)?; + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(BatchError::Unauthorized)?; + if caller != owner && caller != admin { + return Err(BatchError::Unauthorized); + } + + let executed: bool = env + .storage() + .persistent() + .get(&DataKey::BatchExecuted(batch_id)) + .unwrap_or(false); + if !executed { + return Err(BatchError::NotExecuted); + } + let already: bool = env + .storage() + .persistent() + .get(&DataKey::BatchRolledBack(batch_id)) + .unwrap_or(false); + if already { + return Err(BatchError::AlreadyRolledBack); + } + + let config = Self::get_batch_config(env.clone(), op.operation_type.clone()); + if !config.allow_rollback { + return Err(BatchError::RollbackNotAllowed); + } + + let snapshot: Vec = env + .storage() + .persistent() + .get(&DataKey::BatchSnapshot(batch_id)) + // An atomic batch that failed committed nothing, so there is no + // snapshot and nothing to undo. + .ok_or(BatchError::RollbackNotAllowed)?; + + for entry in snapshot.iter() { + if entry.existed { + env.storage() + .persistent() + .set(&DataKey::Subscription(entry.id), &entry.prior); + } else { + env.storage() + .persistent() + .remove(&DataKey::Subscription(entry.id)); + } + } + + env.storage() + .persistent() + .set(&DataKey::BatchRolledBack(batch_id), &true); + env.storage() + .persistent() + .remove(&DataKey::BatchSnapshot(batch_id)); + + let previous = Self::get_batch_status(env.clone(), batch_id); + let status = BatchStatus { + state: BatchState::RolledBack, + total: previous.total, + succeeded: 0, + failed: previous.failed, + started_at: previous.started_at, + completed_at: env.ledger().timestamp(), + duration: previous.duration, + }; + Self::write_status(&env, batch_id, status.clone()); + + // Undoing the writes also undoes their contribution to the success rate. + Self::discount_analytics(&env, &op.operation_type, previous.succeeded); + + env.events().publish( + (symbol_short!("batch"), symbol_short!("rolledbk")), + batch_id, + ); + + Ok(status) + } + + // ── Analytics ──────────────────────────────────────────────────────── + + /// Aggregate statistics across every batch executed by this contract. + pub fn get_batch_analytics(env: Env) -> BatchAnalytics { + env.storage() + .instance() + .get(&DataKey::Analytics) + .unwrap_or_else(BatchAnalytics::empty) + } + + /// Aggregate statistics restricted to one operation type. + pub fn get_batch_analytics_for(env: Env, operation_type: OperationType) -> BatchAnalytics { + env.storage() + .persistent() + .get(&DataKey::AnalyticsFor(operation_type)) + .unwrap_or_else(BatchAnalytics::empty) + } + + // ── Internals ──────────────────────────────────────────────────────── + + /// Apply one item, returning the updated record or a [`CoreError`] code. + fn apply_item( + operation_type: &OperationType, + sub_id: u64, + param: i128, + existing: Option, + ) -> Result { + match operation_type { + OperationType::Create => { + if existing.is_some() { + return Err(CoreError::AlreadyExists as u32); + } + Ok(SubscriptionRecord { + id: sub_id, + charged: 0, + price: param, + active: true, + }) + } + OperationType::Charge => { + let mut sub = existing.ok_or(CoreError::SubscriptionNotFound as u32)?; + if !sub.active { + return Err(CoreError::SubscriptionNotActive as u32); + } + if param < 0 { + return Err(CoreError::InvalidAmount as u32); + } + sub.charged += param; + Ok(sub) + } + OperationType::Update => { + let mut sub = existing.ok_or(CoreError::SubscriptionNotFound as u32)?; + if param < 0 { + return Err(CoreError::InvalidPrice as u32); + } + sub.price = param; + Ok(sub) + } + OperationType::Cancel => { + let mut sub = existing.ok_or(CoreError::SubscriptionNotFound as u32)?; + if !sub.active { + return Err(CoreError::SubscriptionAlreadyCancelled as u32); + } + sub.active = false; + Ok(sub) + } + } + } + + fn write_status(env: &Env, batch_id: u64, status: BatchStatus) { + env.storage() + .persistent() + .set(&DataKey::BatchStatus(batch_id), &status); + } + + fn record_analytics( + env: &Env, + operation_type: &OperationType, + state: &BatchState, + total: u32, + successful: u32, + failed: u32, + duration: u64, + ) { + let apply = |analytics: &mut BatchAnalytics| { + analytics.total_batches += 1; + analytics.total_items += total; + analytics.successful_items += successful; + analytics.failed_items += failed; + analytics.total_duration += duration; + match state { + BatchState::Completed => analytics.completed_batches += 1, + BatchState::PartiallyCompleted => analytics.partial_batches += 1, + _ => analytics.failed_batches += 1, + } + analytics.recompute_derived(); + }; + + let mut global = Self::get_batch_analytics(env.clone()); + apply(&mut global); + env.storage().instance().set(&DataKey::Analytics, &global); + + let mut per_type = Self::get_batch_analytics_for(env.clone(), operation_type.clone()); + apply(&mut per_type); + env.storage() + .persistent() + .set(&DataKey::AnalyticsFor(operation_type.clone()), &per_type); + } + + fn discount_analytics(env: &Env, operation_type: &OperationType, succeeded: u32) { + let apply = |analytics: &mut BatchAnalytics| { + analytics.rolled_back_batches += 1; + analytics.successful_items = analytics.successful_items.saturating_sub(succeeded); + analytics.recompute_derived(); + }; + + let mut global = Self::get_batch_analytics(env.clone()); + apply(&mut global); + env.storage().instance().set(&DataKey::Analytics, &global); + + let mut per_type = Self::get_batch_analytics_for(env.clone(), operation_type.clone()); + apply(&mut per_type); + env.storage() + .persistent() + .set(&DataKey::AnalyticsFor(operation_type.clone()), &per_type); } } diff --git a/contracts/batch/tests/batch_tests.rs b/contracts/batch/tests/batch_tests.rs index 117cb410..2effa89e 100644 --- a/contracts/batch/tests/batch_tests.rs +++ b/contracts/batch/tests/batch_tests.rs @@ -1,9 +1,10 @@ #![cfg(test)] //! Integration tests for the batch operations contract. -use soroban_sdk::{testutils::Address as _, vec, Address, Env, Vec}; +use soroban_sdk::{testutils::Address as _, testutils::Ledger as _, vec, Address, Env, Vec}; use subtrackr_batch::{ - estimate_batch_gas, validate_batch_operation, BatchError, BatchOperation, BatchState, + cancel_reason_code, cancel_reason_from_code, default_config, estimate_batch_gas, + validate_batch_operation, BatchConfig, BatchError, BatchOperation, BatchState, CancelReason, OperationType, SubTrackrBatch, SubTrackrBatchClient, }; @@ -160,3 +161,510 @@ fn records_audit_history() { let history = client.get_batch_history(); assert_eq!(history, vec![&env, a, b]); } + +// ── Status tracking ────────────────────────────────────────────────────── + +#[test] +fn tracks_pending_state_before_execution() { + let (env, client, _admin) = setup(); + let owner = Address::generate(&env); + let id = client.create_batch_operation( + &owner, + &op(&env, OperationType::Create, &[1, 2], &[]), + &false, + ); + + let status = client.get_batch_status(&id); + assert_eq!(status.state, BatchState::Pending); + assert_eq!(status.total, 2); + assert_eq!(status.succeeded, 0); + assert_eq!(status.started_at, 0); + assert_eq!(status.completed_at, 0); +} + +#[test] +fn records_execution_timing() { + let (env, client, _admin) = setup(); + let owner = Address::generate(&env); + env.ledger().with_mut(|l| l.timestamp = 1_000); + + let id = client.create_batch_operation( + &owner, + &op(&env, OperationType::Create, &[1, 2], &[]), + &false, + ); + let result = client.execute_batch(&id); + + let status = client.get_batch_status(&id); + assert_eq!(status.started_at, 1_000); + assert_eq!(status.completed_at, 1_000); + assert_eq!(status.duration, 0); + assert_eq!(result.duration, 0); + assert_eq!(status.succeeded, 2); +} + +#[test] +fn exposes_per_item_results() { + let (env, client, _admin) = setup(); + let owner = Address::generate(&env); + client.seed_subscription(&1); + + let id = client.create_batch_operation( + &owner, + &op(&env, OperationType::Charge, &[1, 2], &[100, 100]), + &false, + ); + let result = client.execute_batch(&id); + + assert_eq!(result.results.len(), 2); + assert!(result.results.get(0).unwrap().success); + assert_eq!(result.results.get(0).unwrap().code, 0); + assert!(!result.results.get(1).unwrap().success); + // 302 == CoreError::SubscriptionNotFound + assert_eq!(result.results.get(1).unwrap().code, 302); + + let stored = client.get_batch_result(&id).unwrap(); + assert_eq!(stored, result); +} + +// ── Update and cancel operations ───────────────────────────────────────── + +#[test] +fn batch_update_rewrites_prices() { + let (env, client, _admin) = setup(); + let owner = Address::generate(&env); + client.seed_subscription(&1); + client.seed_subscription(&2); + + let id = client.create_batch_operation( + &owner, + &op(&env, OperationType::Update, &[1, 2], &[500, 900]), + &false, + ); + let result = client.execute_batch(&id); + + assert_eq!(result.successful_operations, 2); + assert_eq!(client.get_subscription(&1).unwrap().price, 500); + assert_eq!(client.get_subscription(&2).unwrap().price, 900); +} + +#[test] +fn batch_cancel_deactivates_subscriptions() { + let (env, client, _admin) = setup(); + let owner = Address::generate(&env); + client.seed_subscription(&1); + + let reason = cancel_reason_code(&CancelReason::TooExpensive); + let id = client.create_batch_operation( + &owner, + &op(&env, OperationType::Cancel, &[1], &[reason]), + &false, + ); + let result = client.execute_batch(&id); + + assert_eq!(result.successful_operations, 1); + assert!(!client.get_subscription(&1).unwrap().active); +} + +#[test] +fn batch_cancel_rejects_already_cancelled() { + let (env, client, _admin) = setup(); + let owner = Address::generate(&env); + client.seed_subscription(&1); + + let first = + client.create_batch_operation(&owner, &op(&env, OperationType::Cancel, &[1], &[]), &false); + client.execute_batch(&first); + + let second = + client.create_batch_operation(&owner, &op(&env, OperationType::Cancel, &[1], &[]), &false); + let result = client.execute_batch(&second); + assert_eq!(result.failed_operations, 1); + // 501 == CoreError::SubscriptionAlreadyCancelled + assert_eq!(result.results.get(0).unwrap().code, 501); +} + +#[test] +fn cancel_reason_codes_round_trip() { + for reason in [ + CancelReason::TooExpensive, + CancelReason::NoLongerNeeded, + CancelReason::FoundAlternative, + CancelReason::PoorService, + CancelReason::Other, + ] { + let code = cancel_reason_code(&reason); + assert_eq!(cancel_reason_from_code(code), reason); + } + // Unknown codes degrade to `Other` rather than panicking. + assert_eq!(cancel_reason_from_code(99), CancelReason::Other); +} + +#[test] +fn update_requires_one_param_per_subscription() { + let env = Env::default(); + let short = op(&env, OperationType::Update, &[1, 2], &[100]); + assert!(!validate_batch_operation(&short)); + + let matched = op(&env, OperationType::Update, &[1, 2], &[100, 200]); + assert!(validate_batch_operation(&matched)); + + // Cancel reasons are optional. + let no_reasons = op(&env, OperationType::Cancel, &[1, 2], &[]); + assert!(validate_batch_operation(&no_reasons)); +} + +// ── Per-operation configuration ────────────────────────────────────────── + +#[test] +fn exposes_default_config_per_operation_type() { + let (_env, client, _admin) = setup(); + + let create = client.get_batch_config(&OperationType::Create); + assert_eq!(create.max_items, 100); + assert!(!create.atomic_default); + assert!(create.allow_rollback); + + // Money movement is atomic by default; cancellation is not reversible. + assert!( + client + .get_batch_config(&OperationType::Charge) + .atomic_default + ); + assert!( + !client + .get_batch_config(&OperationType::Cancel) + .allow_rollback + ); +} + +#[test] +fn admin_can_tighten_batch_size_per_operation_type() { + let (env, client, admin) = setup(); + let owner = Address::generate(&env); + + client.set_batch_config( + &admin, + &OperationType::Create, + &BatchConfig { + max_items: 2, + atomic_default: true, + allow_rollback: false, + gas_per_item: 100_000, + }, + ); + + let within = op(&env, OperationType::Create, &[1, 2], &[]); + assert!(client + .try_create_batch_operation(&owner, &within, &false) + .is_ok()); + + let over = op(&env, OperationType::Create, &[3, 4, 5], &[]); + assert_eq!( + client.try_create_batch_operation(&owner, &over, &false), + Err(Ok(BatchError::InvalidBatch)) + ); +} + +#[test] +fn non_admin_cannot_change_config() { + let (env, client, _admin) = setup(); + let intruder = Address::generate(&env); + let res = client.try_set_batch_config( + &intruder, + &OperationType::Create, + &default_config(&OperationType::Create), + ); + assert_eq!(res, Err(Ok(BatchError::Unauthorized))); +} + +#[test] +fn rejects_config_above_hard_ceiling() { + let (_env, client, admin) = setup(); + let res = client.try_set_batch_config( + &admin, + &OperationType::Create, + &BatchConfig { + max_items: 101, + atomic_default: false, + allow_rollback: true, + gas_per_item: 100_000, + }, + ); + assert_eq!(res, Err(Ok(BatchError::InvalidBatch))); +} + +#[test] +fn default_atomicity_comes_from_config() { + let (env, client, _admin) = setup(); + let owner = Address::generate(&env); + client.seed_subscription(&1); + + // Charge defaults to atomic, so the missing subscription 2 discards the + // charge against subscription 1 as well. + let id = client.create_batch_operation_default( + &owner, + &op(&env, OperationType::Charge, &[1, 2], &[100, 100]), + ); + let result = client.execute_batch(&id); + assert!(result.rolled_back); + assert_eq!(client.get_subscription(&1).unwrap().charged, 0); +} + +#[test] +fn gas_estimate_follows_configured_rate() { + let (env, client, admin) = setup(); + let owner = Address::generate(&env); + + client.set_batch_config( + &admin, + &OperationType::Create, + &BatchConfig { + max_items: 100, + atomic_default: false, + allow_rollback: true, + gas_per_item: 10_000, + }, + ); + + let id = client.create_batch_operation( + &owner, + &op(&env, OperationType::Create, &[1, 2], &[]), + &false, + ); + let result = client.execute_batch(&id); + assert_eq!(result.gas_estimate, 50_000 + 2 * 10_000); +} + +// ── Rollback ───────────────────────────────────────────────────────────── + +#[test] +fn rollback_removes_subscriptions_created_by_the_batch() { + let (env, client, _admin) = setup(); + let owner = Address::generate(&env); + + let id = client.create_batch_operation( + &owner, + &op(&env, OperationType::Create, &[7, 8], &[]), + &false, + ); + client.execute_batch(&id); + assert!(client.get_subscription(&7).is_some()); + + let status = client.rollback_batch(&owner, &id); + assert_eq!(status.state, BatchState::RolledBack); + assert!(client.get_subscription(&7).is_none()); + assert!(client.get_subscription(&8).is_none()); +} + +#[test] +fn rollback_restores_prior_charge_totals() { + let (env, client, _admin) = setup(); + let owner = Address::generate(&env); + client.seed_subscription(&1); + + let seed = client.create_batch_operation( + &owner, + &op(&env, OperationType::Charge, &[1], &[100]), + &false, + ); + client.execute_batch(&seed); + assert_eq!(client.get_subscription(&1).unwrap().charged, 100); + + let second = client.create_batch_operation( + &owner, + &op(&env, OperationType::Charge, &[1], &[250]), + &false, + ); + client.execute_batch(&second); + assert_eq!(client.get_subscription(&1).unwrap().charged, 350); + + client.rollback_batch(&owner, &second); + // Only the second batch is undone; the first charge stands. + assert_eq!(client.get_subscription(&1).unwrap().charged, 100); +} + +#[test] +fn rollback_of_repeated_subscription_restores_pre_batch_state() { + let (env, client, _admin) = setup(); + let owner = Address::generate(&env); + client.seed_subscription(&1); + + // Subscription 1 is charged twice within one batch. + let id = client.create_batch_operation( + &owner, + &op(&env, OperationType::Charge, &[1, 1], &[100, 200]), + &false, + ); + client.execute_batch(&id); + assert_eq!(client.get_subscription(&1).unwrap().charged, 300); + + client.rollback_batch(&owner, &id); + assert_eq!(client.get_subscription(&1).unwrap().charged, 0); +} + +#[test] +fn rollback_is_rejected_for_operation_types_that_disallow_it() { + let (env, client, _admin) = setup(); + let owner = Address::generate(&env); + client.seed_subscription(&1); + + let id = + client.create_batch_operation(&owner, &op(&env, OperationType::Cancel, &[1], &[]), &false); + client.execute_batch(&id); + + assert_eq!( + client.try_rollback_batch(&owner, &id), + Err(Ok(BatchError::RollbackNotAllowed)) + ); +} + +#[test] +fn rollback_is_rejected_before_execution() { + let (env, client, _admin) = setup(); + let owner = Address::generate(&env); + let id = + client.create_batch_operation(&owner, &op(&env, OperationType::Create, &[1], &[]), &false); + assert_eq!( + client.try_rollback_batch(&owner, &id), + Err(Ok(BatchError::NotExecuted)) + ); +} + +#[test] +fn rollback_cannot_be_applied_twice() { + let (env, client, _admin) = setup(); + let owner = Address::generate(&env); + let id = + client.create_batch_operation(&owner, &op(&env, OperationType::Create, &[1], &[]), &false); + client.execute_batch(&id); + client.rollback_batch(&owner, &id); + assert_eq!( + client.try_rollback_batch(&owner, &id), + Err(Ok(BatchError::AlreadyRolledBack)) + ); +} + +#[test] +fn only_owner_or_admin_may_roll_back() { + let (env, client, admin) = setup(); + let owner = Address::generate(&env); + let intruder = Address::generate(&env); + + let id = + client.create_batch_operation(&owner, &op(&env, OperationType::Create, &[1], &[]), &false); + client.execute_batch(&id); + + assert_eq!( + client.try_rollback_batch(&intruder, &id), + Err(Ok(BatchError::Unauthorized)) + ); + // The admin can still intervene. + assert!(client.try_rollback_batch(&admin, &id).is_ok()); +} + +#[test] +fn atomic_failure_leaves_nothing_to_roll_back() { + let (env, client, _admin) = setup(); + let owner = Address::generate(&env); + client.seed_subscription(&1); + + let id = client.create_batch_operation( + &owner, + &op(&env, OperationType::Charge, &[1, 2], &[100, 100]), + &true, + ); + let result = client.execute_batch(&id); + assert!(result.rolled_back); + + assert_eq!( + client.try_rollback_batch(&owner, &id), + Err(Ok(BatchError::RollbackNotAllowed)) + ); +} + +// ── Analytics ──────────────────────────────────────────────────────────── + +#[test] +fn analytics_start_empty() { + let (_env, client, _admin) = setup(); + let analytics = client.get_batch_analytics(); + assert_eq!(analytics.total_batches, 0); + assert_eq!(analytics.success_rate_bps, 0); + assert_eq!(analytics.avg_duration, 0); +} + +#[test] +fn analytics_track_success_rate_and_timing() { + let (env, client, _admin) = setup(); + let owner = Address::generate(&env); + client.seed_subscription(&1); + + env.ledger().with_mut(|l| l.timestamp = 100); + let first = client.create_batch_operation( + &owner, + &op(&env, OperationType::Create, &[5, 6], &[]), + &false, + ); + client.execute_batch(&first); + + // 1 of 3 charges succeeds, so 3 of 5 items overall. + let second = client.create_batch_operation( + &owner, + &op(&env, OperationType::Charge, &[1, 2, 3], &[10, 10, 10]), + &false, + ); + client.execute_batch(&second); + + let analytics = client.get_batch_analytics(); + assert_eq!(analytics.total_batches, 2); + assert_eq!(analytics.completed_batches, 1); + assert_eq!(analytics.partial_batches, 1); + assert_eq!(analytics.total_items, 5); + assert_eq!(analytics.successful_items, 3); + assert_eq!(analytics.failed_items, 2); + assert_eq!(analytics.success_rate_bps, 6_000); + assert_eq!(analytics.avg_duration, 0); +} + +#[test] +fn analytics_are_partitioned_by_operation_type() { + let (env, client, _admin) = setup(); + let owner = Address::generate(&env); + + let id = client.create_batch_operation( + &owner, + &op(&env, OperationType::Create, &[1, 2], &[]), + &false, + ); + client.execute_batch(&id); + + let creates = client.get_batch_analytics_for(&OperationType::Create); + assert_eq!(creates.total_batches, 1); + assert_eq!(creates.successful_items, 2); + assert_eq!(creates.success_rate_bps, 10_000); + + let charges = client.get_batch_analytics_for(&OperationType::Charge); + assert_eq!(charges.total_batches, 0); +} + +#[test] +fn rollback_discounts_successful_items_from_analytics() { + let (env, client, _admin) = setup(); + let owner = Address::generate(&env); + + let id = client.create_batch_operation( + &owner, + &op(&env, OperationType::Create, &[1, 2], &[]), + &false, + ); + client.execute_batch(&id); + assert_eq!(client.get_batch_analytics().success_rate_bps, 10_000); + + client.rollback_batch(&owner, &id); + + let analytics = client.get_batch_analytics(); + assert_eq!(analytics.rolled_back_batches, 1); + assert_eq!(analytics.successful_items, 0); + assert_eq!(analytics.success_rate_bps, 0); +} diff --git a/docs/BATCH_OPERATIONS.md b/docs/BATCH_OPERATIONS.md new file mode 100644 index 00000000..cae9bcad --- /dev/null +++ b/docs/BATCH_OPERATIONS.md @@ -0,0 +1,210 @@ +# Batch Subscription Operations + +## Overview + +Merchants managing hundreds of subscriptions need to act on them as a group rather than +one at a time. Batch operations bundle a single operation kind — create, update, charge or +cancel — across many subscriptions, with an atomic execution guarantee, tracked status, +after-the-fact rollback, and analytics on success rate and timing. + +The feature spans four layers: + +| Layer | Location | Responsibility | +| ---------- | ----------------------------------------- | ---------------------------------------------------- | +| Contract | `contracts/batch/src/lib.rs` | On-chain execution, atomicity, status, analytics | +| Service | `app/services/batchTransactionService.ts` | Client orchestration, chunking, retry, CSV, export | +| Store | `app/stores/batchStore.ts` | Draft state, configuration, history, analytics | +| UI | `app/screens/BatchOperationsScreen.tsx` | Operator workflow | + +## Operation types + +| Type | `params[i]` means | Effect | +| -------- | ---------------------- | ----------------------------------------- | +| `create` | initial price | Registers a new subscription | +| `update` | new price | Rewrites the subscription price | +| `charge` | amount to charge | Adds to the subscription's charged total | +| `cancel` | cancel reason code | Deactivates the subscription | + +Cancel reason codes are `0` too expensive, `1` no longer needed, `2` found alternative, +`3` poor service, `4` other. Use `cancel_reason_code` / `cancel_reason_from_code` rather +than hard-coding the integers. Reasons are optional: an empty `params` vector records +`Other` for every item. + +## Status tracking + +Every batch moves through an explicit state machine, mirrored between the contract's +`BatchState` and the client's `BatchState`: + +``` +pending ──▶ processing ──▶ completed (no item failed) + ├▶ partial (some items failed, non-atomic) + └▶ failed (atomic batch discarded its writes) + +completed / partial ──▶ rolled_back (explicit reversal) +``` + +`get_batch_status(batch_id)` returns the state alongside `total`, `succeeded`, `failed` +and the `started_at` / `completed_at` ledger timestamps, so a caller can compute progress +and duration without replaying the batch. On the client, `useBatchStore().progress` +exposes the same shape with a `percentComplete` for progress bars. + +## Atomic execution guarantee + +An atomic batch stages every write and flushes it only after the last item succeeds. The +first failure aborts the run, the staging area is discarded, and the batch reports +`failed` with `rolled_back: true` and `successful_operations: 0`. Callers therefore never +observe a half-applied batch. + +A non-atomic batch commits each item as it succeeds and finishes `partial`, reporting per +item outcomes so the operator can retry only what failed. + +Atomicity is chosen per run. `create_batch_operation(owner, operation, atomic)` takes an +explicit flag; `create_batch_operation_default(owner, operation)` uses the operation +type's configured `atomic_default`. + +## Rollback + +A batch that already committed can still be undone. During execution the contract records +a `SnapshotEntry` per touched subscription capturing whether it existed and its prior +state. `rollback_batch(caller, batch_id)` replays that snapshot: subscriptions the batch +created are removed, and pre-existing subscriptions are restored byte for byte. + +Rules enforced by the contract: + +- Only the batch owner or the contract admin may roll back. +- The operation type's configuration must set `allow_rollback`. `cancel` does not, because + cancellation is customer-visible and terminal — a subscriber re-subscribes instead. +- The batch must have executed, and must not already be rolled back. +- An atomic batch that failed has no snapshot: it committed nothing, so there is nothing + to reverse. +- A batch that touches the same subscription twice snapshots it only once, so rollback + restores the state from before the whole batch rather than from mid-batch. + +Rolling back also discounts the batch's successful items from analytics, so the reported +success rate reflects what actually stuck. + +On the client, `batchStore.rollbackBatch()` walks the committed items and calls the +registered `RollbackHandler` for each — the compensating action is caller-supplied, since +only the caller knows how to refund a charge or delete a subscription. A rollback that +cannot reverse every item leaves the batch `partial` rather than claiming a clean +reversal. + +```ts +useBatchStore.getState().setRollbackHandler(async (item, operationType) => { + if (operationType === 'charge') return refundCharge(item.subscriptionId); + if (operationType === 'create') return deleteSubscription(item.subscriptionId); + return { success: false, error: `no compensating action for ${operationType}` }; +}); + +const rollback = await useBatchStore.getState().rollbackBatch(); +// { attempted: 12, reverted: 12, failed: 0 } +``` + +## Configuration per operation type + +Limits differ by risk, so each operation type is configured independently. + +| Field | `create` | `update` | `charge` | `cancel` | +| ------------------------------ | -------- | -------- | -------- | -------- | +| `maxItems` | 100 | 100 | 50 | 50 | +| `chunkSize` (client only) | 50 | 50 | 25 | 25 | +| `atomicDefault` | false | false | **true** | false | +| `allowRollback` | true | true | true | **false**| +| `maxRetries` (client only) | 3 | 3 | 2 | 1 | +| `idempotent` (client only) | false | true | true | true | + +Money movement defaults to atomic so a merchant never half-bills a cohort. Cancellation +keeps small batches and is not reversible. Creates are never deduplicated because two +subscriptions may legitimately share a name. + +On-chain, `set_batch_config(caller, operation_type, config)` is admin-only and cannot +raise `max_items` above the hard ceiling of 100. `get_batch_config(operation_type)` falls +back to `default_config` for types that were never configured. + +On the client, `batchStore.setOperationConfig(type, patch)` patches a single type, +`resetOperationConfig(type)` restores its defaults, and both are persisted. A draft is +validated against the active config before execution, and an oversized batch is rejected +without applying a single item: + +```ts +const validation = useBatchStore.getState().validateDraft(); +// { valid: false, reason: 'A charge batch is limited to 50 items (got 80).' } +``` + +## Idempotency and retry + +The client service tracks the `(operation, subscriptionId)` pairs it has applied. When an +operation type is configured `idempotent`, re-submitting the same batch skips those items +with `status: 'skipped'` instead of double-charging. Successes discarded by an atomic +rollback are removed from that ledger, so a corrected re-run applies them normally. + +`retryFailedItems(retryFn)` re-attempts only failed items, honouring the operation type's +`maxRetries` with exponential backoff (`retryDelayMs * backoffMultiplier ^ retryCount`). +Items that exhaust their budget are left failed rather than retried forever. + +## Analytics + +`computeBatchAnalytics(history)` derives, overall and per operation type: + +- `batches`, `completed`, `partial`, `failed`, `rolledBack` +- `totalItems`, `successfulItems`, `failedItems`, `skippedItems` +- `batchSuccessRate` — fraction of batches that completed with no failures +- `itemSuccessRate` — fraction of individual items that succeeded +- `totalDurationMs`, `avgDurationMs`, `p95DurationMs` +- `avgItemDurationMs` and `throughputPerSecond` + +Only batches that recorded a duration contribute to timing statistics, so history +persisted before timing existed cannot skew the averages. `useBatchStore().analytics()` +computes this from the store's persisted history; `service.getAnalytics()` reads the +service-level history in `AsyncStorage`. + +On-chain, `get_batch_analytics()` and `get_batch_analytics_for(operation_type)` return the +same aggregates maintained incrementally as batches execute, with `success_rate_bps` in +basis points (`10_000` == 100%) and durations in ledger seconds. + +## Gas model + +Both layers use the same estimate: a fixed base of 50,000 plus a per-item cost (100,000 by +default, configurable per operation type on-chain via `gas_per_item`). + +``` +50 items batched : 50,000 + 50 × 100,000 = 5,050,000 +50 items separate: 50 × 150,000 = 7,500,000 +saving : 2,450,000 (32%) +``` + +`calculateBatchGasSavings(itemCount, singleTransactionGas)` returns this comparison for +display. + +## CSV input and result export + +Each operation type accepts a CSV, parsed with quote-aware splitting and case-insensitive +headers: + +- **create** — `name,description,category,price,currency,billingCycle,nextBillingDate,isActive,notificationsEnabled` +- **update** — `subscriptionId` +- **cancel** — `subscriptionId,reason,notes` +- **charge** — `subscriptionId,amount` (`id` and `price` are accepted aliases) + +Rows without a name (create) or subscription id (others) are skipped rather than failing +the whole import. Results export as JSON (`exportBatchResultToJson`) or per-item CSV +(`exportBatchResultToCsv`) including status, error, retry count and per-item duration. + +## Memory and large batches + +Items are applied in chunks of `chunkSize`, bounding peak memory regardless of batch size. +The operator can override the chunk size live from the Options section of the batch screen; +that choice wins over the stored per-operation default. + +## Testing + +```bash +# Contract +cd contracts && cargo test -p subtrackr-batch + +# Client service and store +npx jest --testPathIgnorePatterns "/node_modules/" --testPathPattern "app/(services|stores)/__tests__/batch" +``` + +Note that `app/` is excluded from the default `jest.config.js` `testPathIgnorePatterns`, so +these suites need the override above to run. From 6e5a5bc93facba3a08f258fa6df0a190d44df0e9 Mon Sep 17 00:00:00 2001 From: distributed-nerd <267643428+distributed-nerd@users.noreply.github.com> Date: Sun, 26 Jul 2026 16:21:31 +0100 Subject: [PATCH 2/6] feat(templates): add plan template library with tiers, versioning, sharing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plans were built from scratch every time. Plan templates are reusable blueprints — base price, billing cycle, an optional graduated pricing ladder and a feature list — that a merchant instantiates a plan from. Contract (contracts/subscription): - add a plan_templates module: template registry, per-owner index, version chains, a shared library and per-template analytics - graduated pricing ladders, validated ascending with an unbounded rung only at the end, and quoted per unit - per-instantiation overrides that resolve to plan parameters without ever mutating the template - publishing a version retires the previous one, which stays readable so plans already created from it remain explainable, and withdraws it from the shared library - contract entry points, including create_plan_from_template, which forwards to create_plan so template-made plans need no special casing - namespace the feature's storage behind one StorageKey::PlanTemplate variant rather than six flat ones Backend (backend/services/billing): - planTemplateService with the same library, versioning, sharing, customization and analytics semantics, over a repository interface - quoting reuses TieredPricingCalculator, so client and server price a ladder identically Client: - src/types/planTemplate.ts as the shared vocabulary - planTemplateStore with the library, filters, quoting, versioning, sharing and analytics, seeded with three starter templates - subscriptionStore.addFromTemplate, which records a conversion only when the subscription actually landed - PlanTemplatesScreen to browse, quote, customize and instantiate, registered in the root and subscription navigators Analytics track views, plans created, subscriptions started and revenue, deriving adoption and conversion rates capped at 100%. Docs: add docs/PLAN_TEMPLATES.md. Tests: 30 backend tests, 26 store tests, 5 contract unit tests. --- .../__tests__/planTemplateService.test.ts | 396 +++++++++++ backend/services/billing/index.ts | 28 + backend/services/billing/interfaces.ts | 36 + .../services/billing/planTemplateService.ts | 545 +++++++++++++++ contracts/subscription/src/lib.rs | 192 ++++++ contracts/subscription/src/plan_templates.rs | 651 ++++++++++++++++++ contracts/types/src/lib.rs | 25 + docs/PLAN_TEMPLATES.md | 206 ++++++ src/navigation/AppNavigator.tsx | 6 + src/navigation/modules/SubscriptionStack.tsx | 6 + src/navigation/types.ts | 1 + src/screens/PlanTemplatesScreen.tsx | 395 +++++++++++ src/store/__tests__/planTemplateStore.test.ts | 356 ++++++++++ src/store/planTemplateStore.ts | 624 +++++++++++++++++ src/store/subscriptionStore.ts | 40 ++ src/types/planTemplate.ts | 160 +++++ 16 files changed, 3667 insertions(+) create mode 100644 backend/services/billing/__tests__/planTemplateService.test.ts create mode 100644 backend/services/billing/planTemplateService.ts create mode 100644 contracts/subscription/src/plan_templates.rs create mode 100644 docs/PLAN_TEMPLATES.md create mode 100644 src/screens/PlanTemplatesScreen.tsx create mode 100644 src/store/__tests__/planTemplateStore.test.ts create mode 100644 src/store/planTemplateStore.ts create mode 100644 src/types/planTemplate.ts diff --git a/backend/services/billing/__tests__/planTemplateService.test.ts b/backend/services/billing/__tests__/planTemplateService.test.ts new file mode 100644 index 00000000..db5e7191 --- /dev/null +++ b/backend/services/billing/__tests__/planTemplateService.test.ts @@ -0,0 +1,396 @@ +/** + * Unit tests for planTemplateService.ts + * + * Covers: + * - template library CRUD and browsing filters + * - dynamic pricing tiers and quoting + * - per-instantiation customization + * - versioning and supersession + * - sharing and access control + * - usage/conversion analytics + */ + +import { describe, it, expect, beforeEach } from '@jest/globals'; +import { + InMemoryPlanTemplateRepository, + PlanTemplateService, + canInstantiate, + quoteTemplate, + resolvePlan, + validateTemplateDraft, + validateTiers, +} from '../planTemplateService'; +import { BillingError } from '../errors'; +import type { PlanTemplate, PlanTemplateDraft } from '../../../../src/types/planTemplate'; +import { BillingCycle, SubscriptionCategory } from '../../../../src/types/subscription'; + +const OWNER = 'merchant_1'; +const OTHER = 'merchant_2'; + +const flatDraft = (patch: Partial = {}): PlanTemplateDraft => ({ + name: 'Team', + description: 'Collaboration tier', + category: SubscriptionCategory.SOFTWARE, + billingCycle: BillingCycle.MONTHLY, + currency: 'USD', + basePrice: 49, + pricingModel: 'flat', + tiers: [], + features: [{ key: 'seats', label: 'Seats', includedUnits: 10 }], + tags: ['team'], + ...patch, +}); + +const tieredDraft = (patch: Partial = {}): PlanTemplateDraft => + flatDraft({ + name: 'Usage API', + pricingModel: 'tiered', + basePrice: 1, + tiers: [ + { upToUnits: 1_000, unitPrice: 0 }, + { upToUnits: 10_000, unitPrice: 0.01 }, + { upToUnits: null, unitPrice: 0.005 }, + ], + ...patch, + }); + +let service: PlanTemplateService; +let clock: Date; + +beforeEach(() => { + clock = new Date('2026-01-01T00:00:00.000Z'); + service = new PlanTemplateService(new InMemoryPlanTemplateRepository(), () => clock); +}); + +describe('validation', () => { + it('accepts a well-formed flat draft', () => { + expect(validateTemplateDraft(flatDraft()).valid).toBe(true); + }); + + it('requires a name, a positive price and a currency', () => { + const result = validateTemplateDraft(flatDraft({ name: ' ', basePrice: 0, currency: '' })); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([ + 'Template name is required.', + 'Base price must be positive.', + 'Currency is required.', + ]) + ); + }); + + it('rejects duplicate feature keys', () => { + const result = validateTemplateDraft( + flatDraft({ + features: [ + { key: 'seats', label: 'Seats', includedUnits: 1 }, + { key: 'seats', label: 'More seats', includedUnits: 5 }, + ], + }) + ); + expect(result.valid).toBe(false); + expect(result.errors).toContain('Duplicate feature key "seats".'); + }); + + it('warns rather than fails when a flat template carries unused tiers', () => { + const result = validateTemplateDraft( + flatDraft({ tiers: [{ upToUnits: null, unitPrice: 1 }] }) + ); + expect(result.valid).toBe(true); + expect(result.warnings).toContain('Pricing tiers are ignored by a flat-priced template.'); + }); + + it('requires an ascending, terminally-unbounded ladder', () => { + expect(validateTiers([])).toHaveLength(1); + expect( + validateTiers([ + { upToUnits: 10_000, unitPrice: 1 }, + { upToUnits: 1_000, unitPrice: 1 }, + ]) + ).toContain("Tier 2 must exceed the previous tier's upper bound."); + expect( + validateTiers([ + { upToUnits: null, unitPrice: 1 }, + { upToUnits: 50, unitPrice: 1 }, + ]) + ).toContain('Only the last tier may be unbounded.'); + expect(validateTiers([{ upToUnits: null, unitPrice: -1 }])).toContain( + 'Tier 1 has a negative unit price.' + ); + }); +}); + +describe('library', () => { + it('publishes a first version owned by its author', async () => { + const template = await service.createTemplate(OWNER, flatDraft()); + + expect(template.version).toBe(1); + expect(template.rootId).toBe(template.id); + expect(template.ownerId).toBe(OWNER); + expect(template.shared).toBe(false); + expect(template.active).toBe(true); + }); + + it('refuses to publish an invalid draft', async () => { + await expect(service.createTemplate(OWNER, flatDraft({ basePrice: -1 }))).rejects.toBeInstanceOf( + BillingError + ); + }); + + it('filters the library by pricing model, tag and search text', async () => { + await service.createTemplate(OWNER, flatDraft()); + await service.createTemplate(OWNER, tieredDraft({ tags: ['api'] })); + + expect(await service.listTemplates({ pricingModel: 'tiered' })).toHaveLength(1); + expect(await service.listTemplates({ tags: ['api'] })).toHaveLength(1); + expect(await service.listTemplates({ search: 'collaboration' })).toHaveLength(2); + expect(await service.listTemplates({ search: 'nothing here' })).toHaveLength(0); + }); + + it('hides superseded versions from the library by default', async () => { + const first = await service.createTemplate(OWNER, flatDraft()); + await service.publishVersion(OWNER, first.id, flatDraft({ basePrice: 59 })); + + expect(await service.listTemplates()).toHaveLength(1); + expect(await service.listTemplates({ includeInactive: true })).toHaveLength(2); + }); +}); + +describe('pricing tiers', () => { + let tiered: PlanTemplate; + + beforeEach(async () => { + tiered = await service.createTemplate(OWNER, tieredDraft()); + }); + + it('prices units across the ladder', async () => { + expect((await service.quote(tiered.id, 500)).total).toBe(0); + // 1,000 free, then 1,000 at 0.01. + expect((await service.quote(tiered.id, 2_000)).total).toBeCloseTo(10); + // 1,000 free, 9,000 at 0.01, 5,000 at 0.005. + expect((await service.quote(tiered.id, 15_000)).total).toBeCloseTo(90 + 25); + }); + + it('reports the per-tier breakdown and effective unit price', async () => { + const quote = await service.quote(tiered.id, 2_000); + expect(quote.lines).toHaveLength(2); + expect(quote.lines[0].unitsInTier).toBe(1_000); + expect(quote.lines[1].amount).toBeCloseTo(10); + expect(quote.effectiveUnitPrice).toBeCloseTo(0.005); + }); + + it('quotes a flat template at its base price regardless of units', async () => { + const flat = await service.createTemplate(OWNER, flatDraft()); + expect(quoteTemplate(flat, 0).total).toBe(49); + expect(quoteTemplate(flat, 100_000).total).toBe(49); + }); + + it('treats negative units as zero', async () => { + expect((await service.quote(tiered.id, -5)).units).toBe(0); + }); +}); + +describe('customization', () => { + it('applies overrides without mutating the template', async () => { + const template = await service.createTemplate(OWNER, flatDraft()); + + const resolved = await service.instantiate(OWNER, template.id, { + name: 'Team (EU)', + price: 59, + currency: 'EUR', + }); + + expect(resolved).toMatchObject({ name: 'Team (EU)', price: 59, currency: 'EUR' }); + expect(await service.getTemplate(template.id)).toMatchObject({ + name: 'Team', + basePrice: 49, + currency: 'USD', + }); + }); + + it('drops features the caller removed', () => { + const template = { + ...flatDraft({ + features: [ + { key: 'seats', label: 'Seats', includedUnits: 10 }, + { key: 'sso', label: 'SSO', includedUnits: null }, + ], + }), + id: 't1', + rootId: 't1', + version: 1, + ownerId: OWNER, + shared: false, + active: true, + createdAt: '', + updatedAt: '', + } as PlanTemplate; + + const resolved = resolvePlan(template, { removeFeatureKeys: ['sso'] }); + expect(resolved.features.map((f) => f.key)).toEqual(['seats']); + }); + + it('refuses an override that zeroes the price', async () => { + const template = await service.createTemplate(OWNER, flatDraft()); + await expect(service.instantiate(OWNER, template.id, { price: 0 })).rejects.toThrow( + /price must be positive/i + ); + }); +}); + +describe('versioning', () => { + it('chains versions to the same root and retires the previous one', async () => { + const first = await service.createTemplate(OWNER, flatDraft()); + const second = await service.publishVersion(OWNER, first.id, flatDraft({ basePrice: 59 })); + + expect(second.version).toBe(2); + expect(second.rootId).toBe(first.rootId); + expect((await service.getTemplate(first.id))!.active).toBe(false); + + const versions = await service.listVersions(first.rootId); + expect(versions.map((v) => v.version)).toEqual([1, 2]); + expect((await service.getLatestVersion(first.rootId))!.id).toBe(second.id); + }); + + it('leaves a superseded version readable but not instantiable', async () => { + const first = await service.createTemplate(OWNER, flatDraft()); + await service.publishVersion(OWNER, first.id, flatDraft({ basePrice: 59 })); + + expect(await service.getTemplate(first.id)).not.toBeNull(); + await expect(service.instantiate(OWNER, first.id)).rejects.toThrow(/superseded/); + }); + + it('gives each version its own analytics', async () => { + const first = await service.createTemplate(OWNER, flatDraft()); + await service.instantiate(OWNER, first.id); + const second = await service.publishVersion(OWNER, first.id, flatDraft({ basePrice: 59 })); + + expect((await service.getAnalytics(first.id)).plansCreated).toBe(1); + expect((await service.getAnalytics(second.id)).plansCreated).toBe(0); + }); + + it('only lets the owner publish a new version', async () => { + const first = await service.createTemplate(OWNER, flatDraft()); + await expect(service.publishVersion(OTHER, first.id, flatDraft())).rejects.toThrow( + /Only the owner/ + ); + }); +}); + +describe('sharing', () => { + it('lets another merchant instantiate a shared template but not edit it', async () => { + const template = await service.createTemplate(OWNER, flatDraft()); + await expect(service.instantiate(OTHER, template.id)).rejects.toThrow(/not shared/); + + await service.setShared(OWNER, template.id, true); + await expect(service.instantiate(OTHER, template.id)).resolves.toMatchObject({ price: 49 }); + await expect(service.publishVersion(OTHER, template.id, flatDraft())).rejects.toThrow( + /Only the owner/ + ); + }); + + it('carries sharing across versions and withdraws the superseded one', async () => { + const first = await service.createTemplate(OWNER, flatDraft()); + await service.setShared(OWNER, first.id, true); + const second = await service.publishVersion(OWNER, first.id, flatDraft({ basePrice: 59 })); + + expect(second.shared).toBe(true); + expect((await service.getTemplate(first.id))!.shared).toBe(false); + }); + + it('refuses to share a superseded version', async () => { + const first = await service.createTemplate(OWNER, flatDraft()); + await service.publishVersion(OWNER, first.id, flatDraft({ basePrice: 59 })); + + await expect(service.setShared(OWNER, first.id, true)).rejects.toThrow(/superseded/); + }); + + it('lists a caller their own templates plus everything shared', async () => { + const mine = await service.createTemplate(OWNER, flatDraft()); + const theirs = await service.createTemplate(OTHER, flatDraft({ name: 'Their plan' })); + await service.setShared(OTHER, theirs.id, true); + + const available = await service.listAvailableTemplates(OWNER); + expect(available.map((t) => t.id).sort()).toEqual([mine.id, theirs.id].sort()); + }); + + it('agrees with the standalone access check', async () => { + const template = await service.createTemplate(OWNER, flatDraft()); + expect(canInstantiate(template, OWNER)).toBe(true); + expect(canInstantiate(template, OTHER)).toBe(false); + expect(canInstantiate({ ...template, shared: true }, OTHER)).toBe(true); + expect(canInstantiate({ ...template, active: false }, OWNER)).toBe(false); + }); +}); + +describe('analytics', () => { + it('starts empty', async () => { + const template = await service.createTemplate(OWNER, flatDraft()); + expect(await service.getAnalytics(template.id)).toMatchObject({ + views: 0, + plansCreated: 0, + subscriptionsStarted: 0, + adoptionRate: 0, + conversionRate: 0, + }); + }); + + it('tracks adoption from views to plans', async () => { + const template = await service.createTemplate(OWNER, flatDraft()); + await service.recordView(template.id); + await service.recordView(template.id); + await service.recordView(template.id); + await service.recordView(template.id); + await service.instantiate(OWNER, template.id); + + expect(await service.getAnalytics(template.id)).toMatchObject({ + views: 4, + plansCreated: 1, + adoptionRate: 0.25, + }); + }); + + it('tracks conversion and revenue per subscription', async () => { + const template = await service.createTemplate(OWNER, flatDraft()); + await service.instantiate(OWNER, template.id); + await service.instantiate(OWNER, template.id); + await service.recordSubscription(template.id, 49); + + const analytics = await service.getAnalytics(template.id); + expect(analytics.conversionRate).toBe(0.5); + expect(analytics.revenue).toBe(49); + expect(analytics.averageRevenuePerSubscription).toBe(49); + expect(analytics.lastUsedAt).toBe(clock.toISOString()); + }); + + it('caps a rate at 100% when conversions outrun plans', async () => { + const template = await service.createTemplate(OWNER, flatDraft()); + await service.instantiate(OWNER, template.id); + await service.recordSubscription(template.id); + await service.recordSubscription(template.id); + + expect((await service.getAnalytics(template.id)).conversionRate).toBe(1); + }); + + it('rolls the library up and ranks templates by subscriptions', async () => { + const popular = await service.createTemplate(OWNER, flatDraft({ name: 'Popular' })); + const quiet = await service.createTemplate(OWNER, flatDraft({ name: 'Quiet' })); + await service.setShared(OWNER, popular.id, true); + + await service.recordView(popular.id); + await service.instantiate(OWNER, popular.id); + await service.recordSubscription(popular.id, 100); + await service.recordSubscription(popular.id, 100); + await service.instantiate(OWNER, quiet.id); + + const library = await service.getLibraryAnalytics({ ownerId: OWNER }); + expect(library).toMatchObject({ + templates: 2, + sharedTemplates: 1, + totalPlansCreated: 2, + totalSubscriptionsStarted: 2, + totalRevenue: 200, + }); + expect(library.topTemplateIds).toEqual([popular.id]); + }); +}); diff --git a/backend/services/billing/index.ts b/backend/services/billing/index.ts index 45f6e6e5..e7fbcecd 100644 --- a/backend/services/billing/index.ts +++ b/backend/services/billing/index.ts @@ -49,7 +49,35 @@ export { BackendPartnerService, } from './partnerService'; export type { SplitConfiguration, PartnerPayoutSchedule } from '../../../src/types/partner'; +export { + PlanTemplateService, + InMemoryPlanTemplateRepository, + validateTemplateDraft, + validateTiers, + resolvePlan, + quoteTemplate, + canInstantiate, + emptyTemplateAnalytics, + MAX_TIERS, + MAX_FEATURES, +} from './planTemplateService'; +export type { PlanTemplateRepository } from './planTemplateService'; +export type { + PlanTemplate, + PlanTemplateDraft, + TemplateFeature, + TemplateOverrides, + TemplateFilter, + TemplateQuote, + TemplateQuoteLine, + TemplateAnalytics, + TemplateLibraryAnalytics, + TemplateValidationResult, + TemplatePricingModel, + ResolvedPlan, +} from '../../../src/types/planTemplate'; export type { + IPlanTemplateService, IMeteringService, IPricingService, ITaxService, diff --git a/backend/services/billing/interfaces.ts b/backend/services/billing/interfaces.ts index b745d762..db1bf8ef 100644 --- a/backend/services/billing/interfaces.ts +++ b/backend/services/billing/interfaces.ts @@ -1,6 +1,16 @@ import { UsageMetric, UsageIngestResult } from './meteringService'; import { AggregationFunction, AggregationWindow, UsageThresholdAlert } from '../../../src/types/usage'; import { PriceRecommendation, ABTestScenario, PricingContext } from './pricingService'; +import type { + PlanTemplate, + PlanTemplateDraft, + ResolvedPlan, + TemplateAnalytics, + TemplateFilter, + TemplateLibraryAnalytics, + TemplateOverrides, + TemplateQuote, +} from '../../../src/types/planTemplate'; import { TaxCalculationResult, TaxInvoiceContext, @@ -191,3 +201,29 @@ export interface ILoyaltyService { createApiResponse(data: T): any; createErrorResponse(error: string): any; } + +export interface IPlanTemplateService { + createTemplate(ownerId: string, draft: PlanTemplateDraft): Promise; + getTemplate(id: string): Promise; + listTemplates(filter?: TemplateFilter): Promise; + listAvailableTemplates(callerId: string): Promise; + publishVersion( + ownerId: string, + templateId: string, + draft: PlanTemplateDraft + ): Promise; + listVersions(rootId: string): Promise; + getLatestVersion(rootId: string): Promise; + setShared(ownerId: string, templateId: string, shared: boolean): Promise; + instantiate( + callerId: string, + templateId: string, + overrides?: TemplateOverrides + ): Promise; + quote(templateId: string, units: number): Promise; + getAnalytics(templateId: string): Promise; + recordView(templateId: string): Promise; + recordPlanCreated(templateId: string): Promise; + recordSubscription(templateId: string, revenue?: number): Promise; + getLibraryAnalytics(filter?: TemplateFilter): Promise; +} diff --git a/backend/services/billing/planTemplateService.ts b/backend/services/billing/planTemplateService.ts new file mode 100644 index 00000000..3319426a --- /dev/null +++ b/backend/services/billing/planTemplateService.ts @@ -0,0 +1,545 @@ +/** + * Plan template library. + * + * Merchants build plans from reusable blueprints instead of from scratch. A + * template carries a base price, an optional graduated pricing ladder, and a + * feature list. Instantiating a template resolves it into concrete plan + * parameters, optionally customized, without ever mutating the template. + * + * Templates are immutable once published: an edit publishes a new version + * chained to the same root, so plans already created from an earlier version + * stay explainable. Sharing publishes a template to a library other merchants + * may instantiate but never edit. + * + * Mirrors the `plan_templates` module of the subscription contract. + */ + +import { BillingError, BillingErrorCode } from './errors'; +import { TieredPricingCalculator } from './tieredPricingCalculator'; +import type { + PlanTemplate, + PlanTemplateDraft, + ResolvedPlan, + TemplateAnalytics, + TemplateFeature, + TemplateFilter, + TemplateLibraryAnalytics, + TemplateOverrides, + TemplateQuote, + TemplateValidationResult, +} from '../../../src/types/planTemplate'; +import type { PricingTier } from '../../../src/types/usage'; + +/** Ceiling on tiers in one template, bounding the cost of a price quote. */ +export const MAX_TIERS = 12; + +/** Ceiling on features listed by one template. */ +export const MAX_FEATURES = 32; + +/** Storage abstraction — implemented by the persistence layer. */ +export interface PlanTemplateRepository { + save(template: PlanTemplate): Promise; + get(id: string): Promise; + list(): Promise; + saveAnalytics(analytics: TemplateAnalytics): Promise; + getAnalytics(templateId: string): Promise; +} + +/** In-memory repository, used by tests and the local development server. */ +export class InMemoryPlanTemplateRepository implements PlanTemplateRepository { + private templates = new Map(); + private analytics = new Map(); + + async save(template: PlanTemplate): Promise { + this.templates.set(template.id, { ...template }); + } + + async get(id: string): Promise { + const found = this.templates.get(id); + return found ? { ...found } : null; + } + + async list(): Promise { + return [...this.templates.values()].map((t) => ({ ...t })); + } + + async saveAnalytics(analytics: TemplateAnalytics): Promise { + this.analytics.set(analytics.templateId, { ...analytics }); + } + + async getAnalytics(templateId: string): Promise { + const found = this.analytics.get(templateId); + return found ? { ...found } : null; + } +} + +export function emptyTemplateAnalytics(templateId: string): TemplateAnalytics { + return { + templateId, + views: 0, + plansCreated: 0, + subscriptionsStarted: 0, + revenue: 0, + adoptionRate: 0, + conversionRate: 0, + averageRevenuePerSubscription: 0, + lastUsedAt: null, + }; +} + +const ratio = (numerator: number, denominator: number): number => + denominator === 0 ? 0 : Math.min(1, numerator / denominator); + +function withDerivedRates(analytics: TemplateAnalytics): TemplateAnalytics { + return { + ...analytics, + adoptionRate: ratio(analytics.plansCreated, analytics.views), + conversionRate: ratio(analytics.subscriptionsStarted, analytics.plansCreated), + averageRevenuePerSubscription: + analytics.subscriptionsStarted === 0 + ? 0 + : analytics.revenue / analytics.subscriptionsStarted, + }; +} + +/** + * A ladder is valid when it is non-empty within `MAX_TIERS`, strictly + * ascending, priced non-negatively, and unbounded only on its last rung. + */ +export function validateTiers(tiers: PricingTier[]): string[] { + const errors: string[] = []; + if (tiers.length === 0) { + errors.push('A tiered template needs at least one pricing tier.'); + return errors; + } + if (tiers.length > MAX_TIERS) { + errors.push(`A template supports at most ${MAX_TIERS} pricing tiers.`); + } + + let previous = 0; + tiers.forEach((tier, index) => { + if (tier.unitPrice < 0) { + errors.push(`Tier ${index + 1} has a negative unit price.`); + } + if (tier.upToUnits === null) { + if (index !== tiers.length - 1) { + errors.push('Only the last tier may be unbounded.'); + } + return; + } + if (tier.upToUnits <= previous) { + errors.push(`Tier ${index + 1} must exceed the previous tier's upper bound.`); + } + previous = tier.upToUnits; + }); + + return errors; +} + +/** Validate a draft before it is published. */ +export function validateTemplateDraft(draft: PlanTemplateDraft): TemplateValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + if (!draft.name?.trim()) errors.push('Template name is required.'); + if (draft.basePrice <= 0) errors.push('Base price must be positive.'); + if (!draft.currency?.trim()) errors.push('Currency is required.'); + if (draft.features.length > MAX_FEATURES) { + errors.push(`A template supports at most ${MAX_FEATURES} features.`); + } + + const featureKeys = new Set(); + for (const feature of draft.features) { + if (featureKeys.has(feature.key)) { + errors.push(`Duplicate feature key "${feature.key}".`); + } + featureKeys.add(feature.key); + } + + if (draft.pricingModel === 'tiered') { + errors.push(...validateTiers(draft.tiers)); + } else if (draft.tiers.length > 0) { + warnings.push('Pricing tiers are ignored by a flat-priced template.'); + } + + if (!draft.description?.trim()) { + warnings.push('A description helps merchants pick the right template.'); + } + if (draft.features.length === 0) { + warnings.push('Templates without features are hard to compare.'); + } + + return { valid: errors.length === 0, errors, warnings }; +} + +/** Resolve a template into concrete plan parameters, applying overrides. */ +export function resolvePlan( + template: PlanTemplate, + overrides: TemplateOverrides = {} +): ResolvedPlan { + const removed = new Set(overrides.removeFeatureKeys ?? []); + const features: TemplateFeature[] = template.features.filter((f) => !removed.has(f.key)); + + return { + templateId: template.id, + templateVersion: template.version, + name: overrides.name ?? template.name, + description: template.description, + price: overrides.price ?? template.basePrice, + currency: overrides.currency ?? template.currency, + billingCycle: overrides.billingCycle ?? template.billingCycle, + category: overrides.category ?? template.category, + features, + }; +} + +/** Price a template for `units` of usage, with the per-tier breakdown. */ +export function quoteTemplate(template: PlanTemplate, units: number): TemplateQuote { + const safeUnits = Math.max(0, units); + + if (template.pricingModel === 'flat' || template.tiers.length === 0) { + return { + templateId: template.id, + units: safeUnits, + total: template.basePrice, + lines: [], + effectiveUnitPrice: safeUnits === 0 ? 0 : template.basePrice / safeUnits, + }; + } + + const { totalAmount, lines } = new TieredPricingCalculator(template.tiers).calculate(safeUnits); + return { + templateId: template.id, + units: safeUnits, + total: totalAmount, + lines, + effectiveUnitPrice: safeUnits === 0 ? 0 : totalAmount / safeUnits, + }; +} + +/** A template may be instantiated by its owner, or by anyone once shared. */ +export function canInstantiate(template: PlanTemplate, callerId: string): boolean { + return template.active && (template.shared || template.ownerId === callerId); +} + +function matchesFilter(template: PlanTemplate, filter: TemplateFilter): boolean { + if (!filter.includeInactive && !template.active) return false; + if (filter.sharedOnly && !template.shared) return false; + if (filter.ownerId && template.ownerId !== filter.ownerId) return false; + if (filter.category && template.category !== filter.category) return false; + if (filter.billingCycle && template.billingCycle !== filter.billingCycle) return false; + if (filter.pricingModel && template.pricingModel !== filter.pricingModel) return false; + if (filter.tags?.length && !filter.tags.every((tag) => template.tags.includes(tag))) { + return false; + } + if (filter.search) { + const needle = filter.search.toLowerCase(); + const haystack = `${template.name} ${template.description} ${template.tags.join(' ')}`; + if (!haystack.toLowerCase().includes(needle)) return false; + } + return true; +} + +export class PlanTemplateService { + private sequence = 0; + + constructor( + private readonly repo: PlanTemplateRepository, + private readonly now: () => Date = () => new Date() + ) {} + + private nextId(): string { + this.sequence += 1; + return `tpl_${this.sequence.toString(36)}_${Math.random().toString(36).slice(2, 8)}`; + } + + // ── Library ──────────────────────────────────────────────────────── + + /** Publish the first version of a template. */ + async createTemplate(ownerId: string, draft: PlanTemplateDraft): Promise { + const validation = validateTemplateDraft(draft); + if (!validation.valid) { + throw new BillingError( + BillingErrorCode.INVALID_PLAN, + `Invalid plan template: ${validation.errors.join('; ')}`, + { ownerId } + ); + } + + const id = this.nextId(); + const timestamp = this.now().toISOString(); + const template: PlanTemplate = { + ...draft, + id, + rootId: id, + version: 1, + ownerId, + shared: false, + active: true, + createdAt: timestamp, + updatedAt: timestamp, + }; + + await this.repo.save(template); + await this.repo.saveAnalytics(emptyTemplateAnalytics(id)); + return template; + } + + async getTemplate(id: string): Promise { + return this.repo.get(id); + } + + /** Browse the library. Superseded versions are hidden unless asked for. */ + async listTemplates(filter: TemplateFilter = {}): Promise { + const templates = await this.repo.list(); + return templates + .filter((template) => matchesFilter(template, filter)) + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + } + + /** + * Templates a caller may instantiate: their own, plus everything shared. + */ + async listAvailableTemplates(callerId: string): Promise { + const templates = await this.repo.list(); + return templates + .filter((template) => canInstantiate(template, callerId)) + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + } + + // ── Versioning ───────────────────────────────────────────────────── + + /** + * Publish a new version, superseding `templateId`. + * + * The previous version is deactivated but kept readable, and leaves the + * shared library so it can no longer be browsed or instantiated. Analytics + * start fresh, since they measure a single version's performance. + */ + async publishVersion( + ownerId: string, + templateId: string, + draft: PlanTemplateDraft + ): Promise { + const previous = await this.requireTemplate(templateId); + this.requireOwner(previous, ownerId, 'publish a new version of'); + + const validation = validateTemplateDraft(draft); + if (!validation.valid) { + throw new BillingError( + BillingErrorCode.INVALID_PLAN, + `Invalid plan template: ${validation.errors.join('; ')}`, + { templateId } + ); + } + + const wasShared = previous.shared; + const timestamp = this.now().toISOString(); + const id = this.nextId(); + + const next: PlanTemplate = { + ...draft, + id, + rootId: previous.rootId, + version: previous.version + 1, + ownerId: previous.ownerId, + // Sharing carries across versions, so a shared template stays in the + // library at its newest version. + shared: wasShared, + active: true, + createdAt: timestamp, + updatedAt: timestamp, + }; + + await this.repo.save({ ...previous, active: false, shared: false, updatedAt: timestamp }); + await this.repo.save(next); + await this.repo.saveAnalytics(emptyTemplateAnalytics(id)); + return next; + } + + /** Every version of a template chain, oldest first. */ + async listVersions(rootId: string): Promise { + const templates = await this.repo.list(); + return templates + .filter((template) => template.rootId === rootId) + .sort((a, b) => a.version - b.version); + } + + async getLatestVersion(rootId: string): Promise { + const versions = await this.listVersions(rootId); + return versions.length === 0 ? null : versions[versions.length - 1]; + } + + // ── Sharing ──────────────────────────────────────────────────────── + + /** Publish a template to, or withdraw it from, the shared library. */ + async setShared(ownerId: string, templateId: string, shared: boolean): Promise { + const template = await this.requireTemplate(templateId); + this.requireOwner(template, ownerId, 'share'); + + if (!template.active && shared) { + throw new BillingError( + BillingErrorCode.INVALID_PLAN, + `Template ${templateId} is superseded and cannot be shared.`, + { templateId } + ); + } + + const updated = { ...template, shared, updatedAt: this.now().toISOString() }; + await this.repo.save(updated); + return updated; + } + + // ── Instantiation ────────────────────────────────────────────────── + + /** + * Resolve a template into plan parameters and record the usage. + * + * The template itself is never mutated by an instantiation. + */ + async instantiate( + callerId: string, + templateId: string, + overrides: TemplateOverrides = {} + ): Promise { + const template = await this.requireTemplate(templateId); + + if (!canInstantiate(template, callerId)) { + throw new BillingError( + BillingErrorCode.INVALID_PLAN, + template.active + ? `Template ${templateId} is not shared with ${callerId}.` + : `Template ${templateId} is superseded; use its latest version.`, + { templateId, callerId } + ); + } + + const resolved = resolvePlan(template, overrides); + if (resolved.price <= 0) { + throw new BillingError( + BillingErrorCode.INVALID_PLAN, + 'Resolved plan price must be positive.', + { templateId } + ); + } + + await this.recordPlanCreated(templateId); + return resolved; + } + + /** Price a template for `units` of usage. */ + async quote(templateId: string, units: number): Promise { + const template = await this.requireTemplate(templateId); + return quoteTemplate(template, units); + } + + // ── Analytics ────────────────────────────────────────────────────── + + async getAnalytics(templateId: string): Promise { + const stored = await this.repo.getAnalytics(templateId); + return stored ?? emptyTemplateAnalytics(templateId); + } + + /** Record that the template was previewed in the library. */ + async recordView(templateId: string): Promise { + return this.mutateAnalytics(templateId, (analytics) => ({ + ...analytics, + views: analytics.views + 1, + })); + } + + /** Record that a plan was instantiated from the template. */ + async recordPlanCreated(templateId: string): Promise { + return this.mutateAnalytics(templateId, (analytics) => ({ + ...analytics, + plansCreated: analytics.plansCreated + 1, + lastUsedAt: this.now().toISOString(), + })); + } + + /** Record that a plan created from the template gained a subscriber. */ + async recordSubscription(templateId: string, revenue = 0): Promise { + return this.mutateAnalytics(templateId, (analytics) => ({ + ...analytics, + subscriptionsStarted: analytics.subscriptionsStarted + 1, + revenue: analytics.revenue + Math.max(0, revenue), + lastUsedAt: this.now().toISOString(), + })); + } + + /** Roll up analytics across a filtered slice of the library. */ + async getLibraryAnalytics(filter: TemplateFilter = {}): Promise { + const templates = await this.listTemplates({ includeInactive: true, ...filter }); + const perTemplate = await Promise.all( + templates.map(async (template) => ({ + template, + analytics: await this.getAnalytics(template.id), + })) + ); + + const roll = perTemplate.reduce( + (acc, { template, analytics }) => { + acc.templates += 1; + if (template.shared) acc.sharedTemplates += 1; + acc.totalViews += analytics.views; + acc.totalPlansCreated += analytics.plansCreated; + acc.totalSubscriptionsStarted += analytics.subscriptionsStarted; + acc.totalRevenue += analytics.revenue; + return acc; + }, + { + templates: 0, + sharedTemplates: 0, + totalViews: 0, + totalPlansCreated: 0, + totalSubscriptionsStarted: 0, + totalRevenue: 0, + } + ); + + const topTemplateIds = perTemplate + .filter(({ analytics }) => analytics.subscriptionsStarted > 0) + .sort((a, b) => b.analytics.subscriptionsStarted - a.analytics.subscriptionsStarted) + .map(({ template }) => template.id); + + return { + ...roll, + adoptionRate: ratio(roll.totalPlansCreated, roll.totalViews), + conversionRate: ratio(roll.totalSubscriptionsStarted, roll.totalPlansCreated), + topTemplateIds, + }; + } + + // ── Internals ────────────────────────────────────────────────────── + + private async mutateAnalytics( + templateId: string, + patch: (analytics: TemplateAnalytics) => TemplateAnalytics + ): Promise { + const next = withDerivedRates(patch(await this.getAnalytics(templateId))); + await this.repo.saveAnalytics(next); + return next; + } + + private async requireTemplate(templateId: string): Promise { + const template = await this.repo.get(templateId); + if (!template) { + throw new BillingError( + BillingErrorCode.INVALID_PLAN, + `Plan template ${templateId} not found.`, + { templateId } + ); + } + return template; + } + + private requireOwner(template: PlanTemplate, callerId: string, action: string): void { + if (template.ownerId !== callerId) { + throw new BillingError( + BillingErrorCode.INVALID_PLAN, + `Only the owner of template ${template.id} may ${action} it.`, + { templateId: template.id, callerId } + ); + } + } +} diff --git a/contracts/subscription/src/lib.rs b/contracts/subscription/src/lib.rs index a193a56c..1456a7df 100644 --- a/contracts/subscription/src/lib.rs +++ b/contracts/subscription/src/lib.rs @@ -4,6 +4,7 @@ mod gas_optimization; mod gas_profiler; mod gas_storage; mod invoice_branding; +mod plan_templates; mod revenue; #[cfg(test)] mod test; @@ -1702,6 +1703,197 @@ impl SubTrackrSubscription { revenue::get_revenue_schedule(&env, &storage, subscription_id) } + // ── Plan Template API ── + + /// Register the first version of a reusable plan template. + pub fn create_plan_template( + env: Env, + proxy: Address, + storage: Address, + owner: Address, + name: String, + description: String, + base_price: i128, + token: Address, + interval: Interval, + tiers: Vec, + features: Vec, + ) -> u64 { + proxy.require_auth(); + owner.require_auth(); + plan_templates::create_template( + &env, + &storage, + &owner, + name, + description, + base_price, + token, + interval, + tiers, + features, + ) + } + + /// Publish a new version of a template, superseding the one given. + /// + /// Plans already created from the previous version are unaffected. + pub fn publish_plan_template_version( + env: Env, + proxy: Address, + storage: Address, + owner: Address, + template_id: u64, + name: String, + description: String, + base_price: i128, + tiers: Vec, + features: Vec, + ) -> u64 { + proxy.require_auth(); + owner.require_auth(); + plan_templates::publish_version( + &env, + &storage, + &owner, + template_id, + name, + description, + base_price, + tiers, + features, + ) + } + + /// Publish a template to, or withdraw it from, the shared library. + pub fn share_plan_template( + env: Env, + proxy: Address, + storage: Address, + owner: Address, + template_id: u64, + shared: bool, + ) { + proxy.require_auth(); + owner.require_auth(); + plan_templates::set_shared(&env, &storage, &owner, template_id, shared); + } + + pub fn get_plan_template( + env: Env, + proxy: Address, + storage: Address, + template_id: u64, + ) -> Option { + proxy.require_auth(); + plan_templates::get_template(&env, &storage, template_id) + } + + pub fn list_owner_plan_templates( + env: Env, + proxy: Address, + storage: Address, + owner: Address, + ) -> Vec { + proxy.require_auth(); + plan_templates::get_owner_templates(&env, &storage, &owner) + } + + pub fn list_shared_plan_templates(env: Env, proxy: Address, storage: Address) -> Vec { + proxy.require_auth(); + plan_templates::get_shared_templates(&env, &storage) + } + + /// Every version id of a template chain, oldest first. + pub fn list_plan_template_versions( + env: Env, + proxy: Address, + storage: Address, + root_id: u64, + ) -> Vec { + proxy.require_auth(); + plan_templates::get_template_versions(&env, &storage, root_id) + } + + pub fn get_latest_plan_template( + env: Env, + proxy: Address, + storage: Address, + root_id: u64, + ) -> Option { + proxy.require_auth(); + plan_templates::get_latest_version(&env, &storage, root_id) + } + + /// Price a template for `units` of usage: its pricing ladder when it has + /// one, otherwise its flat base price. + pub fn quote_plan_template( + env: Env, + proxy: Address, + storage: Address, + template_id: u64, + units: u64, + ) -> i128 { + proxy.require_auth(); + let template = + plan_templates::get_template(&env, &storage, template_id).expect("Template not found"); + plan_templates::quote_template(&template, units) + } + + /// Create a plan from a template, applying any per-instantiation overrides. + pub fn create_plan_from_template( + env: Env, + proxy: Address, + storage: Address, + merchant: Address, + template_id: u64, + overrides: plan_templates::TemplateOverrides, + ) -> u64 { + proxy.require_auth(); + merchant.require_auth(); + + let resolved = + plan_templates::instantiate(&env, &storage, &merchant, template_id, overrides); + + Self::create_plan( + env, + proxy, + storage, + merchant, + resolved.name, + resolved.price, + resolved.token, + resolved.interval, + ) + } + + /// Record that a template was previewed in the library. + pub fn record_plan_template_view(env: Env, proxy: Address, storage: Address, template_id: u64) { + proxy.require_auth(); + plan_templates::record_view(&env, &storage, template_id); + } + + /// Record that a plan created from a template gained a subscriber. + pub fn record_plan_template_subscription( + env: Env, + proxy: Address, + storage: Address, + template_id: u64, + ) { + proxy.require_auth(); + plan_templates::record_subscription(&env, &storage, template_id); + } + + pub fn get_plan_template_analytics( + env: Env, + proxy: Address, + storage: Address, + template_id: u64, + ) -> plan_templates::TemplateAnalytics { + proxy.require_auth(); + plan_templates::get_analytics(&env, &storage, template_id) + } + // ── Quota & Usage API ── pub fn set_plan_quotas( diff --git a/contracts/subscription/src/plan_templates.rs b/contracts/subscription/src/plan_templates.rs new file mode 100644 index 00000000..3bc285a3 --- /dev/null +++ b/contracts/subscription/src/plan_templates.rs @@ -0,0 +1,651 @@ +#![allow(dead_code)] +//! Plan template library for SubTrackr merchants. +//! +//! A template is a reusable blueprint for a plan: a base price, a billing +//! interval, an optional graduated pricing ladder and a feature list. Merchants +//! instantiate a plan from a template — optionally overriding price, interval +//! or name — instead of rebuilding one from scratch every time. +//! +//! The module covers: +//! +//! * **Library** — templates are owned by a merchant and listed per owner. +//! * **Dynamic pricing tiers** — a graduated ladder priced per unit, so one +//! template can serve usage-based plans. +//! * **Customization** — per-instantiation overrides that never mutate the +//! template itself. +//! * **Versioning** — publishing a change creates a new version chained to the +//! same root, leaving already-instantiated plans on the version they used. +//! * **Sharing** — a template can be published to a shared library that other +//! merchants may instantiate but never edit. +//! * **Analytics** — views, plans created and subscriptions started, with +//! adoption and conversion rates in basis points. +//! +//! All storage is delegated to the shared storage contract via the +//! `storage_persistent_*` helpers defined in the parent module. + +use soroban_sdk::{contracttype, Address, Env, String, Vec}; +use subtrackr_types::{Interval, StorageKey, TemplateKey}; + +use crate::{storage_persistent_get, storage_persistent_set}; + +/// Denominator for the basis-point rates reported by [`TemplateAnalytics`]. +pub const BPS_DENOMINATOR: u32 = 10_000; + +/// Ceiling on tiers in one template, bounding the cost of a price quote. +pub const MAX_TIERS: u32 = 12; + +/// Ceiling on features listed by one template. +pub const MAX_FEATURES: u32 = 32; + +// ── Types ───────────────────────────────────────────────────────────────────── + +/// One rung of a graduated pricing ladder. +/// +/// `up_to_units` is the inclusive upper bound of the rung in units; `None` +/// means unbounded and is only valid on the last rung. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct PricingTier { + pub up_to_units: Option, + pub unit_price: i128, +} + +/// A reusable plan blueprint. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct PlanTemplate { + pub id: u64, + /// First version's id. Equal to `id` for a first version. + pub root_id: u64, + pub version: u32, + pub owner: Address, + pub name: String, + pub description: String, + /// Flat price per interval, used when `tiers` is empty. + pub base_price: i128, + pub token: Address, + pub interval: Interval, + /// Graduated ladder; empty for a flat-priced template. + pub tiers: Vec, + pub features: Vec, + /// Published to the shared library, so other merchants may instantiate it. + pub shared: bool, + /// A superseded version stays readable but cannot be instantiated. + pub active: bool, + pub created_at: u64, +} + +/// Per-instantiation overrides. Every field is optional; `None` keeps the +/// template's value. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct TemplateOverrides { + pub name: Option, + pub price: Option, + pub interval: Option, + pub token: Option
, +} + +/// The concrete plan parameters a template resolves to. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct ResolvedPlan { + pub template_id: u64, + pub name: String, + pub price: i128, + pub token: Address, + pub interval: Interval, +} + +/// Usage and conversion counters for one template. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct TemplateAnalytics { + pub template_id: u64, + /// Times the template was previewed in the library. + pub views: u32, + /// Plans instantiated from the template. + pub plans_created: u32, + /// Subscriptions started against those plans. + pub subscriptions_started: u32, + /// `plans_created / views` in basis points. + pub adoption_bps: u32, + /// `subscriptions_started / plans_created` in basis points. + pub conversion_bps: u32, + pub last_used_at: u64, +} + +impl TemplateAnalytics { + pub fn empty(template_id: u64) -> Self { + TemplateAnalytics { + template_id, + views: 0, + plans_created: 0, + subscriptions_started: 0, + adoption_bps: 0, + conversion_bps: 0, + last_used_at: 0, + } + } + + fn recompute_derived(&mut self) { + self.adoption_bps = ratio_bps(self.plans_created, self.views); + self.conversion_bps = ratio_bps(self.subscriptions_started, self.plans_created); + } +} + +/// `numerator / denominator` in basis points, saturating at 100% and returning +/// `0` for an empty denominator. +pub fn ratio_bps(numerator: u32, denominator: u32) -> u32 { + if denominator == 0 { + return 0; + } + let raw = (numerator as u64 * BPS_DENOMINATOR as u64) / denominator as u64; + if raw > BPS_DENOMINATOR as u64 { + BPS_DENOMINATOR + } else { + raw as u32 + } +} + +// ── Pure helpers ────────────────────────────────────────────────────────────── + +/// A ladder is valid when it is non-empty within [`MAX_TIERS`], strictly +/// ascending, priced non-negatively, and unbounded only on its last rung. +pub fn validate_tiers(tiers: &Vec) -> bool { + let len = tiers.len(); + if len == 0 || len > MAX_TIERS { + return false; + } + + let mut previous: u64 = 0; + for (i, tier) in tiers.iter().enumerate() { + if tier.unit_price < 0 { + return false; + } + match tier.up_to_units { + None => { + // Only the last rung may be unbounded. + if (i as u32) + 1 != len { + return false; + } + } + Some(bound) => { + if bound == 0 || bound <= previous { + return false; + } + previous = bound; + } + } + } + true +} + +/// A template is valid when its price is positive, its ladder (if any) is +/// valid, and its feature list stays within [`MAX_FEATURES`]. +pub fn validate_template( + base_price: i128, + tiers: &Vec, + features: &Vec, +) -> bool { + if base_price <= 0 { + return false; + } + if features.len() > MAX_FEATURES { + return false; + } + tiers.is_empty() || validate_tiers(tiers) +} + +/// Price `units` against a graduated ladder: each rung prices only the units +/// that fall inside it. +pub fn calculate_tiered_price(tiers: &Vec, units: u64) -> i128 { + let mut remaining = units; + let mut lower_bound: u64 = 0; + let mut total: i128 = 0; + + for tier in tiers.iter() { + if remaining == 0 { + break; + } + let capacity = match tier.up_to_units { + None => remaining, + Some(bound) => bound.saturating_sub(lower_bound), + }; + let units_in_tier = if remaining < capacity { + remaining + } else { + capacity + }; + total += units_in_tier as i128 * tier.unit_price; + remaining -= units_in_tier; + if let Some(bound) = tier.up_to_units { + lower_bound = bound; + } + } + + total +} + +/// Price a template for `units` of usage: the ladder when the template defines +/// one, otherwise the flat base price. +pub fn quote_template(template: &PlanTemplate, units: u64) -> i128 { + if template.tiers.is_empty() { + template.base_price + } else { + calculate_tiered_price(&template.tiers, units) + } +} + +/// Apply overrides to a template without mutating it. +pub fn resolve_plan(template: &PlanTemplate, overrides: &TemplateOverrides) -> ResolvedPlan { + ResolvedPlan { + template_id: template.id, + name: overrides.name.clone().unwrap_or(template.name.clone()), + price: overrides.price.unwrap_or(template.base_price), + token: overrides.token.clone().unwrap_or(template.token.clone()), + interval: overrides + .interval + .clone() + .unwrap_or(template.interval.clone()), + } +} + +/// A template may be instantiated by its owner, or by anyone once shared. +/// Superseded versions are never instantiable. +pub fn can_instantiate(template: &PlanTemplate, caller: &Address) -> bool { + template.active && (template.shared || &template.owner == caller) +} + +// ── Storage helpers ─────────────────────────────────────────────────────────── + +pub fn get_template(env: &Env, storage: &Address, template_id: u64) -> Option { + storage_persistent_get( + env, + storage, + StorageKey::PlanTemplate(TemplateKey::Template(template_id)), + ) +} + +fn put_template(env: &Env, storage: &Address, template: &PlanTemplate) { + storage_persistent_set( + env, + storage, + StorageKey::PlanTemplate(TemplateKey::Template(template.id)), + template.clone(), + ); +} + +pub fn get_owner_templates(env: &Env, storage: &Address, owner: &Address) -> Vec { + storage_persistent_get( + env, + storage, + StorageKey::PlanTemplate(TemplateKey::ByOwner(owner.clone())), + ) + .unwrap_or(Vec::new(env)) +} + +pub fn get_shared_templates(env: &Env, storage: &Address) -> Vec { + storage_persistent_get(env, storage, StorageKey::PlanTemplate(TemplateKey::Shared)) + .unwrap_or(Vec::new(env)) +} + +/// Every version id of a template chain, oldest first. +pub fn get_template_versions(env: &Env, storage: &Address, root_id: u64) -> Vec { + storage_persistent_get( + env, + storage, + StorageKey::PlanTemplate(TemplateKey::Versions(root_id)), + ) + .unwrap_or(Vec::new(env)) +} + +/// The newest version of a template chain. +pub fn get_latest_version(env: &Env, storage: &Address, root_id: u64) -> Option { + let versions = get_template_versions(env, storage, root_id); + versions + .last() + .and_then(|id| get_template(env, storage, id)) +} + +pub fn get_analytics(env: &Env, storage: &Address, template_id: u64) -> TemplateAnalytics { + storage_persistent_get( + env, + storage, + StorageKey::PlanTemplate(TemplateKey::Analytics(template_id)), + ) + .unwrap_or(TemplateAnalytics::empty(template_id)) +} + +fn put_analytics(env: &Env, storage: &Address, analytics: &TemplateAnalytics) { + storage_persistent_set( + env, + storage, + StorageKey::PlanTemplate(TemplateKey::Analytics(analytics.template_id)), + analytics.clone(), + ); +} + +fn next_template_id(env: &Env, storage: &Address) -> u64 { + let count: u64 = + storage_persistent_get(env, storage, StorageKey::PlanTemplate(TemplateKey::Count)) + .unwrap_or(0); + let next = count + 1; + storage_persistent_set( + env, + storage, + StorageKey::PlanTemplate(TemplateKey::Count), + next, + ); + next +} + +fn index_for_owner(env: &Env, storage: &Address, owner: &Address, template_id: u64) { + let mut owned = get_owner_templates(env, storage, owner); + owned.push_back(template_id); + storage_persistent_set( + env, + storage, + StorageKey::PlanTemplate(TemplateKey::ByOwner(owner.clone())), + owned, + ); +} + +fn append_version(env: &Env, storage: &Address, root_id: u64, template_id: u64) { + let mut versions = get_template_versions(env, storage, root_id); + versions.push_back(template_id); + storage_persistent_set( + env, + storage, + StorageKey::PlanTemplate(TemplateKey::Versions(root_id)), + versions, + ); +} + +// ── Operations ──────────────────────────────────────────────────────────────── + +/// Register the first version of a template. Panics on an invalid definition. +pub fn create_template( + env: &Env, + storage: &Address, + owner: &Address, + name: String, + description: String, + base_price: i128, + token: Address, + interval: Interval, + tiers: Vec, + features: Vec, +) -> u64 { + assert!( + validate_template(base_price, &tiers, &features), + "Invalid plan template" + ); + + let id = next_template_id(env, storage); + let template = PlanTemplate { + id, + root_id: id, + version: 1, + owner: owner.clone(), + name, + description, + base_price, + token, + interval, + tiers, + features, + shared: false, + active: true, + created_at: env.ledger().timestamp(), + }; + + put_template(env, storage, &template); + index_for_owner(env, storage, owner, id); + append_version(env, storage, id, id); + put_analytics(env, storage, &TemplateAnalytics::empty(id)); + + id +} + +/// Publish a new version of `template_id`, superseding it. +/// +/// The previous version is deactivated but kept readable, so plans already +/// instantiated from it stay explainable. Analytics start fresh for the new +/// version, since they measure that version's own performance. +pub fn publish_version( + env: &Env, + storage: &Address, + caller: &Address, + template_id: u64, + name: String, + description: String, + base_price: i128, + tiers: Vec, + features: Vec, +) -> u64 { + let mut previous = get_template(env, storage, template_id).expect("Template not found"); + assert!(&previous.owner == caller, "Only the owner may publish"); + assert!( + validate_template(base_price, &tiers, &features), + "Invalid plan template" + ); + + let was_shared = previous.shared; + let id = next_template_id(env, storage); + let template = PlanTemplate { + id, + root_id: previous.root_id, + version: previous.version + 1, + owner: previous.owner.clone(), + name, + description, + base_price, + token: previous.token.clone(), + interval: previous.interval.clone(), + tiers, + features, + // Sharing carries across versions, so a shared template stays in the + // library at its newest version. + shared: was_shared, + active: true, + created_at: env.ledger().timestamp(), + }; + + // The superseded version stays readable but leaves the shared library, so + // it can neither be instantiated nor browsed. + previous.active = false; + previous.shared = false; + put_template(env, storage, &previous); + + put_template(env, storage, &template); + index_for_owner(env, storage, &template.owner, id); + append_version(env, storage, template.root_id, id); + put_analytics(env, storage, &TemplateAnalytics::empty(id)); + + if was_shared { + set_shared_membership(env, storage, template_id, false); + set_shared_membership(env, storage, id, true); + } + + id +} + +/// Publish a template to, or withdraw it from, the shared library. +pub fn set_shared(env: &Env, storage: &Address, caller: &Address, template_id: u64, shared: bool) { + let mut template = get_template(env, storage, template_id).expect("Template not found"); + assert!(&template.owner == caller, "Only the owner may share"); + + if template.shared == shared { + return; + } + template.shared = shared; + put_template(env, storage, &template); + set_shared_membership(env, storage, template_id, shared); +} + +fn set_shared_membership(env: &Env, storage: &Address, template_id: u64, member: bool) { + let current = get_shared_templates(env, storage); + let mut next: Vec = Vec::new(env); + let mut present = false; + for id in current.iter() { + if id == template_id { + present = true; + if !member { + continue; + } + } + next.push_back(id); + } + if member && !present { + next.push_back(template_id); + } + storage_persistent_set( + env, + storage, + StorageKey::PlanTemplate(TemplateKey::Shared), + next, + ); +} + +/// Resolve a template into concrete plan parameters, recording the usage. +/// +/// Returns the parameters for the caller to create a plan with; the template +/// itself is never mutated by an instantiation. +pub fn instantiate( + env: &Env, + storage: &Address, + caller: &Address, + template_id: u64, + overrides: TemplateOverrides, +) -> ResolvedPlan { + let template = get_template(env, storage, template_id).expect("Template not found"); + assert!( + can_instantiate(&template, caller), + "Template is not available to this caller" + ); + + let resolved = resolve_plan(&template, &overrides); + assert!(resolved.price > 0, "Resolved price must be positive"); + + let mut analytics = get_analytics(env, storage, template_id); + analytics.plans_created += 1; + analytics.last_used_at = env.ledger().timestamp(); + analytics.recompute_derived(); + put_analytics(env, storage, &analytics); + + resolved +} + +/// Record that the template was previewed in the library. +pub fn record_view(env: &Env, storage: &Address, template_id: u64) { + let mut analytics = get_analytics(env, storage, template_id); + analytics.views += 1; + analytics.recompute_derived(); + put_analytics(env, storage, &analytics); +} + +/// Record that a plan instantiated from the template gained a subscriber. +pub fn record_subscription(env: &Env, storage: &Address, template_id: u64) { + let mut analytics = get_analytics(env, storage, template_id); + analytics.subscriptions_started += 1; + analytics.last_used_at = env.ledger().timestamp(); + analytics.recompute_derived(); + put_analytics(env, storage, &analytics); +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::vec; + + fn tier(up_to: Option, price: i128) -> PricingTier { + PricingTier { + up_to_units: up_to, + unit_price: price, + } + } + + #[test] + fn ratio_bps_handles_empty_and_saturating_denominators() { + assert_eq!(ratio_bps(0, 0), 0); + assert_eq!(ratio_bps(5, 0), 0); + assert_eq!(ratio_bps(1, 4), 2_500); + assert_eq!(ratio_bps(4, 4), 10_000); + // More conversions than plans cannot exceed 100%. + assert_eq!(ratio_bps(9, 4), 10_000); + } + + #[test] + fn validates_ascending_bounded_ladders() { + let env = Env::default(); + assert!(validate_tiers(&vec![ + &env, + tier(Some(1_000), 0), + tier(Some(10_000), 10), + tier(None, 5), + ])); + + // Empty ladder. + assert!(!validate_tiers(&Vec::::new(&env))); + // Descending bounds. + assert!(!validate_tiers(&vec![ + &env, + tier(Some(10_000), 10), + tier(Some(1_000), 5), + ])); + // Unbounded rung before the end. + assert!(!validate_tiers(&vec![ + &env, + tier(None, 10), + tier(Some(50), 5) + ])); + // Negative unit price. + assert!(!validate_tiers(&vec![&env, tier(None, -1)])); + } + + #[test] + fn validates_template_definitions() { + let env = Env::default(); + let no_tiers: Vec = Vec::new(&env); + let features: Vec = Vec::new(&env); + + assert!(validate_template(100, &no_tiers, &features)); + // A flat template still needs a positive price. + assert!(!validate_template(0, &no_tiers, &features)); + assert!(!validate_template(-1, &no_tiers, &features)); + // A supplied ladder must itself be valid. + assert!(!validate_template( + 100, + &vec![&env, tier(Some(0), 1)], + &features + )); + } + + #[test] + fn prices_units_across_the_ladder() { + let env = Env::default(); + let tiers = vec![ + &env, + tier(Some(1_000), 0), + tier(Some(10_000), 10), + tier(None, 5), + ]; + + // Entirely inside the free rung. + assert_eq!(calculate_tiered_price(&tiers, 500), 0); + // 1,000 free then 1,000 at 10. + assert_eq!(calculate_tiered_price(&tiers, 2_000), 10_000); + // 1,000 free, 9,000 at 10, 5,000 at 5. + assert_eq!(calculate_tiered_price(&tiers, 15_000), 90_000 + 25_000); + assert_eq!(calculate_tiered_price(&tiers, 0), 0); + } + + #[test] + fn ladder_caps_at_the_last_bounded_rung() { + let env = Env::default(); + // No unbounded rung: units past the top bound are not charged. + let tiers = vec![&env, tier(Some(100), 7)]; + assert_eq!(calculate_tiered_price(&tiers, 250), 700); + } +} diff --git a/contracts/types/src/lib.rs b/contracts/types/src/lib.rs index 08c1c75f..6596d27e 100644 --- a/contracts/types/src/lib.rs +++ b/contracts/types/src/lib.rs @@ -745,6 +745,31 @@ pub enum StorageKey { // ── Added in storage version 9 (Cross-Chain) ── /// Pending cross-chain transfer request (subscription_id -> CrossChainTransferRequest) CrossChainTransfer(u64), + + // ── Added in storage version 10 (Plan templates) ── + /// Plan template registry. The whole feature namespaces itself behind one + /// variant so a cohesive group of keys costs a single case here. + PlanTemplate(TemplateKey), +} + +/// Sub-keys of [`StorageKey::PlanTemplate`]. +/// +/// IMPORTANT: Never reorder existing variants. Append new variants only. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum TemplateKey { + /// template_id -> PlanTemplate + Template(u64), + /// Monotonic template id counter. + Count, + /// owner -> Vec of template ids they authored + ByOwner(Address), + /// root template id -> Vec of every version's id, oldest first + Versions(u64), + /// template_id -> TemplateAnalytics + Analytics(u64), + /// Vec of template ids published to the shared library. + Shared, } #[contracttype] diff --git a/docs/PLAN_TEMPLATES.md b/docs/PLAN_TEMPLATES.md new file mode 100644 index 00000000..757ad1f5 --- /dev/null +++ b/docs/PLAN_TEMPLATES.md @@ -0,0 +1,206 @@ +# Plan Templates + +## Overview + +Merchants were building every plan from scratch. Plan templates are reusable +blueprints — a base price, a billing cycle, an optional graduated pricing ladder +and a feature list — that a merchant instantiates a plan from, optionally +customized, in one step. + +The feature spans four layers: + +| Layer | Location | Responsibility | +| ---------- | ------------------------------------------------- | ------------------------------------------------- | +| Contract | `contracts/subscription/src/plan_templates.rs` | On-chain registry, versioning, sharing, analytics | +| Backend | `backend/services/billing/planTemplateService.ts` | Billing-domain library and quoting | +| Store | `src/store/planTemplateStore.ts` | Client library, filters, analytics | +| UI | `src/screens/PlanTemplatesScreen.tsx` | Browse, quote, customize, instantiate | + +`src/types/planTemplate.ts` is the shared vocabulary across the TypeScript +layers, and mirrors the contract's types. + +## Template anatomy + +```ts +interface PlanTemplate { + id: string; + rootId: string; // first version's id; equal to id for a first version + version: number; + ownerId: string; + name: string; + description: string; + category: SubscriptionCategory; + billingCycle: BillingCycle; + currency: string; + basePrice: number; // flat price per interval + pricingModel: 'flat' | 'tiered'; + tiers: PricingTier[]; // graduated ladder; empty for a flat template + features: TemplateFeature[]; + shared: boolean; // published to the shared library + active: boolean; // false once superseded by a newer version + tags: string[]; + createdAt: string; + updatedAt: string; +} +``` + +A `PlanTemplateDraft` is the same shape minus identity, ownership, version and +lifecycle, all of which are assigned on publish. + +## Library + +`createTemplate(ownerId, draft)` publishes version 1. The library is browsed +with a filter: + +```ts +usePlanTemplateStore.getState().listTemplates({ + pricingModel: 'tiered', + tags: ['api'], + search: 'usage', +}); +``` + +`listAvailableTemplates(callerId)` returns everything a caller may instantiate: +their own templates plus every shared one. Superseded versions are excluded from +both unless `includeInactive` is set. + +An empty client library is seeded with `STARTER_TEMPLATES` (Starter, Team and a +usage-based API plan) so a new merchant has something to instantiate on day one. +`seedStarterTemplates` is a no-op once the library holds anything. + +## Dynamic pricing tiers + +A `tiered` template prices usage with a graduated ladder — each rung prices only +the units that fall inside it: + +```ts +tiers: [ + { upToUnits: 1_000, unitPrice: 0 }, // first 1,000 free + { upToUnits: 10_000, unitPrice: 0.01 }, // next 9,000 at a cent + { upToUnits: null, unitPrice: 0.005 }, // everything above at half a cent +] +``` + +`quote(templateId, units)` returns the total, the per-rung breakdown, and the +effective unit price. 15,000 units against the ladder above cost +`0 + 9,000 × 0.01 + 5,000 × 0.005 = 115`. + +A ladder is valid when it is non-empty within `MAX_TIERS` (12), strictly +ascending, priced non-negatively, and unbounded only on its last rung. A ladder +with no unbounded rung stops charging past its top bound. Tiers supplied out of +order are sorted before quoting. + +A `flat` template quotes its base price regardless of units. Tiers attached to a +flat template are ignored, which validation reports as a warning rather than an +error. + +## Customization + +Instantiating never mutates the template. Overrides apply to that one plan: + +```ts +const resolved = await useSubscriptionStore.getState().addFromTemplate( + merchantId, + templateId, + { name: 'Team (EU)', price: 59, currency: 'EUR', removeFeatureKeys: ['sso'] }, +); +``` + +Omitted fields keep the template's value. A resolved price of zero or less is +rejected, so an override cannot produce a free plan by accident. + +`addFromTemplate` resolves the template, creates the subscription, and records +the conversion — but only if the subscription actually landed, so a failed +create does not inflate the template's conversion rate. + +## Versioning + +Templates are immutable once published. Editing means publishing a new version: + +``` +v1 ──▶ v2 ──▶ v3 all sharing one rootId + │ │ + │ └─ active: false once v3 publishes + └─ active: false once v2 publishes +``` + +`publishVersion(ownerId, templateId, draft)` creates the new version, chains it +to the same `rootId`, and retires the previous one. A superseded version stays +readable — so plans already created from it remain explainable — but cannot be +instantiated, and leaves the shared library. + +Each version carries its own analytics, since the counters measure how that +version performed. `listVersions(rootId)` returns the chain oldest first; +`getLatestVersion(rootId)` returns its head. + +## Sharing + +`setShared(ownerId, templateId, shared)` publishes a template to a library other +merchants can browse and instantiate but never edit — only the owner may publish +a version or change sharing. Sharing carries across versions, so a shared +template stays in the library at its newest version while the superseded one is +withdrawn. A superseded version cannot be shared. + +## Analytics + +Per template: + +| Metric | Meaning | +| ------------------------------- | ------------------------------------------------ | +| `views` | Times the template was previewed in the library | +| `plansCreated` | Plans instantiated from it | +| `subscriptionsStarted` | Subscriptions started against those plans | +| `revenue` | Revenue attributed to those subscriptions | +| `adoptionRate` | `plansCreated / views`, 0-1 | +| `conversionRate` | `subscriptionsStarted / plansCreated`, 0-1 | +| `averageRevenuePerSubscription` | `revenue / subscriptionsStarted` | +| `lastUsedAt` | Last instantiation or subscription | + +Rates are capped at 100%, so a template whose plans each gained several +subscribers reports `1` rather than an impossible rate. + +`getLibraryAnalytics(filter)` rolls these up across a filtered slice and ranks +`topTemplateIds` by subscriptions started. + +On-chain, `get_plan_template_analytics(template_id)` returns the same counters +with `adoption_bps` and `conversion_bps` in basis points (`10_000` == 100%). + +## Contract API + +```rust +create_plan_template(proxy, storage, owner, name, description, base_price, token, interval, tiers, features) -> u64 +publish_plan_template_version(proxy, storage, owner, template_id, name, description, base_price, tiers, features) -> u64 +share_plan_template(proxy, storage, owner, template_id, shared) +create_plan_from_template(proxy, storage, merchant, template_id, overrides) -> u64 +quote_plan_template(proxy, storage, template_id, units) -> i128 + +get_plan_template(proxy, storage, template_id) -> Option +list_owner_plan_templates(proxy, storage, owner) -> Vec +list_shared_plan_templates(proxy, storage) -> Vec +list_plan_template_versions(proxy, storage, root_id) -> Vec +get_latest_plan_template(proxy, storage, root_id) -> Option +get_plan_template_analytics(proxy, storage, template_id) -> TemplateAnalytics +record_plan_template_view(proxy, storage, template_id) +record_plan_template_subscription(proxy, storage, template_id) +``` + +`create_plan_from_template` resolves the template and forwards the result to +`create_plan`, so template-created plans are ordinary plans with no special +casing downstream. + +Template state lives behind a single `StorageKey::PlanTemplate(TemplateKey)` +variant — the feature namespaces its keys rather than adding six flat variants +to the shared storage key enum. + +## Testing + +```bash +# Billing domain +npx jest -c jest.backend.config.js backend/services/billing/__tests__/planTemplateService.test.ts + +# Client store +npx jest src/store/__tests__/planTemplateStore.test.ts + +# Contract +cd contracts && cargo test -p subtrackr-subscription +``` diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index b09eb45a..5b5bf39f 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -90,6 +90,7 @@ const ChangePlanScreen = lazyScreen(() => import('../screens/ChangePlanScreen')) const BillingSettingsScreen = lazyScreen(() => import('../screens/BillingSettingsScreen')); const CustomerHealthScreen = lazyScreen(() => import('../screens/CustomerHealthScreen')); const BillingAlignmentScreen = lazyScreen(() => import('../screens/BillingAlignmentScreen')); +const PlanTemplatesScreen = lazyScreen(() => import('../screens/PlanTemplatesScreen')); const PaymentMethodsScreen = lazyScreen(() => import('../../app/screens/PaymentMethodsScreen').then((m) => ({ default: m.PaymentMethodsScreen, @@ -609,6 +610,11 @@ const SettingsStack = () => ( component={BillingAlignmentScreen} options={{ headerShown: false }} /> + import('../../screens/ChangePlanScreen const CancellationFlowScreen = lazyScreen(() => import('../../screens/CancellationFlowScreen')); const PauseSubscriptionScreen = lazyScreen(() => import('../../screens/PauseSubscriptionScreen')); const UsageDashboardScreen = lazyScreen(() => import('../../screens/UsageDashboard')); +const PlanTemplatesScreen = lazyScreen(() => import('../../screens/PlanTemplatesScreen')); export const SubscriptionStack = () => ( @@ -50,5 +51,10 @@ export const SubscriptionStack = () => ( component={UsageDashboardScreen} options={{ headerShown: false }} /> + ); diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 0cd7c908..a33cd5e4 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -67,6 +67,7 @@ export type RootStackParamList = { CustomerHealth: undefined; BillingSettings: undefined; BillingAlignment: undefined; + PlanTemplates: undefined; ChangePlan: { subscriptionId: string }; PauseResume: { id: string }; PaymentMethods: undefined; diff --git a/src/screens/PlanTemplatesScreen.tsx b/src/screens/PlanTemplatesScreen.tsx new file mode 100644 index 00000000..13cbbc5e --- /dev/null +++ b/src/screens/PlanTemplatesScreen.tsx @@ -0,0 +1,395 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { + Alert, + SafeAreaView, + ScrollView, + StyleSheet, + Switch, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { Ionicons } from '@expo/vector-icons'; + +import { Card } from '../components/common/Card'; +import { Button } from '../components/common/Button'; +import { + usePlanTemplateStore, + canInstantiate, + computeLibraryAnalytics, +} from '../store/planTemplateStore'; +import { useSubscriptionStore } from '../store/subscriptionStore'; +import { useUserStore } from '../store/userStore'; +import { PlanTemplate, TemplatePricingModel } from '../types/planTemplate'; +import { borderRadius, colors, spacing, typography } from '../utils/constants'; +import { formatCurrency } from '../utils/formatting'; + +const PRICING_MODEL_FILTERS: { value: TemplatePricingModel | 'all'; label: string }[] = [ + { value: 'all', label: 'All' }, + { value: 'flat', label: 'Flat' }, + { value: 'tiered', label: 'Usage' }, +]; + +const QUOTE_UNIT_PRESETS = [500, 5_000, 25_000]; + +const formatPercent = (fraction: number): string => `${Math.round(fraction * 100)}%`; + +const PlanTemplatesScreen: React.FC = () => { + const navigation = useNavigation(); + const currentUserId = useUserStore((s) => s.user?.id) ?? 'me'; + + const { listVersions, getAnalytics, seedStarterTemplates, recordView, setShared, quote } = + usePlanTemplateStore(); + const templates = usePlanTemplateStore((s) => s.templates); + const analytics = usePlanTemplateStore((s) => s.analytics); + const addFromTemplate = useSubscriptionStore((s) => s.addFromTemplate); + + const [search, setSearch] = useState(''); + const [pricingModel, setPricingModel] = useState('all'); + const [sharedOnly, setSharedOnly] = useState(false); + const [selectedId, setSelectedId] = useState(null); + const [quoteUnits, setQuoteUnits] = useState(QUOTE_UNIT_PRESETS[1]); + const [priceOverride, setPriceOverride] = useState(''); + + useEffect(() => { + seedStarterTemplates(currentUserId); + }, [currentUserId, seedStarterTemplates]); + + const available = useMemo(() => { + const needle = search.trim().toLowerCase(); + return templates + .filter((template) => canInstantiate(template, currentUserId)) + .filter((template) => { + if (sharedOnly && !template.shared) return false; + if (pricingModel !== 'all' && template.pricingModel !== pricingModel) return false; + if (!needle) return true; + const haystack = `${template.name} ${template.description} ${template.tags.join(' ')}`; + return haystack.toLowerCase().includes(needle); + }) + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + }, [templates, search, pricingModel, sharedOnly, currentUserId]); + + const library = useMemo( + () => computeLibraryAnalytics(templates, analytics, { ownerId: currentUserId }), + [templates, analytics, currentUserId] + ); + + const handleSelect = (template: PlanTemplate) => { + const next = selectedId === template.id ? null : template.id; + setSelectedId(next); + setPriceOverride(''); + if (next) recordView(template.id); + }; + + const handleShareToggle = (template: PlanTemplate) => { + try { + setShared(currentUserId, template.id, !template.shared); + } catch (error) { + Alert.alert('Cannot share template', (error as Error).message); + } + }; + + const handleUse = async (template: PlanTemplate) => { + const parsed = parseFloat(priceOverride); + try { + const resolved = await addFromTemplate(currentUserId, template.id, { + price: Number.isFinite(parsed) && parsed > 0 ? parsed : undefined, + }); + Alert.alert( + 'Subscription created', + `${resolved.name} at ${formatCurrency(resolved.price, resolved.currency)} per ${resolved.billingCycle}.` + ); + } catch (error) { + Alert.alert('Could not use template', (error as Error).message); + } + }; + + const renderLibraryAnalytics = () => ( + + Library performance + + + {library.templates} + Templates + + + {library.totalPlansCreated} + Plans made + + + {formatPercent(library.conversionRate)} + Conversion + + + {formatPercent(library.adoptionRate)} + Adoption + + + + {library.sharedTemplates} shared · {library.totalViews} view(s) ·{' '} + {library.totalSubscriptionsStarted} subscription(s) started + + + ); + + const renderFilters = () => ( + + + + {PRICING_MODEL_FILTERS.map((option) => { + const active = pricingModel === option.value; + return ( + setPricingModel(option.value)} + accessibilityRole="button" + accessibilityState={{ selected: active }} + accessibilityLabel={`Filter by ${option.label} pricing`}> + {option.label} + + ); + })} + + + Shared library only + + + + ); + + const renderDetail = (template: PlanTemplate) => { + const templateAnalytics = getAnalytics(template.id); + const versions = listVersions(template.rootId); + const priceQuote = template.pricingModel === 'tiered' ? quote(template.id, quoteUnits) : null; + + return ( + + Features + {template.features.length === 0 ? ( + No features listed. + ) : ( + template.features.map((feature) => ( + + • {feature.label} + {feature.includedUnits !== null ? ` — ${feature.includedUnits} included` : ''} + {feature.highlight ? ' ★' : ''} + + )) + )} + + {template.pricingModel === 'tiered' && ( + <> + Pricing tiers + {template.tiers.map((tier, index) => ( + + • Up to {tier.upToUnits === null ? 'unlimited' : tier.upToUnits.toLocaleString()}{' '} + units at {formatCurrency(tier.unitPrice, template.currency)} each + + ))} + + {QUOTE_UNIT_PRESETS.map((units) => { + const active = quoteUnits === units; + return ( + setQuoteUnits(units)} + accessibilityRole="button" + accessibilityLabel={`Quote ${units} units`}> + + {units.toLocaleString()} + + + ); + })} + + {priceQuote && ( + + {quoteUnits.toLocaleString()} units ={' '} + {formatCurrency(priceQuote.total, template.currency)} ( + {formatCurrency(priceQuote.effectiveUnitPrice, template.currency)} per unit) + + )} + + )} + + Customize + + + Performance + + {templateAnalytics.views} view(s) · {templateAnalytics.plansCreated} plan(s) ·{' '} + {formatPercent(templateAnalytics.conversionRate)} conversion ·{' '} + {formatCurrency(templateAnalytics.revenue, template.currency)} attributed + + + Versions + + {versions.map((v) => `v${v.version}${v.active ? ' (current)' : ''}`).join(' · ')} + + + +