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/screens/PaymentMethodsScreen.tsx b/app/screens/PaymentMethodsScreen.tsx index ae51726a..ed3ba815 100644 --- a/app/screens/PaymentMethodsScreen.tsx +++ b/app/screens/PaymentMethodsScreen.tsx @@ -10,7 +10,13 @@ import { Alert, FlatList, } from 'react-native'; -import { usePaymentStore, PaymentMethod, PaymentPriority } from '../stores/paymentStore'; +import { + usePaymentStore, + PaymentMethod, + PaymentPriority, + FallbackChain, + ExpiryAlert, +} from '../stores/paymentStore'; import { Card } from '../../src/components/common/Card'; import { Button } from '../../src/components/common/Button'; import { useThemeColors } from '../../src/hooks/useThemeColors'; @@ -24,6 +30,14 @@ const PRIORITY_COLORS: Record = { fallback: '#6b7280', }; +const SEVERITY_COLORS: Record = { + expired: '#ef4444', + critical: '#f59e0b', + warning: '#6b7280', +}; + +const formatPercent = (fraction: number): string => `${Math.round(fraction * 100)}%`; + export const PaymentMethodsScreen: React.FC = () => { const colors = useThemeColors(); const styles = useMemo(() => createStyles(colors), [colors]); @@ -31,12 +45,19 @@ export const PaymentMethodsScreen: React.FC = () => { const { methods, attemptLog, + chains, addMethod, removeMethod, verifyMethod, setPriority, - getExpiringMethods, deactivateExpired, + createChain, + deleteChain, + reorderChain, + validateChain, + resolveChainMethods, + getExpiryAlerts, + getAnalytics, } = usePaymentStore(); const [label, setLabel] = useState(''); @@ -44,8 +65,14 @@ export const PaymentMethodsScreen: React.FC = () => { const [tokenAddress, setTokenAddress] = useState(''); const [priority, setPriorityState] = useState('primary'); const [maxSpend, setMaxSpend] = useState(''); + const [chainName, setChainName] = useState(''); + const [chainSelection, setChainSelection] = useState([]); - const expiringMethods = useMemo(() => getExpiringMethods(), [methods, getExpiringMethods]); + const alerts = useMemo(() => getExpiryAlerts(), [methods, chains, getExpiryAlerts]); + const analytics = useMemo( + () => getAnalytics(), + [methods, attemptLog, getAnalytics] + ); const handleAdd = useCallback(() => { if (!label.trim() || !tokenAddress.trim()) { @@ -72,6 +99,33 @@ export const PaymentMethodsScreen: React.FC = () => { } }, [label, tokenType, tokenAddress, priority, maxSpend, addMethod]); + const toggleChainSelection = useCallback((id: string) => { + setChainSelection((current) => + current.includes(id) ? current.filter((candidate) => candidate !== id) : [...current, id] + ); + }, []); + + const handleCreateChain = useCallback(() => { + try { + createChain(chainName.trim() || `Chain ${chains.length + 1}`, chainSelection); + setChainName(''); + setChainSelection([]); + } catch (e) { + Alert.alert('Cannot create chain', (e as Error).message); + } + }, [chainName, chainSelection, chains.length, createChain]); + + /** Move a method one place earlier in its chain. */ + const promoteInChain = useCallback( + (chain: FallbackChain, index: number) => { + if (index === 0) return; + const next = [...chain.methodIds]; + [next[index - 1], next[index]] = [next[index], next[index - 1]]; + reorderChain(chain.id, next); + }, + [reorderChain] + ); + const renderMethod = ({ item }: { item: PaymentMethod }) => ( @@ -95,9 +149,7 @@ export const PaymentMethodsScreen: React.FC = () => { {item.expiresAt !== null && ( - - Expires: {new Date(item.expiresAt).toLocaleDateString()} - + Expires: {new Date(item.expiresAt).toLocaleDateString()} )} {!item.isVerified && ( @@ -129,6 +181,55 @@ export const PaymentMethodsScreen: React.FC = () => { const activeMethods = methods.filter((m) => m.isActive); + const renderChain = (chain: FallbackChain) => { + const validation = validateChain(chain); + const usable = resolveChainMethods(chain); + const labels = new Map(methods.map((m) => [m.id, m.label])); + + return ( + + + + {chain.name} + + {chain.subscriptionId ? `Subscription ${chain.subscriptionId}` : 'All subscriptions'}{' '} + · {usable.length}/{chain.methodIds.length} usable + + + - - - ); -} -``` +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/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/developer-portal/components/ApiPlayground.tsx b/developer-portal/components/ApiPlayground.tsx index d25d6d62..7de54249 100644 --- a/developer-portal/components/ApiPlayground.tsx +++ b/developer-portal/components/ApiPlayground.tsx @@ -26,14 +26,18 @@ const ENDPOINTS: Endpoint[] = [ path: '/v1/subscriptions', name: 'Create Subscription', hasBody: true, - defaultBody: JSON.stringify({ - name: "Netflix", - category: "streaming", - price: 15.99, - currency: "USD", - billingCycle: "monthly", - startDate: "2024-01-01T00:00:00Z" - }, null, 2) + defaultBody: JSON.stringify( + { + name: 'Netflix', + category: 'streaming', + price: 15.99, + currency: 'USD', + billingCycle: 'monthly', + startDate: '2024-01-01T00:00:00Z', + }, + null, + 2 + ), }, { id: 'list_pay', method: 'GET', path: '/v1/payments', name: 'List Payments' }, ]; @@ -45,8 +49,11 @@ export const ApiPlayground: React.FC = () => { const [apiKey, setApiKey] = useState('sk_test_your_api_key_here'); const [requestBody, setRequestBody] = useState(ENDPOINTS[0].defaultBody || ''); const [selectedLang, setSelectedLang] = useState('cURL'); - - const [response, setResponse] = useState<{ status: number | null, data: string | null }>({ status: null, data: null }); + + const [response, setResponse] = useState<{ status: number | null; data: string | null }>({ + status: null, + data: null, + }); const [loading, setLoading] = useState(false); const handleEndpointSelect = (endpoint: Endpoint) => { @@ -61,8 +68,10 @@ export const ApiPlayground: React.FC = () => { const bodyStr = selectedEndpoint.hasBody ? `\n -d '${requestBody}'` : ''; const bodyJs = selectedEndpoint.hasBody ? `,\n body: JSON.stringify(${requestBody})` : ''; const bodyPy = selectedEndpoint.hasBody ? `\npayload = ${requestBody}` : ''; - const bodyGo = selectedEndpoint.hasBody ? `\npayload := strings.NewReader(\`${requestBody}\`)` : ''; - + const bodyGo = selectedEndpoint.hasBody + ? `\npayload := strings.NewReader(\`${requestBody}\`)` + : ''; + switch (selectedLang) { case 'cURL': return `curl -X ${method} ${url} \\ @@ -118,31 +127,45 @@ func main() { setTimeout(() => { let mockResponse = {}; let mockStatus = 200; - + if (selectedEndpoint.id === 'list_sub') { mockResponse = { success: true, - data: [{ id: "sub_123", name: "Netflix", price: 15.99, status: "active" }], - pagination: { page: 1, limit: 20, total: 1 } + data: [{ id: 'sub_123', name: 'Netflix', price: 15.99, status: 'active' }], + pagination: { page: 1, limit: 20, total: 1 }, }; } else if (selectedEndpoint.id === 'create_sub') { try { const bodyData = JSON.parse(requestBody); - mockResponse = { success: true, data: { id: "sub_new", ...bodyData, status: "active", createdAt: new Date().toISOString() } }; + mockResponse = { + success: true, + data: { + id: 'sub_new', + ...bodyData, + status: 'active', + createdAt: new Date().toISOString(), + }, + }; mockStatus = 201; - } catch(e) { - mockResponse = { success: false, error: { code: "INVALID_REQUEST", message: "Invalid JSON body" } }; + } catch (e) { + mockResponse = { + success: false, + error: { code: 'INVALID_REQUEST', message: 'Invalid JSON body' }, + }; mockStatus = 400; } } else { mockResponse = { success: true, data: [] }; } - + if (apiKey === '') { - mockResponse = { success: false, error: { code: "UNAUTHORIZED", message: "Missing API Key" } }; + mockResponse = { + success: false, + error: { code: 'UNAUTHORIZED', message: 'Missing API Key' }, + }; mockStatus = 401; } - + setResponse({ status: mockStatus, data: JSON.stringify(mockResponse, null, 2) }); setLoading(false); }, 800); @@ -151,20 +174,37 @@ func main() { return ( Interactive API Playground - + {/* Left Column - Configuration */} Endpoint - - {ENDPOINTS.map(ep => ( - + {ENDPOINTS.map((ep) => ( + handleEndpointSelect(ep)} - > - {ep.method} - {ep.name} + style={[ + styles.endpointTab, + selectedEndpoint.id === ep.id && styles.endpointTabSelected, + ]} + onPress={() => handleEndpointSelect(ep)}> + + {ep.method} + + + {ep.name} + ))} @@ -205,18 +245,25 @@ func main() { - {LANGUAGES.map(lang => ( - ( + setSelectedLang(lang)} - > - {lang} + onPress={() => setSelectedLang(lang)}> + + {lang} + ))} - {generateCode()} + + {generateCode()} + @@ -224,12 +271,20 @@ func main() { Response - + {response.status} - {response.data} + + {response.data} + )} diff --git a/developer-portal/components/LogDashboard.tsx b/developer-portal/components/LogDashboard.tsx index c41e7b19..0cfe2e55 100644 --- a/developer-portal/components/LogDashboard.tsx +++ b/developer-portal/components/LogDashboard.tsx @@ -1,5 +1,13 @@ import React, { useState, useEffect } from 'react'; -import { View, Text, StyleSheet, FlatList, TextInput, TouchableOpacity, ActivityIndicator } from 'react-native'; +import { + View, + Text, + StyleSheet, + FlatList, + TextInput, + TouchableOpacity, + ActivityIndicator, +} from 'react-native'; import { logStorage, LogEntry, LogSearchQuery } from '../../backend/elasticsearch/logStorage'; export const LogDashboard: React.FC = () => { @@ -31,7 +39,8 @@ export const LogDashboard: React.FC = () => { const renderLog = ({ item }: { item: LogEntry }) => ( - + {item.level.toUpperCase()} {new Date(item.timestamp).toLocaleString()} @@ -46,7 +55,7 @@ export const LogDashboard: React.FC = () => { return ( Log Analytics Dashboard - + = ({ onNavigate }) => { ))} - + ); 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. diff --git a/docs/NOTIFICATIONS.md b/docs/NOTIFICATIONS.md new file mode 100644 index 00000000..94d20161 --- /dev/null +++ b/docs/NOTIFICATIONS.md @@ -0,0 +1,212 @@ +# Notification Preferences and Delivery + +## Overview + +Notifications used to go out without any preference management: a subscriber +either had push permission or did not. Preferences are now held **per +notification type and per channel**, delivery fans out across those channels +with fallback, and every attempt lands in a history that carries read status and +feeds engagement analytics. + +| Layer | Location | Responsibility | +| ---------- | ----------------------------------------------------------- | ------------------------------------------------- | +| Types | `src/types/notification.ts` | Shared vocabulary across all layers | +| Backend | `backend/services/notification/notificationCenterService.ts` | Server-side preferences, delivery, history | +| Service | `src/services/notificationService.ts` | On-device delivery across channels | +| Store | `src/store/notificationPreferencesStore.ts` | Client preferences, history, analytics, templates | +| UI | `src/screens/NotificationPreferencesScreen.tsx` | Preference matrix, engagement, recent activity | + +## Channels and types + +Four channels: `email`, `push`, `sms`, `in_app`. + +| Type | Category | Priority | Required | Default channels | +| ------------------ | -------- | ----------- | -------- | ----------------------- | +| `renewal_reminder` | billing | informative | no | push, email | +| `charge_success` | billing | informative | no | push, in_app | +| `charge_failed` | billing | critical | **yes** | push, email, sms | +| `dunning` | billing | critical | **yes** | email, push | +| `trial_ending` | billing | informative | no | push, email | +| `security_alert` | security | critical | **yes** | push, email, sms | +| `product_update` | product | informative | no | in_app | +| `promotion` | marketing| marketing | no | email | +| `digest` | product | informative | no | email | + +A **required** type cannot be muted and cannot have every channel turned off — a +subscriber must stay reachable about money and account security. Turning off the +last remaining channel of a required type throws on the server and is a no-op on +the client, so the switch simply refuses to move. + +## Preferences + +```ts +interface TypePreference { + type: NotificationType; + channels: Record; + fallbackOrder: NotificationChannel[]; // tried in order + muted: boolean; // non-required types only +} +``` + +Client actions: + +```ts +const store = useNotificationPreferencesStore.getState(); +store.setChannelPreference('renewal_reminder', 'sms', true); +store.setFallbackOrder('renewal_reminder', ['email', 'push']); +store.setMuted('promotion', true); +store.setQuietHours({ enabled: true, startHour: 22, endHour: 8 }); +store.updatePreferences({ minimumPriority: 'critical' }); +``` + +The server mirrors these as `setChannelPreference`, `setFallbackOrder`, +`setMuted`, `setQuietHours` and `setMinimumPriority` on +`NotificationCenterService`. + +Category opt-ins (`billing`, `product`, `marketing`, `security`) remain the +coarse switch; a type is only delivered if its category is opted in, unless the +type is required. + +## Channel resolution + +`resolveChannels(preferences, type)` returns the channels to attempt, in order: + +1. Nothing, if the type is muted, its category is opted out, or its priority is + below the subscriber's `minimumPriority` — required types skip all three + checks. +2. Otherwise the `fallbackOrder` entries that are still enabled, then any other + enabled channel. +3. If a required type somehow has every channel disabled, its first default + channel is used as a guaranteed route. + +## Multi-channel delivery + +```ts +const result = await deliverNotification({ + type: 'charge_failed', + variables: { plan: 'Netflix', amount: '$15.99' }, + subject: 'Payment failed', + body: 'We could not complete your renewal.', + firstSuccessOnly: true, +}); +// { delivered: ['push'], failed: [], suppressed: [], scheduled: false, records: [...] } +``` + +By default a notification fans out to every resolved channel. +`firstSuccessOnly` stops at the first channel that accepts, which suits recovery +flows where one successful reach is enough. + +Channels are backed by transports. Push is delivered on-device through Expo; +email, SMS and in-app are handed to transports the host app registers, so +neither layer is tied to a specific provider: + +```ts +registerChannelTransport('email', async ({ subject, body }) => sendEmail(subject, body)); +``` + +A channel with no registered transport records a failure rather than silently +dropping the notification. + +## Suppression versus failure + +A notification the subscriber has muted, or that no channel accepts, is recorded +with `status: 'suppressed'` and a reason — never dropped silently. Analytics can +then distinguish three different outcomes: + +| Status | Meaning | +| ------------ | ----------------------------------------------------------- | +| `scheduled` | Deferred, e.g. past quiet hours; not yet sent | +| `delivered` | A transport accepted it | +| `failed` | A transport refused, threw, or was not registered | +| `suppressed` | Preferences stopped it before any transport was consulted | + +## Scheduling + +Non-critical notifications that land inside the subscriber's quiet window are +recorded as `scheduled` for the end of the window rather than sent. Critical +notifications go out immediately, whatever the hour. + +A quiet window that wraps midnight (22:00–08:00) is handled correctly: the +deferral target rolls to the next day when the window has already started. + +On the server, `flushScheduled(at)` sends every scheduled notification whose time +has arrived and returns the records that went out, so a worker can report on a +drained queue. + +`PushScheduleEngine` (`src/services/pushScheduleEngine.ts`) remains the +per-user, open-rate-driven delivery-window optimizer and digest batcher; the +notification center handles preference resolution, routing and history. + +## Templates + +A template is a subject and body with `{{variable}}` placeholders, registered per +`(type, channel)` pair so each channel can carry copy of the right length: + +```ts +store.upsertTemplate({ + type: 'renewal_reminder', + channel: 'email', + subject: '{{plan}} renews soon', + body: 'We will charge {{amount}} on {{date}}.', + variables: ['plan', 'amount', 'date'], +}); +``` + +Every upsert bumps the template's `version`. Rendering substitutes the supplied +values and reports any declared variable that had no value, leaving its +placeholder blank rather than printing `{{date}}` to a subscriber. A channel with +no template falls back to the copy passed into `deliverNotification`. + +Rich-text HTML bodies are sanitized separately by +`backend/services/notification/templateService.ts`, which strips executable +markup on save. + +## History and read status + +Every delivery attempt is one history record per channel. The client retains the +most recent 200 on device; the server retains 500 per subscriber. + +```ts +store.getHistory({ type: 'charge_failed', unreadOnly: true, since: '2026-03-01T00:00:00Z' }); +store.markRead(recordId); +store.markClicked(recordId); // implies a read +store.markAllRead(); +store.unreadCount(); +``` + +A click implies a read, so engagement is never reported as "clicked but never +opened". Marking read twice keeps the first timestamp, so time-to-read stays +accurate. + +## Analytics + +`computeAnalytics(records)` returns, in total and partitioned by channel and by +type: + +| Metric | Meaning | +| --------------------- | --------------------------------------------------- | +| `sent` | Attempts that reached a transport | +| `delivered` | Attempts a transport accepted | +| `failed` | Attempts a transport refused | +| `suppressed` | Attempts preferences stopped | +| `opened` / `clicked` | Records with a read / click timestamp | +| `deliveryRate` | `delivered / (delivered + failed)` | +| `openRate` | `opened / delivered` | +| `clickRate` | `clicked / delivered` | + +Plus `unreadCount`, `averageTimeToReadMs` over read notifications only, and +`bestChannel` — the channel with the highest open rate over at least one +delivery, which the settings screen surfaces as "you open Email most". + +Suppressed notifications are counted but excluded from the delivery rate: they +never reached a transport, so they say nothing about deliverability. + +## Testing + +```bash +# Notification domain +npx jest -c jest.backend.config.js backend/services/notification/__tests__/notificationCenterService.test.ts + +# Client store and delivery path +npx jest src/store/__tests__/notificationPreferencesStore.test.ts +``` diff --git a/docs/PAYMENT_METHODS.md b/docs/PAYMENT_METHODS.md new file mode 100644 index 00000000..89ed7bdf --- /dev/null +++ b/docs/PAYMENT_METHODS.md @@ -0,0 +1,183 @@ +# Payment Methods and Fallback Chains + +## Overview + +Payment methods used to be managed one at a time, ordered only by an implicit +priority label, with no way to say "try the card, then the USDC wallet, then the +treasury". A **fallback chain** is that explicit, ordered route. A charge walks +it until one method succeeds, so a single expired card no longer means a failed +renewal. + +| Layer | Location | Responsibility | +| ---------- | --------------------------------------------------- | -------------------------------------------------- | +| Types | `src/types/wallet.ts` | Chain, share, alert and analytics shapes | +| Service | `src/services/paymentMethodService.ts` | On-chain charging, chains, alerts, analytics | +| Store | `src/store/walletStore.ts` | Wallet-backed methods, chains, shares | +| Backend | `backend/services/billing/paymentMethodRegistry.ts` | Server-side registry, chains, charging, analytics | +| App store | `app/stores/paymentStore.ts` | Local method model used by the merchant screen | +| UI | `app/screens/PaymentMethodsScreen.tsx` | CRUD, chain builder, alerts, analytics | + +The service and the registry mirror each other deliberately: the client charges +through a connected wallet, the server charges through a processor, and both +expose the same chain semantics so a merchant sees one model. + +## Method CRUD + +```ts +const method = registry.addMethod(merchantId, { + label: 'Primary card', + kind: 'card', + reference: '4242', + currency: 'USD', + spendLimit: 500, // 0 means unlimited + expiresAt: '2027-01-31T00:00:00.000Z', +}); +registry.verifyMethod(method.id); +``` + +A `crypto` method is verified on registration — the wallet proved itself by +signing. Every other kind needs an explicit verification step before it can be +charged, and an unverified method is skipped by a chain rather than attempted. + +Removing a method also drops it from every chain, so a chain never points at +something that can no longer be charged. + +## Fallback chains + +```ts +interface FallbackChain { + id: string; + name: string; + methodIds: string[]; // tried in this order + subscriptionId: string | null; // null applies to every subscription + maxAttempts: number; // 0 means try the whole chain + stopOnHardDecline: boolean; + isActive: boolean; +} +``` + +A chain is valid when it names at least one known method, holds at most +`MAX_CHAIN_LENGTH` (5), lists no method twice, and every method belongs to the +merchant. Validation also warns — without failing — when: + +- the chain has a single usable method, which means no fallback at all +- some listed methods are inactive, unverified or expired + +Both warnings can appear together; they are independent facts about the chain. + +`resolveChainMethods(chain)` returns what the chain will actually attempt: +active, verified, unexpired methods, capped by `maxAttempts`. + +### Selection + +`chainForSubscription(subscriptionId)` prefers a chain scoped to that +subscription, then the global chain, then nothing. Inactive chains are ignored. +When no chain is configured at all, the client derives one from the priority +ordering and the server falls back to every registered method — so a merchant +who has never touched this feature still gets sensible routing. + +## Charging + +```ts +const result = await registry.charge(merchantId, subscriptionId, 100); +// { +// success: true, +// succeededMethodId: 'pm_2', +// succeededAtPosition: 1, // the fallback did the work +// attempts: [ {…failed}, {…succeeded} ], +// haltedOnHardDecline: false, +// } +``` + +Each entry is tried in turn. Declines fall through to the next method, with one +exception: a **hard decline** — expired, inactive or unverified — halts the whole +chain when `stopOnHardDecline` is set, because falling through would only repeat +a configuration problem rather than find a working route. + +Failure reasons are recorded per attempt: `expired`, `inactive`, `unverified`, +`limit_exceeded`, `declined`, `unknown`. Every attempt, successful or not, joins +the attempt log that analytics reads. + +## Expiry tracking and alerts + +`getExpiryAlerts(merchantId, withinDays = 30)` grades every method with an +expiry date: + +| Severity | When | +| ---------- | ---------------------------------------- | +| `expired` | The date has passed | +| `critical` | Within `EXPIRY_CRITICAL_DAYS` (7) | +| `warning` | Within the window but further out | + +Alerts are sorted soonest-first, and a method still sitting in an **active +chain** is flagged in its message — its expiry will break a charge, not merely +retire an unused method: + +> Primary card expires in 3 day(s) and is still in a fallback chain + +`deactivateExpired(merchantId)` retires everything past its date in one pass and +returns how many it touched. + +## Sharing + +A method can be granted to another account: + +| Role | May do | +| --------- | --------------------------------------------------------- | +| `viewer` | See the method in listings | +| `charger` | Also spend from it, bounded by the share's `spendLimit` | + +```ts +registry.shareMethod(methodId, 'team_member', 'charger', { spendLimit: 100 }); +registry.canGranteeCharge(methodId, 'team_member', 100); // true +registry.canGranteeCharge(methodId, 'team_member', 101); // false +``` + +A share with an `expiresAt` stops granting access once that moment passes — an +elapsed share behaves exactly like a revoked one, so no cleanup job is needed to +close it. Sharing with the method's own owner, or from an inactive method, is +rejected. + +## Analytics + +| Metric | Meaning | +| --------------------- | ----------------------------------------------------------- | +| `totalAttempts` | Every charge attempt across every method | +| `successRate` | `totalSuccesses / totalAttempts` | +| `fallbackRate` | Fraction of successful charges that needed a fallback | +| `byMethod` | Per-method attempts, successes, volume, top failure reason | +| `failureReasons` | Declines ranked by frequency | +| `mostReliableMethodId`| Highest success rate over at least one attempt | +| `activeMethods` | Methods currently chargeable | +| `expiringMethods` | Methods approaching expiry, excluding those already expired | + +`fallbackRate` is the number that says whether a chain is doing real work: a +rate of zero means the head of the chain always lands, while a high rate means +the primary method is failing often enough to be worth investigating. + +Analytics are scoped per merchant — one merchant's attempts never appear in +another's numbers. + +## UI + +`PaymentMethodsScreen` surfaces all of it: + +- add, verify, re-prioritise and remove methods +- an expiry alert panel colour-coded by severity, with a one-tap + "deactivate expired" +- a chain builder: tap methods in the order they should be tried, name the + chain, create it; each chain then shows its ordered steps with "move up", + plus any validation warnings +- an analytics panel with attempts, success rate, fallback rate and per-method + breakdown +- recent attempts, annotated with the chain step they came from + +## Testing + +```bash +# Server-side registry +npx jest -c jest.backend.config.js backend/services/billing/__tests__/paymentMethodRegistry.test.ts + +# App store (app/ is excluded from the default jest config, so override it) +npx jest --testPathIgnorePatterns "/node_modules/" --testPathPattern "app/stores/__tests__/paymentStore" +``` 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/scripts/dr-failover.js b/scripts/dr-failover.js index 540982a3..49fb1472 100644 --- a/scripts/dr-failover.js +++ b/scripts/dr-failover.js @@ -2,12 +2,12 @@ const { disasterRecoveryService } = require('../backend/dr/DisasterRecoveryServi async function triggerFailover() { console.log('🚨 EMERGENCY: Triggering Automated Disaster Recovery Failover 🚨'); - + console.log('\nInitiating failover sequence...'); const start = Date.now(); - + const result = await disasterRecoveryService.failover(); - + const elapsed = Date.now() - start; if (result.success) { @@ -17,7 +17,9 @@ async function triggerFailover() { } else { console.error(`\n❌ Failover failed after ${elapsed}ms.`); console.error('Errors encountered:', result.errors); - console.error('\nPlease escalate to the incident response team immediately and consult docs/runbooks/02-incident-response.md'); + console.error( + '\nPlease escalate to the incident response team immediately and consult docs/runbooks/02-incident-response.md' + ); process.exit(1); } } diff --git a/scripts/dr-test.js b/scripts/dr-test.js index 756dfce2..9ff429de 100644 --- a/scripts/dr-test.js +++ b/scripts/dr-test.js @@ -3,15 +3,15 @@ const { drMonitor } = require('../backend/dr/drMonitoring'); async function runTest() { console.log('--- Starting Disaster Recovery Automated Drill ---'); - + // 1. Run the DR Drill console.log('\nRunning core DR drill (Backup -> Verify -> Restore)...'); const drillResult = await disasterRecoveryService.runDrDrill(); - + console.log('Drill passed:', drillResult.passed); console.log('Backup ID:', drillResult.backupId); console.log('RTO Compliant:', drillResult.rtoCompliant, `(${drillResult.recovery.durationMs}ms)`); - + if (!drillResult.passed) { console.error('DR Drill failed details:', JSON.stringify(drillResult, null, 2)); process.exit(1); 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 }} /> + = ({ children }) => ( - {children} -); +import { navigationAnalytics } from '../analytics'; describe('NavigationAnalytics', () => { beforeEach(() => { diff --git a/src/navigation/analytics.ts b/src/navigation/analytics.ts index a79928b4..bc944f8a 100644 --- a/src/navigation/analytics.ts +++ b/src/navigation/analytics.ts @@ -1,5 +1,3 @@ -import { NavigationState, PartialState, Route } from '@react-navigation/native'; - export interface NavigationAnalyticsEvent { type: 'screen_view' | 'navigation_action' | 'navigation_error' | 'deep_link'; screenName: string; diff --git a/src/navigation/modules/SettingsStack.tsx b/src/navigation/modules/SettingsStack.tsx index e4879d9f..372b6305 100644 --- a/src/navigation/modules/SettingsStack.tsx +++ b/src/navigation/modules/SettingsStack.tsx @@ -5,7 +5,11 @@ import { RootStackParamList } from '../types'; const Stack = createNativeStackNavigator(); -const SettingsScreen = lazyScreen(() => import('../../screens/SettingsScreen')); +// SettingsScreen has no default export, so the named one is mapped onto the +// shape `lazyScreen` expects. +const SettingsScreen = lazyScreen(() => + import('../../screens/SettingsScreen').then((m) => ({ default: m.SettingsScreen })) +); const LanguageSettingsScreen = lazyScreen(() => import('../../screens/LanguageSettingsScreen')); const NotificationPreferencesScreen = lazyScreen( () => import('../../screens/NotificationPreferencesScreen') diff --git a/src/navigation/modules/SocialStack.tsx b/src/navigation/modules/SocialStack.tsx index 972adbdd..d51d927b 100644 --- a/src/navigation/modules/SocialStack.tsx +++ b/src/navigation/modules/SocialStack.tsx @@ -27,7 +27,11 @@ const SegmentDetailScreen = lazyScreen(() => const GroupManagementScreen = lazyScreen(() => import('../../screens/GroupManagementScreen')); const SupportDashboardScreen = lazyScreen(() => import('../../screens/SupportDashboardScreen')); const TrialDetailsScreen = lazyScreen(() => import('../../screens/TrialDetailsScreen')); -const NotFoundScreen = lazyScreen(() => import('../../screens/NotFoundScreen')); +// NotFoundScreen has no default export, so the named one is mapped onto the +// shape `lazyScreen` expects. +const NotFoundScreen = lazyScreen(() => + import('../../screens/NotFoundScreen').then((m) => ({ default: m.NotFoundScreen })) +); export const SocialStack = () => ( diff --git a/src/navigation/modules/SubscriptionStack.tsx b/src/navigation/modules/SubscriptionStack.tsx index 5cd44933..77f3a121 100644 --- a/src/navigation/modules/SubscriptionStack.tsx +++ b/src/navigation/modules/SubscriptionStack.tsx @@ -12,6 +12,7 @@ const ChangePlanScreen = lazyScreen(() => 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/MerchantOnboardingScreen.tsx b/src/screens/MerchantOnboardingScreen.tsx index 9d10a530..e1395691 100644 --- a/src/screens/MerchantOnboardingScreen.tsx +++ b/src/screens/MerchantOnboardingScreen.tsx @@ -26,7 +26,6 @@ const MerchantOnboardingScreen: React.FC = () => { onboarding, isLoading, startOnboarding, - submitDocument, nextStep, previousStep, requestVerification, diff --git a/src/screens/NotificationPreferencesScreen.tsx b/src/screens/NotificationPreferencesScreen.tsx index 75456eb9..08ba3729 100644 --- a/src/screens/NotificationPreferencesScreen.tsx +++ b/src/screens/NotificationPreferencesScreen.tsx @@ -1,8 +1,18 @@ import React from 'react'; import { View, Text, StyleSheet, ScrollView, Switch, TouchableOpacity } from 'react-native'; import { useThemeColors } from '../hooks/useThemeColors'; -import { useNotificationPreferencesStore } from '../store/notificationPreferencesStore'; +import { + useNotificationPreferencesStore, + computeAnalytics, +} from '../store/notificationPreferencesStore'; import type { OptInCategory, NotificationPriority } from '../services/pushScheduleEngine'; +import { + NOTIFICATION_CHANNELS, + NOTIFICATION_TYPES, + NOTIFICATION_TYPE_META, + type NotificationChannel, + type NotificationType, +} from '../types/notification'; const OPT_IN_LABELS: Record = { billing: { label: 'Billing & Payments', desc: 'Payment due, charge results, invoice ready' }, @@ -17,6 +27,13 @@ const PRIORITY_LABELS: Record = { + email: 'Email', + push: 'Push', + sms: 'SMS', + in_app: 'In-app', +}; + const DIGEST_OPTIONS: { value: 'immediate' | 'daily' | 'weekly'; label: string; @@ -35,13 +52,27 @@ const DIGEST_OPTIONS: { }, ]; +const formatPercent = (fraction: number): string => `${Math.round(fraction * 100)}%`; + const NotificationPreferencesScreen = () => { const colors = useThemeColors(); const styles = React.useMemo(() => createStyles(colors), [colors]); - const { preferences, toggleCategory, setQuietHours, updatePreferences } = - useNotificationPreferencesStore(); + const { + preferences, + toggleCategory, + setQuietHours, + updatePreferences, + setChannelPreference, + setMuted, + markRead, + markAllRead, + } = useNotificationPreferencesStore(); + const history = useNotificationPreferencesStore((s) => s.history); + const analytics = React.useMemo(() => computeAnalytics(history), [history]); const priorityOrder: NotificationPriority[] = ['critical', 'informative', 'marketing']; + const recentHistory = React.useMemo(() => history.slice(0, 20), [history]); + const unread = analytics.unreadCount; return ( { accessibilityLabel="Notification Preferences screen"> Notification Preferences - Control which notifications you receive, when they're delivered, and how they're batched. + Control which notifications you receive, on which channels, when they're delivered, and how + they're batched. {/* Opt-in categories */} @@ -81,6 +113,68 @@ const NotificationPreferencesScreen = () => { })} + {/* Per-type channel routing */} + + Delivery channels + + Choose how each kind of notification reaches you. Channels are tried in order until one + accepts. + + + + {NOTIFICATION_CHANNELS.map((channel) => ( + + {CHANNEL_LABELS[channel]} + + ))} + + {NOTIFICATION_TYPES.map((type: NotificationType) => { + const meta = NOTIFICATION_TYPE_META[type]; + const preference = preferences.types[type]; + if (!preference) return null; + return ( + + + + {meta.label} + {meta.description} + {meta.required && ( + + Required — always sent on at least one channel + + )} + + {!meta.required && ( + setMuted(type, !preference.muted)} + accessibilityRole="button" + accessibilityLabel={`${preference.muted ? 'Unmute' : 'Mute'} ${meta.label}`}> + {preference.muted ? 'Unmute' : 'Mute'} + + )} + + + {NOTIFICATION_CHANNELS.map((channel) => { + const enabled = preference.channels[channel]; + return ( + + setChannelPreference(type, channel, next)} + accessibilityLabel={`${CHANNEL_LABELS[channel]} for ${meta.label}`} + accessibilityRole="switch" + accessibilityState={{ checked: enabled, disabled: preference.muted }} + /> + + ); + })} + + + ); + })} + + {/* Minimum priority filter */} Minimum priority @@ -199,6 +293,78 @@ const NotificationPreferencesScreen = () => { )} + {/* Engagement analytics */} + {analytics.totals.sent > 0 && ( + + Engagement + + + {analytics.totals.delivered} + Delivered + + + {formatPercent(analytics.totals.openRate)} + Open rate + + + {formatPercent(analytics.totals.clickRate)} + Click rate + + + {unread} + Unread + + + + {formatPercent(analytics.totals.deliveryRate)} delivered on first attempt ·{' '} + {analytics.totals.suppressed} suppressed by your preferences + {analytics.bestChannel + ? ` · you open ${CHANNEL_LABELS[analytics.bestChannel]} most` + : ''} + + + )} + + {/* History */} + {recentHistory.length > 0 && ( + + + Recent notifications + {unread > 0 && ( + markAllRead()} + accessibilityRole="button" + accessibilityLabel="Mark all notifications read"> + Mark all read + + )} + + {recentHistory.map((record) => ( + markRead(record.id)} + accessibilityRole="button" + accessibilityLabel={`${record.title}, ${record.readAt ? 'read' : 'unread'}`}> + + + {record.title} + + + {record.body} + + + {CHANNEL_LABELS[record.channel]} · {record.status} + {record.reason ? ` · ${record.reason}` : ''} ·{' '} + {new Date(record.createdAt).toLocaleString()} + + + {!record.readAt && } + + ))} + + )} + A/B test variant: {preferences.abVariant} — notification copy and timing may vary as we optimize delivery for you. @@ -238,6 +404,23 @@ function createStyles(colors: ReturnType) { rowLabel: { fontSize: 15, fontWeight: '600', color: colors.text.primary }, rowDesc: { fontSize: 12, color: colors.textSecondary, marginTop: 2, lineHeight: 16 }, requiredTag: { fontSize: 11, color: colors.primary, marginTop: 4, fontWeight: '500' }, + channelHeaderRow: { flexDirection: 'row', alignItems: 'center', marginTop: 12 }, + channelHeader: { + width: 56, + textAlign: 'center', + fontSize: 11, + fontWeight: '600', + color: colors.textSecondary, + }, + typeRow: { + paddingVertical: 10, + borderBottomWidth: 1, + borderBottomColor: colors.border.default, + }, + typeHeader: { flexDirection: 'row', alignItems: 'flex-start' }, + muteLink: { fontSize: 13, color: colors.primary, fontWeight: '600' }, + channelRow: { flexDirection: 'row', justifyContent: 'flex-end', marginTop: 6 }, + channelCell: { width: 56, alignItems: 'center' }, optionRow: { flexDirection: 'row', alignItems: 'center', @@ -269,6 +452,20 @@ function createStyles(colors: ReturnType) { }, timeBtnActive: { backgroundColor: colors.primary, borderColor: colors.primary }, timeBtnText: { fontSize: 13, color: colors.text.primary }, + statsRow: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 8 }, + statBox: { alignItems: 'center', flex: 1 }, + statValue: { fontSize: 18, fontWeight: '700', color: colors.text.primary }, + statLabel: { fontSize: 11, color: colors.textSecondary }, + historyRow: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 10, + borderBottomWidth: 1, + borderBottomColor: colors.border.default, + }, + unreadLabel: { color: colors.primary }, + historyMeta: { fontSize: 11, color: colors.textSecondary, marginTop: 4 }, + unreadDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: colors.primary }, footer: { fontSize: 12, color: colors.textSecondary, textAlign: 'center', lineHeight: 18 }, }); } diff --git a/src/screens/PauseResumeScreen.tsx b/src/screens/PauseResumeScreen.tsx index 1b1fb560..7e452369 100644 --- a/src/screens/PauseResumeScreen.tsx +++ b/src/screens/PauseResumeScreen.tsx @@ -33,15 +33,8 @@ const PauseResumeScreen: React.FC = ({ route }) => { const colors = useThemeColors(); const styles = useMemo(() => createStyles(colors), [colors]); - const { - subscriptions, - isLoading, - pauseRecords, - pauseAnalytics, - pauseSubscription, - resumeSubscription, - getPauseHistory, - } = useSubscriptionStore(); + const { subscriptions, isLoading, pauseSubscription, resumeSubscription, getPauseHistory } = + useSubscriptionStore(); const subscription = subscriptions.find((s) => s.id === subscriptionId); const pauseHistory = useMemo( @@ -51,7 +44,7 @@ const PauseResumeScreen: React.FC = ({ route }) => { ); const [selectedDuration, setSelectedDuration] = useState(null); - const [reason, setReason] = useState(''); + const [reason] = useState(''); const activePause = pauseHistory.find((p) => p.status === 'active'); 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(' · ')} + + + +