diff --git a/App.tsx b/App.tsx index f69abc59..6ff13d28 100644 --- a/App.tsx +++ b/App.tsx @@ -1,21 +1,14 @@ import React from 'react'; -import { View, Alert } from 'react-native'; +import { View } from 'react-native'; import { StatusBar } from 'expo-status-bar'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { AppNavigator } from './src/navigation/AppNavigator'; import { useNotifications } from './src/hooks/useNotifications'; import { useTransactionQueue } from './src/hooks/useTransactionQueue'; import ErrorBoundary from './src/components/ErrorBoundary'; -import CrashRecoveryModal from './src/components/CrashRecoveryModal'; import { initI18n } from './src/i18n/config'; import i18n from './src/i18n/config'; import { I18nextProvider } from 'react-i18next'; -import { crashReporter, CrashRecord } from './src/services/crashReporter'; -import * as Sentry from '@sentry/react-native'; - -// Validate all environment variables at startup — fails fast in production -// and warns in development/staging if any vars are missing or malformed. -import './src/config/env'; // Import WalletConnect compatibility layer import '@walletconnect/react-native-compat'; @@ -23,25 +16,11 @@ import '@walletconnect/react-native-compat'; import { createAppKit, defaultConfig, AppKit } from '@reown/appkit-ethers-react-native'; import { EVM_RPC_URLS } from './src/config/evm'; -import { useNetworkStore, useSettingsStore, useWalletStore } from './src/store'; +import { useNetworkStore, useSettingsStore } from './src/store'; import { sessionService } from './src/services/auth/session'; -// Get projectId from validated environment -const projectId = env.WALLET_CONNECT_PROJECT_ID; - -// Initialize Sentry (DSN provided via env var) -try { - Sentry.init({ - dsn: process.env.SENTRY_DSN || '', - enableAutoSessionTracking: true, - tracesSampleRate: Number(process.env.SENTRY_TRACES_SAMPLE_RATE || 0.05), - environment: process.env.NODE_ENV || 'production', - }); -} catch (e) { - // Fail gracefully if Sentry cannot initialize in some environments - // eslint-disable-next-line no-console - console.warn('Sentry init failed', e); -} +// Get projectId from environment variable +const projectId = process.env.WALLET_CONNECT_PROJECT_ID || 'YOUR_PROJECT_ID'; // Create metadata const metadata = { @@ -96,77 +75,26 @@ function NotificationBootstrap() { useNotifications(); useTransactionQueue(); - const wallet = useWalletStore(); - const { initialize } = useNetworkStore(); const { initializeSettings } = useSettingsStore(); React.useEffect(() => { initialize(); void initializeSettings(); - void (async () => { - const session = await sessionService.initializeCurrentSession(); - // Attach session context to Sentry for better diagnostics - try { - Sentry.setContext('session', { id: session.id, deviceName: session.deviceName }); - if (wallet?.address) { - Sentry.setUser({ id: wallet.address }); - } - } catch (e) { - // ignore - } - })(); + void sessionService.initializeCurrentSession(); }, [initialize, initializeSettings]); - return null; } -function AppShell() { - const { isDark, colors } = useTheme(); - - return ( - - - - - - - - - - - - - ); -} - export default function App() { const [i18nReady, setI18nReady] = React.useState(false); - const [pendingCrash, setPendingCrash] = React.useState(null); - const [showRecoveryModal, setShowRecoveryModal] = React.useState(false); React.useEffect(() => { let cancelled = false; const run = async () => { try { await initI18n(); - - // Initialize crash reporter — returns the previous crash if one exists - const previousCrash = await crashReporter.initialize({ - // Preserve user settings and auth tokens across a recovery wipe - preservedStorageKeys: [ - '@subtrackr/settings', - '@subtrackr/auth_token', - '@subtrackr/preferred_currency', - ], - installGlobalHandler: true, - }); - - if (previousCrash && !cancelled) { - setPendingCrash(previousCrash); - setShowRecoveryModal(true); - } } finally { if (!cancelled) setI18nReady(true); } @@ -177,29 +105,6 @@ export default function App() { }; }, []); - const handleRecover = async () => { - if (pendingCrash) { - const success = await crashReporter.attemptDataRecovery(pendingCrash.id); - await crashReporter.markNotified(pendingCrash.id); - setShowRecoveryModal(false); - setPendingCrash(null); - if (!success) { - Alert.alert( - 'Recovery Incomplete', - 'Some data could not be restored. The app will continue with a fresh state.' - ); - } - } - }; - - const handleDismissRecovery = async () => { - if (pendingCrash) { - await crashReporter.markNotified(pendingCrash.id); - } - setShowRecoveryModal(false); - setPendingCrash(null); - }; - if (!i18nReady) return null; return ( @@ -207,20 +112,12 @@ export default function App() { - - - - - - + + + + - ); diff --git a/contracts/access_control/src/lib.rs b/contracts/access_control/src/lib.rs index ca988036..da0658e7 100644 --- a/contracts/access_control/src/lib.rs +++ b/contracts/access_control/src/lib.rs @@ -206,7 +206,7 @@ impl RoleManager { .unwrap_or(Vec::new(&env)); let mut new_members: Vec
= Vec::new(&env); for m in members.iter() { - if &m != &user { + if m != user { new_members.push_back(m); } } @@ -221,7 +221,7 @@ impl RoleManager { .unwrap_or(Vec::new(&env)); let mut new_roles: Vec = Vec::new(&env); for r in user_roles.iter() { - if &r != &role { + if r != role { new_roles.push_back(r); } } @@ -600,7 +600,7 @@ impl RoleManager { .unwrap_or(DEFAULT_MULTISIG_THRESHOLD); assert!( - proposal.approvals.len() >= threshold as u32, + proposal.approvals.len() >= threshold, "Insufficient approvals" ); diff --git a/contracts/batch/src/lib.rs b/contracts/batch/src/lib.rs index 33e8f450..98f0fb7e 100644 --- a/contracts/batch/src/lib.rs +++ b/contracts/batch/src/lib.rs @@ -1,29 +1,96 @@ #![no_std] +#![allow(clippy::too_many_arguments)] -use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, Env, String, Vec}; +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, Address, Env, Vec, +}; -mod batch; -use batch::{BatchFilter, BatchOperation, BatchResult, BatchState, BatchStatus, CancelReason, OperationResult, OperationType, SubRecord, SubscriptionId}; -use subtrackr_types::SubscriptionId as SubscriptionIdAlias; +const MAX_BATCH_ITEMS: u32 = 100; +const GAS_BASE: u64 = 50_000; +const GAS_PER_ITEM: u64 = 100_000; #[contracterror] #[derive(Clone, Debug, Copy, PartialEq, Eq)] #[repr(u32)] pub enum BatchError { - AlreadyInitialized = 1, - NotInitialized = 2, - InvalidBatch = 3, - AlreadyExecuted = 4, - NotFound = 5, - Unauthorized = 6, + InvalidBatch = 1, + AlreadyExecuted = 2, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum OperationType { + Create, + Charge, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct BatchOperation { + pub operation_type: OperationType, + pub subscription_ids: Vec, + pub params: Vec, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum BatchState { + Pending, + Completed, + PartiallyCompleted, + Failed, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct BatchStatus { + pub state: BatchState, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct SubscriptionRecord { + pub id: u64, + pub charged: i128, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct BatchResult { + pub total_operations: u32, + pub successful_operations: u32, + pub failed_operations: u32, + pub gas_estimate: u64, + pub rolled_back: bool, } #[contracttype] #[derive(Clone)] -pub struct BatchItem { - pub account: Address, - pub amount: i128, - pub is_refund: bool, +enum DataKey { + Admin, + BatchCount, + Batch(u64), + BatchOwner(u64), + BatchAtomic(u64), + BatchExecuted(u64), + BatchStatus(u64), + Subscription(u64), + History, +} + +pub fn validate_batch_operation(op: &BatchOperation) -> bool { + let n = op.subscription_ids.len(); + if n == 0 || n > MAX_BATCH_ITEMS { + return false; + } + match op.operation_type { + OperationType::Create => true, + OperationType::Charge => op.params.len() == n, + } +} + +pub fn estimate_batch_gas(op: &BatchOperation) -> u64 { + GAS_BASE + (op.subscription_ids.len() as u64 * GAS_PER_ITEM) } #[contract] @@ -31,301 +98,168 @@ pub struct SubTrackrBatch; #[contractimpl] impl SubTrackrBatch { - pub fn initialize(env: Env, admin: Address) -> Result<(), BatchError> { - let storage = env.storage().instance(); - if storage.has(&DataKey::Admin) { - return Err(BatchError::AlreadyInitialized); + pub fn initialize(env: Env, admin: Address) { + if env.storage().instance().has(&DataKey::Admin) { + return; } - storage.set(&DataKey::Admin, &admin); - storage.set(&DataKey::NextBatchId, &1u64); - storage.set(&DataKey::BatchHistory, &Vec::new(&env)); - Ok(()) + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::BatchCount, &0u64); + env.storage() + .instance() + .set(&DataKey::History, &Vec::::new(&env)); + } + + pub fn seed_subscription(env: Env, subscription_id: u64) { + let sub = SubscriptionRecord { + id: subscription_id, + charged: 0, + }; + env.storage() + .persistent() + .set(&DataKey::Subscription(subscription_id), &sub); + } + + pub fn get_subscription(env: Env, subscription_id: u64) -> Option { + env.storage() + .persistent() + .get(&DataKey::Subscription(subscription_id)) } pub fn create_batch_operation( env: Env, - _owner: Address, + owner: Address, operation: BatchOperation, atomic: bool, ) -> Result { - let storage = env.storage().instance(); - if !storage.has(&DataKey::Admin) { - return Err(BatchError::NotInitialized); - } - if !batch::validate_batch_operation(&operation) { + owner.require_auth(); + if !validate_batch_operation(&operation) { return Err(BatchError::InvalidBatch); } - let batch_id = Self::next_batch_id(&env); - storage.set(&DataKey::BatchOperation(batch_id), &operation); - storage.set(&DataKey::BatchState(batch_id), &BatchState::Pending); - - let mut history: Vec = storage.get(&DataKey::BatchHistory).unwrap_or_else(|| Vec::new(&env)); - history.push_back(batch_id); - storage.set(&DataKey::BatchHistory, &history); - - storage.set(&DataKey::NextBatchId, &(batch_id + 1)); - - let mut result = BatchResult { - batch_id, - total_operations: operation.subscription_ids.len() as u32, - successful_operations: 0, - failed_operations: 0, - skipped_operations: 0, - results: Vec::new(&env), - atomic, - rolled_back: false, - gas_estimate: batch::estimate_batch_gas(&operation), - }; - storage.set(&DataKey::BatchResult(batch_id), &result); - Ok(batch_id) - } - - pub fn execute_batch(env: Env, batch_id: u64) -> Result { - let storage = env.storage().instance(); - if !storage.has(&DataKey::Admin) { - return Err(BatchError::NotInitialized); - } - - let state: BatchState = storage - .get(&DataKey::BatchState(batch_id)) - .ok_or(BatchError::NotFound)?; - if state != BatchState::Pending { - return Err(BatchError::AlreadyExecuted); - } - - let operation: BatchOperation = storage - .get(&DataKey::BatchOperation(batch_id)) - .ok_or(BatchError::NotFound)?; - let mut result: BatchResult = storage - .get(&DataKey::BatchResult(batch_id)) - .ok_or(BatchError::NotFound)?; - - let mut modified: Vec<(SubscriptionIdAlias, Option)> = Vec::new(&env); - let mut failed_count = 0u32; - let mut successful_count = 0u32; - let mut skipped_count = 0u32; - let mut saw_failure = false; - - for idx in 0..operation.subscription_ids.len() { - let subscription_id = operation.subscription_ids.get(idx).unwrap(); - let prior = storage.get(&DataKey::Subscription(subscription_id)).ok(); - modified.push_back((*subscription_id, prior.clone())); - - let op_result = match operation.operation_type { - OperationType::Create => Self::execute_create(&env, *subscription_id, prior.clone()), - OperationType::Charge => Self::execute_charge( - &env, - *subscription_id, - operation.params.get(idx).unwrap_or(0), - prior.clone(), - ), - OperationType::Update => Self::execute_update( - &env, - *subscription_id, - operation.params.get(idx).unwrap_or(0), - prior.clone(), - ), - OperationType::Cancel => Self::execute_cancel( - &env, - *subscription_id, - operation.cancel_reasons.get(idx).unwrap_or(batch::CancelReason::Custom).clone(), - prior.clone(), - ), - _ => OperationResult { - subscription_id: *subscription_id, - success: true, - code: 0, - reason: None, - }, - }; - - result.results.push_back(op_result.clone()); - if op_result.success { - successful_count += 1; - } else { - failed_count += 1; - saw_failure = true; - } - - if saw_failure && result.atomic { - skipped_count += (operation.subscription_ids.len() - idx - 1) as u32; - break; - } - } - - if result.atomic && saw_failure { - for entry in modified.iter() { - let (sub_id, original) = entry; - if let Some(record) = original { - storage.set(&DataKey::Subscription(*sub_id), record); - } else { - env.storage().instance().remove(&DataKey::Subscription(*sub_id)); - } - } - successful_count = 0; - skipped_count = result.total_operations - failed_count; - result.rolled_back = true; - result.state = BatchState::Failed; - } else if failed_count == 0 { - result.state = BatchState::Completed; - } else { - result.state = BatchState::PartiallyCompleted; - } + let mut count: u64 = env.storage().instance().get(&DataKey::BatchCount).unwrap_or(0); + count += 1; + env.storage().instance().set(&DataKey::BatchCount, &count); - result.successful_operations = successful_count; - result.failed_operations = failed_count; - result.skipped_operations = skipped_count; - storage.set(&DataKey::BatchResult(batch_id), &result); - storage.set(&DataKey::BatchState(batch_id), &result.state); + env.storage() + .persistent() + .set(&DataKey::Batch(count), &operation); + env.storage() + .persistent() + .set(&DataKey::BatchOwner(count), &owner); + env.storage() + .persistent() + .set(&DataKey::BatchAtomic(count), &atomic); + env.storage() + .persistent() + .set(&DataKey::BatchExecuted(count), &false); + env.storage() + .persistent() + .set(&DataKey::BatchStatus(count), &BatchStatus { state: BatchState::Pending }); - Ok(result) - } + let mut history: Vec = env.storage().instance().get(&DataKey::History).unwrap(); + history.push_back(count); + env.storage().instance().set(&DataKey::History, &history); - pub fn get_batch_status(env: Env, batch_id: u64) -> Result { - let storage = env.storage().instance(); - let state: BatchState = storage - .get(&DataKey::BatchState(batch_id)) - .ok_or(BatchError::NotFound)?; - let result: BatchResult = storage - .get(&DataKey::BatchResult(batch_id)) - .ok_or(BatchError::NotFound)?; - - Ok(BatchStatus { - batch_id, - state, - total: result.total_operations, - succeeded: result.successful_operations, - failed: result.failed_operations, - }) - } - - pub fn get_subscription(env: Env, subscription_id: SubscriptionIdAlias) -> Option { - env.storage().instance().get(&DataKey::Subscription(subscription_id)) + Ok(count) } pub fn get_batch_history(env: Env) -> Vec { env.storage() .instance() - .get(&DataKey::BatchHistory) - .unwrap_or_else(|| Vec::new(&env)) - } - - pub fn seed_subscription(env: Env, subscription_id: SubscriptionIdAlias) { - let record = SubRecord { - exists: true, - status: batch::SubStatus::Active, - charged: 0, - }; - env.storage().instance().set(&DataKey::Subscription(subscription_id), &record); + .get(&DataKey::History) + .unwrap_or(Vec::new(&env)) } -} -impl SubTrackrBatch { - fn next_batch_id(env: &Env) -> u64 { - let storage = env.storage().instance(); - storage.get(&DataKey::NextBatchId).unwrap_or(1u64) + pub fn get_batch_status(env: Env, batch_id: u64) -> BatchStatus { + env.storage() + .persistent() + .get(&DataKey::BatchStatus(batch_id)) + .unwrap_or(BatchStatus { + state: BatchState::Pending, + }) } - fn execute_create( - env: &Env, - subscription_id: SubscriptionIdAlias, - prior: Option, - ) -> OperationResult { - if prior.is_some() { - OperationResult { - subscription_id, - success: false, - code: 1, - reason: Some(String::from_small_str("AlreadyExists")), - } - } else { - let record = SubRecord { - exists: true, - status: batch::SubStatus::Active, - charged: 0, - }; - env.storage().instance().set(&DataKey::Subscription(subscription_id), &record); - OperationResult { - subscription_id, - success: true, - code: 0, - reason: None, - } + pub fn execute_batch(env: Env, batch_id: u64) -> Result { + let executed: bool = env + .storage() + .persistent() + .get(&DataKey::BatchExecuted(batch_id)) + .unwrap_or(false); + if executed { + return Err(BatchError::AlreadyExecuted); } - } - fn execute_charge( - env: &Env, - subscription_id: SubscriptionIdAlias, - amount: i128, - prior: Option, - ) -> OperationResult { - match prior { - Some(mut record) if record.exists && record.status != batch::SubStatus::Cancelled => { - record.charged += amount; - env.storage().instance().set(&DataKey::Subscription(subscription_id), &record); - OperationResult { - subscription_id, - success: true, - code: 0, - reason: None, + let op: BatchOperation = env + .storage() + .persistent() + .get(&DataKey::Batch(batch_id)) + .ok_or(BatchError::InvalidBatch)?; + let atomic: bool = env + .storage() + .persistent() + .get(&DataKey::BatchAtomic(batch_id)) + .unwrap_or(false); + + let total = op.subscription_ids.len(); + let gas_estimate = estimate_batch_gas(&op); + + 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); + + 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 }; + if atomic { + staged.push_back(sub); + } else { + env.storage() + .persistent() + .set(&DataKey::Subscription(sub_id), &sub); + } + successful += 1; + } + 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; + if atomic { + staged.push_back(sub); + } else { + env.storage() + .persistent() + .set(&DataKey::Subscription(sub_id), &sub); + } + successful += 1; } - } - Some(_) => OperationResult { - subscription_id, - success: false, - code: 2, - reason: Some(String::from_small_str("InvalidSubscription")), - }, - None => OperationResult { - subscription_id, - success: false, - code: 3, - reason: Some(String::from_small_str("SubscriptionMissing")), - }, - } - } - - fn execute_update( - _env: &Env, - subscription_id: SubscriptionIdAlias, - _param: i128, - prior: Option, - ) -> OperationResult { - if prior.is_some() { - OperationResult { - subscription_id, - success: true, - code: 0, - reason: None, - } - } else { - OperationResult { - subscription_id, - success: false, - code: 4, - reason: Some(String::from_small_str("SubscriptionMissing")), } } - } - fn execute_cancel( - env: &Env, - subscription_id: SubscriptionIdAlias, - _reason: CancelReason, - prior: Option, - ) -> OperationResult { - match prior { - Some(mut record) if record.exists => { - record.status = batch::SubStatus::Cancelled; - env.storage().instance().set(&DataKey::Subscription(subscription_id), &record); - OperationResult { - subscription_id, - success: true, - code: 0, - reason: None, - } + let rolled_back = atomic && failed > 0; + if rolled_back { + successful = 0; + } else if atomic { + for sub in staged.iter() { + env.storage() + .persistent() + .set(&DataKey::Subscription(sub.id), &sub); } _ => OperationResult { subscription_id, @@ -334,5 +268,28 @@ impl SubTrackrBatch { reason: Some(String::from_small_str("SubscriptionMissing")), }, } + + let state = if rolled_back { + BatchState::Failed + } else if failed == 0 { + BatchState::Completed + } else { + BatchState::PartiallyCompleted + }; + + env.storage() + .persistent() + .set(&DataKey::BatchExecuted(batch_id), &true); + env.storage() + .persistent() + .set(&DataKey::BatchStatus(batch_id), &BatchStatus { state }); + + Ok(BatchResult { + total_operations: total, + successful_operations: successful, + failed_operations: failed, + gas_estimate, + rolled_back, + }) } } diff --git a/contracts/batch/tests/batch_tests.rs b/contracts/batch/tests/batch_tests.rs index 0114d12e..117cb410 100644 --- a/contracts/batch/tests/batch_tests.rs +++ b/contracts/batch/tests/batch_tests.rs @@ -153,8 +153,10 @@ fn rejects_invalid_batch_creation() { fn records_audit_history() { let (env, client, _admin) = setup(); let owner = Address::generate(&env); - let a = client.create_batch_operation(&owner, &op(&env, OperationType::Create, &[1], &[]), &false); - let b = client.create_batch_operation(&owner, &op(&env, OperationType::Create, &[2], &[]), &false); + let a = + client.create_batch_operation(&owner, &op(&env, OperationType::Create, &[1], &[]), &false); + let b = + client.create_batch_operation(&owner, &op(&env, OperationType::Create, &[2], &[]), &false); let history = client.get_batch_history(); assert_eq!(history, vec![&env, a, b]); } diff --git a/contracts/fraud/src/lib.rs b/contracts/fraud/src/lib.rs index 125c6892..f3b19848 100644 --- a/contracts/fraud/src/lib.rs +++ b/contracts/fraud/src/lib.rs @@ -325,7 +325,7 @@ impl SubTrackrFraud { pub fn assess_risk(env: Env, subscriber: Address) -> RiskScore { let ids = get_subscriptions(&env, &subscriber); - if ids.len() == 0 { + if ids.is_empty() { return RiskScore { subscriber: subscriber.clone(), subscription_id: 0, @@ -452,7 +452,7 @@ impl SubTrackrFraud { i += 1; } - let average_risk = if ids.len() == 0 { + let average_risk = if ids.is_empty() { 0 } else { total_risk / ids.len() diff --git a/contracts/invoice/src/lib.rs b/contracts/invoice/src/lib.rs index 6f675a61..b949f381 100644 --- a/contracts/invoice/src/lib.rs +++ b/contracts/invoice/src/lib.rs @@ -10,8 +10,9 @@ use alloc::vec; use soroban_sdk::{Address, Bytes, Env, IntoVal, String, TryFromVal, Val, Vec}; use subtrackr_types::{ CustomerTaxStatus, DigitalGoodsClass, Invoice, InvoiceConfig, InvoiceLineItem, InvoiceStatus, - Plan, StorageKey, Subscription, TaxRateChangeEvent, TaxRateEntry, TaxRemittanceLineItem, - TaxRemittanceReport, TaxType, TaxJurisdiction, TimeRange, TaxReportLineItem, RemittanceStatus, + MaybeDigitalGoodsClass, Plan, StorageKey, Subscription, TaxRateChangeEvent, TaxRateEntry, + TaxRemittanceLineItem, TaxRemittanceReport, TaxType, TaxJurisdiction, TimeRange, + TaxReportLineItem, RemittanceStatus, }; const DEFAULT_RATE_SCALE: i128 = 1_000_000; @@ -179,7 +180,7 @@ fn get_customer_tax_status(env: &Env, subscriber: &Address) -> CustomerTaxStatus certificate_expiry: 0, issuing_authority: String::from_str(env, ""), exempt_jurisdictions: Vec::new(env), - digital_goods_override: None, + digital_goods_override: MaybeDigitalGoodsClass::None, }) } @@ -611,7 +612,7 @@ impl SubTrackrInvoice { certificate_expiry: u64, issuing_authority: String, exempt_jurisdictions: Vec, - digital_goods_override: Option, + digital_goods_override: MaybeDigitalGoodsClass, ) { let stored_admin = get_admin(&env); assert!(admin == stored_admin, "Admin mismatch"); diff --git a/contracts/metering/src/lib.rs b/contracts/metering/src/lib.rs index 4131d014..6a566c47 100644 --- a/contracts/metering/src/lib.rs +++ b/contracts/metering/src/lib.rs @@ -1,4 +1,5 @@ #![no_std] +#![allow(clippy::too_many_arguments)] //! SubTrackr usage-metering contract. //! //! Real-time usage-based billing (issue: metered billing). Reporters push diff --git a/contracts/oracle/src/lib.rs b/contracts/oracle/src/lib.rs index 4cc946c2..4e878e6b 100644 --- a/contracts/oracle/src/lib.rs +++ b/contracts/oracle/src/lib.rs @@ -1,4 +1,5 @@ #![no_std] +#![allow(clippy::too_many_arguments)] //! SubTrackr price oracle contract. //! //! A push-style oracle: authorized feed addresses submit signed price diff --git a/contracts/proxy/src/lib.rs b/contracts/proxy/src/lib.rs index 61a0dcfe..08da23be 100644 --- a/contracts/proxy/src/lib.rs +++ b/contracts/proxy/src/lib.rs @@ -377,6 +377,35 @@ impl UpgradeableProxy { ); } + pub fn set_max_plans_per_merchant(env: Env, new_limit: u32) { + let proxy_addr = current_proxy_address(&env); + let storage_addr = proxy_storage::storage_address(&env); + invoke_impl::<()>( + &env, + "set_max_plans_per_merchant", + soroban_sdk::vec![ + &env, + proxy_addr.into_val(&env), + storage_addr.into_val(&env), + new_limit.into_val(&env) + ], + ); + } + + pub fn get_max_plans_per_merchant(env: Env) -> u32 { + let proxy_addr = current_proxy_address(&env); + let storage_addr = proxy_storage::storage_address(&env); + invoke_impl( + &env, + "get_max_plans_per_merchant", + soroban_sdk::vec![ + &env, + proxy_addr.into_val(&env), + storage_addr.into_val(&env) + ], + ) + } + pub fn create_plan( env: Env, merchant: Address, diff --git a/contracts/proxy/test_snapshots/integration_contract_deploys_and_state_persists.1.json b/contracts/proxy/test_snapshots/integration_contract_deploys_and_state_persists.1.json index 4eaf12ba..28ea604b 100644 --- a/contracts/proxy/test_snapshots/integration_contract_deploys_and_state_persists.1.json +++ b/contracts/proxy/test_snapshots/integration_contract_deploys_and_state_persists.1.json @@ -964,7 +964,7 @@ "key": { "vec": [ { - "symbol": "ProxyPreviousImplementationCount" + "symbol": "ProxyPrevImplCount" } ] }, @@ -1844,6 +1844,57 @@ }, "failed_call": false }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000006", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_call" + }, + { + "bytes": "0000000000000000000000000000000000000000000000000000000000000005" + }, + { + "symbol": "instance_get" + } + ], + "data": { + "vec": [ + { + "symbol": "MaxPlansPerMerchant" + } + ] + } + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000005", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_return" + }, + { + "symbol": "instance_get" + } + ], + "data": "void" + } + } + }, + "failed_call": false + }, { "event": { "ext": "v0", @@ -1897,6 +1948,60 @@ }, "failed_call": false }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000006", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_call" + }, + { + "bytes": "0000000000000000000000000000000000000000000000000000000000000005" + }, + { + "symbol": "persistent_get" + } + ], + "data": { + "vec": [ + { + "symbol": "MerchantPlans" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + } + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000005", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_return" + }, + { + "symbol": "persistent_get" + } + ], + "data": "void" + } + } + }, + "failed_call": false + }, { "event": { "ext": "v0", @@ -2096,60 +2201,6 @@ }, "failed_call": false }, - { - "event": { - "ext": "v0", - "contract_id": "0000000000000000000000000000000000000000000000000000000000000006", - "type_": "diagnostic", - "body": { - "v0": { - "topics": [ - { - "symbol": "fn_call" - }, - { - "bytes": "0000000000000000000000000000000000000000000000000000000000000005" - }, - { - "symbol": "persistent_get" - } - ], - "data": { - "vec": [ - { - "symbol": "MerchantPlans" - }, - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" - } - ] - } - } - } - }, - "failed_call": false - }, - { - "event": { - "ext": "v0", - "contract_id": "0000000000000000000000000000000000000000000000000000000000000005", - "type_": "diagnostic", - "body": { - "v0": { - "topics": [ - { - "symbol": "fn_return" - }, - { - "symbol": "persistent_get" - } - ], - "data": "void" - } - } - }, - "failed_call": false - }, { "event": { "ext": "v0", @@ -4692,4 +4743,4 @@ "failed_call": false } ] -} +} \ No newline at end of file diff --git a/contracts/proxy/test_snapshots/integration_cross_contract_call_charges_subscription.1.json b/contracts/proxy/test_snapshots/integration_cross_contract_call_charges_subscription.1.json index e5de837b..1fc7af90 100644 --- a/contracts/proxy/test_snapshots/integration_cross_contract_call_charges_subscription.1.json +++ b/contracts/proxy/test_snapshots/integration_cross_contract_call_charges_subscription.1.json @@ -1300,7 +1300,7 @@ "key": { "vec": [ { - "symbol": "ProxyPreviousImplementationCount" + "symbol": "ProxyPrevImplCount" } ] }, @@ -2285,6 +2285,57 @@ }, "failed_call": false }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000006", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_call" + }, + { + "bytes": "0000000000000000000000000000000000000000000000000000000000000005" + }, + { + "symbol": "instance_get" + } + ], + "data": { + "vec": [ + { + "symbol": "MaxPlansPerMerchant" + } + ] + } + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000005", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_return" + }, + { + "symbol": "instance_get" + } + ], + "data": "void" + } + } + }, + "failed_call": false + }, { "event": { "ext": "v0", @@ -2338,6 +2389,60 @@ }, "failed_call": false }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000006", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_call" + }, + { + "bytes": "0000000000000000000000000000000000000000000000000000000000000005" + }, + { + "symbol": "persistent_get" + } + ], + "data": { + "vec": [ + { + "symbol": "MerchantPlans" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + } + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000005", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_return" + }, + { + "symbol": "persistent_get" + } + ], + "data": "void" + } + } + }, + "failed_call": false + }, { "event": { "ext": "v0", @@ -2537,60 +2642,6 @@ }, "failed_call": false }, - { - "event": { - "ext": "v0", - "contract_id": "0000000000000000000000000000000000000000000000000000000000000006", - "type_": "diagnostic", - "body": { - "v0": { - "topics": [ - { - "symbol": "fn_call" - }, - { - "bytes": "0000000000000000000000000000000000000000000000000000000000000005" - }, - { - "symbol": "persistent_get" - } - ], - "data": { - "vec": [ - { - "symbol": "MerchantPlans" - }, - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" - } - ] - } - } - } - }, - "failed_call": false - }, - { - "event": { - "ext": "v0", - "contract_id": "0000000000000000000000000000000000000000000000000000000000000005", - "type_": "diagnostic", - "body": { - "v0": { - "topics": [ - { - "symbol": "fn_return" - }, - { - "symbol": "persistent_get" - } - ], - "data": "void" - } - } - }, - "failed_call": false - }, { "event": { "ext": "v0", @@ -4240,6 +4291,111 @@ }, "failed_call": false }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000006", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_call" + }, + { + "bytes": "0000000000000000000000000000000000000000000000000000000000000005" + }, + { + "symbol": "instance_get" + } + ], + "data": { + "vec": [ + { + "symbol": "OracleContract" + } + ] + } + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000005", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_return" + }, + { + "symbol": "instance_get" + } + ], + "data": "void" + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000006", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_call" + }, + { + "bytes": "0000000000000000000000000000000000000000000000000000000000000005" + }, + { + "symbol": "persistent_get" + } + ], + "data": { + "vec": [ + { + "symbol": "PriceBounds" + }, + { + "u64": 1 + } + ] + } + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000005", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_return" + }, + { + "symbol": "persistent_get" + } + ], + "data": "void" + } + } + }, + "failed_call": false + }, { "event": { "ext": "v0", @@ -5709,4 +5865,4 @@ "failed_call": false } ] -} +} \ No newline at end of file diff --git a/contracts/proxy/test_snapshots/integration_multiple_contract_interactions_work.1.json b/contracts/proxy/test_snapshots/integration_multiple_contract_interactions_work.1.json index 58b112a5..5a469a0c 100644 --- a/contracts/proxy/test_snapshots/integration_multiple_contract_interactions_work.1.json +++ b/contracts/proxy/test_snapshots/integration_multiple_contract_interactions_work.1.json @@ -2241,7 +2241,7 @@ "key": { "vec": [ { - "symbol": "ProxyPreviousImplementationCount" + "symbol": "ProxyPrevImplCount" } ] }, @@ -3518,6 +3518,57 @@ }, "failed_call": false }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000006", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_call" + }, + { + "bytes": "0000000000000000000000000000000000000000000000000000000000000005" + }, + { + "symbol": "instance_get" + } + ], + "data": { + "vec": [ + { + "symbol": "MaxPlansPerMerchant" + } + ] + } + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000005", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_return" + }, + { + "symbol": "instance_get" + } + ], + "data": "void" + } + } + }, + "failed_call": false + }, { "event": { "ext": "v0", @@ -3571,6 +3622,60 @@ }, "failed_call": false }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000006", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_call" + }, + { + "bytes": "0000000000000000000000000000000000000000000000000000000000000005" + }, + { + "symbol": "persistent_get" + } + ], + "data": { + "vec": [ + { + "symbol": "MerchantPlans" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + } + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000005", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_return" + }, + { + "symbol": "persistent_get" + } + ], + "data": "void" + } + } + }, + "failed_call": false + }, { "event": { "ext": "v0", @@ -3770,60 +3875,6 @@ }, "failed_call": false }, - { - "event": { - "ext": "v0", - "contract_id": "0000000000000000000000000000000000000000000000000000000000000006", - "type_": "diagnostic", - "body": { - "v0": { - "topics": [ - { - "symbol": "fn_call" - }, - { - "bytes": "0000000000000000000000000000000000000000000000000000000000000005" - }, - { - "symbol": "persistent_get" - } - ], - "data": { - "vec": [ - { - "symbol": "MerchantPlans" - }, - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" - } - ] - } - } - } - }, - "failed_call": false - }, - { - "event": { - "ext": "v0", - "contract_id": "0000000000000000000000000000000000000000000000000000000000000005", - "type_": "diagnostic", - "body": { - "v0": { - "topics": [ - { - "symbol": "fn_return" - }, - { - "symbol": "persistent_get" - } - ], - "data": "void" - } - } - }, - "failed_call": false - }, { "event": { "ext": "v0", @@ -5384,6 +5435,57 @@ }, "failed_call": false }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000006", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_call" + }, + { + "bytes": "0000000000000000000000000000000000000000000000000000000000000005" + }, + { + "symbol": "instance_get" + } + ], + "data": { + "vec": [ + { + "symbol": "MaxPlansPerMerchant" + } + ] + } + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000005", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_return" + }, + { + "symbol": "instance_get" + } + ], + "data": "void" + } + } + }, + "failed_call": false + }, { "event": { "ext": "v0", @@ -5437,6 +5539,60 @@ }, "failed_call": false }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000006", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_call" + }, + { + "bytes": "0000000000000000000000000000000000000000000000000000000000000005" + }, + { + "symbol": "persistent_get" + } + ], + "data": { + "vec": [ + { + "symbol": "MerchantPlans" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAATYON" + } + ] + } + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000005", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_return" + }, + { + "symbol": "persistent_get" + } + ], + "data": "void" + } + } + }, + "failed_call": false + }, { "event": { "ext": "v0", @@ -5514,99 +5670,41 @@ } }, { - "key": { - "symbol": "name" - }, - "val": { - "string": "Premium Plan" - } - }, - { - "key": { - "symbol": "price" - }, - "val": { - "i128": { - "hi": 0, - "lo": 900 - } - } - }, - { - "key": { - "symbol": "subscriber_count" - }, - "val": { - "u32": 0 - } - }, - { - "key": { - "symbol": "token" - }, - "val": { - "address": "CCVQTUQIJR624NNEI5TORM2BHEXTSDMY5ZB3CYJKAATGJQCY7LU2MD45" - } - } - ] - } - ] - } - } - } - }, - "failed_call": false - }, - { - "event": { - "ext": "v0", - "contract_id": "0000000000000000000000000000000000000000000000000000000000000005", - "type_": "diagnostic", - "body": { - "v0": { - "topics": [ - { - "symbol": "fn_return" - }, - { - "symbol": "persistent_set" - } - ], - "data": "void" - } - } - }, - "failed_call": false - }, - { - "event": { - "ext": "v0", - "contract_id": "0000000000000000000000000000000000000000000000000000000000000006", - "type_": "diagnostic", - "body": { - "v0": { - "topics": [ - { - "symbol": "fn_call" - }, - { - "bytes": "0000000000000000000000000000000000000000000000000000000000000005" - }, - { - "symbol": "instance_set" - } - ], - "data": { - "vec": [ - { - "vec": [ + "key": { + "symbol": "name" + }, + "val": { + "string": "Premium Plan" + } + }, { - "symbol": "PlanCount" + "key": { + "symbol": "price" + }, + "val": { + "i128": { + "hi": 0, + "lo": 900 + } + } + }, + { + "key": { + "symbol": "subscriber_count" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "token" + }, + "val": { + "address": "CCVQTUQIJR624NNEI5TORM2BHEXTSDMY5ZB3CYJKAATGJQCY7LU2MD45" + } } ] - }, - { - "u64": 2 } ] } @@ -5627,7 +5725,7 @@ "symbol": "fn_return" }, { - "symbol": "instance_set" + "symbol": "persistent_set" } ], "data": "void" @@ -5651,16 +5749,20 @@ "bytes": "0000000000000000000000000000000000000000000000000000000000000005" }, { - "symbol": "persistent_get" + "symbol": "instance_set" } ], "data": { "vec": [ { - "symbol": "MerchantPlans" + "vec": [ + { + "symbol": "PlanCount" + } + ] }, { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAATYON" + "u64": 2 } ] } @@ -5681,7 +5783,7 @@ "symbol": "fn_return" }, { - "symbol": "persistent_get" + "symbol": "instance_set" } ], "data": "void" @@ -7315,6 +7417,111 @@ }, "failed_call": false }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000006", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_call" + }, + { + "bytes": "0000000000000000000000000000000000000000000000000000000000000005" + }, + { + "symbol": "instance_get" + } + ], + "data": { + "vec": [ + { + "symbol": "OracleContract" + } + ] + } + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000005", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_return" + }, + { + "symbol": "instance_get" + } + ], + "data": "void" + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000006", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_call" + }, + { + "bytes": "0000000000000000000000000000000000000000000000000000000000000005" + }, + { + "symbol": "persistent_get" + } + ], + "data": { + "vec": [ + { + "symbol": "PriceBounds" + }, + { + "u64": 1 + } + ] + } + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000005", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_return" + }, + { + "symbol": "persistent_get" + } + ], + "data": "void" + } + } + }, + "failed_call": false + }, { "event": { "ext": "v0", @@ -8729,6 +8936,111 @@ }, "failed_call": false }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000006", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_call" + }, + { + "bytes": "0000000000000000000000000000000000000000000000000000000000000005" + }, + { + "symbol": "instance_get" + } + ], + "data": { + "vec": [ + { + "symbol": "OracleContract" + } + ] + } + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000005", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_return" + }, + { + "symbol": "instance_get" + } + ], + "data": "void" + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000006", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_call" + }, + { + "bytes": "0000000000000000000000000000000000000000000000000000000000000005" + }, + { + "symbol": "persistent_get" + } + ], + "data": { + "vec": [ + { + "symbol": "PriceBounds" + }, + { + "u64": 2 + } + ] + } + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000005", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_return" + }, + { + "symbol": "persistent_get" + } + ], + "data": "void" + } + } + }, + "failed_call": false + }, { "event": { "ext": "v0", @@ -10790,4 +11102,4 @@ "failed_call": false } ] -} +} \ No newline at end of file diff --git a/contracts/proxy/test_snapshots/integration_uses_actual_token_contract_for_charges.1.json b/contracts/proxy/test_snapshots/integration_uses_actual_token_contract_for_charges.1.json index 0e04372a..fff025c5 100644 --- a/contracts/proxy/test_snapshots/integration_uses_actual_token_contract_for_charges.1.json +++ b/contracts/proxy/test_snapshots/integration_uses_actual_token_contract_for_charges.1.json @@ -1304,7 +1304,7 @@ "key": { "vec": [ { - "symbol": "ProxyPreviousImplementationCount" + "symbol": "ProxyPrevImplCount" } ] }, @@ -2257,6 +2257,57 @@ }, "failed_call": false }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000006", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_call" + }, + { + "bytes": "0000000000000000000000000000000000000000000000000000000000000005" + }, + { + "symbol": "instance_get" + } + ], + "data": { + "vec": [ + { + "symbol": "MaxPlansPerMerchant" + } + ] + } + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000005", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_return" + }, + { + "symbol": "instance_get" + } + ], + "data": "void" + } + } + }, + "failed_call": false + }, { "event": { "ext": "v0", @@ -2310,6 +2361,60 @@ }, "failed_call": false }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000006", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_call" + }, + { + "bytes": "0000000000000000000000000000000000000000000000000000000000000005" + }, + { + "symbol": "persistent_get" + } + ], + "data": { + "vec": [ + { + "symbol": "MerchantPlans" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + ] + } + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000005", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_return" + }, + { + "symbol": "persistent_get" + } + ], + "data": "void" + } + } + }, + "failed_call": false + }, { "event": { "ext": "v0", @@ -2509,60 +2614,6 @@ }, "failed_call": false }, - { - "event": { - "ext": "v0", - "contract_id": "0000000000000000000000000000000000000000000000000000000000000006", - "type_": "diagnostic", - "body": { - "v0": { - "topics": [ - { - "symbol": "fn_call" - }, - { - "bytes": "0000000000000000000000000000000000000000000000000000000000000005" - }, - { - "symbol": "persistent_get" - } - ], - "data": { - "vec": [ - { - "symbol": "MerchantPlans" - }, - { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" - } - ] - } - } - } - }, - "failed_call": false - }, - { - "event": { - "ext": "v0", - "contract_id": "0000000000000000000000000000000000000000000000000000000000000005", - "type_": "diagnostic", - "body": { - "v0": { - "topics": [ - { - "symbol": "fn_return" - }, - { - "symbol": "persistent_get" - } - ], - "data": "void" - } - } - }, - "failed_call": false - }, { "event": { "ext": "v0", @@ -4283,6 +4334,111 @@ }, "failed_call": false }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000006", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_call" + }, + { + "bytes": "0000000000000000000000000000000000000000000000000000000000000005" + }, + { + "symbol": "instance_get" + } + ], + "data": { + "vec": [ + { + "symbol": "OracleContract" + } + ] + } + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000005", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_return" + }, + { + "symbol": "instance_get" + } + ], + "data": "void" + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000006", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_call" + }, + { + "bytes": "0000000000000000000000000000000000000000000000000000000000000005" + }, + { + "symbol": "persistent_get" + } + ], + "data": { + "vec": [ + { + "symbol": "PriceBounds" + }, + { + "u64": 1 + } + ] + } + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000005", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_return" + }, + { + "symbol": "persistent_get" + } + ], + "data": "void" + } + } + }, + "failed_call": false + }, { "event": { "ext": "v0", @@ -5835,4 +5991,4 @@ "failed_call": false } ] -} +} \ No newline at end of file diff --git a/contracts/proxy/test_snapshots/upgrade_flow_preserves_state_and_enforces_timelocks.1.json b/contracts/proxy/test_snapshots/upgrade_flow_preserves_state_and_enforces_timelocks.1.json index 9e5dfc05..05cee692 100644 --- a/contracts/proxy/test_snapshots/upgrade_flow_preserves_state_and_enforces_timelocks.1.json +++ b/contracts/proxy/test_snapshots/upgrade_flow_preserves_state_and_enforces_timelocks.1.json @@ -1626,27 +1626,27 @@ "key": { "vec": [ { - "symbol": "ProxyPreviousImplementation" - }, - { - "u32": 0 + "symbol": "ProxyPrevImplCount" } ] }, "val": { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" + "u32": 1 } }, { "key": { "vec": [ { - "symbol": "ProxyPreviousImplementationCount" + "symbol": "ProxyPreviousImplementation" + }, + { + "u32": 0 } ] }, "val": { - "u32": 1 + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" } }, { @@ -3968,7 +3968,7 @@ "data": { "vec": [ { - "string": "caught panic 'Upgrade timelock not expired' from contract function 'Symbol(obj#1021)'" + "string": "caught panic 'Upgrade timelock not expired' from contract function 'Symbol(obj#989)'" }, { "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" @@ -6023,7 +6023,7 @@ "data": { "vec": [ { - "string": "caught panic 'Upgrade timelock not expired' from contract function 'Symbol(obj#1953)'" + "string": "caught panic 'Upgrade timelock not expired' from contract function 'Symbol(obj#1917)'" }, { "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" @@ -6414,4 +6414,4 @@ "failed_call": false } ] -} +} \ No newline at end of file diff --git a/contracts/proxy/test_snapshots/upgrade_validation_failure_does_not_change_implementation.1.json b/contracts/proxy/test_snapshots/upgrade_validation_failure_does_not_change_implementation.1.json index b9965b47..3730b4c3 100644 --- a/contracts/proxy/test_snapshots/upgrade_validation_failure_does_not_change_implementation.1.json +++ b/contracts/proxy/test_snapshots/upgrade_validation_failure_does_not_change_implementation.1.json @@ -481,6 +481,62 @@ 4095 ] ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM", + "key": { + "vec": [ + { + "symbol": "ProxyScheduledUpgrade" + } + ] + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM", + "key": { + "vec": [ + { + "symbol": "ProxyScheduledUpgrade" + } + ] + }, + "durability": "temporary", + "val": { + "map": [ + { + "key": { + "symbol": "execute_after" + }, + "val": { + "u64": 1700000201 + } + }, + { + "key": { + "symbol": "implementation" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" + } + } + ] + } + } + }, + "ext": "v0" + }, + 120960 + ] + ], [ { "contract_data": { @@ -838,27 +894,27 @@ "key": { "vec": [ { - "symbol": "ProxyPreviousImplementation" - }, - { - "u32": 0 + "symbol": "ProxyPrevImplCount" } ] }, "val": { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + "u32": 1 } }, { "key": { "vec": [ { - "symbol": "ProxyPreviousImplementationCount" + "symbol": "ProxyPreviousImplementation" + }, + { + "u32": 0 } ] }, "val": { - "u32": 1 + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" } }, { @@ -873,35 +929,6 @@ "u64": 50 } }, - { - "key": { - "vec": [ - { - "symbol": "ProxyScheduledUpgrade" - } - ] - }, - "val": { - "map": [ - { - "key": { - "symbol": "execute_after" - }, - "val": { - "u64": 1700000201 - } - }, - { - "key": { - "symbol": "implementation" - }, - "val": { - "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" - } - } - ] - } - }, { "key": { "vec": [ @@ -2489,7 +2516,7 @@ "data": { "vec": [ { - "string": "caught panic 'incompatible state' from contract function 'Symbol(obj#1043)'" + "string": "caught panic 'incompatible state' from contract function 'Symbol(obj#965)'" }, { "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" @@ -2803,4 +2830,4 @@ "failed_call": false } ] -} +} \ No newline at end of file diff --git a/contracts/proxy/tests/integration_soroban.rs b/contracts/proxy/tests/integration_soroban.rs index 53fec2a9..78f7a3e8 100644 --- a/contracts/proxy/tests/integration_soroban.rs +++ b/contracts/proxy/tests/integration_soroban.rs @@ -80,6 +80,24 @@ fn setup_integration() -> IntegrationSetup { } } +fn setup_proxy_only() -> (Env, Address, Address, Address) { + let env = Env::default(); + env.mock_all_auths_allowing_non_root_auth(); + env.ledger().set_timestamp(1_700_000_000); + + let admin = Address::generate(&env); + let merchant = Address::generate(&env); + + let storage_id = env.register_contract(None, SubTrackrStorage); + let implementation_id = env.register_contract(None, SubTrackrSubscription); + + let proxy_id = env.register_contract(None, UpgradeableProxy); + let proxy = UpgradeableProxyClient::new(&env, &proxy_id); + proxy.initialize(&admin, &storage_id, &implementation_id, &0u64, &0u64); + + (env, proxy_id, admin, merchant) +} + #[test] fn integration_contract_deploys_and_state_persists() { let setup = setup_integration(); @@ -196,3 +214,44 @@ fn integration_multiple_contract_interactions_work() { assert_eq!(token.balance(&setup.merchant), 500); assert_eq!(second_token.balance(&second_merchant), 900); } + +#[test] +fn integration_plan_limit_blocks_third_plan() { + let (env, proxy_id, _admin, merchant) = setup_proxy_only(); + let proxy = UpgradeableProxyClient::new(&env, &proxy_id); + + proxy.set_max_plans_per_merchant(&2u32); + + let token_admin = Address::generate(&env); + let token_id = env.register_stellar_asset_contract_v2(token_admin); + + let name = String::from_str(&env, "Limited Plan"); + proxy.create_plan(&merchant, &name, &500, &token_id.address(), &Interval::Monthly); + proxy.create_plan(&merchant, &name, &600, &token_id.address(), &Interval::Monthly); + + let res = proxy.try_create_plan(&merchant, &name, &700, &token_id.address(), &Interval::Monthly); + assert!(res.is_err()); +} + +#[test] +fn integration_lowering_plan_limit_does_not_affect_existing_plans() { + let (env, proxy_id, _admin, merchant) = setup_proxy_only(); + let proxy = UpgradeableProxyClient::new(&env, &proxy_id); + + let token_admin = Address::generate(&env); + let token_id = env.register_stellar_asset_contract_v2(token_admin); + let name = String::from_str(&env, "Plan"); + + let p1 = proxy.create_plan(&merchant, &name, &100, &token_id.address(), &Interval::Monthly); + let p2 = proxy.create_plan(&merchant, &name, &200, &token_id.address(), &Interval::Monthly); + let p3 = proxy.create_plan(&merchant, &name, &300, &token_id.address(), &Interval::Monthly); + + proxy.set_max_plans_per_merchant(&2u32); + + assert!(proxy.get_plan(&p1).active); + assert!(proxy.get_plan(&p2).active); + assert!(proxy.get_plan(&p3).active); + + let res = proxy.try_create_plan(&merchant, &name, &400, &token_id.address(), &Interval::Monthly); + assert!(res.is_err()); +} diff --git a/contracts/storage/src/transient_storage_tests.rs b/contracts/storage/src/transient_storage_tests.rs index af362065..6e425e13 100644 --- a/contracts/storage/src/transient_storage_tests.rs +++ b/contracts/storage/src/transient_storage_tests.rs @@ -8,13 +8,12 @@ /// /// Run with: /// cargo test -p subtrackr-storage -- transient --nocapture - #[cfg(test)] -mod transient_storage_tests { - use crate::SubTrackrStorage; +mod tests { + use crate::{SubTrackrStorage, SubTrackrStorageClient}; use soroban_sdk::{ testutils::{Address as _, Ledger, LedgerInfo}, - Address, Env, String as SorobanString, + Address, Env, IntoVal, String as SorobanString, TryFromVal, }; use subtrackr_types::StorageKey; @@ -23,12 +22,17 @@ mod transient_storage_tests { fn setup() -> (Env, Address, Address) { let env = Env::default(); env.mock_all_auths(); + // Configure a small minimum TTL for temporary entries so expiry is testable. + env.ledger().set(LedgerInfo { + min_temp_entry_ttl: 1, + ..env.ledger().get() + }); let admin = Address::generate(&env); let implementation = Address::generate(&env); let contract_id = env.register_contract(None, SubTrackrStorage); - let client = soroban_sdk::contract_client!(env, SubTrackrStorage, &contract_id); + let client = SubTrackrStorageClient::new(&env, &contract_id); client.initialize(&admin, &implementation); (env, contract_id, implementation) @@ -39,7 +43,7 @@ mod transient_storage_tests { #[test] fn test_temporary_set_and_get_roundtrip() { let (env, contract_id, _impl) = setup(); - let client = soroban_sdk::contract_client!(env, SubTrackrStorage, &contract_id); + let client = SubTrackrStorageClient::new(&env, &contract_id); let caller = Address::generate(&env); let fname = SorobanString::from_str(&env, "subscribe"); @@ -47,18 +51,18 @@ mod transient_storage_tests { let timestamp: u64 = 1_000_000; // Write with a 12-ledger TTL (≈ 60 s) - client.temporary_set(&key, ×tamp.into(), &12u32); + client.temporary_set(&key, ×tamp.into_val(&env), &12u32); let result: Option = client .temporary_get(&key) - .map(|v| soroban_sdk::TryFromVal::try_from_val(&env, &v).unwrap()); + .map(|v| TryFromVal::try_from_val(&env, &v).unwrap()); assert_eq!(result, Some(timestamp)); } #[test] fn test_temporary_get_returns_none_for_missing_key() { let (env, contract_id, _impl) = setup(); - let client = soroban_sdk::contract_client!(env, SubTrackrStorage, &contract_id); + let client = SubTrackrStorageClient::new(&env, &contract_id); let caller = Address::generate(&env); let fname = SorobanString::from_str(&env, "nonexistent"); @@ -71,14 +75,14 @@ mod transient_storage_tests { #[test] fn test_temporary_remove_clears_entry() { let (env, contract_id, _impl) = setup(); - let client = soroban_sdk::contract_client!(env, SubTrackrStorage, &contract_id); + let client = SubTrackrStorageClient::new(&env, &contract_id); let caller = Address::generate(&env); let fname = SorobanString::from_str(&env, "cancel_subscription"); let key = StorageKey::TmpLastCall(caller, fname); let ts: u64 = 999; - client.temporary_set(&key, &ts.into(), &10u32); + client.temporary_set(&key, &ts.into_val(&env), &10u32); assert!(client.temporary_get(&key).is_some()); client.temporary_remove(&key); @@ -90,21 +94,21 @@ mod transient_storage_tests { #[test] fn test_temporary_entry_expires_after_ttl() { let (env, contract_id, _impl) = setup(); - let client = soroban_sdk::contract_client!(env, SubTrackrStorage, &contract_id); + let client = SubTrackrStorageClient::new(&env, &contract_id); let caller = Address::generate(&env); let fname = SorobanString::from_str(&env, "create_plan"); let key = StorageKey::TmpLastCall(caller, fname); let ts: u64 = 500; - // Write with TTL = 5 ledgers - client.temporary_set(&key, &ts.into(), &5u32); + // Write with TTL = 5 ledgers. + client.temporary_set(&key, &ts.into_val(&env), &5u32); assert!( client.temporary_get(&key).is_some(), "entry should exist before expiry" ); - // Advance ledger sequence past the TTL + // Advance ledger sequence past the TTL. env.ledger().set(LedgerInfo { sequence_number: env.ledger().sequence() + 6, ..env.ledger().get() @@ -122,7 +126,7 @@ mod transient_storage_tests { #[test] fn test_tmp_last_call_keys_are_isolated_per_caller() { let (env, contract_id, _impl) = setup(); - let client = soroban_sdk::contract_client!(env, SubTrackrStorage, &contract_id); + let client = SubTrackrStorageClient::new(&env, &contract_id); let caller_a = Address::generate(&env); let caller_b = Address::generate(&env); @@ -131,8 +135,8 @@ mod transient_storage_tests { let key_a = StorageKey::TmpLastCall(caller_a.clone(), fname.clone()); let key_b = StorageKey::TmpLastCall(caller_b.clone(), fname.clone()); - client.temporary_set(&key_a, &(100u64).into(), &20u32); - client.temporary_set(&key_b, &(200u64).into(), &20u32); + client.temporary_set(&key_a, &100u64.into_val(&env), &20u32); + client.temporary_set(&key_b, &200u64.into_val(&env), &20u32); let val_a: u64 = soroban_sdk::TryFromVal::try_from_val(&env, &client.temporary_get(&key_a).unwrap()) @@ -148,7 +152,7 @@ mod transient_storage_tests { #[test] fn test_tmp_last_call_keys_are_isolated_per_function() { let (env, contract_id, _impl) = setup(); - let client = soroban_sdk::contract_client!(env, SubTrackrStorage, &contract_id); + let client = SubTrackrStorageClient::new(&env, &contract_id); let caller = Address::generate(&env); let fname_a = SorobanString::from_str(&env, "subscribe"); @@ -157,8 +161,8 @@ mod transient_storage_tests { let key_a = StorageKey::TmpLastCall(caller.clone(), fname_a); let key_b = StorageKey::TmpLastCall(caller.clone(), fname_b); - client.temporary_set(&key_a, &(111u64).into(), &20u32); - client.temporary_set(&key_b, &(222u64).into(), &20u32); + client.temporary_set(&key_a, &111u64.into_val(&env), &20u32); + client.temporary_set(&key_b, &222u64.into_val(&env), &20u32); let val_a: u64 = soroban_sdk::TryFromVal::try_from_val(&env, &client.temporary_get(&key_a).unwrap()) @@ -176,7 +180,7 @@ mod transient_storage_tests { #[test] fn test_proxy_scheduled_upgrade_stored_in_temporary() { let (env, contract_id, _impl) = setup(); - let client = soroban_sdk::contract_client!(env, SubTrackrStorage, &contract_id); + let client = SubTrackrStorageClient::new(&env, &contract_id); let new_impl = Address::generate(&env); let execute_after: u64 = env.ledger().timestamp() + 86_400; // +1 day @@ -188,11 +192,11 @@ mod transient_storage_tests { let key = StorageKey::ProxyScheduledUpgrade; // TTL = 120 960 ledgers (≈ 7 days) - client.temporary_set(&key, &upgrade.into(), &120_960u32); + client.temporary_set(&key, &upgrade.into_val(&env), &120_960u32); let stored: Option = client .temporary_get(&key) - .map(|v| soroban_sdk::TryFromVal::try_from_val(&env, &v).unwrap()); + .map(|v| TryFromVal::try_from_val(&env, &v).unwrap()); assert!(stored.is_some()); let stored = stored.unwrap(); @@ -203,7 +207,7 @@ mod transient_storage_tests { #[test] fn test_proxy_scheduled_upgrade_cleared_after_execution() { let (env, contract_id, _impl) = setup(); - let client = soroban_sdk::contract_client!(env, SubTrackrStorage, &contract_id); + let client = SubTrackrStorageClient::new(&env, &contract_id); let new_impl = Address::generate(&env); let upgrade = subtrackr_types::ScheduledUpgrade { @@ -212,7 +216,7 @@ mod transient_storage_tests { }; let key = StorageKey::ProxyScheduledUpgrade; - client.temporary_set(&key, &upgrade.into(), &120_960u32); + client.temporary_set(&key, &upgrade.into_val(&env), &120_960u32); assert!(client.temporary_get(&key).is_some()); // Simulate upgrade execution: clear the entry @@ -225,26 +229,26 @@ mod transient_storage_tests { #[test] fn test_persistent_storage_unaffected_by_transient_changes() { let (env, contract_id, _impl) = setup(); - let client = soroban_sdk::contract_client!(env, SubTrackrStorage, &contract_id); + let client = SubTrackrStorageClient::new(&env, &contract_id); // Write a persistent value let plan_id: u64 = 1; let key = StorageKey::Plan(plan_id); // We can't easily write a full Plan here without the subscription crate, // so we write a simple u64 to verify the persistent bridge is unaffected. - client.persistent_set(&key, &(42u64).into()); + client.persistent_set(&key, &42u64.into_val(&env)); // Write and remove a temporary value with the same numeric id let caller = Address::generate(&env); let fname = SorobanString::from_str(&env, "test"); let tmp_key = StorageKey::TmpLastCall(caller, fname); - client.temporary_set(&tmp_key, &(99u64).into(), &5u32); + client.temporary_set(&tmp_key, &99u64.into_val(&env), &5u32); client.temporary_remove(&tmp_key); // Persistent value must still be intact let persisted: Option = client .persistent_get(&key) - .map(|v| soroban_sdk::TryFromVal::try_from_val(&env, &v).unwrap()); + .map(|v| TryFromVal::try_from_val(&env, &v).unwrap()); assert_eq!(persisted, Some(42u64)); } @@ -253,7 +257,7 @@ mod transient_storage_tests { #[test] fn test_instance_storage_unaffected_by_transient_changes() { let (env, contract_id, _impl) = setup(); - let client = soroban_sdk::contract_client!(env, SubTrackrStorage, &contract_id); + let client = SubTrackrStorageClient::new(&env, &contract_id); // Admin is set during initialize() in instance storage let admin_from_instance = client.get_admin(); @@ -262,7 +266,7 @@ mod transient_storage_tests { let caller = Address::generate(&env); let fname = SorobanString::from_str(&env, "test_fn"); let tmp_key = StorageKey::TmpLastCall(caller, fname); - client.temporary_set(&tmp_key, &(1u64).into(), &1u32); + client.temporary_set(&tmp_key, &1u64.into_val(&env), &1u32); // Advance past TTL env.ledger().set(LedgerInfo { @@ -279,14 +283,14 @@ mod transient_storage_tests { #[test] fn test_minimum_ttl_is_one_ledger() { let (env, contract_id, _impl) = setup(); - let client = soroban_sdk::contract_client!(env, SubTrackrStorage, &contract_id); + let client = SubTrackrStorageClient::new(&env, &contract_id); let caller = Address::generate(&env); let fname = SorobanString::from_str(&env, "fn"); let key = StorageKey::TmpLastCall(caller, fname); // TTL = 0 should be treated as 1 ledger (minimum) - client.temporary_set(&key, &(1u64).into(), &0u32); + client.temporary_set(&key, &1u64.into_val(&env), &0u32); assert!(client.temporary_get(&key).is_some()); } } diff --git a/contracts/subscription/Cargo.toml b/contracts/subscription/Cargo.toml index 53584cf3..08acd6ef 100644 --- a/contracts/subscription/Cargo.toml +++ b/contracts/subscription/Cargo.toml @@ -10,12 +10,13 @@ name = "subtrackr_subscription" path = "src/lib.rs" crate-type = ["cdylib", "rlib"] -serde = "1.0" - [dependencies] soroban-sdk = "21.0.0" subtrackr-types = { path = "../types" } subtrackr-oracle = { path = "../oracle" } +[features] +extended = [] + [dev-dependencies] soroban-sdk = { version = "21.0.0", features = ["testutils"] } diff --git a/contracts/subscription/src/gas_optimization.rs b/contracts/subscription/src/gas_optimization.rs index 2d848dc5..d20b8996 100644 --- a/contracts/subscription/src/gas_optimization.rs +++ b/contracts/subscription/src/gas_optimization.rs @@ -1,276 +1,373 @@ -/// Gas optimisation module for the SubTrackr subscription contract. -/// -/// Provides: -/// 1. Storage migration helpers (v2 → v3 packed layout). -/// 2. Benchmark comparison utilities (before/after gas estimates). -/// 3. Read/write trade-off documentation for each packing decision. -/// -/// # Read vs write gas trade-offs -/// -/// | Operation | Before (unpacked) | After (packed) | Δ | -/// |------------------|-------------------|----------------|--------| -/// | subscribe() | 13 slot writes | 7 slot writes | -46% | -/// | get_sub() | 13 slot reads | 7 slot reads | -46% | -/// | charge_sub() | 13+8 = 21 writes | 7+4 = 11 writes| -48% | -/// | create_plan() | 8 slot writes | 4 slot writes | -50% | -/// -/// Packing introduces a small decode cost (bit-shifts and masks) on every -/// read — estimated at 2–4 instructions per field. For a typical read of -/// all 13 fields this is ~50 extra instructions vs saving 6 storage-read -/// fees (each ~10 000 gas on Soroban). Net saving per read: ~59 950 gas. - -use soroban_sdk::{Address, Env, String}; - -use crate::gas_storage::{ - pack_flags, pack_ids, pack_pause, pack_plan_id_count, pack_price_interval_flags, - pack_timestamps_a, pack_timestamps_b, scale_amount, slot_audit_report, unpack_active, - unpack_charge_count, unpack_flag, unpack_id, unpack_interval_secs, unpack_last_charged_at, - unpack_next_charge_at, unpack_pause_duration, unpack_paused_at, unpack_plan_id, - unpack_plan_id_from_pack, unpack_price, unpack_started_at, unpack_status, - unpack_subscriber_count, FLAG_CRYPTO_ENABLED, FLAG_NOTIFICATIONS, FLAG_REFUND_PENDING, - PackedPlan, PackedSubscription, STATUS_ACTIVE, STATUS_CANCELLED, STATUS_EXPIRED, - STATUS_PAUSED, -}; - -// ───────────────────────────────────────────────────────────────────────────── -// Substrate types mirrored here for migration (avoid cross-crate dep in tests) -// ───────────────────────────────────────────────────────────────────────────── - -/// Status values — kept in sync with `subtrackr_types::SubscriptionStatus`. -#[derive(Clone, PartialEq, Debug)] -pub enum SubStatus { - Active, - Paused, - Cancelled, - Expired, +#![allow(dead_code)] +//! Gas Optimization and Targeting Module +//! Provides optimization recommendations and tracks gas targets. + +use soroban_sdk::{Env, String, Vec}; + +/// Optimization level +#[derive(Clone, Copy)] +pub enum OptimizationLevel { + Critical, // > 150% of target + High, // 100-150% of target + Medium, // 80-100% of target + Optimal, // < 80% of target } -impl SubStatus { - pub fn to_flag(&self) -> u8 { +impl OptimizationLevel { + pub fn as_str(self) -> &'static str { match self { - SubStatus::Active => STATUS_ACTIVE, - SubStatus::Paused => STATUS_PAUSED, - SubStatus::Cancelled => STATUS_CANCELLED, - SubStatus::Expired => STATUS_EXPIRED, - } - } - - pub fn from_flag(f: u8) -> Self { - match f { - STATUS_PAUSED => SubStatus::Paused, - STATUS_CANCELLED => SubStatus::Cancelled, - STATUS_EXPIRED => SubStatus::Expired, - _ => SubStatus::Active, + Self::Critical => "critical", + Self::High => "high", + Self::Medium => "medium", + Self::Optimal => "optimal", } } } -// ───────────────────────────────────────────────────────────────────────────── -// Migration helper -// ───────────────────────────────────────────────────────────────────────────── - -/// Input shape for migrating an existing (unpacked) subscription row. -pub struct LegacySubscription { - pub id: u64, - pub plan_id: u64, - pub subscriber: Address, - pub status: SubStatus, - pub started_at: u64, - pub last_charged_at: u64, - pub next_charge_at: u64, - pub total_paid: i128, - pub charge_count: u64, - pub paused_at: u64, - pub pause_duration: u64, - /// Legacy bool flags (may not exist in very old rows — default false). - pub crypto_enabled: bool, - pub notifications_enabled: bool, - pub refund_pending: bool, +/// Optimization recommendation +#[derive(Clone)] +pub struct OptimizationRecommendation { + pub function_name: String, + pub severity: OptimizationLevel, + pub current_gas: u64, + pub target_gas: u64, + pub potential_savings: u64, + pub recommendation: String, } -/// Input shape for migrating an existing (unpacked) plan row. -pub struct LegacyPlan { - pub id: u64, - pub merchant: Address, - pub name: String, - pub price: i128, - pub interval_secs: u64, - pub active: bool, - pub subscriber_count: u32, - pub token: Address, -} +/// Gas optimization targets for each function +pub struct GasOptimizationTargets; -/// Convert a legacy subscription row to the packed representation. -/// -/// Called once per subscription during the v2 → v3 migration in `lib.rs`. -/// The result is written back to persistent storage under the same key, -/// replacing the old layout atomically. -pub fn migrate_subscription(leg: LegacySubscription) -> PackedSubscription { - PackedSubscription { - id_and_plan: pack_ids(leg.id, leg.plan_id), - subscriber: leg.subscriber, - flags: pack_flags( - leg.status.to_flag(), - leg.crypto_enabled, - leg.notifications_enabled, - leg.refund_pending, - ), - timestamps_a: pack_timestamps_a(leg.started_at, leg.last_charged_at), - timestamps_b: pack_timestamps_b(leg.next_charge_at, leg.charge_count), - total_paid_scaled: scale_amount(leg.total_paid), - pause_pack: pack_pause(leg.paused_at, leg.pause_duration), +impl GasOptimizationTargets { + /// Get target gas for initialization functions + pub fn initialize_target() -> u64 { + 25_000 // Minimal storage setup } -} -/// Convert a legacy plan row to the packed representation. -pub fn migrate_plan(leg: LegacyPlan) -> PackedPlan { - PackedPlan { - id_and_count: pack_plan_id_count(leg.id, leg.subscriber_count), - merchant: leg.merchant, - name: leg.name, - price_interval_flags: pack_price_interval_flags(leg.price, leg.interval_secs, leg.active), - token: leg.token, + /// Get target gas for plan creation + pub fn create_plan_target() -> u64 { + 75_000 // Multiple storage writes } -} -// ───────────────────────────────────────────────────────────────────────────── -// Unpack helpers exposed to the rest of the contract -// ───────────────────────────────────────────────────────────────────────────── - -/// Decode all fields from a `PackedSubscription` in one call. -/// Returns a tuple matching the original `Subscription` field order. -#[allow(clippy::type_complexity)] -pub fn unpack_subscription( - p: &PackedSubscription, -) -> ( - u64, // id - u64, // plan_id - SubStatus, - u64, // started_at - u64, // last_charged_at - u64, // next_charge_at - i128, // total_paid - u64, // charge_count - u64, // paused_at - u64, // pause_duration - bool, // crypto_enabled - bool, // notifications_enabled - bool, // refund_pending -) { - ( - unpack_id(p.id_and_plan), - unpack_plan_id(p.id_and_plan), - SubStatus::from_flag(unpack_status(p.flags)), - unpack_started_at(p.timestamps_a), - unpack_last_charged_at(p.timestamps_a), - unpack_next_charge_at(p.timestamps_b), - crate::gas_storage::unscale_amount(p.total_paid_scaled), - unpack_charge_count(p.timestamps_b), - unpack_paused_at(p.pause_pack), - unpack_pause_duration(p.pause_pack), - unpack_flag(p.flags, FLAG_CRYPTO_ENABLED), - unpack_flag(p.flags, FLAG_NOTIFICATIONS), - unpack_flag(p.flags, FLAG_REFUND_PENDING), - ) -} + /// Get target gas for subscription + pub fn subscribe_target() -> u64 { + 65_000 // Create subscription + index + } + + /// Get target gas for charge operation + pub fn charge_subscription_target() -> u64 { + 150_000 // Token transfer + storage updates + } + + /// Get target gas for cancel subscription + pub fn cancel_subscription_target() -> u64 { + 45_000 // Remove from indexes + decrement counts + } + + /// Get target gas for pause subscription + pub fn pause_subscription_target() -> u64 { + 35_000 // Single storage write + } + + /// Get target gas for resume subscription + pub fn resume_subscription_target() -> u64 { + 40_000 // Single storage write + time calculation + } + + /// Get target gas for request refund + pub fn request_refund_target() -> u64 { + 30_000 // Storage write + validation + } + + /// Get target gas for approve refund + pub fn approve_refund_target() -> u64 { + 35_000 // Storage write + transfer + } + + /// Get target gas for request transfer + pub fn request_transfer_target() -> u64 { + 25_000 // Storage write + } + + /// Get target gas for accept transfer + pub fn accept_transfer_target() -> u64 { + 85_000 // Multiple storage operations + } + + /// Get target gas for plan query + pub fn get_plan_target() -> u64 { + 15_000 // Read from storage + } + + /// Get target gas for subscription query + pub fn get_subscription_target() -> u64 { + 15_000 // Read from storage + } + + /// Get target for user subscriptions query + pub fn get_user_subscriptions_target() -> u64 { + 20_000 // Read + iteration + } -/// Decode all fields from a `PackedPlan`. -pub fn unpack_plan(p: &PackedPlan) -> (u64, u32, i128, u64, bool) { - ( - unpack_plan_id_from_pack(p.id_and_count), - unpack_subscriber_count(p.id_and_count), - unpack_price(p.price_interval_flags), - unpack_interval_secs(p.price_interval_flags), - unpack_active(p.price_interval_flags), - ) + /// Get all targets as a map + pub fn all_targets(env: &Env) -> Vec<(String, u64)> { + soroban_sdk::vec![ + env, + (String::from_str(env, "initialize"), Self::initialize_target()), + (String::from_str(env, "create_plan"), Self::create_plan_target()), + (String::from_str(env, "subscribe"), Self::subscribe_target()), + (String::from_str(env, "charge_subscription"), Self::charge_subscription_target()), + (String::from_str(env, "cancel_subscription"), Self::cancel_subscription_target()), + (String::from_str(env, "pause_subscription"), Self::pause_subscription_target()), + (String::from_str(env, "resume_subscription"), Self::resume_subscription_target()), + (String::from_str(env, "request_refund"), Self::request_refund_target()), + (String::from_str(env, "approve_refund"), Self::approve_refund_target()), + (String::from_str(env, "request_transfer"), Self::request_transfer_target()), + (String::from_str(env, "accept_transfer"), Self::accept_transfer_target()), + (String::from_str(env, "get_plan"), Self::get_plan_target()), + (String::from_str(env, "get_subscription"), Self::get_subscription_target()), + (String::from_str(env, "get_user_subscriptions"), Self::get_user_subscriptions_target()), + ] + } } -// ───────────────────────────────────────────────────────────────────────────── -// Gas benchmark comparison -// ───────────────────────────────────────────────────────────────────────────── - -/// Approximate gas costs (in Soroban fee units) for storage operations. -/// Based on Soroban mainnet fee schedule (subject to network upgrades). -const GAS_PER_SLOT_READ: u64 = 10_000; -const GAS_PER_SLOT_WRITE: u64 = 20_000; -/// Cost of bit-shift / mask operations per field (instruction gas). -const GAS_PER_DECODE_OP: u64 = 2; - -#[derive(Debug)] -pub struct GasBenchmark { - /// Name of the operation being measured. - pub operation: &'static str, - /// Estimated gas before packing. - pub gas_before: u64, - /// Estimated gas after packing. - pub gas_after: u64, - /// Absolute saving. - pub saving: u64, - /// Saving as a percentage of the before cost. - pub saving_pct: u64, +/// Gas optimization strategies and recommendations +pub struct GasOptimizations; + +impl GasOptimizations { + /// Get optimization recommendations for a specific function + pub fn get_recommendations_for_function(env: &Env, function_name: &str, current_gas: u64) -> Vec { + let mut recommendations = Vec::new(env); + + match function_name { + "create_plan" => { + if current_gas > 100_000 { + recommendations.push_back(String::from_str( + env, + "Consider batch validation of plan parameters before storage writes", + )); + } + recommendations.push_back(String::from_str( + env, + "Cache merchant address to reduce lookup operations", + )); + } + "charge_subscription" => { + if current_gas > 180_000 { + recommendations.push_back(String::from_str( + env, + "Optimize token transfer: consider using batch transfers for multiple subscriptions", + )); + } + recommendations.push_back(String::from_str( + env, + "Consider deferring storage writes to a separate operation", + )); + } + "accept_transfer" => { + if current_gas > 110_000 { + recommendations.push_back(String::from_str( + env, + "Reduce vector operations: pre-allocate vector size", + )); + } + recommendations.push_back(String::from_str( + env, + "Consider removing vector iteration: use index-based updates", + )); + } + "get_user_subscriptions" => { + if current_gas > 25_000 { + recommendations.push_back(String::from_str( + env, + "Consider limiting result set with pagination", + )); + } + } + "subscribe" => { + if current_gas > 80_000 { + recommendations.push_back(String::from_str( + env, + "Batch storage operations: combine multiple sets into single storage call", + )); + } + } + _ => { + recommendations.push_back(String::from_str(env, "Monitor function for optimization opportunities")); + } + } + + recommendations + } + + /// Get common optimization strategies + pub fn get_general_optimizations(env: &Env) -> Vec { + let mut optimizations = Vec::new(env); + + optimizations.push_back(String::from_str( + env, + "Use persistent instead of instance storage for rarely-accessed data", + )); + optimizations.push_back(String::from_str( + env, + "Batch multiple storage operations into single contract calls", + )); + optimizations.push_back(String::from_str( + env, + "Cache frequently accessed data in local variables", + )); + optimizations.push_back(String::from_str( + env, + "Avoid unnecessary vector iterations when possible", + )); + optimizations.push_back(String::from_str( + env, + "Pre-allocate vectors with expected capacity", + )); + optimizations.push_back(String::from_str( + env, + "Use efficient data structures for lookups (indices/mappings)", + )); + optimizations.push_back(String::from_str( + env, + "Minimize cross-contract calls: batch related operations", + )); + optimizations.push_back(String::from_str( + env, + "Use event publishing instead of storage for audit trails", + )); + optimizations.push_back(String::from_str( + env, + "Consider time-lock patterns for expensive operations", + )); + optimizations.push_back(String::from_str( + env, + "Monitor and break down complex functions into optimizable parts", + )); + + optimizations + } + + /// Categorize gas usage by severity + pub fn categorize_gas_usage(current_gas: u64, target_gas: u64) -> OptimizationLevel { + if current_gas > (target_gas * 150) / 100 { + OptimizationLevel::Critical + } else if current_gas > target_gas { + OptimizationLevel::High + } else if current_gas > (target_gas * 80) / 100 { + OptimizationLevel::Medium + } else { + OptimizationLevel::Optimal + } + } + + /// Calculate potential gas savings + pub fn calculate_savings(current_gas: u64, target_gas: u64) -> u64 { + current_gas.saturating_sub(target_gas) + } } -/// Build a benchmark report for the four hot-path operations. -pub fn benchmark_report() -> [GasBenchmark; 4] { - // subscribe(): 13 writes before → 7 writes after; 13 decode ops added - let sub_before = 13 * GAS_PER_SLOT_WRITE; - let sub_after = 7 * GAS_PER_SLOT_WRITE + 13 * GAS_PER_DECODE_OP; - - // get_subscription(): 13 reads before → 7 reads after; 13 decode ops - let get_before = 13 * GAS_PER_SLOT_READ; - let get_after = 7 * GAS_PER_SLOT_READ + 13 * GAS_PER_DECODE_OP; - - // charge_subscription(): 13+8 = 21 writes before → 7+4 = 11 writes after - let charge_before = 21 * GAS_PER_SLOT_WRITE; - let charge_after = 11 * GAS_PER_SLOT_WRITE + 21 * GAS_PER_DECODE_OP; - - // create_plan(): 8 writes before → 4 writes after - let plan_before = 8 * GAS_PER_SLOT_WRITE; - let plan_after = 4 * GAS_PER_SLOT_WRITE + 8 * GAS_PER_DECODE_OP; - - [ - GasBenchmark { - operation: "subscribe", - gas_before: sub_before, - gas_after: sub_after, - saving: sub_before.saturating_sub(sub_after), - saving_pct: 100 - (sub_after * 100 / sub_before), - }, - GasBenchmark { - operation: "get_subscription", - gas_before: get_before, - gas_after: get_after, - saving: get_before.saturating_sub(get_after), - saving_pct: 100 - (get_after * 100 / get_before), - }, - GasBenchmark { - operation: "charge_subscription", - gas_before: charge_before, - gas_after: charge_after, - saving: charge_before.saturating_sub(charge_after), - saving_pct: 100 - (charge_after * 100 / charge_before), - }, - GasBenchmark { - operation: "create_plan", - gas_before: plan_before, - gas_after: plan_after, - saving: plan_before.saturating_sub(plan_after), - saving_pct: 100 - (plan_after * 100 / plan_before), - }, - ] +#[allow(dead_code)] +pub fn get_optimization_priorities( + env: &Env, + _gas_metrics: Vec<(String, u64)>, +) -> Vec<(String, u64, String)> { + Vec::new(env) } -/// Print-friendly benchmark summary (returns a static str for use in events/logs). -pub fn print_benchmark_summary(_env: &Env) { - let report = benchmark_report(); - for b in &report { - // In production use env.events() to publish; here we rely on the - // caller to surface these via the gas_profiler event stream. - let _ = b; // suppress unused warning in no_std +/// Best practices for gas efficiency +pub mod best_practices { + use soroban_sdk::{String, Vec, Env}; + + pub fn get_storage_best_practices(env: &Env) -> Vec { + let mut practices = Vec::new(env); + + practices.push_back(String::from_str( + env, + "Use instance storage for frequently accessed config, persistent for user data", + )); + practices.push_back(String::from_str( + env, + "Minimize storage key complexity: use simple types when possible", + )); + practices.push_back(String::from_str( + env, + "Batch related updates to reduce total storage operations", + )); + practices.push_back(String::from_str( + env, + "Consider denormalization to reduce number of storage reads", + )); + + practices + } + + pub fn get_contract_interaction_best_practices(env: &Env) -> Vec { + let mut practices = Vec::new(env); + + practices.push_back(String::from_str( + env, + "Minimize cross-contract calls: combine operations when possible", + )); + practices.push_back(String::from_str( + env, + "Cache contract client instances for repeated calls", + )); + practices.push_back(String::from_str( + env, + "Batch token operations to reduce call count", + )); + practices.push_back(String::from_str( + env, + "Use events for audit trails instead of storage", + )); + + practices } -} -/// Slot audit — returns the static audit string from gas_storage. -pub fn audit_slots() -> &'static str { - slot_audit_report() + pub fn get_computation_best_practices(env: &Env) -> Vec { + let mut practices = Vec::new(env); + + practices.push_back(String::from_str( + env, + "Avoid complex computations in hot paths", + )); + practices.push_back(String::from_str( + env, + "Pre-compute complex values outside contract when possible", + )); + practices.push_back(String::from_str( + env, + "Use efficient algorithms: O(n) preferred over O(n²)", + )); + practices.push_back(String::from_str( + env, + "Short-circuit evaluations to exit early", + )); + + practices + } + + pub fn get_validation_best_practices(env: &Env) -> Vec { + let mut practices = Vec::new(env); + + practices.push_back(String::from_str( + env, + "Validate inputs early to fail fast", + )); + practices.push_back(String::from_str( + env, + "Use assertions for critical validations", + )); + practices.push_back(String::from_str( + env, + "Batch validation of related parameters", + )); + practices.push_back(String::from_str( + env, + "Cache validation results when applicable", + )); + + practices + } } diff --git a/contracts/subscription/src/gas_profiler.rs b/contracts/subscription/src/gas_profiler.rs index b705cfc4..6ffb16e8 100644 --- a/contracts/subscription/src/gas_profiler.rs +++ b/contracts/subscription/src/gas_profiler.rs @@ -1,9 +1,8 @@ -/// Gas Profiling Module for SubTrackr Subscription Contract -/// Tracks gas consumption for each contract function and provides optimization insights -/// Updated for Issue #411: integrates with gas_optimization benchmark report. - -use soroban_sdk::{Address, Env, String, Symbol, Vec}; -use crate::gas_optimization::{audit_slots, benchmark_report}; +#![allow(dead_code)] +#![allow(unused_variables)] +//! Gas Profiling Module for SubTrackr Subscription Contract +//! Tracks gas consumption for each contract function and provides optimization insights. +use soroban_sdk::{Address, Env, String, Vec}; /// Gas profile entry for a function call #[derive(Clone)] @@ -237,21 +236,9 @@ impl GasProfiler { /// Get optimization recommendations pub fn get_optimization_recommendations( env: &Env, - _storage: &Address, + storage: &Address, ) -> Vec { - // Emit benchmark report as events for the monitoring dashboard - let report = benchmark_report(); - for b in &report { - env.events().publish( - (String::from_str(env, "gas_benchmark"), String::from_str(env, b.operation)), - (b.gas_before, b.gas_after, b.saving_pct), - ); - } - // Publish slot audit summary - env.events().publish( - (String::from_str(env, "slot_audit"),), - (String::from_str(env, audit_slots()),), - ); + // Returns array of optimization suggestions based on profiling data soroban_sdk::vec![env] } } diff --git a/contracts/subscription/src/gas_storage.rs b/contracts/subscription/src/gas_storage.rs index c03e4e44..68b1c46d 100644 --- a/contracts/subscription/src/gas_storage.rs +++ b/contracts/subscription/src/gas_storage.rs @@ -1,319 +1,204 @@ -/// Gas-optimised storage layout for SubTrackr subscription contract. -/// -/// # Design goals -/// 1. **50% fewer storage slots** — pack multiple small fields into a single u128/u64. -/// 2. **Bit-packing for booleans and enums** — 8+ flags fit in a single byte. -/// 3. **Compact timestamps** — store seconds-since-epoch as u32 (valid until 2106). -/// 4. **Compact amounts** — store token amounts scaled to 7 d.p. in a u64 (max ~1.8 × 10¹²). -/// 5. **Backward-compatible migration** — `unpack_*` functions accept both old and new formats. -/// -/// # Slot audit -/// -/// ## Before packing (original `Subscription` struct) -/// | Field | Type | Slots | -/// |--------------------|-------|-------| -/// | id | u64 | 1 | -/// | plan_id | u64 | 1 | -/// | subscriber | Addr | 1 | -/// | status | enum | 1 | -/// | started_at | u64 | 1 | -/// | last_charged_at | u64 | 1 | -/// | next_charge_at | u64 | 1 | -/// | total_paid | i128 | 1 | -/// | total_gas_spent | u64 | 1 | -/// | charge_count | u64 | 1 | -/// | paused_at | u64 | 1 | -/// | pause_duration | u64 | 1 | -/// | refund_requested | i128 | 1 | -/// | **Total** | | **13**| -/// -/// ## After packing (`PackedSubscription`) -/// | Field(s) | Packed into | Slots | -/// |---------------------------------------|-------------|-------| -/// | id, plan_id | u128 | 1 | -/// | subscriber | Address | 1 | -/// | flags (status×3 bits + bools×5 bits) | u8 in u64 | 1 | -/// | started_at (u32), last_charged_at(u32)| u64 | 1 | -/// | next_charge_at (u32), charge_count(u32)| u64 | 1 | -/// | total_paid (scaled u64) | u64 | 1 | -/// | paused_at(u32), pause_duration(u32) | u64 | 1 | -/// | **Total** | | **7** | -/// -/// **Reduction: 13 → 7 slots = 46% fewer slots** (exceeds 50% target when -/// factoring in the `Plan` struct packing below which hits exactly 50%). - -use soroban_sdk::contracttype; - -// ───────────────────────────────────────────────────────────────────────────── -// Constants -// ───────────────────────────────────────────────────────────────────────────── - -/// Amount scaling factor: all token amounts stored as (real_value × AMOUNT_SCALE). -/// 7 decimal places supports sub-cent precision for most tokens. -pub const AMOUNT_SCALE: u64 = 10_000_000; - -/// Maximum storable timestamp as u32 (year 2106). -pub const MAX_U32_TIMESTAMP: u64 = u32::MAX as u64; - -// ───────────────────────────────────────────────────────────────────────────── -// Status / flag bit layout -// ───────────────────────────────────────────────────────────────────────────── -// -// Bits 0-2 : SubscriptionStatus (3 bits → 8 values, we use 4) -// Bit 3 : is_crypto_enabled -// Bit 4 : notifications_enabled -// Bit 5 : refund_pending -// Bit 6 : reserved -// Bit 7 : reserved -// -// Status encoding: -// 0b000 = Active -// 0b001 = Paused -// 0b010 = Cancelled -// 0b011 = Expired -// 0b100–0b111 = reserved - -pub const STATUS_MASK: u8 = 0b0000_0111; -pub const STATUS_ACTIVE: u8 = 0b000; -pub const STATUS_PAUSED: u8 = 0b001; -pub const STATUS_CANCELLED: u8 = 0b010; -pub const STATUS_EXPIRED: u8 = 0b011; - -pub const FLAG_CRYPTO_ENABLED: u8 = 1 << 3; -pub const FLAG_NOTIFICATIONS: u8 = 1 << 4; -pub const FLAG_REFUND_PENDING: u8 = 1 << 5; - -// ───────────────────────────────────────────────────────────────────────────── -// Packed subscription storage struct -// ───────────────────────────────────────────────────────────────────────────── - -/// Compact on-chain representation of a subscription. -/// Replaces the original 13-slot struct with 7 slots. -#[contracttype] -#[derive(Clone, Debug)] -pub struct PackedSubscription { - /// `id` in the high 64 bits, `plan_id` in the low 64 bits. - pub id_and_plan: u128, - - /// Subscriber address (1 slot — cannot be packed further). - pub subscriber: soroban_sdk::Address, - - /// Bit-packed flags byte stored in low 8 bits of a u64. - /// bits 0-2: status bits 3-7: boolean flags (see constants above) - pub flags: u64, - - /// `started_at` in high 32 bits, `last_charged_at` in low 32 bits. - /// Both are seconds-since-epoch cast to u32 (valid until year 2106). - pub timestamps_a: u64, - - /// `next_charge_at` in high 32 bits, `charge_count` in low 32 bits. - pub timestamps_b: u64, - - /// `total_paid` scaled by `AMOUNT_SCALE`, stored as u64. - /// Max representable: ~1.8 × 10¹² / AMOUNT_SCALE ≈ 184,467 token units. - pub total_paid_scaled: u64, - - /// `paused_at` in high 32 bits, `pause_duration` in low 32 bits (seconds). - pub pause_pack: u64, -} - -// ───────────────────────────────────────────────────────────────────────────── -// Packed plan storage struct (before: 8 slots → after: 4 slots = 50% reduction) -// ───────────────────────────────────────────────────────────────────────────── - -/// Compact on-chain representation of a billing plan. -#[contracttype] -#[derive(Clone, Debug)] -pub struct PackedPlan { - /// `id` in high 32 bits, `subscriber_count` in low 32 bits. - pub id_and_count: u64, - - /// Merchant address. - pub merchant: soroban_sdk::Address, - - /// Plan name (Soroban String — variable length, 1 slot). - pub name: soroban_sdk::String, - - /// `price_scaled` (u64) in high bits; `interval_secs` (u32) + `active` flag - /// bit in the remaining 32+1 bits, packed into a u128: - /// bits 127-64 : price scaled by AMOUNT_SCALE - /// bits 63-32 : interval in seconds (u32 — max ~136 years) - /// bit 0 : active flag - pub price_interval_flags: u128, - - /// Token contract address. - pub token: soroban_sdk::Address, -} - -// ───────────────────────────────────────────────────────────────────────────── -// Pack / unpack helpers — Subscription -// ───────────────────────────────────────────────────────────────────────────── - -/// Encode `id` and `plan_id` into a single u128. -#[inline] -pub fn pack_ids(id: u64, plan_id: u64) -> u128 { - ((id as u128) << 64) | (plan_id as u128) -} - -/// Decode `id` from the packed u128. -#[inline] -pub fn unpack_id(v: u128) -> u64 { - (v >> 64) as u64 -} - -/// Decode `plan_id` from the packed u128. -#[inline] -pub fn unpack_plan_id(v: u128) -> u64 { - v as u64 -} - -/// Encode `started_at` and `last_charged_at` into a u64. -/// Truncates timestamps to u32 (valid until year 2106). -#[inline] -pub fn pack_timestamps_a(started_at: u64, last_charged_at: u64) -> u64 { - ((started_at.min(MAX_U32_TIMESTAMP) as u64) << 32) - | (last_charged_at.min(MAX_U32_TIMESTAMP) as u64 & 0xFFFF_FFFF) -} - -#[inline] -pub fn unpack_started_at(v: u64) -> u64 { - v >> 32 -} - -#[inline] -pub fn unpack_last_charged_at(v: u64) -> u64 { - v & 0xFFFF_FFFF -} - -/// Encode `next_charge_at` and `charge_count` into a u64. -#[inline] -pub fn pack_timestamps_b(next_charge_at: u64, charge_count: u64) -> u64 { - ((next_charge_at.min(MAX_U32_TIMESTAMP) as u64) << 32) - | (charge_count.min(u32::MAX as u64) & 0xFFFF_FFFF) -} - -#[inline] -pub fn unpack_next_charge_at(v: u64) -> u64 { - v >> 32 -} - -#[inline] -pub fn unpack_charge_count(v: u64) -> u64 { - v & 0xFFFF_FFFF -} - -/// Encode `paused_at` and `pause_duration` into a u64. -#[inline] -pub fn pack_pause(paused_at: u64, pause_duration: u64) -> u64 { - ((paused_at.min(MAX_U32_TIMESTAMP) as u64) << 32) - | (pause_duration.min(u32::MAX as u64) & 0xFFFF_FFFF) -} - -#[inline] -pub fn unpack_paused_at(v: u64) -> u64 { - v >> 32 -} - -#[inline] -pub fn unpack_pause_duration(v: u64) -> u64 { - v & 0xFFFF_FFFF -} - -/// Build the flags byte from individual fields. -#[inline] -pub fn pack_flags( - status: u8, // 0–3 (STATUS_* constants) - crypto_enabled: bool, - notifications: bool, - refund_pending: bool, -) -> u64 { - let mut f: u8 = status & STATUS_MASK; - if crypto_enabled { f |= FLAG_CRYPTO_ENABLED; } - if notifications { f |= FLAG_NOTIFICATIONS; } - if refund_pending { f |= FLAG_REFUND_PENDING; } - f as u64 -} - -#[inline] -pub fn unpack_status(flags: u64) -> u8 { - (flags as u8) & STATUS_MASK -} - -#[inline] -pub fn unpack_flag(flags: u64, bit: u8) -> bool { - ((flags as u8) & bit) != 0 -} - -/// Scale a raw i128 amount for storage in a u64. -/// Saturates to u64::MAX on overflow. -#[inline] -pub fn scale_amount(raw: i128) -> u64 { - if raw < 0 { return 0; } - let scaled = (raw as u128).saturating_mul(AMOUNT_SCALE as u128); - scaled.min(u64::MAX as u128) as u64 -} - -/// Unscale a stored u64 amount back to i128. -#[inline] -pub fn unscale_amount(stored: u64) -> i128 { - (stored / AMOUNT_SCALE) as i128 -} - -// ───────────────────────────────────────────────────────────────────────────── -// Pack / unpack helpers — Plan -// ───────────────────────────────────────────────────────────────────────────── - -#[inline] -pub fn pack_plan_id_count(id: u64, subscriber_count: u32) -> u64 { - ((id & 0xFFFF_FFFF) << 32) | (subscriber_count as u64) -} - -#[inline] -pub fn unpack_plan_id_from_pack(v: u64) -> u64 { - v >> 32 -} - -#[inline] -pub fn unpack_subscriber_count(v: u64) -> u32 { - v as u32 -} - -/// Pack price, interval, and active flag into u128: -/// bits 127-64: price_scaled (u64) -/// bits 63-32 : interval_secs (u32) -/// bit 0 : active flag -#[inline] -pub fn pack_price_interval_flags(price: i128, interval_secs: u64, active: bool) -> u128 { - let price_scaled = scale_amount(price) as u128; - let interval = (interval_secs.min(u32::MAX as u64) as u128) & 0xFFFF_FFFF; - let flag: u128 = if active { 1 } else { 0 }; - (price_scaled << 64) | (interval << 32) | flag -} - -#[inline] -pub fn unpack_price(v: u128) -> i128 { - let scaled = (v >> 64) as u64; - unscale_amount(scaled) -} - -#[inline] -pub fn unpack_interval_secs(v: u128) -> u64 { - ((v >> 32) & 0xFFFF_FFFF) as u64 -} - -#[inline] -pub fn unpack_active(v: u128) -> bool { - (v & 1) != 0 -} - -// ───────────────────────────────────────────────────────────────────────────── -// Slot usage report (compile-time documentation) -// ───────────────────────────────────────────────────────────────────────────── - -/// Returns a static slot-usage audit string for use in tests and dashboards. -pub fn slot_audit_report() -> &'static str { - "Subscription: 13 slots → 7 slots (-46%)\n\ - Plan: 8 slots → 4 slots (-50%)\n\ - Combined: 21 slots → 11 slots (-48%)\n\ - Target: 50% — met for Plan; Subscription within 4% of target." +#![allow(dead_code)] +#![allow(unused_variables)] +//! Gas Storage Module +//! Manages storage and retrieval of gas profiling metrics. +use soroban_sdk::{Address, Env, String as SorobanString}; +use crate::gas_profiler::{GasProfile}; + +/// Storage keys for gas metrics +#[derive(Clone)] +pub enum GasStorageKey { + /// Function gas profile: StorageKey::GasProfile(function_name) + GasProfile(SorobanString), + /// Daily gas usage: StorageKey::DailyGasUsage(timestamp) + DailyGasUsage(u64), + /// Weekly gas usage: StorageKey::WeeklyGasUsage(timestamp) + WeeklyGasUsage(u64), + /// Monthly gas usage: StorageKey::MonthlyGasUsage(timestamp) + MonthlyGasUsage(u64), + /// Total cumulative gas used + TotalGasUsed, + /// Total number of contract calls + TotalCallCount, + /// Gas alert count by type + AlertCount(SorobanString), + /// Last recorded gas usage for a function + LastGasUsage(SorobanString), +} + +/// Gas metrics storage handler +pub struct GasMetricsStorage; + +impl GasMetricsStorage { + /// Store a gas profile for a function + pub fn store_profile( + env: &Env, + storage: &Address, + profile: &GasProfile, + ) { + let key = format_gas_profile_key(env, &profile.function_name); + // Serialize and store profile + // This would use actual storage + } + + /// Retrieve a gas profile for a function + pub fn get_profile( + env: &Env, + storage: &Address, + function_name: &SorobanString, + ) -> Option { + // Retrieve and deserialize profile + None + } + + /// Update daily gas aggregates + pub fn update_daily_aggregate( + env: &Env, + storage: &Address, + day_timestamp: u64, + gas_used: u64, + ) { + // Increment daily aggregate for the given day + } + + /// Update weekly gas aggregates + pub fn update_weekly_aggregate( + env: &Env, + storage: &Address, + week_timestamp: u64, + gas_used: u64, + ) { + // Increment weekly aggregate for the given week + } + + /// Update monthly gas aggregates + pub fn update_monthly_aggregate( + env: &Env, + storage: &Address, + month_timestamp: u64, + gas_used: u64, + ) { + // Increment monthly aggregate for the given month + } + + /// Get daily gas usage + pub fn get_daily_usage( + env: &Env, + storage: &Address, + day_timestamp: u64, + ) -> u64 { + // Retrieve daily aggregate + 0 + } + + /// Get weekly gas usage + pub fn get_weekly_usage( + env: &Env, + storage: &Address, + week_timestamp: u64, + ) -> u64 { + // Retrieve weekly aggregate + 0 + } + + /// Get monthly gas usage + pub fn get_monthly_usage( + env: &Env, + storage: &Address, + month_timestamp: u64, + ) -> u64 { + // Retrieve monthly aggregate + 0 + } + + /// Get total gas used since contract deployment + pub fn get_total_gas_used(env: &Env, storage: &Address) -> u64 { + // Retrieve total gas used + 0 + } + + /// Get total number of calls + pub fn get_total_call_count(env: &Env, storage: &Address) -> u64 { + // Retrieve total call count + 0 + } + + /// Increment total gas used + pub fn increment_total_gas( + env: &Env, + storage: &Address, + gas_amount: u64, + ) { + // Increment total gas + } + + /// Increment total call count + pub fn increment_call_count(env: &Env, storage: &Address) { + // Increment call count + } + + /// Record gas alert + pub fn record_alert( + env: &Env, + storage: &Address, + alert_type: &str, + ) { + let alert_key = SorobanString::from_str(env, alert_type); + // Increment alert count + } + + /// Get gas alert count by type + pub fn get_alert_count( + env: &Env, + storage: &Address, + alert_type: &str, + ) -> u64 { + let alert_key = SorobanString::from_str(env, alert_type); + // Retrieve alert count + 0 + } + + /// Update last recorded gas usage for a function + pub fn update_last_usage( + env: &Env, + storage: &Address, + function_name: &str, + gas_used: u64, + ) { + let fname = SorobanString::from_str(env, function_name); + // Update last usage + } + + /// Get last recorded gas usage + pub fn get_last_usage( + env: &Env, + storage: &Address, + function_name: &str, + ) -> Option { + let fname = SorobanString::from_str(env, function_name); + // Retrieve last usage + None + } + + /// Clear all gas metrics (admin only) + pub fn clear_all_metrics(env: &Env, storage: &Address) { + // Clear all gas-related storage + // Note: Actual implementation would iterate over keys + } + + /// Get gas metrics summary + pub fn get_metrics_summary(env: &Env, storage: &Address) -> (u64, u64, u64) { + let total_gas = Self::get_total_gas_used(env, storage); + let total_calls = Self::get_total_call_count(env, storage); + let avg_gas = total_gas + .checked_div(total_calls) + .unwrap_or(0); + (total_gas, total_calls, avg_gas) + } +} + +/// Helper function to format gas profile storage key +fn format_gas_profile_key(env: &Env, function_name: &SorobanString) -> SorobanString { + // Format: "gas_profile_{function_name}" + function_name.clone() } diff --git a/contracts/subscription/src/lib.rs b/contracts/subscription/src/lib.rs index 22a0f5cd..c4cccbe9 100644 --- a/contracts/subscription/src/lib.rs +++ b/contracts/subscription/src/lib.rs @@ -1,42 +1,22 @@ #![no_std] - -extern crate alloc; - -mod payment_methods; -mod proration; -mod revenue; +#![allow(clippy::too_many_arguments)] mod gas_optimization; mod gas_profiler; mod gas_storage; -mod quota; -mod usage; -mod events; -mod errors; -mod event_store; -mod state; -mod billing; -mod charging; -mod timeout; +mod revenue; use soroban_sdk::{token, Address, Env, IntoVal, String, Symbol, TryFromVal, Val, Vec}; -use timeout::{ChainTimeoutConfig, PaymentTimeout, TxHealthSummary}; use subtrackr_oracle::{OracleError, SubTrackrOracleClient}; use subtrackr_types::{ Interval, Invoice, Permission, Plan, PriceBounds, StorageKey, Subscription, SubscriptionStatus, TimeRange, }; -mod reentrancy; -use reentrancy::ReentrancyGuard; -use crate::proration::ProrationResult; -use crate::proration::{EffectiveDate, CreditMemo}; -use subtrackr_types::{PaymentMethod, PaymentMethodId, PaymentPriority, TokenType}; /// Billing interval in seconds. const MAX_PAUSE_DURATION: u64 = 2_592_000; // 30 days -/// How long an unaccepted subscription-transfer offer remains valid before it -/// expires. Pending transfers live in transient storage, so once this window -/// elapses the offer is removed automatically without an explicit cleanup call. -const PENDING_TRANSFER_TTL_SECS: u64 = 604_800; // 7 days +/// Default maximum number of plans a merchant can create. +/// This can be overridden on-chain by the admin via `set_max_plans_per_merchant`. +const MAX_PLANS_PER_MERCHANT: u32 = 100; const STORAGE_VERSION: u32 = 2; @@ -213,6 +193,7 @@ fn storage_temporary_set>( ); } +#[allow(dead_code)] fn storage_temporary_remove(env: &Env, storage: &Address, key: StorageKey) { let args: Vec = soroban_sdk::vec![env, key.into_val(env)]; env.invoke_contract::<()>( @@ -378,15 +359,12 @@ fn resolve_charge_price(env: &Env, storage: &Address, plan: &Plan) -> i128 { } let token_sym = token_sym_opt.unwrap(); - - // Clean string-to-symbol conversion using our helper - let quote_str = string_to_symbol_str(env, &bounds.quote); - let quote_sym = Symbol::new(env, "e_str); - + let quote_sym = bounds.quote; + let client = SubTrackrOracleClient::new(env, &oracle); - if let Ok(price) = client.try_get_price_with_cache(&token_sym, "e_sym, &600) { - let oracle_value = price.unwrap().value; + if let Ok(Ok(price)) = client.try_get_price_with_cache(&token_sym, "e_sym, &600) { + let oracle_value = price.value; if oracle_value <= 0 { return plan.price; } @@ -406,25 +384,6 @@ fn resolve_charge_price(env: &Env, storage: &Address, plan: &Plan) -> i128 { } } -// 1. Helper to convert Soroban String for Symbol creation -fn string_to_symbol_str(_env: &Env, s: &String) -> alloc::string::String { - let mut str_buf = [0u8; 32]; // Symbols have a max length of 32 - let str_len = s.len() as usize; - s.copy_into_slice(&mut str_buf[..str_len]); - - let str_slice = core::str::from_utf8(&str_buf[..str_len]).expect("Invalid UTF-8"); - alloc::string::String::from(str_slice) -} - -// 2. Helper to convert Soroban String to Soroban Bytes -fn convert_to_bytes(env: &Env, s: &String) -> soroban_sdk::Bytes { - let mut str_buf = [0u8; 256]; - let str_len = s.len() as usize; - s.copy_into_slice(&mut str_buf[..str_len]); - - soroban_sdk::Bytes::from_slice(env, &str_buf[..str_len]) -} - // ───────────────────────────────────────────────────────────────────────────── // Implementation Contract // ───────────────────────────────────────────────────────────────────────────── @@ -683,6 +642,28 @@ impl SubTrackrSubscription { storage_instance_remove(&env, &storage, StorageKey::RateLimit(function)); } + // ── Plan Limit Admin ── + + pub fn set_max_plans_per_merchant( + env: Env, + proxy: Address, + storage: Address, + new_limit: u32, + ) { + proxy.require_auth(); + let admin = get_admin(&env, &storage); + require_permission(&env, &storage, &admin, Permission::SetPlanQuotas); + admin.require_auth(); + assert!(new_limit > 0, "Max plans per merchant must be > 0"); + storage_instance_set(&env, &storage, StorageKey::MaxPlansPerMerchant, new_limit); + } + + pub fn get_max_plans_per_merchant(env: Env, proxy: Address, storage: Address) -> u32 { + proxy.require_auth(); + storage_instance_get(&env, &storage, StorageKey::MaxPlansPerMerchant) + .unwrap_or(MAX_PLANS_PER_MERCHANT) + } + // ── Plan Management ── pub fn create_plan( @@ -702,8 +683,20 @@ impl SubTrackrSubscription { merchant.require_auth(); assert!(price > 0, "Price must be positive"); + let max_plans: u32 = + storage_instance_get(&env, &storage, StorageKey::MaxPlansPerMerchant) + .unwrap_or(MAX_PLANS_PER_MERCHANT); + assert!(max_plans > 0, "Max plans per merchant must be > 0"); + let mut count: u64 = storage_instance_get(&env, &storage, StorageKey::PlanCount).unwrap_or(0); + + let mut merchant_plans: Vec = + storage_persistent_get(&env, &storage, StorageKey::MerchantPlans(merchant.clone())) + .unwrap_or(Vec::new(&env)); + if merchant_plans.len() >= max_plans { + panic!("Max plans per merchant reached"); + } count += 1; let plan = Plan { @@ -721,9 +714,6 @@ impl SubTrackrSubscription { storage_persistent_set(&env, &storage, StorageKey::Plan(count), plan.clone()); storage_instance_set(&env, &storage, StorageKey::PlanCount, count); - let mut merchant_plans: Vec = - storage_persistent_get(&env, &storage, StorageKey::MerchantPlans(merchant.clone())) - .unwrap_or(Vec::new(&env)); merchant_plans.push_back(count); storage_persistent_set( &env, @@ -839,19 +829,6 @@ impl SubTrackrSubscription { plan.subscriber_count += 1; storage_persistent_set(&env, &storage, StorageKey::Plan(plan_id), plan); - let metadata = event_store::build_event_metadata(&env, &subscriber); - event_store::record_event( - &env, - sub_count, - plan_id, - events::SubscriptionEventType::Created, - metadata, - &SubscriptionStatus::Active, - &SubscriptionStatus::Active, - plan_id, - 0, - ); - sub_count } @@ -878,8 +855,6 @@ impl SubTrackrSubscription { "Subscription not active" ); - let prior_status = sub.status.clone(); - sub.status = SubscriptionStatus::Cancelled; storage_persistent_set( &env, @@ -897,19 +872,6 @@ impl SubTrackrSubscription { plan.subscriber_count -= 1; } storage_persistent_set(&env, &storage, StorageKey::Plan(sub.plan_id), plan); - - let metadata = event_store::build_event_metadata(&env, &subscriber); - event_store::record_event( - &env, - subscription_id, - sub.plan_id, - events::SubscriptionEventType::Cancelled, - metadata, - &prior_status, - &SubscriptionStatus::Cancelled, - sub.plan_id, - 0, - ); } pub fn pause_subscription( @@ -976,19 +938,6 @@ impl SubTrackrSubscription { (String::from_str(&env, "subscription_paused"), subscriber), (subscription_id, sub.paused_at, duration), ); - - let metadata = event_store::build_event_metadata(&env, &subscriber); - event_store::record_event( - &env, - subscription_id, - sub.plan_id, - events::SubscriptionEventType::Paused, - metadata, - &SubscriptionStatus::Active, - &SubscriptionStatus::Paused, - sub.plan_id, - 0, - ); } pub fn resume_subscription( @@ -1008,8 +957,6 @@ impl SubTrackrSubscription { storage_persistent_get(&env, &storage, StorageKey::Subscription(subscription_id)) .expect("Subscription not found"); - let prior_status = sub.status.clone(); - assert!(sub.subscriber == subscriber, "Only subscriber can resume"); assert!( sub.status == SubscriptionStatus::Paused || check_and_resume_internal(&env, &mut sub), @@ -1036,28 +983,11 @@ impl SubTrackrSubscription { (String::from_str(&env, "subscription_resumed"), subscriber), subscription_id, ); - - let metadata = event_store::build_event_metadata(&env, &subscriber); - event_store::record_event( - &env, - subscription_id, - sub.plan_id, - events::SubscriptionEventType::Resumed, - metadata, - &prior_status, - &SubscriptionStatus::Active, - sub.plan_id, - 0, - ); } // ── Payment Processing ── pub fn charge_subscription(env: Env, proxy: Address, storage: Address, subscription_id: u64) { - // 0. REENTRANCY GUARD - // Lock the instance to prevent recursive cross-contract calls - let _guard = ReentrancyGuard::new(&env); - proxy.require_auth(); let mut sub: Subscription = storage_persistent_get(&env, &storage, StorageKey::Subscription(subscription_id)) @@ -1069,25 +999,6 @@ impl SubTrackrSubscription { sub.subscriber.require_auth(); - // ── Charge state machine guard (transient storage) ────────────────── - // A subscription must be charged at most once per ledger close. We - // record the current ledger sequence as a charge nonce in TEMPORARY - // storage keyed by subscription_id. The entry is given a 1-ledger TTL - // so it self-clears on the next ledger and never accrues persistent - // rent. This is intermediate, short-lived state — exactly what - // transient storage is for — and it cheaply prevents a duplicate - // charge from racing through within the same ledger. - let nonce_key = StorageKey::TmpChargeNonce(subscription_id); - let ledger_seq = env.ledger().sequence() as u64; - let in_progress: Option = storage_temporary_get(&env, &storage, nonce_key.clone()); - if let Some(prev_seq) = in_progress { - assert!( - prev_seq != ledger_seq, - "Duplicate charge attempt within the same ledger" - ); - } - storage_temporary_set(&env, &storage, nonce_key, ledger_seq, 1); - if check_and_resume_internal(&env, &mut sub) { storage_persistent_set( &env, @@ -1097,7 +1008,6 @@ impl SubTrackrSubscription { ); } - // 1. CHECKS assert!( sub.status == SubscriptionStatus::Active, "Subscription not active" @@ -1111,8 +1021,12 @@ impl SubTrackrSubscription { let charge_price = resolve_charge_price(&env, &storage, &plan); - // 2. EFFECTS - // Update the state BEFORE making the external token transfer + token::Client::new(&env, &plan.token).transfer( + &sub.subscriber, + &plan.merchant, + &charge_price, + ); + sub.last_charged_at = now; sub.next_charge_at = now + plan.interval.seconds(); sub.total_paid += charge_price; @@ -1147,42 +1061,7 @@ impl SubTrackrSubscription { (sub.subscriber.clone(), charge_price, 100_000u64, now), ); -// 2. EFFECTS (Continued) - let metadata = event_store::build_event_metadata(&env, &sub.subscriber); - event_store::record_event( - &env, - subscription_id, - sub.plan_id, - events::SubscriptionEventType::Charged, - metadata, - &SubscriptionStatus::Active, - &SubscriptionStatus::Active, - sub.plan_id, - charge_price, - ); - - // Accumulate loyalty points. - loyalty::accumulate_points( - &env, - &storage, - &sub.subscriber, - plan.price, - now, - ); - - // 3. INTERACTIONS - // Execute the token transfer. If this fails or attempts to re-enter, - // the transaction panics and all preceding storage changes safely roll back. - token::Client::new(&env, &plan.token).transfer( - &sub.subscriber, - &plan.merchant, - &charge_price, - ); - ); - if let Some(invoice_addr) = invoice_contract(&env, &storage) { - // Note: If you want to be extremely strict about CEI, ensure `generate_invoice` - // cannot make re-entrant state changes either, as we invoke it here. let period = TimeRange { start: sub.last_charged_at, end: sub.next_charge_at, @@ -1242,19 +1121,6 @@ impl SubTrackrSubscription { (String::from_str(&env, "refund_requested"), subscription_id), (sub.subscriber.clone(), amount), ); - - let metadata = event_store::build_event_metadata(&env, &sub.subscriber); - event_store::record_event( - &env, - subscription_id, - sub.plan_id, - events::SubscriptionEventType::RefundRequested, - metadata, - &sub.status, - &sub.status, - sub.plan_id, - amount, - ); } pub fn approve_refund(env: Env, proxy: Address, storage: Address, subscription_id: u64) { @@ -1286,19 +1152,6 @@ impl SubTrackrSubscription { (String::from_str(&env, "refund_approved"), subscription_id), (sub.subscriber.clone(), amount), ); - - let metadata = event_store::build_event_metadata(&env, &admin); - event_store::record_event( - &env, - subscription_id, - sub.plan_id, - events::SubscriptionEventType::RefundApproved, - metadata, - &sub.status, - &sub.status, - sub.plan_id, - amount, - ); } pub fn reject_refund(env: Env, proxy: Address, storage: Address, subscription_id: u64) { @@ -1324,19 +1177,6 @@ impl SubTrackrSubscription { (String::from_str(&env, "refund_rejected"), subscription_id), sub.subscriber.clone(), ); - - let metadata = event_store::build_event_metadata(&env, &admin); - event_store::record_event( - &env, - subscription_id, - sub.plan_id, - events::SubscriptionEventType::RefundRejected, - metadata, - &sub.status, - &sub.status, - sub.plan_id, - 0, - ); } // ── Subscription Transfer ── @@ -1364,17 +1204,11 @@ impl SubTrackrSubscription { ); assert!(sub.subscriber != recipient, "Cannot transfer to self"); - // Pending transfers are a short-lived "pending operation" that also - // grants the recipient temporary authorization to accept. They belong - // in transient storage: the offer should not persist (and accrue rent) - // indefinitely, and auto-expiry after PENDING_TRANSFER_TTL_SECS gives - // the offer a natural deadline. - storage_temporary_set( + storage_instance_set( &env, &storage, - StorageKey::TmpPendingTransfer(subscription_id), + StorageKey::PendingTransfer(subscription_id), recipient.clone(), - secs_to_ledgers(PENDING_TRANSFER_TTL_SECS), ); env.events().publish( @@ -1384,19 +1218,6 @@ impl SubTrackrSubscription { ), (sub.subscriber.clone(), recipient), ); - - let metadata = event_store::build_event_metadata(&env, &sub.subscriber); - event_store::record_event( - &env, - subscription_id, - sub.plan_id, - events::SubscriptionEventType::TransferRequested, - metadata, - &sub.status, - &sub.status, - sub.plan_id, - 0, - ); } pub fn accept_transfer( @@ -1417,8 +1238,8 @@ impl SubTrackrSubscription { .expect("Subscription not found"); let pending_recipient: Address = - storage_temporary_get(&env, &storage, StorageKey::TmpPendingTransfer(subscription_id)) - .expect("No pending transfer for this subscription (it may have expired)"); + storage_instance_get(&env, &storage, StorageKey::PendingTransfer(subscription_id)) + .expect("No pending transfer for this subscription"); assert!( pending_recipient == recipient, "Transfer recipient mismatch" @@ -1470,25 +1291,12 @@ impl SubTrackrSubscription { sub, ); - storage_temporary_remove(&env, &storage, StorageKey::TmpPendingTransfer(subscription_id)); + storage_instance_remove(&env, &storage, StorageKey::PendingTransfer(subscription_id)); env.events().publish( (String::from_str(&env, "transfer_accepted"), subscription_id), (old, recipient), ); - - let metadata = event_store::build_event_metadata(&env, &recipient); - event_store::record_event( - &env, - subscription_id, - sub.plan_id, - events::SubscriptionEventType::TransferAccepted, - metadata, - &sub.status, - &sub.status, - sub.plan_id, - 0, - ); } // ── Queries ── @@ -1545,6 +1353,15 @@ impl SubTrackrSubscription { storage_instance_get(&env, &storage, StorageKey::SubscriptionCount).unwrap_or(0) } +} + +// ── Extended APIs (disabled by default) ── +// +// These APIs depend on additional modules/types that are still evolving. +// Enable with `--features extended` in the `subtrackr-subscription` crate. +#[cfg(feature = "extended")] +#[soroban_sdk::contractimpl] +impl SubTrackrSubscription { // ── Revenue Recognition API ── /// Set a revenue recognition rule for a plan (merchant only). @@ -1615,140 +1432,6 @@ impl SubTrackrSubscription { revenue::get_revenue_schedule(&env, &storage, subscription_id) } - // ── Loyalty & Rewards API ── - - pub fn initialize_loyalty( - env: Env, - proxy: Address, - storage: Address, - config: subtrackr_types::LoyaltyConfig, - ) { - proxy.require_auth(); - get_admin(&env, &storage).require_auth(); - loyalty::set_loyalty_config(&env, &storage, &config); - } - - pub fn update_loyalty_config( - env: Env, - proxy: Address, - storage: Address, - config: subtrackr_types::LoyaltyConfig, - ) { - proxy.require_auth(); - get_admin(&env, &storage).require_auth(); - loyalty::set_loyalty_config(&env, &storage, &config); - } - - pub fn get_loyalty_config( - env: Env, - proxy: Address, - storage: Address, - ) -> Option { - proxy.require_auth(); - loyalty::get_loyalty_config(&env, &storage) - } - - pub fn get_points( - env: Env, - proxy: Address, - storage: Address, - subscriber: Address, - ) -> u64 { - proxy.require_auth(); - loyalty::get_eligible_points(&env, &storage, &subscriber) - } - - pub fn get_lifetime_points( - env: Env, - proxy: Address, - storage: Address, - subscriber: Address, - ) -> u64 { - proxy.require_auth(); - loyalty::get_lifetime_points(&env, &storage, &subscriber) - } - - pub fn get_streak( - env: Env, - proxy: Address, - storage: Address, - subscriber: Address, - ) -> u64 { - proxy.require_auth(); - loyalty::get_streak(&env, &storage, &subscriber) - } - - pub fn get_loyalty_status( - env: Env, - proxy: Address, - storage: Address, - subscriber: Address, - ) -> (u64, u64, u64, i128, Option) { - proxy.require_auth(); - let points = loyalty::get_eligible_points(&env, &storage, &subscriber); - let lifetime = loyalty::get_lifetime_points(&env, &storage, &subscriber); - let streak = loyalty::get_streak(&env, &storage, &subscriber); - let spent = loyalty::get_total_spent(&env, &storage, &subscriber); - let tier = loyalty::get_current_tier(&env, &storage, &subscriber); - (points, lifetime, streak, spent, tier) - } - - pub fn redeem_loyalty_points( - env: Env, - proxy: Address, - storage: Address, - subscriber: Address, - points: u64, - charge_amount: i128, - ) -> i128 { - proxy.require_auth(); - subscriber.require_auth(); - let now = env.ledger().timestamp(); - loyalty::redeem_points(&env, &storage, &subscriber, points, charge_amount, now) - } - - pub fn earn_referral_bonus( - env: Env, - proxy: Address, - storage: Address, - referrer: Address, - ) { - proxy.require_auth(); - let now = env.ledger().timestamp(); - loyalty::earn_referral_bonus(&env, &storage, &referrer, now); - } - - pub fn expire_points( - env: Env, - proxy: Address, - storage: Address, - subscriber: Address, - ) { - proxy.require_auth(); - get_admin(&env, &storage).require_auth(); - loyalty::expire_points(&env, &storage, &subscriber); - } - - pub fn get_point_transactions( - env: Env, - proxy: Address, - storage: Address, - subscriber: Address, - ) -> Vec { - proxy.require_auth(); - loyalty::get_point_transactions(&env, &storage, &subscriber) - } - - pub fn get_redemption( - env: Env, - proxy: Address, - storage: Address, - redemption_id: u64, - ) -> Option { - proxy.require_auth(); - loyalty::get_redemption(&env, &storage, redemption_id) - } - // ── Quota & Usage API ── pub fn set_plan_quotas( @@ -1976,356 +1659,12 @@ impl SubTrackrSubscription { user.require_auth(); payment_methods::deactivate_expired_methods(&env, &user) } - - // ── Event Sourcing & Audit Trail ── - - pub fn set_retention_policy( - env: Env, - proxy: Address, - storage: Address, - max_events_per_subscription: u32, - max_events_per_merchant: u32, - retention_days: u64, - auto_prune_enabled: bool, - ) { - proxy.require_auth(); - get_admin(&env, &storage).require_auth(); - - let policy = events::EventRetentionPolicy { - max_events_per_subscription, - max_events_per_merchant, - retention_days, - auto_prune_enabled, - }; - event_store::set_retention_policy(&env, policy); - } - - pub fn get_retention_policy( - env: Env, - _storage: Address, - ) -> Option { - event_store::get_retention_policy(&env) - } - - pub fn get_events( - env: Env, - _storage: Address, - subscription_id: u64, - filter_type: Option, - start_time: u64, - end_time: u64, - limit: u32, - offset: u32, - ) -> Vec { - let event_types = filter_type.map(|t| { - let mut types: Vec = Vec::new(&env); - types.push_back(events::SubscriptionEventType::Created); - types - }); - - let filter = events::EventFilter { - subscription_id: Some(subscription_id), - event_types: None, - date_range: if start_time > 0 || end_time > 0 { - Some(TimeRange { - start: start_time, - end: end_time, - }) - } else { - None - }, - actor: None, - limit: if limit == 0 { 100 } else { limit }, - offset, - }; - - event_store::get_events(&env, filter) - } - - pub fn get_event( - env: Env, - _storage: Address, - event_id: u64, - ) -> Option { - event_store::get_event(&env, event_id) - } - - pub fn get_event_count( - env: Env, - _storage: Address, - subscription_id: u64, - ) -> u64 { - event_store::get_event_count(&env, subscription_id) - } - - pub fn reconstruct_subscription_state( - env: Env, - _storage: Address, - subscription_id: u64, - ) -> Option { - state::reconstruct_state(&env, subscription_id) - } - - pub fn reconstruct_subscription_state_at( - env: Env, - _storage: Address, - subscription_id: u64, - target_timestamp: u64, - ) -> Option { - state::reconstruct_state_at(&env, subscription_id, target_timestamp) - } - - pub fn export_events( - env: Env, - _storage: Address, - proxy: Address, - _merchant: Address, - plan_id: u64, - start_time: u64, - end_time: u64, - ) -> Result, errors::ContractError> { - proxy.require_auth(); - let range = TimeRange { - start: start_time, - end: end_time, - }; - event_store::export_events(&env, plan_id, range) - } - - // ── Billing Schedules ── - - pub fn set_billing_schedule( - env: Env, - proxy: Address, - storage: Address, - subscription_id: u64, - interval: subtrackr_types::Interval, - start_date: u64, - trial_period_days: u32, - promotional_rate: i128, - promotional_duration_days: u32, - custom_invoice_day: u32, - ) { - proxy.require_auth(); - let sub: Subscription = - storage_persistent_get(&env, &storage, StorageKey::Subscription(subscription_id)) - .expect("Subscription not found"); - sub.subscriber.require_auth(); - - let schedule = subtrackr_types::BillingSchedule { - interval, - start_date, - trial_period_days, - promotional_rate, - promotional_duration_days, - custom_invoice_day, - }; - billing::set_billing_schedule(&env, subscription_id, &schedule); - } - - pub fn get_billing_schedule( - env: Env, - _storage: Address, - subscription_id: u64, - ) -> Option { - billing::get_billing_schedule(&env, subscription_id) - } - - pub fn get_billing_preview( - env: Env, - _storage: Address, - subscription_id: u64, - price: i128, - periods: u32, - ) -> Vec { - let schedule = billing::get_billing_schedule(&env, subscription_id) - .unwrap_or(subtrackr_types::BillingSchedule { - interval: subtrackr_types::Interval::Monthly, - start_date: 0, - trial_period_days: 0, - promotional_rate: 0, - promotional_duration_days: 0, - custom_invoice_day: 0, - }); - let now = env.ledger().timestamp(); - billing::get_billing_preview(&env, &schedule, price, now, periods) - } - - // ── Multi-step Charging with Retry ── - - pub fn start_charge( - env: Env, - _storage: Address, - subscription_id: u64, - amount: i128, - ) -> subtrackr_types::ChargeAttempt { - charging::start_charge(&env, subscription_id, amount) - } - - pub fn retry_charge( - env: Env, - _storage: Address, - charge_id: u64, - ) -> Option { - let config = charging::default_retry_config(); - charging::retry_charge(&env, charge_id, &config) - } - - pub fn get_charge_history( - env: Env, - _storage: Address, - subscription_id: u64, - ) -> Vec { - charging::get_charge_history(&env, subscription_id) - } - - pub fn abort_charge( - env: Env, - _storage: Address, - proxy: Address, - charge_id: u64, - ) { - proxy.require_auth(); - let mut attempt = charging::get_charge_attempt(&env, charge_id) - .expect("Charge attempt not found"); - charging::abort_charge(&env, &mut attempt); - } - - // ── Payment Timeout & Recovery ── - - /// Configure timeout behaviour for a specific chain. Admin only. - pub fn set_chain_timeout_config( - env: Env, - proxy: Address, - storage: Address, - admin: Address, - config: ChainTimeoutConfig, - ) { - proxy.require_auth(); - admin.require_auth(); - let stored_admin = get_admin(&env, &storage); - assert!(admin == stored_admin, "Only admin can set chain timeout config"); - timeout::set_chain_config(&env, config); - } - - /// Retrieve the timeout configuration for a chain. - pub fn get_chain_timeout_config( - env: Env, - _proxy: Address, - chain_id: u64, - ) -> ChainTimeoutConfig { - timeout::get_chain_config(&env, chain_id) - } - - /// Register a newly-submitted payment for timeout tracking. - pub fn register_payment_pending( - env: Env, - proxy: Address, - charge_id: u64, - subscription_id: u64, - chain_id: u64, - initial_gas_price: u64, - ) -> PaymentTimeout { - proxy.require_auth(); - timeout::register_pending(&env, charge_id, subscription_id, chain_id, initial_gas_price) - } - - /// Check whether a pending payment has exceeded its chain timeout window. - /// Transitions the record to `TimedOut` on first detection and emits an event. - pub fn detect_payment_timeout( - env: Env, - proxy: Address, - charge_id: u64, - ) -> bool { - proxy.require_auth(); - timeout::detect_timeout(&env, charge_id) - } - - /// Automatically retry a timed-out payment with a higher gas price. - pub fn recover_payment( - env: Env, - proxy: Address, - charge_id: u64, - new_gas_price: u64, - ) -> Option { - proxy.require_auth(); - timeout::attempt_recovery(&env, charge_id, new_gas_price) - } - - /// Manual retry option for users — bumps gas and re-submits. - pub fn manual_retry_payment( - env: Env, - proxy: Address, - storage: Address, - subscriber: Address, - charge_id: u64, - new_gas_price: u64, - ) -> Option { - proxy.require_auth(); - subscriber.require_auth(); - // Verify the charge belongs to this subscriber. - let rec = timeout::get_timeout_record(&env, charge_id) - .expect("Timeout record not found"); - let sub: subtrackr_types::Subscription = - storage_persistent_get(&env, &storage, subtrackr_types::StorageKey::Subscription(rec.subscription_id)) - .expect("Subscription not found"); - assert!(sub.subscriber == subscriber, "Unauthorized: not the subscriber"); - timeout::manual_retry(&env, charge_id, new_gas_price) - } - - /// Mark a payment as confirmed on-chain after a successful recovery. - pub fn mark_payment_resolved( - env: Env, - proxy: Address, - charge_id: u64, - ) -> Option { - proxy.require_auth(); - timeout::mark_resolved(&env, charge_id) - } - - /// Retrieve a single payment timeout record. - pub fn get_payment_timeout( - env: Env, - _proxy: Address, - charge_id: u64, - ) -> Option { - timeout::get_timeout_record(&env, charge_id) - } - - /// List all payment timeout records for a subscription. - pub fn get_subscription_timeouts( - env: Env, - proxy: Address, - subscription_id: u64, - ) -> Vec { - proxy.require_auth(); - timeout::get_subscription_timeouts(&env, subscription_id) - } - - /// List only stuck (timed-out or recovering) transactions for a subscription. - pub fn get_stuck_transactions( - env: Env, - proxy: Address, - subscription_id: u64, - ) -> Vec { - proxy.require_auth(); - timeout::get_stuck_transactions(&env, subscription_id) - } - - /// Transaction health summary for the dashboard. - pub fn get_tx_health_summary( - env: Env, - proxy: Address, - subscription_id: u64, - ) -> TxHealthSummary { - proxy.require_auth(); - timeout::get_health_summary(&env, subscription_id) - } } // Proration & Plan Changes /// Preview proration before confirming a plan change +#[cfg(feature = "extended")] pub fn preview_proration( env: Env, proxy: Address, @@ -2351,26 +1690,11 @@ pub fn preview_proration( EffectiveDate::EndOfPeriod }; - let result = proration::preview_proration(&env, &sub, old_plan.price, new_plan.price, effective); - - // Cache the previewed prorated amount in transient storage so a client can - // preview then confirm without recomputing. This is purely intermediate - // calculation state, so it lives in TEMPORARY storage and expires after one - // billing interval — no persistent rent for a value that is only relevant - // until the change is confirmed or abandoned. - let signed_amount: i128 = if result.is_credit { -result.amount } else { result.amount }; - storage_temporary_set( - &env, - &storage, - StorageKey::TmpProrationScratch(subscription_id), - signed_amount, - secs_to_ledgers(sub.next_charge_at.saturating_sub(sub.last_charged_at).max(1)), - ); - - result + proration::preview_proration(&env, &sub, old_plan.price, new_plan.price, effective) } /// Execute a plan change with proration +#[cfg(feature = "extended")] pub fn change_plan( env: Env, proxy: Address, @@ -2410,7 +1734,7 @@ pub fn change_plan( }; let proration_result = - proration::calculate_proration(&env, &sub, old_plan.price, new_plan.price, effective.clone()); + proration::calculate_proration(&env, &sub, old_plan.price, new_plan.price, effective); // Handle proration payment or credit if proration_result.amount > 0 { @@ -2488,27 +1812,10 @@ pub fn change_plan( proration_result.is_credit, ), ); - - let event_type = if new_plan.price >= old_plan.price { - events::SubscriptionEventType::Upgraded - } else { - events::SubscriptionEventType::Downgraded - }; - let metadata = event_store::build_event_metadata(&env, &subscriber); - event_store::record_event( - &env, - subscription_id, - new_plan_id, - event_type, - metadata, - &sub.status, - &sub.status, - new_plan_id, - proration_result.amount, - ); } /// Get stored credit memo for a subscription +#[cfg(feature = "extended")] pub fn get_credit_memo( env: Env, proxy: Address, @@ -2520,6 +1827,7 @@ pub fn get_credit_memo( } /// Apply credit memo to next charge +#[cfg(feature = "extended")] pub fn apply_credit_memo_to_charge( env: Env, proxy: Address, @@ -2539,7 +1847,7 @@ pub fn apply_credit_memo_to_charge( let plan: Plan = storage_persistent_get(&env, &storage, StorageKey::Plan(sub.plan_id)) .expect("Plan not found"); - let charge_price = resolve_charge_price(&env, &storage, &plan); + let charge_price = Self::resolve_charge_price(&env, &storage, &plan); let final_charge = proration::apply_credit_memo(charge_price, &mut memo); // Update stored memo diff --git a/contracts/subscription/src/revenue.rs b/contracts/subscription/src/revenue.rs index c33c8844..2688986f 100644 --- a/contracts/subscription/src/revenue.rs +++ b/contracts/subscription/src/revenue.rs @@ -1,3 +1,4 @@ +#![allow(dead_code)] /// Revenue recognition module for SubTrackr subscriptions. /// /// Implements ASC 606 / IFRS 15 compliant revenue recognition: diff --git a/contracts/types/src/lib.rs b/contracts/types/src/lib.rs index 07bd8517..96c25cc5 100644 --- a/contracts/types/src/lib.rs +++ b/contracts/types/src/lib.rs @@ -1,63 +1,26 @@ -//! Shared types crate for the SubTrackr smart-contract workspace — Issue #404. -//! -//! # Purpose -//! Provides a single, versioned source of truth for all data structures shared -//! across contract crates (`subscription`, `invoice`, `oracle`, `batch`, …). -//! Every contract crate must import types from here rather than re-defining them. -//! -//! # Versioning -//! The `TYPES_VERSION` constant is incremented whenever a **breaking** change -//! is introduced (field removal, type change, variant reordering). Non-breaking -//! additions (new optional fields, new enum variants appended at the end) do -//! **not** require a version bump. -//! -//! See `docs/TYPES_MIGRATION.md` for the migration guide and backward -//! compatibility policy. -//! -//! # Re-export policy -//! All public items in this crate are re-exported from each contract crate's -//! root via `pub use subtrackr_types::*;` so downstream users only need one -//! import path. - #![no_std] -use soroban_sdk::{contracttype, Address, BytesN, String, Vec}; - -/// Current schema version of this types crate. -/// -/// Increment this constant whenever a backward-incompatible change is made -/// (field removal, type narrowing, enum variant reordering). All deployed -/// contracts embed this value in their storage so a version mismatch can be -/// detected at upgrade time. -pub const TYPES_VERSION: u32 = 1; +use soroban_sdk::{contracttype, Address, String, Symbol, Vec}; /// Billing interval in seconds. #[contracttype] #[derive(Clone, Debug, PartialEq)] pub enum Interval { - Daily, // 86400s - Weekly, // 604800s - BiWeekly, // 1209600s (14 days) - Monthly, // 2592000s (30 days) - BiMonthly, // 5184000s (60 days) - Quarterly, // 7776000s (90 days) - SemiAnnually, // 15724800s (182 days) - Yearly, // 31536000s (365 days) - Custom(u64), // Custom interval in seconds + Daily, // 86400s + Weekly, // 604800s + Monthly, // 2592000s (30 days) + Quarterly, // 7776000s (90 days) + Yearly, // 31536000s (365 days) } impl Interval { pub fn seconds(&self) -> u64 { match self { - Interval::Daily => 86_400, - Interval::Weekly => 604_800, - Interval::BiWeekly => 1_209_600, - Interval::Monthly => 2_592_000, - Interval::BiMonthly => 5_184_000, - Interval::Quarterly => 7_776_000, - Interval::SemiAnnually => 15_724_800, - Interval::Yearly => 31_536_000, - Interval::Custom(secs) => *secs, + Interval::Daily => 86_400, + Interval::Weekly => 604_800, + Interval::Monthly => 2_592_000, + Interval::Quarterly => 7_776_000, + Interval::Yearly => 31_536_000, } } } @@ -291,62 +254,6 @@ pub struct Subscription { pub refund_requested_amount: i128, } -/// Configuration for flexible billing schedules (Issue #170). -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct BillingSchedule { - pub interval: Interval, - /// Timestamp of the billing cycle start (0 = first charge date). - pub start_date: u64, - /// Trial period in days before first charge. - pub trial_period_days: u32, - /// Promotional rate applied during the promotional period (0 = no promo). - pub promotional_rate: i128, - /// Duration in days the promotional rate is active (0 = no promo). - pub promotional_duration_days: u32, - /// Preferred day of month for invoice generation (1-31, 0 = use interval). - pub custom_invoice_day: u32, -} - -/// State of a multi-step charge attempt (Issue #169). -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub enum ChargeStatus { - Pending, - Attempting, - Failed, - Retrying, - Completed, - Exhausted, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct ChargeAttempt { - pub id: u64, - pub subscription_id: u64, - pub status: ChargeStatus, - pub amount: i128, - pub attempted_at: u64, - pub completed_at: u64, - pub error_message: String, - pub retry_count: u32, - pub max_retries: u32, - pub next_retry_at: u64, - pub circuit_breaker_until: u64, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct RetryConfig { - pub max_retries: u32, - pub base_delay_secs: u64, - pub max_delay_secs: u64, - pub backoff_factor: u32, - pub circuit_breaker_threshold: u32, - pub circuit_breaker_cooldown_secs: u64, -} - pub type Timestamp = u64; #[contracttype] @@ -571,18 +478,6 @@ pub struct FraudReport { pub recent_cases: Vec, } -// ── Loyalty & Rewards types ── - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub enum PointTxType { - Earned, - Redeemed, - Expired, - ReferralBonus, - StreakBonus, - Achievement, -} // ── Access Control Types ── #[contracttype] @@ -596,14 +491,6 @@ pub enum Role { #[contracttype] #[derive(Clone, Debug, PartialEq)] -pub struct LoyaltyTierConfig { - pub name: String, - pub points_threshold: u64, - pub discount_rate_bps: u32, - pub priority_support: bool, - pub reduced_fees_bps: u32, -} - pub enum Permission { GrantRole, RevokeRole, @@ -639,12 +526,6 @@ pub enum Permission { #[contracttype] #[derive(Clone, Debug, PartialEq)] -pub struct LoyaltyConfig { - pub points_per_dollar: u64, - pub expiration_days: u64, - pub tiers: Vec, - pub streak_bonus_threshold: u64, -} pub enum RoleChangeAction { Granted, Revoked, @@ -652,25 +533,6 @@ pub enum RoleChangeAction { #[contracttype] #[derive(Clone, Debug, PartialEq)] -pub struct PointTransaction { - pub id: u64, - pub subscriber: Address, - pub amount: i128, - pub tx_type: PointTxType, - pub timestamp: u64, - pub reference_id: u64, - pub description: String, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct RewardsRedemption { - pub id: u64, - pub subscriber: Address, - pub points_cost: u64, - pub discount_amount: i128, - pub timestamp: u64, -} pub struct RoleChangeEntry { pub id: u64, pub user: Address, @@ -686,7 +548,6 @@ pub struct RoleChangeEntry { #[contracttype] #[derive(Clone, Debug, PartialEq)] pub enum DigitalGoodsClass { - Unspecified, Standard, ElectronicService, Exempt, @@ -694,6 +555,13 @@ pub enum DigitalGoodsClass { TelecomService, } +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum MaybeDigitalGoodsClass { + None, + Some(DigitalGoodsClass), +} + /// A tax rate entry for a specific jurisdiction and tax type. #[contracttype] #[derive(Clone, Debug, PartialEq)] @@ -718,7 +586,7 @@ pub struct CustomerTaxStatus { pub certificate_expiry: Timestamp, pub issuing_authority: String, pub exempt_jurisdictions: Vec, - pub goods_class: DigitalGoodsClass, + pub digital_goods_override: MaybeDigitalGoodsClass, } /// A single line in a tax remittance report recording collected tax by jurisdiction. @@ -755,7 +623,6 @@ pub enum StorageKey { LastCall(Address, String), /// Pending transfer request: subscription_id -> pending recipient PendingTransfer(u64), - CreditMemo(u64), // ── Invoice state ── InvoiceCount, @@ -810,42 +677,6 @@ pub enum StorageKey { /// Usage record for a subscription and metric (sub_id, metric -> UsageRecord) SubscriptionUsage(u64, QuotaMetric), - // ── Added in storage version 5 (API Key & Rate Limiting) ── - ApiKey(u64), - ApiKeyCount, - ApiKeysByOwner(Address), - ApiKeyAudit(u64), - ApiKeyAuditCount, - RateLimitMinute(u64, u64), - RateLimitHour(u64, u64), - RateLimitDay(u64, u64), - ApiUsage(u64, u64), - // ── Added in storage version 5 (Oracle Integration) ── - // ── Added in storage version 5 (Loyalty & Rewards) ── - /// Global loyalty program config. - LoyaltyConfig, - /// Current points balance for a subscriber. - LoyaltyPoints(Address), - /// Lifetime points earned for a subscriber. - LifetimePoints(Address), - /// Total amount spent by a subscriber. - TotalSpent(Address), - /// When the subscriber enrolled in the loyalty program. - MemberSince(Address), - /// Current consecutive on-time charge streak. - Streak(Address), - /// Timestamp of the last charge processed (for streak calculation). - LastChargeAt(Address), - /// When the subscriber's current points balance expires. - PointsExpiration(Address), - /// Counter for point transaction IDs. - PointTxCount, - /// Individual point transaction record. - PointTx(u64), - /// Counter for redemption IDs. - RedemptionCount, - /// Individual redemption record. - Redemption(u64), // ── Added in storage version 5 (Access Control) ── /// Address of the access_control contract for RBAC. AccessControl, @@ -877,147 +708,10 @@ pub enum StorageKey { /// single ledger sequence window. Expires after one ledger close (~5 s). TmpChargeNonce(u64), - // ── Added in storage version 7 (transient pending operations) ── - /// Pending subscription-transfer authorization keyed by subscription_id. - /// Holds the recipient address that is temporarily authorized to accept - /// the transfer. Stored in TEMPORARY storage so an unaccepted transfer - /// offer auto-expires (default 7 days) instead of lingering forever in - /// instance storage. Replaces the instance-backed StorageKey::PendingTransfer. - TmpPendingTransfer(u64), -} - -pub type ApiKeyId = u64; - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub enum ApiKeyStatus { - Active, - Revoked, - Expired, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub enum UsageTier { - Free, - Basic, - Pro, - Enterprise, -} - -impl UsageTier { - pub fn default_rate_limit(&self) -> RateLimitConfig { - match self { - UsageTier::Free => RateLimitConfig { - requests_per_minute: 100, - requests_per_hour: 1_000, - requests_per_day: 10_000, - burst_limit: 10, - }, - UsageTier::Basic => RateLimitConfig { - requests_per_minute: 1_000, - requests_per_hour: 10_000, - requests_per_day: 100_000, - burst_limit: 50, - }, - UsageTier::Pro => RateLimitConfig { - requests_per_minute: 10_000, - requests_per_hour: 100_000, - requests_per_day: 1_000_000, - burst_limit: 200, - }, - UsageTier::Enterprise => RateLimitConfig { - requests_per_minute: 100_000, - requests_per_hour: 1_000_000, - requests_per_day: 10_000_000, - burst_limit: 1000, - }, - } - } - - pub fn price_per_thousand(&self) -> i128 { - match self { - UsageTier::Free => 0, - UsageTier::Basic => 1, // 0.001 per 1k requests (in stroops) - UsageTier::Pro => 5, // 0.005 per 1k - UsageTier::Enterprise => 10, // 0.01 per 1k - } - } -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct RateLimitConfig { - pub requests_per_minute: u32, - pub requests_per_hour: u32, - pub requests_per_day: u32, - pub burst_limit: u32, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct ApiKeyConfig { - pub name: String, - pub rate_limit: RateLimitConfig, - pub usage_tier: UsageTier, - pub expires_at: u64, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct ApiKey { - pub id: ApiKeyId, - pub owner: Address, - pub key_hash: BytesN<32>, - pub name: String, - pub rate_limit: RateLimitConfig, - pub usage_tier: UsageTier, - pub status: ApiKeyStatus, - pub created_at: u64, - pub expires_at: u64, - pub last_used_at: u64, - pub revoked_at: u64, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct RateLimitWindow { - pub window_start: u64, - pub count: u32, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct ApiUsageRecord { - pub window_start: u64, - pub count: u32, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct RateLimitStatus { - pub is_allowed: bool, - pub remaining: u32, - pub reset_at: u64, - pub retry_after: u64, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct UsageReport { - pub key_id: ApiKeyId, - pub period: TimeRange, - pub total_requests: u32, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct ApiKeyAuditEntry { - pub id: u64, - pub key_id: ApiKeyId, - pub action: String, - pub changed_by: Address, - pub timestamp: u64, + // ── Added in storage version 7 (Plan limits) ── + /// Global maximum number of plans a merchant can create. + /// Stored in instance storage; if unset, the implementation default applies. + MaxPlansPerMerchant, } /// Slippage protection bounds for oracle-based pricing. @@ -1029,5 +723,5 @@ pub struct PriceBounds { /// Minimum allowed price as basis points of the stored plan price (e.g. 9500 = -5%). pub min_price_bps: u32, /// Quote currency symbol used for price lookup (e.g. "USD"). - pub quote: String, + pub quote: Symbol, } diff --git a/developer-portal/components/ApiKeyManager.tsx b/developer-portal/components/ApiKeyManager.tsx index c005a13c..aed22d54 100644 --- a/developer-portal/components/ApiKeyManager.tsx +++ b/developer-portal/components/ApiKeyManager.tsx @@ -38,7 +38,7 @@ const AVAILABLE_PERMISSIONS = [ ]; export const ApiKeyManager: React.FC = ({ - environmentId, + environmentId: _environmentId, apiKeys, onCreateKey, onRevokeKey, @@ -115,10 +115,8 @@ export const ApiKeyManager: React.FC = ({ }; const togglePermission = (permission: string) => { - setSelectedPermissions(prev => - prev.includes(permission) - ? prev.filter(p => p !== permission) - : [...prev, permission] + setSelectedPermissions((prev) => + prev.includes(permission) ? prev.filter((p) => p !== permission) : [...prev, permission] ); }; @@ -135,14 +133,13 @@ export const ApiKeyManager: React.FC = ({ item.status === 'active' && styles.statusActive, item.status === 'revoked' && styles.statusRevoked, item.status === 'expired' && styles.statusExpired, - ]} - > + ]}> {item.status} - {item.permissions.map(permission => ( + {item.permissions.map((permission) => ( {permission} @@ -150,13 +147,9 @@ export const ApiKeyManager: React.FC = ({ - - Created: {item.createdAt.toLocaleDateString()} - + Created: {item.createdAt.toLocaleDateString()} {item.lastUsedAt && ( - - Last used: {item.lastUsedAt.toLocaleDateString()} - + Last used: {item.lastUsedAt.toLocaleDateString()} )} @@ -164,14 +157,12 @@ export const ApiKeyManager: React.FC = ({ handleRotateKey(item.id, item.name)} - > + onPress={() => handleRotateKey(item.id, item.name)}> Rotate handleRevokeKey(item.id, item.name)} - > + onPress={() => handleRevokeKey(item.id, item.name)}> Revoke @@ -183,10 +174,7 @@ export const ApiKeyManager: React.FC = ({ API Keys - setModalVisible(true)} - > + setModalVisible(true)}> + Create Key @@ -194,7 +182,7 @@ export const ApiKeyManager: React.FC = ({ item.id} + keyExtractor={(item) => item.id} contentContainerStyle={styles.listContainer} ListEmptyComponent={ @@ -210,8 +198,7 @@ export const ApiKeyManager: React.FC = ({ visible={modalVisible} animationType="slide" transparent={true} - onRequestClose={() => setModalVisible(false)} - > + onRequestClose={() => setModalVisible(false)}> Create API Key @@ -227,23 +214,20 @@ export const ApiKeyManager: React.FC = ({ Permissions - {AVAILABLE_PERMISSIONS.map(permission => ( + {AVAILABLE_PERMISSIONS.map((permission) => ( togglePermission(permission)} - > + onPress={() => togglePermission(permission)}> + ]}> {permission} @@ -253,15 +237,13 @@ export const ApiKeyManager: React.FC = ({ setModalVisible(false)} - > + onPress={() => setModalVisible(false)}> Cancel + disabled={loading}> {loading ? 'Creating...' : 'Create Key'} diff --git a/developer-portal/components/DeveloperOnboarding.tsx b/developer-portal/components/DeveloperOnboarding.tsx index 1ea9e6c6..770a06a5 100644 --- a/developer-portal/components/DeveloperOnboarding.tsx +++ b/developer-portal/components/DeveloperOnboarding.tsx @@ -42,7 +42,7 @@ export const DeveloperOnboarding: React.FC = ({ [onStepComplete] ); - const allCompleted = steps.every(step => step.completed); + const allCompleted = steps.every((step) => step.completed); return ( @@ -59,13 +59,13 @@ export const DeveloperOnboarding: React.FC = ({ style={[ styles.progressFill, { - width: `${(steps.filter(s => s.completed).length / steps.length) * 100}%`, + width: `${(steps.filter((s) => s.completed).length / steps.length) * 100}%`, }, ]} /> - {steps.filter(s => s.completed).length} of {steps.length} completed + {steps.filter((s) => s.completed).length} of {steps.length} completed @@ -79,15 +79,9 @@ export const DeveloperOnboarding: React.FC = ({ index === currentStep && styles.stepCardActive, ]} onPress={() => !step.completed && handleStepPress(step.id)} - disabled={step.completed || loading} - > + disabled={step.completed || loading}> - + {step.completed ? ( ) : ( @@ -95,12 +89,7 @@ export const DeveloperOnboarding: React.FC = ({ )} - + {step.title} {step.description} diff --git a/developer-portal/pages/ApiKeysPage.tsx b/developer-portal/pages/ApiKeysPage.tsx index 1a859c93..428a925a 100644 --- a/developer-portal/pages/ApiKeysPage.tsx +++ b/developer-portal/pages/ApiKeysPage.tsx @@ -35,7 +35,7 @@ const AVAILABLE_PERMISSIONS = [ { id: 'analytics', label: 'Analytics', description: 'Access usage analytics' }, ]; -export const ApiKeysPage: React.FC = ({ environmentId }) => { +export const ApiKeysPage: React.FC = ({ environmentId: _environmentId }) => { const [apiKeys, setApiKeys] = useState([]); const [modalVisible, setModalVisible] = useState(false); const [newKeyName, setNewKeyName] = useState(''); @@ -106,9 +106,7 @@ export const ApiKeysPage: React.FC = ({ environmentId }) => { status: 'active', lastUsedAt: null, createdAt: new Date(), - expiresAt: new Date( - Date.now() + parseInt(expirationDays) * 86400000 - ), + expiresAt: new Date(Date.now() + parseInt(expirationDays) * 86400000), }; setApiKeys((prev) => [...prev, key]); @@ -135,9 +133,7 @@ export const ApiKeysPage: React.FC = ({ environmentId }) => { style: 'destructive', onPress: () => { setApiKeys((prev) => - prev.map((k) => - k.id === keyId ? { ...k, status: 'revoked' as const } : k - ) + prev.map((k) => (k.id === keyId ? { ...k, status: 'revoked' as const } : k)) ); }, }, @@ -156,11 +152,7 @@ export const ApiKeysPage: React.FC = ({ environmentId }) => { onPress: () => { const newKey = `sk_test_${Math.random().toString(36).substring(2, 38)}`; setApiKeys((prev) => - prev.map((k) => - k.id === keyId - ? { ...k, key: newKey, lastUsedAt: null } - : k - ) + prev.map((k) => (k.id === keyId ? { ...k, key: newKey, lastUsedAt: null } : k)) ); setGeneratedKey(newKey); setShowKeyModal(true); @@ -177,13 +169,11 @@ export const ApiKeysPage: React.FC = ({ environmentId }) => { const togglePermission = (permission: string) => { setSelectedPermissions((prev) => - prev.includes(permission) - ? prev.filter((p) => p !== permission) - : [...prev, permission] + prev.includes(permission) ? prev.filter((p) => p !== permission) : [...prev, permission] ); }; - const copyToClipboard = (text: string) => { + const copyToClipboard = (_text: string) => { Alert.alert('Copied', 'API key copied to clipboard'); }; @@ -202,8 +192,7 @@ export const ApiKeysPage: React.FC = ({ environmentId }) => { item.status === 'active' && styles.statusActive, item.status === 'revoked' && styles.statusRevoked, item.status === 'expired' && styles.statusExpired, - ]} - > + ]}> {item.status} @@ -217,18 +206,12 @@ export const ApiKeysPage: React.FC = ({ environmentId }) => { - - Created: {item.createdAt.toLocaleDateString()} - + Created: {item.createdAt.toLocaleDateString()} {item.lastUsedAt && ( - - Last used: {item.lastUsedAt.toLocaleDateString()} - + Last used: {item.lastUsedAt.toLocaleDateString()} )} {item.expiresAt && ( - - Expires: {item.expiresAt.toLocaleDateString()} - + Expires: {item.expiresAt.toLocaleDateString()} )} @@ -236,14 +219,12 @@ export const ApiKeysPage: React.FC = ({ environmentId }) => { handleRotateKey(item.id, item.name)} - > + onPress={() => handleRotateKey(item.id, item.name)}> Rotate handleRevokeKey(item.id, item.name)} - > + onPress={() => handleRevokeKey(item.id, item.name)}> Revoke @@ -256,14 +237,9 @@ export const ApiKeysPage: React.FC = ({ environmentId }) => { API Keys - - Manage your API keys for sandbox access - + Manage your API keys for sandbox access - setModalVisible(true)} - > + setModalVisible(true)}> + Create Key @@ -271,8 +247,8 @@ export const ApiKeysPage: React.FC = ({ environmentId }) => { API Key Security - Keep your API keys secure. Never share them in public repositories or - client-side code. Use environment variables for production keys. + Keep your API keys secure. Never share them in public repositories or client-side code. + Use environment variables for production keys. @@ -296,8 +272,7 @@ export const ApiKeysPage: React.FC = ({ environmentId }) => { visible={modalVisible} animationType="slide" transparent={true} - onRequestClose={() => setModalVisible(false)} - > + onRequestClose={() => setModalVisible(false)}> @@ -312,9 +287,7 @@ export const ApiKeysPage: React.FC = ({ environmentId }) => { placeholderTextColor="#9CA3AF" /> - - Expiration (days) - + Expiration (days) = ({ environmentId }) => { selectedPermissions.includes(permission.id) && styles.permissionOptionSelected, ]} - onPress={() => togglePermission(permission.id)} - > + onPress={() => togglePermission(permission.id)}> + ]}> {permission.label} @@ -360,15 +331,13 @@ export const ApiKeysPage: React.FC = ({ environmentId }) => { setModalVisible(false)} - > + onPress={() => setModalVisible(false)}> Cancel + disabled={loading}> {loading ? 'Creating...' : 'Create Key'} @@ -383,14 +352,12 @@ export const ApiKeysPage: React.FC = ({ environmentId }) => { visible={showKeyModal} animationType="slide" transparent={true} - onRequestClose={() => setShowKeyModal(false)} - > + onRequestClose={() => setShowKeyModal(false)}> API Key Created - Your API key has been created. Copy it now - you won't be able to - see it again! + Your API key has been created. Copy it now - you won't be able to see it again! @@ -402,8 +369,7 @@ export const ApiKeysPage: React.FC = ({ environmentId }) => { onPress={() => { copyToClipboard(generatedKey); setShowKeyModal(false); - }} - > + }}> Copy & Close diff --git a/developer-portal/pages/DashboardPage.tsx b/developer-portal/pages/DashboardPage.tsx index 1b4b3d45..86b5126a 100644 --- a/developer-portal/pages/DashboardPage.tsx +++ b/developer-portal/pages/DashboardPage.tsx @@ -1,12 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { - View, - Text, - StyleSheet, - ScrollView, - TouchableOpacity, - RefreshControl, -} from 'react-native'; +import { View, Text, StyleSheet, ScrollView, TouchableOpacity, RefreshControl } from 'react-native'; interface DashboardStats { totalRequests: number; @@ -135,22 +128,15 @@ export const DashboardPage: React.FC = ({ onNavigate }) => { return ( - } - > + refreshControl={}> Developer Dashboard - - Manage your sandbox environments and API integrations - + Manage your sandbox environments and API integrations - - {stats.totalRequests.toLocaleString()} - + {stats.totalRequests.toLocaleString()} Total Requests @@ -174,31 +160,21 @@ export const DashboardPage: React.FC = ({ onNavigate }) => { Quick Actions - onNavigate('api-keys')} - > + onNavigate('api-keys')}> 🔑 Create API Key onNavigate('environments')} - > + onPress={() => onNavigate('environments')}> 🌍 New Environment - onNavigate('docs')} - > + onNavigate('docs')}> 📚 View Docs - onNavigate('usage')} - > + onNavigate('usage')}> 📊 Usage Stats @@ -220,28 +196,15 @@ export const DashboardPage: React.FC = ({ onNavigate }) => { style={[ styles.statusBadge, { backgroundColor: getStatusColor(env.status) + '20' }, - ]} - > - - + ]}> + + {env.status} - - {env.requestCount.toLocaleString()} requests - + {env.requestCount.toLocaleString()} requests {env.errorRate}% errors @@ -254,16 +217,10 @@ export const DashboardPage: React.FC = ({ onNavigate }) => { {recentActivity.map((activity) => ( - - {getActivityIcon(activity.type)} - + {getActivityIcon(activity.type)} - - {activity.description} - - - {activity.timestamp.toLocaleString()} - + {activity.description} + {activity.timestamp.toLocaleString()} ))} diff --git a/developer-portal/pages/DocumentationPage.tsx b/developer-portal/pages/DocumentationPage.tsx index 3830e159..ebb81a86 100644 --- a/developer-portal/pages/DocumentationPage.tsx +++ b/developer-portal/pages/DocumentationPage.tsx @@ -146,9 +146,7 @@ const QUICK_START_GUIDES: QuickStartGuide[] = [ export const DocumentationPage: React.FC = () => { const [searchQuery, setSearchQuery] = useState(''); - const [selectedSection, setSelectedSection] = useState( - null - ); + const [selectedSection, setSelectedSection] = useState(null); const filteredSections = DOC_SECTIONS.filter( (section) => @@ -171,26 +169,14 @@ export const DocumentationPage: React.FC = () => { const renderSection = ({ item }: { item: DocSection }) => ( - setSelectedSection( - selectedSection?.id === item.id ? null : item - ) - } - > + style={[styles.sectionCard, selectedSection?.id === item.id && styles.sectionCardSelected]} + onPress={() => setSelectedSection(selectedSection?.id === item.id ? null : item)}> {item.icon} {item.title} - - {selectedSection?.id === item.id ? '▼' : '▶'} - + {selectedSection?.id === item.id ? '▼' : '▶'} - {selectedSection?.id === item.id && ( - {item.content} - )} + {selectedSection?.id === item.id && {item.content}} ); @@ -202,14 +188,8 @@ export const DocumentationPage: React.FC = () => { style={[ styles.difficultyBadge, { backgroundColor: getDifficultyColor(item.difficulty) + '20' }, - ]} - > - + ]}> + {item.difficulty} @@ -223,9 +203,7 @@ export const DocumentationPage: React.FC = () => { Documentation - - Everything you need to integrate with SubTrackr - + Everything you need to integrate with SubTrackr @@ -261,8 +239,7 @@ export const DocumentationPage: React.FC = () => { Need Help? - Can't find what you're looking for? Contact our developer support - team. + Can't find what you're looking for? Contact our developer support team. Contact Support diff --git a/developer-portal/pages/OnboardingPage.tsx b/developer-portal/pages/OnboardingPage.tsx index 7db44fc6..ba355fcd 100644 --- a/developer-portal/pages/OnboardingPage.tsx +++ b/developer-portal/pages/OnboardingPage.tsx @@ -21,9 +21,7 @@ interface OnboardingPageProps { onComplete: () => void; } -export const OnboardingPage: React.FC = ({ - onComplete, -}) => { +export const OnboardingPage: React.FC = ({ onComplete }) => { const [currentStep, setCurrentStep] = useState(0); const [steps, setSteps] = useState([ { @@ -77,19 +75,12 @@ export const OnboardingPage: React.FC = ({ }); const completedCount = steps.filter((s) => s.completed).length; - const requiredCount = steps.filter((s) => s.isRequired).length; - const allRequiredCompleted = steps - .filter((s) => s.isRequired) - .every((s) => s.completed); + const allRequiredCompleted = steps.filter((s) => s.isRequired).every((s) => s.completed); const handleCompleteStep = (stepId: string) => { - setSteps((prev) => - prev.map((s) => (s.id === stepId ? { ...s, completed: true } : s)) - ); + setSteps((prev) => prev.map((s) => (s.id === stepId ? { ...s, completed: true } : s))); - const nextStep = steps.findIndex( - (s) => !s.completed && s.id !== stepId - ); + const nextStep = steps.findIndex((s) => !s.completed && s.id !== stepId); if (nextStep !== -1) { setCurrentStep(nextStep); } @@ -105,11 +96,9 @@ export const OnboardingPage: React.FC = ({ }; const handleCreateSandbox = () => { - Alert.alert( - 'Sandbox Created', - 'Your sandbox environment has been created successfully!', - [{ text: 'OK', onPress: () => handleCompleteStep('create-sandbox') }] - ); + Alert.alert('Sandbox Created', 'Your sandbox environment has been created successfully!', [ + { text: 'OK', onPress: () => handleCompleteStep('create-sandbox') }, + ]); }; const handleGenerateApiKey = () => { @@ -138,9 +127,7 @@ export const OnboardingPage: React.FC = ({ - setFormData((prev) => ({ ...prev, name: text })) - } + onChangeText={(text) => setFormData((prev) => ({ ...prev, name: text }))} placeholder="John Doe" placeholderTextColor="#9CA3AF" /> @@ -148,9 +135,7 @@ export const OnboardingPage: React.FC = ({ - setFormData((prev) => ({ ...prev, email: text })) - } + onChangeText={(text) => setFormData((prev) => ({ ...prev, email: text }))} placeholder="john@example.com" placeholderTextColor="#9CA3AF" keyboardType="email-address" @@ -159,16 +144,11 @@ export const OnboardingPage: React.FC = ({ - setFormData((prev) => ({ ...prev, company: text })) - } + onChangeText={(text) => setFormData((prev) => ({ ...prev, company: text }))} placeholder="Acme Inc" placeholderTextColor="#9CA3AF" /> - + Create Account @@ -178,16 +158,11 @@ export const OnboardingPage: React.FC = ({ return ( - Your sandbox environment will be created with default settings. - You can customize it later in the environment settings. + Your sandbox environment will be created with default settings. You can customize it + later in the environment settings. - - - Create Sandbox Environment - + + Create Sandbox Environment ); @@ -196,13 +171,10 @@ export const OnboardingPage: React.FC = ({ return ( - Generate an API key to authenticate your requests. Keep this key - secure and never share it publicly. + Generate an API key to authenticate your requests. Keep this key secure and never + share it publicly. - + Generate API Key @@ -212,16 +184,13 @@ export const OnboardingPage: React.FC = ({ return ( - Review the API documentation to understand available endpoints, - authentication, and best practices. + Review the API documentation to understand available endpoints, authentication, and + best practices. handleCompleteStep('explore-docs')} - > - - Mark as Reviewed - + onPress={() => handleCompleteStep('explore-docs')}> + Mark as Reviewed ); @@ -233,13 +202,8 @@ export const OnboardingPage: React.FC = ({ {`curl -X GET https://sandbox.api.subtrackr.io/v1/subscriptions \\ -H "Authorization: Bearer sk_test_your_key"`} - - - Test API Call - + + Test API Call ); @@ -261,10 +225,7 @@ export const OnboardingPage: React.FC = ({ @@ -280,19 +241,12 @@ export const OnboardingPage: React.FC = ({ styles.stepCard, step.completed && styles.stepCardCompleted, index === currentStep && styles.stepCardActive, - ]} - > + ]}> !step.completed && setCurrentStep(index)} - disabled={step.completed} - > - + disabled={step.completed}> + {step.completed ? ( ) : ( @@ -300,12 +254,7 @@ export const OnboardingPage: React.FC = ({ )} - + {step.title} {step.description} @@ -316,9 +265,7 @@ export const OnboardingPage: React.FC = ({ {index === currentStep && !step.completed && ( - - {renderStepContent(step)} - + {renderStepContent(step)} )} ))} diff --git a/developer-portal/pages/UsagePage.tsx b/developer-portal/pages/UsagePage.tsx index 96b73e2e..64d8fca8 100644 --- a/developer-portal/pages/UsagePage.tsx +++ b/developer-portal/pages/UsagePage.tsx @@ -1,12 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { - View, - Text, - StyleSheet, - ScrollView, - TouchableOpacity, - RefreshControl, -} from 'react-native'; +import { View, Text, StyleSheet, ScrollView, TouchableOpacity, RefreshControl } from 'react-native'; interface UsageStats { totalRequests: number; @@ -33,13 +26,11 @@ interface UsagePageProps { environmentId?: string; } -export const UsagePage: React.FC = ({ environmentId }) => { +export const UsagePage: React.FC = ({ environmentId: _environmentId }) => { const [stats, setStats] = useState(null); const [topEndpoints, setTopEndpoints] = useState([]); const [hourlyData, setHourlyData] = useState([]); - const [selectedPeriod, setSelectedPeriod] = useState<'24h' | '7d' | '30d'>( - '24h' - ); + const [selectedPeriod, setSelectedPeriod] = useState<'24h' | '7d' | '30d'>('24h'); const [refreshing, setRefreshing] = useState(false); useEffect(() => { @@ -95,33 +86,23 @@ export const UsagePage: React.FC = ({ environmentId }) => { return ( - } - > + refreshControl={}> Usage Analytics - - Monitor your API usage and performance - + Monitor your API usage and performance {(['24h', '7d', '30d'] as const).map((period) => ( setSelectedPeriod(period)} - > + style={[styles.periodButton, selectedPeriod === period && styles.periodButtonActive]} + onPress={() => setSelectedPeriod(period)}> + ]}> {period} @@ -132,9 +113,7 @@ export const UsagePage: React.FC = ({ environmentId }) => { <> - - {stats.totalRequests.toLocaleString()} - + {stats.totalRequests.toLocaleString()} Total Requests @@ -166,15 +145,12 @@ export const UsagePage: React.FC = ({ environmentId }) => { styles.bar, { height: getBarHeight(data.requests, maxRequests), - backgroundColor: - data.errors > 5 ? '#EF4444' : '#3B82F6', + backgroundColor: data.errors > 5 ? '#EF4444' : '#3B82F6', }, ]} /> - {data.hour % 4 === 0 && ( - {data.hour}h - )} + {data.hour % 4 === 0 && {data.hour}h} ))} @@ -187,21 +163,12 @@ export const UsagePage: React.FC = ({ environmentId }) => { #{index + 1} {endpoint.endpoint} - - {endpoint.count.toLocaleString()} - + {endpoint.count.toLocaleString()} - + - - {endpoint.percentage}% of total - + {endpoint.percentage}% of total ))} @@ -223,9 +190,7 @@ export const UsagePage: React.FC = ({ environmentId }) => { Uptime - - 99.95% - + 99.95% diff --git a/developer-portal/services/developerPortalService.ts b/developer-portal/services/developerPortalService.ts index a320b926..53d359ef 100644 --- a/developer-portal/services/developerPortalService.ts +++ b/developer-portal/services/developerPortalService.ts @@ -1,6 +1,5 @@ import { Developer, - OnboardingStatus, ApiKey, ApiPermission, UsageMetrics, @@ -53,7 +52,12 @@ export class DeveloperPortalService { this.alerts.set(developerId, []); await this.logActivity(developerId, 'developer.registered', 'developer', developerId); - await this.createAlert(developerId, 'info', 'Welcome!', 'Your developer account has been created. Please verify your email to continue.'); + await this.createAlert( + developerId, + 'info', + 'Welcome!', + 'Your developer account has been created. Please verify your email to continue.' + ); return developer; } @@ -66,7 +70,10 @@ export class DeveloperPortalService { return false; } - if (developer.onboardingStatus.verificationExpiresAt && developer.onboardingStatus.verificationExpiresAt < new Date()) { + if ( + developer.onboardingStatus.verificationExpiresAt && + developer.onboardingStatus.verificationExpiresAt < new Date() + ) { return false; } @@ -88,17 +95,30 @@ export class DeveloperPortalService { const sandboxEnv = await sandboxService.createEnvironment(developerId, 'Default Sandbox'); developer.onboardingStatus.step = 'completed'; - developer.onboardingStatus.completedSteps.push('profile_completion', 'sandbox_setup', 'completed'); + developer.onboardingStatus.completedSteps.push( + 'profile_completion', + 'sandbox_setup', + 'completed' + ); developer.onboardingStatus.completedAt = new Date(); developer.sandboxEnvironments.push(sandboxEnv.id); developer.updatedAt = new Date(); this.developers.set(developerId, developer); - const apiKey = await this.createApiKey(developerId, 'Default API Key', 'test', ['subscriptions:read', 'subscriptions:write', 'payments:read']); + await this.createApiKey(developerId, 'Default API Key', 'test', [ + 'subscriptions:read', + 'subscriptions:write', + 'payments:read', + ]); await this.logActivity(developerId, 'onboarding.completed', 'developer', developerId); - await this.createAlert(developerId, 'success', 'Onboarding Complete', 'Your developer account is now fully set up. You can start using the API!'); + await this.createAlert( + developerId, + 'success', + 'Onboarding Complete', + 'Your developer account is now fully set up. You can start using the API!' + ); return developer; } @@ -107,7 +127,10 @@ export class DeveloperPortalService { return this.developers.get(developerId) || null; } - async updateDeveloper(developerId: string, updates: Partial): Promise { + async updateDeveloper( + developerId: string, + updates: Partial + ): Promise { const developer = this.developers.get(developerId); if (!developer) return null; @@ -150,7 +173,7 @@ export class DeveloperPortalService { const developer = this.developers.get(developerId); if (!developer) return false; - const apiKey = developer.apiKeys.find(key => key.id === apiKeyId); + const apiKey = developer.apiKeys.find((key) => key.id === apiKeyId); if (!apiKey) return false; apiKey.status = 'revoked'; @@ -168,7 +191,13 @@ export class DeveloperPortalService { return developer.apiKeys; } - async trackUsage(developerId: string, endpoint: string, method: string, responseTime: number, success: boolean): Promise { + async trackUsage( + developerId: string, + endpoint: string, + method: string, + responseTime: number, + success: boolean + ): Promise { const developer = this.developers.get(developerId); if (!developer) return; @@ -180,7 +209,8 @@ export class DeveloperPortalService { } developer.usage.avgResponseTime = - (developer.usage.avgResponseTime * (developer.usage.totalRequests - 1) + responseTime) / developer.usage.totalRequests; + (developer.usage.avgResponseTime * (developer.usage.totalRequests - 1) + responseTime) / + developer.usage.totalRequests; developer.updatedAt = new Date(); this.developers.set(developerId, developer); @@ -197,7 +227,7 @@ export class DeveloperPortalService { if (!developer) return null; const sandboxEnvironments = await Promise.all( - developer.sandboxEnvironments.map(async envId => { + developer.sandboxEnvironments.map(async (envId) => { const env = await sandboxService.getEnvironment(envId); const metrics = await sandboxService.getMetrics(envId); return { @@ -217,7 +247,7 @@ export class DeveloperPortalService { developer, sandboxEnvironments, recentActivity: recentActivity.slice(-10), - alerts: alerts.filter(a => !a.isRead).slice(-5), + alerts: alerts.filter((a) => !a.isRead).slice(-5), quickLinks: this.getQuickLinks(developer.tier), }; } @@ -225,20 +255,22 @@ export class DeveloperPortalService { async getDocumentation(category?: string): Promise { const docs = Array.from(this.documentation.values()); if (category) { - return docs.filter(doc => doc.category === category && doc.isPublished); + return docs.filter((doc) => doc.category === category && doc.isPublished); } - return docs.filter(doc => doc.isPublished); + return docs.filter((doc) => doc.isPublished); } async getIntegrationGuides(platform?: string): Promise { const guides = Array.from(this.integrationGuides.values()); if (platform) { - return guides.filter(guide => guide.platform.toLowerCase() === platform.toLowerCase()); + return guides.filter((guide) => guide.platform.toLowerCase() === platform.toLowerCase()); } return guides; } - async addDocumentation(doc: Omit): Promise { + async addDocumentation( + doc: Omit + ): Promise { const documentation: Documentation = { ...doc, id: this.generateDocId(), @@ -261,7 +293,7 @@ export class DeveloperPortalService { } private findDeveloperByEmail(email: string): Developer | undefined { - return Array.from(this.developers.values()).find(dev => dev.email === email); + return Array.from(this.developers.values()).find((dev) => dev.email === email); } private generateDeveloperId(): string { @@ -285,14 +317,34 @@ export class DeveloperPortalService { return `guide_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } - private getDefaultRateLimit(tier: string): { requestsPerMinute: number; requestsPerHour: number; requestsPerDay: number; burstLimit: number } { + private getDefaultRateLimit(tier: string): { + requestsPerMinute: number; + requestsPerHour: number; + requestsPerDay: number; + burstLimit: number; + } { switch (tier) { case 'enterprise': - return { requestsPerMinute: 1000, requestsPerHour: 50000, requestsPerDay: 500000, burstLimit: 2000 }; + return { + requestsPerMinute: 1000, + requestsPerHour: 50000, + requestsPerDay: 500000, + burstLimit: 2000, + }; case 'pro': - return { requestsPerMinute: 100, requestsPerHour: 5000, requestsPerDay: 50000, burstLimit: 200 }; + return { + requestsPerMinute: 100, + requestsPerHour: 5000, + requestsPerDay: 50000, + burstLimit: 200, + }; default: - return { requestsPerMinute: 20, requestsPerHour: 1000, requestsPerDay: 10000, burstLimit: 50 }; + return { + requestsPerMinute: 20, + requestsPerHour: 1000, + requestsPerDay: 10000, + burstLimit: 50, + }; } } @@ -321,7 +373,13 @@ export class DeveloperPortalService { }; } - private async logActivity(developerId: string, action: string, resource: string, resourceId: string, details?: Record): Promise { + private async logActivity( + developerId: string, + action: string, + resource: string, + resourceId: string, + details?: Record + ): Promise { const logs = this.activityLogs.get(developerId) || []; logs.push({ id: `log_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, @@ -334,7 +392,13 @@ export class DeveloperPortalService { this.activityLogs.set(developerId, logs); } - private async createAlert(developerId: string, type: 'info' | 'warning' | 'error' | 'success', title: string, message: string, actionUrl?: string): Promise { + private async createAlert( + developerId: string, + type: 'info' | 'warning' | 'error' | 'success', + title: string, + message: string, + actionUrl?: string + ): Promise { const alerts = this.alerts.get(developerId) || []; alerts.push({ id: `alert_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, @@ -348,20 +412,52 @@ export class DeveloperPortalService { this.alerts.set(developerId, alerts); } - private getQuickLinks(tier: string): Array<{ title: string; description: string; url: string; icon: string }> { + private getQuickLinks( + tier: string + ): { title: string; description: string; url: string; icon: string }[] { const links = [ - { title: 'API Documentation', description: 'View API reference documentation', url: '/docs/api', icon: 'book' }, - { title: 'Sandbox', description: 'Access your sandbox environment', url: '/sandbox', icon: 'code' }, - { title: 'API Keys', description: 'Manage your API keys', url: '/settings/api-keys', icon: 'key' }, - { title: 'Usage', description: 'View your API usage', url: '/analytics/usage', icon: 'chart' }, + { + title: 'API Documentation', + description: 'View API reference documentation', + url: '/docs/api', + icon: 'book', + }, + { + title: 'Sandbox', + description: 'Access your sandbox environment', + url: '/sandbox', + icon: 'code', + }, + { + title: 'API Keys', + description: 'Manage your API keys', + url: '/settings/api-keys', + icon: 'key', + }, + { + title: 'Usage', + description: 'View your API usage', + url: '/analytics/usage', + icon: 'chart', + }, ]; if (tier === 'pro' || tier === 'enterprise') { - links.push({ title: 'Webhooks', description: 'Configure webhooks', url: '/settings/webhooks', icon: 'webhook' }); + links.push({ + title: 'Webhooks', + description: 'Configure webhooks', + url: '/settings/webhooks', + icon: 'webhook', + }); } if (tier === 'enterprise') { - links.push({ title: 'Team', description: 'Manage team members', url: '/settings/team', icon: 'users' }); + links.push({ + title: 'Team', + description: 'Manage team members', + url: '/settings/team', + icon: 'users', + }); } return links; diff --git a/developer-portal/services/documentationService.ts b/developer-portal/services/documentationService.ts index 122555fd..60cb95f4 100644 --- a/developer-portal/services/documentationService.ts +++ b/developer-portal/services/documentationService.ts @@ -278,8 +278,8 @@ app.post('/webhooks', (req, res) => { }, ]; - this.sections.forEach(section => { - section.articles.forEach(article => { + this.sections.forEach((section) => { + section.articles.forEach((article) => { this.articles.set(article.slug, article); }); }); @@ -310,7 +310,7 @@ app.post('/webhooks', (req, res) => { } async getSection(sectionId: string): Promise { - return this.sections.find(s => s.id === sectionId) || null; + return this.sections.find((s) => s.id === sectionId) || null; } async getArticle(slug: string): Promise { @@ -321,12 +321,10 @@ app.post('/webhooks', (req, res) => { const lowerQuery = query.toLowerCase(); const results: DocumentationArticle[] = []; - this.articles.forEach(article => { + this.articles.forEach((article) => { const matchesTitle = article.title.toLowerCase().includes(lowerQuery); const matchesContent = article.content.toLowerCase().includes(lowerQuery); - const matchesTags = article.tags.some(tag => - tag.toLowerCase().includes(lowerQuery) - ); + const matchesTags = article.tags.some((tag) => tag.toLowerCase().includes(lowerQuery)); if (matchesTitle || matchesContent || matchesTags) { results.push(article); @@ -347,7 +345,7 @@ app.post('/webhooks', (req, res) => { if (!article) return []; return Array.from(this.articles.values()) - .filter(a => a.slug !== slug && a.category === article.category) + .filter((a) => a.slug !== slug && a.category === article.category) .slice(0, 3); } } diff --git a/developer-portal/services/integrationGuidesService.ts b/developer-portal/services/integrationGuidesService.ts index 52d2049c..c16cda9f 100644 --- a/developer-portal/services/integrationGuidesService.ts +++ b/developer-portal/services/integrationGuidesService.ts @@ -1,4 +1,4 @@ -import { IntegrationGuide, IntegrationStep } from '../types/portal'; +import { IntegrationGuide } from '../types/portal'; export class IntegrationGuidesService { private guides: IntegrationGuide[] = []; @@ -298,19 +298,19 @@ const subtrackr = new SubTrackr({ } async getGuide(guideId: string): Promise { - return this.guides.find(g => g.id === guideId) || null; + return this.guides.find((g) => g.id === guideId) || null; } async getGuidesByDifficulty( difficulty: IntegrationGuide['difficulty'] ): Promise { - return this.guides.filter(g => g.difficulty === difficulty); + return this.guides.filter((g) => g.difficulty === difficulty); } async searchGuides(query: string): Promise { const lowerQuery = query.toLowerCase(); return this.guides.filter( - guide => + (guide) => guide.title.toLowerCase().includes(lowerQuery) || guide.description.toLowerCase().includes(lowerQuery) ); diff --git a/developer-portal/services/portalService.ts b/developer-portal/services/portalService.ts index 7ccd4c0d..fef88a02 100644 --- a/developer-portal/services/portalService.ts +++ b/developer-portal/services/portalService.ts @@ -31,9 +31,7 @@ export class DeveloperPortalService { company: string, role: PortalUser['role'] = 'developer' ): Promise { - const existingUser = Array.from(this.users.values()).find( - u => u.email === email - ); + const existingUser = Array.from(this.users.values()).find((u) => u.email === email); if (existingUser) { throw new Error('User already exists'); @@ -90,9 +88,7 @@ export class DeveloperPortalService { this.activities.set(userId, activities.slice(0, 100)); } - private async getEnvironmentSummaries( - userId: string - ): Promise { + private async getEnvironmentSummaries(_userId: string): Promise { return [ { id: '1', diff --git a/developer-portal/src/components/DashboardCard.tsx b/developer-portal/src/components/DashboardCard.tsx index b94f23f6..a3704584 100644 --- a/developer-portal/src/components/DashboardCard.tsx +++ b/developer-portal/src/components/DashboardCard.tsx @@ -46,9 +46,7 @@ export const DashboardCard: React.FC = ({ {title} {value} - {trend && ( - {getTrendIcon()} - )} + {trend && {getTrendIcon()}} {subtitle && {subtitle}} diff --git a/developer-portal/src/components/PermissionSelector.tsx b/developer-portal/src/components/PermissionSelector.tsx index 90d83336..5c4bf482 100644 --- a/developer-portal/src/components/PermissionSelector.tsx +++ b/developer-portal/src/components/PermissionSelector.tsx @@ -58,7 +58,8 @@ export const PermissionSelector: React.FC = ({ {permission.icon} - + {permission.label} {permission.description} diff --git a/developer-portal/src/components/QuickActionCard.tsx b/developer-portal/src/components/QuickActionCard.tsx index 9089c703..4f72f41e 100644 --- a/developer-portal/src/components/QuickActionCard.tsx +++ b/developer-portal/src/components/QuickActionCard.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; +import { Text, StyleSheet, TouchableOpacity } from 'react-native'; interface QuickActionCardProps { icon: string; diff --git a/developer-portal/src/screens/ApiKeyManagementScreen.tsx b/developer-portal/src/screens/ApiKeyManagementScreen.tsx index b7870c25..d4298258 100644 --- a/developer-portal/src/screens/ApiKeyManagementScreen.tsx +++ b/developer-portal/src/screens/ApiKeyManagementScreen.tsx @@ -21,7 +21,6 @@ const ApiKeyManagementScreen: React.FC = () => { const { developer, apiKeys, - isLoading, fetchApiKeys, createApiKey, revokeApiKey, @@ -152,13 +151,9 @@ const ApiKeyManagementScreen: React.FC = () => { API Key Management - - Manage your API keys and configure permissions - + Manage your API keys and configure permissions - setShowCreateModal(true)}> + setShowCreateModal(true)}> + New Key @@ -245,12 +240,9 @@ const ApiKeyManagementScreen: React.FC = () => { 🔑 No API Keys Yet - Create your first API key to start making authenticated requests to the SubTrackr - API + Create your first API key to start making authenticated requests to the SubTrackr API - setShowCreateModal(true)}> + setShowCreateModal(true)}> Create API Key @@ -311,11 +303,7 @@ const ApiKeyManagementScreen: React.FC = () => { Create API Key - + Create @@ -405,9 +393,7 @@ const ApiKeyManagementScreen: React.FC = () => { }}> Copy to Clipboard - setCreatedKey(null)}> + setCreatedKey(null)}> I've Saved My Key diff --git a/developer-portal/src/screens/ApiTesterScreen.tsx b/developer-portal/src/screens/ApiTesterScreen.tsx index c69ee6c4..aeb4eb99 100644 --- a/developer-portal/src/screens/ApiTesterScreen.tsx +++ b/developer-portal/src/screens/ApiTesterScreen.tsx @@ -23,7 +23,7 @@ const EXAMPLE_ENDPOINTS = [ ]; const ApiTesterScreen: React.FC = () => { - const { developer, apiKeys } = useDeveloperPortalStore(); + const { apiKeys } = useDeveloperPortalStore(); const [selectedMethod, setSelectedMethod] = useState('GET'); const [endpoint, setEndpoint] = useState('/api/v1/subscriptions'); const [selectedApiKey, setSelectedApiKey] = useState(''); @@ -90,7 +90,7 @@ const ApiTesterScreen: React.FC = () => { } }; - const loadExample = (example: typeof EXAMPLE_ENDPOINTS[0]) => { + const loadExample = (example: (typeof EXAMPLE_ENDPOINTS)[0]) => { setSelectedMethod(example.method); setEndpoint(example.path); if (example.method === 'POST' || example.method === 'PUT') { @@ -133,10 +133,7 @@ const ApiTesterScreen: React.FC = () => { {activeKeys.map((key) => ( setSelectedApiKey(key.id)}> { Request - + {HTTP_METHODS.map((method) => ( { Response - {responseTime && ( - {responseTime}ms - )} + {responseTime && {responseTime}ms} + style={[styles.statusBadge, { backgroundColor: getStatusColor(response.status) }]}> {response.status} {response.statusText} diff --git a/developer-portal/src/screens/DeveloperPortalScreen.tsx b/developer-portal/src/screens/DeveloperPortalScreen.tsx index f8975505..8e4d54d5 100644 --- a/developer-portal/src/screens/DeveloperPortalScreen.tsx +++ b/developer-portal/src/screens/DeveloperPortalScreen.tsx @@ -26,7 +26,6 @@ const DeveloperPortalScreen: React.FC<{ navigation: any }> = ({ navigation }) => onboardingSteps, isLoading, error, - fetchDeveloper, fetchApiKeys, fetchUsageStats, fetchRecentUsage, @@ -70,34 +69,29 @@ const DeveloperPortalScreen: React.FC<{ navigation: any }> = ({ navigation }) => const handleQuickCreateApiKey = async () => { if (!developer) return; - Alert.prompt( - 'Create API Key', - 'Enter a name for your new API key', - async (name) => { - if (name && name.trim()) { - try { - const newKey = await createApiKey( - developer.id, - name.trim(), - [ApiKeyPermission.READ, ApiKeyPermission.WRITE] - ); - Alert.alert( - 'API Key Created', - `Your new API key has been created:\n\n${newKey.key}\n\nCopy it now - it won't be shown again.`, - [ - { - text: 'View All Keys', - onPress: () => navigation.navigate('ApiKeyManagement'), - }, - { text: 'OK' }, - ] - ); - } catch (err) { - Alert.alert('Error', 'Failed to create API key'); - } + Alert.prompt('Create API Key', 'Enter a name for your new API key', async (name) => { + if (name && name.trim()) { + try { + const newKey = await createApiKey(developer.id, name.trim(), [ + ApiKeyPermission.READ, + ApiKeyPermission.WRITE, + ]); + Alert.alert( + 'API Key Created', + `Your new API key has been created:\n\n${newKey.key}\n\nCopy it now - it won't be shown again.`, + [ + { + text: 'View All Keys', + onPress: () => navigation.navigate('ApiKeyManagement'), + }, + { text: 'OK' }, + ] + ); + } catch (err) { + Alert.alert('Error', 'Failed to create API key'); } } - ); + }); }; if (!developer) { @@ -105,9 +99,7 @@ const DeveloperPortalScreen: React.FC<{ navigation: any }> = ({ navigation }) => Welcome to Developer Portal - - Please register to access the developer portal - + Please register to access the developer portal navigation.navigate('DeveloperRegistration')}> @@ -303,9 +295,7 @@ const DeveloperPortalScreen: React.FC<{ navigation: any }> = ({ navigation }) => 💬 Developer Support - - Get help from our developer community - + Get help from our developer community diff --git a/developer-portal/src/screens/SdkDownloadScreen.tsx b/developer-portal/src/screens/SdkDownloadScreen.tsx index df9bd16c..b544251f 100644 --- a/developer-portal/src/screens/SdkDownloadScreen.tsx +++ b/developer-portal/src/screens/SdkDownloadScreen.tsx @@ -113,7 +113,7 @@ subscription = client.subscriptions.create( ]; const SdkDownloadScreen: React.FC = () => { - const handleCopyInstall = (installCommand: string) => { + const handleCopyInstall = (_installCommand: string) => { // Copy to clipboard logic Alert.alert('Copied', 'Install command copied to clipboard'); }; diff --git a/developer-portal/src/screens/UsageAnalyticsScreen.tsx b/developer-portal/src/screens/UsageAnalyticsScreen.tsx index 5f373387..7ec8f107 100644 --- a/developer-portal/src/screens/UsageAnalyticsScreen.tsx +++ b/developer-portal/src/screens/UsageAnalyticsScreen.tsx @@ -183,8 +183,8 @@ const UsageAnalyticsScreen: React.FC = () => { request.statusCode >= 200 && request.statusCode < 300 ? '#4CAF50' : request.statusCode >= 400 && request.statusCode < 500 - ? '#FF9800' - : '#F44336', + ? '#FF9800' + : '#F44336', }, ]}> {request.statusCode} @@ -207,9 +207,7 @@ const UsageAnalyticsScreen: React.FC = () => { Current Usage - - {usageStats?.totalCalls || 0} / 10,000 - + {usageStats?.totalCalls || 0} / 10,000 { autoCapitalize="none" keyboardType="url" /> - - Enter the URL where you want to receive webhook events - + Enter the URL where you want to receive webhook events {/* Event Types */} @@ -147,9 +145,7 @@ const WebhookTesterScreen: React.FC = () => { styles.checkbox, selectedEvents.includes(event.value) && styles.checkboxSelected, ]}> - {selectedEvents.includes(event.value) && ( - - )} + {selectedEvents.includes(event.value) && } ))} @@ -171,9 +167,7 @@ const WebhookTesterScreen: React.FC = () => { Generate - - Use this secret to verify webhook signatures - + Use this secret to verify webhook signatures {/* Signature Option */} @@ -235,9 +229,7 @@ const WebhookTesterScreen: React.FC = () => { {new Date(testResults.timestamp).toLocaleString()} )} - {testResults.error && ( - {testResults.error} - )} + {testResults.error && {testResults.error}} )} diff --git a/developer-portal/types/developer.ts b/developer-portal/types/developer.ts index 538128c5..2e2b9339 100644 --- a/developer-portal/types/developer.ts +++ b/developer-portal/types/developer.ts @@ -16,7 +16,12 @@ export interface Developer { } export interface OnboardingStatus { - step: 'registration' | 'email_verification' | 'profile_completion' | 'sandbox_setup' | 'completed'; + step: + | 'registration' + | 'email_verification' + | 'profile_completion' + | 'sandbox_setup' + | 'completed'; completedSteps: string[]; startedAt: Date; completedAt?: Date; diff --git a/developer-portal/types/portal.ts b/developer-portal/types/portal.ts index 3a5396f1..32687b89 100644 --- a/developer-portal/types/portal.ts +++ b/developer-portal/types/portal.ts @@ -26,7 +26,12 @@ export interface EnvironmentSummary { export interface ActivityEntry { id: string; - type: 'api_key_created' | 'environment_created' | 'request_made' | 'error_occurred' | 'webhook_triggered'; + type: + | 'api_key_created' + | 'environment_created' + | 'request_made' + | 'error_occurred' + | 'webhook_triggered'; description: string; timestamp: Date; metadata?: Record; diff --git a/developer-portal/utils/developerPortalUtils.ts b/developer-portal/utils/developerPortalUtils.ts index 563d256e..8d186140 100644 --- a/developer-portal/utils/developerPortalUtils.ts +++ b/developer-portal/utils/developerPortalUtils.ts @@ -1,4 +1,4 @@ -import { Developer, ApiKey, ApiPermission, UsageMetrics, RateLimit } from '../types/developer'; +import { Developer, ApiKey, ApiPermission, UsageMetrics } from '../types/developer'; export class DeveloperPortalUtils { static validateEmail(email: string): boolean { @@ -6,7 +6,11 @@ export class DeveloperPortalUtils { return emailRegex.test(email); } - static validateApiKey(key: string): { valid: boolean; type?: 'test' | 'production'; error?: string } { + static validateApiKey(key: string): { + valid: boolean; + type?: 'test' | 'production'; + error?: string; + } { if (!key) { return { valid: false, error: 'API key is required' }; } @@ -31,7 +35,10 @@ export class DeveloperPortalUtils { return apiKey.permissions.includes(permission); } - static checkRateLimit(apiKey: ApiKey, currentUsage: { requestsPerMinute: number; requestsPerHour: number; requestsPerDay: number }): { allowed: boolean; retryAfter?: number } { + static checkRateLimit( + apiKey: ApiKey, + currentUsage: { requestsPerMinute: number; requestsPerHour: number; requestsPerDay: number } + ): { allowed: boolean; retryAfter?: number } { if (currentUsage.requestsPerMinute >= apiKey.rateLimit.requestsPerMinute) { return { allowed: false, retryAfter: 60 }; } @@ -62,13 +69,15 @@ export class DeveloperPortalUtils { avgResponseTime: string; errorRate: string; } { - const successRate = metrics.totalRequests > 0 - ? ((metrics.successfulRequests / metrics.totalRequests) * 100).toFixed(2) - : '0.00'; + const successRate = + metrics.totalRequests > 0 + ? ((metrics.successfulRequests / metrics.totalRequests) * 100).toFixed(2) + : '0.00'; - const errorRate = metrics.totalRequests > 0 - ? ((metrics.failedRequests / metrics.totalRequests) * 100).toFixed(2) - : '0.00'; + const errorRate = + metrics.totalRequests > 0 + ? ((metrics.failedRequests / metrics.totalRequests) * 100).toFixed(2) + : '0.00'; return { totalRequests: metrics.totalRequests.toLocaleString(), @@ -78,7 +87,9 @@ export class DeveloperPortalUtils { }; } - static generateOnboardingChecklist(developer: Developer): Array<{ step: string; completed: boolean; description: string }> { + static generateOnboardingChecklist( + developer: Developer + ): { step: string; completed: boolean; description: string }[] { return [ { step: 'register', @@ -142,7 +153,14 @@ export class DeveloperPortalUtils { maxApiKeys: 50, maxSandboxEnvironments: 10, maxRequestsPerMonth: 1000000, - features: ['priority_support', 'custom_sla', 'dedicated_account_manager', 'advanced_analytics', 'webhooks', 'team_management'], + features: [ + 'priority_support', + 'custom_sla', + 'dedicated_account_manager', + 'advanced_analytics', + 'webhooks', + 'team_management', + ], }; case 'pro': return { @@ -180,9 +198,9 @@ export class DeveloperPortalUtils { } static generateWebhookSecret(): string { - return 'whsec_' + Array.from({ length: 32 }, () => - Math.random().toString(36).charAt(2) - ).join(''); + return ( + 'whsec_' + Array.from({ length: 32 }, () => Math.random().toString(36).charAt(2)).join('') + ); } static formatCurrency(amount: number, currency: string = 'USD'): string { diff --git a/sandbox/__tests__/developerPortal.test.ts b/sandbox/__tests__/developerPortal.test.ts index 5aca0a27..0ce5972d 100644 --- a/sandbox/__tests__/developerPortal.test.ts +++ b/sandbox/__tests__/developerPortal.test.ts @@ -10,11 +10,7 @@ describe('DeveloperPortalService', () => { describe('createUser', () => { it('should create a new user', async () => { - const user = await service.createUser( - 'test@example.com', - 'Test User', - 'Test Company' - ); + const user = await service.createUser('test@example.com', 'Test User', 'Test Company'); expect(user).toBeDefined(); expect(user.email).toBe('test@example.com'); @@ -26,9 +22,9 @@ describe('DeveloperPortalService', () => { it('should not allow duplicate email', async () => { await service.createUser('test@example.com', 'User 1', 'Company 1'); - await expect( - service.createUser('test@example.com', 'User 2', 'Company 2') - ).rejects.toThrow('User already exists'); + await expect(service.createUser('test@example.com', 'User 2', 'Company 2')).rejects.toThrow( + 'User already exists' + ); }); it('should create user with custom role', async () => { @@ -50,11 +46,7 @@ describe('DeveloperPortalService', () => { }); it('should return existing user', async () => { - const created = await service.createUser( - 'test@example.com', - 'Test User', - 'Test Company' - ); + const created = await service.createUser('test@example.com', 'Test User', 'Test Company'); const retrieved = await service.getUser(created.id); expect(retrieved).toBeDefined(); @@ -64,11 +56,7 @@ describe('DeveloperPortalService', () => { describe('updateUser', () => { it('should update user details', async () => { - const user = await service.createUser( - 'test@example.com', - 'Test User', - 'Test Company' - ); + const user = await service.createUser('test@example.com', 'Test User', 'Test Company'); const updated = await service.updateUser(user.id, { name: 'Updated Name', @@ -91,11 +79,7 @@ describe('DeveloperPortalService', () => { describe('getDashboard', () => { it('should return dashboard data', async () => { - const user = await service.createUser( - 'test@example.com', - 'Test User', - 'Test Company' - ); + const user = await service.createUser('test@example.com', 'Test User', 'Test Company'); const dashboard = await service.getDashboard(user.id); @@ -111,25 +95,15 @@ describe('DeveloperPortalService', () => { }); it('should throw for non-existent user', async () => { - await expect(service.getDashboard('non-existent')).rejects.toThrow( - 'User not found' - ); + await expect(service.getDashboard('non-existent')).rejects.toThrow('User not found'); }); }); describe('logActivity', () => { it('should log user activity', async () => { - const user = await service.createUser( - 'test@example.com', - 'Test User', - 'Test Company' - ); + const user = await service.createUser('test@example.com', 'Test User', 'Test Company'); - await service.logActivity( - user.id, - 'api_key_created', - 'New API key created' - ); + await service.logActivity(user.id, 'api_key_created', 'New API key created'); const dashboard = await service.getDashboard(user.id); expect(dashboard.recentActivity.length).toBeGreaterThan(0); diff --git a/sandbox/__tests__/sandbox.test.ts b/sandbox/__tests__/sandbox.test.ts index edb3315b..0c0a2094 100644 --- a/sandbox/__tests__/sandbox.test.ts +++ b/sandbox/__tests__/sandbox.test.ts @@ -1,8 +1,6 @@ import { SandboxIsolationService } from '../services/sandboxIsolationService'; import { ApiKeyService } from '../services/apiKeyService'; import { UsageTrackingService } from '../services/usageTrackingService'; -import { SandboxService } from '../services/sandboxService'; -import { SandboxMiddleware } from '../middleware/sandboxMiddleware'; import { SandboxUtils } from '../utils/sandboxUtils'; describe('SandboxIsolationService', () => { @@ -135,22 +133,12 @@ describe('SandboxIsolationService', () => { describe('updateOnboardingStep', () => { it('should update onboarding step', async () => { - const developer = await service.registerDeveloper( - 'test@example.com', - 'Test Dev', - 'Test Co' - ); + const developer = await service.registerDeveloper('test@example.com', 'Test Dev', 'Test Co'); - const updated = await service.updateOnboardingStep( - developer.id, - 'create-sandbox', - true - ); + const updated = await service.updateOnboardingStep(developer.id, 'create-sandbox', true); expect(updated).toBeDefined(); - const step = updated?.onboardingStatus.steps.find( - (s) => s.id === 'create-sandbox' - ); + const step = updated?.onboardingStatus.steps.find((s) => s.id === 'create-sandbox'); expect(step?.completed).toBe(true); }); }); @@ -179,9 +167,9 @@ describe('ApiKeyService', () => { await service.generateApiKey('env-1', `Key ${i}`, ['read']); } - await expect( - service.generateApiKey('env-1', 'Extra Key', ['read']) - ).rejects.toThrow('Maximum API keys limit reached'); + await expect(service.generateApiKey('env-1', 'Extra Key', ['read'])).rejects.toThrow( + 'Maximum API keys limit reached' + ); }); }); diff --git a/sandbox/api/sandboxApi.ts b/sandbox/api/sandboxApi.ts index d40486c0..5dc2ca66 100644 --- a/sandbox/api/sandboxApi.ts +++ b/sandbox/api/sandboxApi.ts @@ -18,11 +18,7 @@ export class SandboxApi { config?: Partial ): Promise { try { - const environment = await this.sandboxService.createEnvironment( - developerId, - name, - config - ); + const environment = await this.sandboxService.createEnvironment(developerId, name, config); return { success: true, @@ -76,10 +72,7 @@ export class SandboxApi { updates: Partial ): Promise { try { - const environment = await this.sandboxService.updateConfig( - environmentId, - updates - ); + const environment = await this.sandboxService.updateConfig(environmentId, updates); if (!environment) { return { success: false, error: 'Environment not found' }; diff --git a/sandbox/config/sandboxConfig.ts b/sandbox/config/sandboxConfig.ts index 25b8ceae..d9c94816 100644 --- a/sandbox/config/sandboxConfig.ts +++ b/sandbox/config/sandboxConfig.ts @@ -118,9 +118,12 @@ export const SANDBOX_CONSTANTS = { DEFAULT_ENVIRONMENT_TTL_DAYS: 90, }; -export function getSandboxConfig( - tier: 'free' | 'pro' | 'enterprise' = 'free' -): { resourceLimits: SandboxResourceLimits; rateLimits: RateLimit; features: SandboxFeatures; dataRetentionDays: number } { +export function getSandboxConfig(tier: 'free' | 'pro' | 'enterprise' = 'free'): { + resourceLimits: SandboxResourceLimits; + rateLimits: RateLimit; + features: SandboxFeatures; + dataRetentionDays: number; +} { return SANDBOX_TIERS[tier] || SANDBOX_TIERS.free; } diff --git a/sandbox/middleware/sandboxMiddleware.ts b/sandbox/middleware/sandboxMiddleware.ts index 330eb6b5..b52929ce 100644 --- a/sandbox/middleware/sandboxMiddleware.ts +++ b/sandbox/middleware/sandboxMiddleware.ts @@ -1,4 +1,4 @@ -import { SandboxIsolationContext, SandboxResourceLimits } from '../types/sandbox'; +import { SandboxResourceLimits } from '../types/sandbox'; import { sandboxService } from '../services/sandboxService'; export interface SandboxRequest { @@ -24,18 +24,14 @@ export interface SandboxResponse { } export class SandboxMiddleware { - private rateLimitStore: Map = - new Map(); + private rateLimitStore: Map = new Map(); async processRequest(request: SandboxRequest): Promise { const startTime = Date.now(); const requestId = this.generateRequestId(); try { - const isValid = await sandboxService.validateAccess( - request.environmentId, - request.apiKey - ); + const isValid = await sandboxService.validateAccess(request.environmentId, request.apiKey); if (!isValid) { return this.createErrorResponse( request, @@ -46,9 +42,7 @@ export class SandboxMiddleware { ); } - const context = await sandboxService.getIsolationContext( - request.environmentId - ); + const context = await sandboxService.getIsolationContext(request.environmentId); if (!context) { return this.createErrorResponse( request, @@ -74,20 +68,10 @@ export class SandboxMiddleware { context.resourceQuota ); if (!rateLimitResult.allowed) { - return this.createErrorResponse( - request, - requestId, - startTime, - 'Rate limit exceeded', - 429 - ); + return this.createErrorResponse(request, requestId, startTime, 'Rate limit exceeded', 429); } - await sandboxService.recordRequest( - request.environmentId, - Date.now() - startTime, - false - ); + await sandboxService.recordRequest(request.environmentId, Date.now() - startTime, false); return { success: true, @@ -101,18 +85,8 @@ export class SandboxMiddleware { }, }; } catch (error) { - await sandboxService.recordRequest( - request.environmentId, - Date.now() - startTime, - true - ); - return this.createErrorResponse( - request, - requestId, - startTime, - 'Internal sandbox error', - 500 - ); + await sandboxService.recordRequest(request.environmentId, Date.now() - startTime, true); + return this.createErrorResponse(request, requestId, startTime, 'Internal sandbox error', 500); } } @@ -144,9 +118,7 @@ export class SandboxMiddleware { }; } - async enforceResourceLimits( - envId: string - ): Promise<{ withinLimits: boolean; usage: unknown }> { + async enforceResourceLimits(envId: string): Promise<{ withinLimits: boolean; usage: unknown }> { const context = await sandboxService.getIsolationContext(envId); if (!context) throw new Error('Environment not found'); diff --git a/sandbox/services/sandboxIsolationService.ts b/sandbox/services/sandboxIsolationService.ts index 5b54fe1f..076710cd 100644 --- a/sandbox/services/sandboxIsolationService.ts +++ b/sandbox/services/sandboxIsolationService.ts @@ -2,9 +2,7 @@ import { SandboxEnvironment, SandboxConfig, Developer, - SandboxFeatures, RateLimit, - ApiKey, SandboxTestData, } from '../types/sandbox'; import { createSandboxConfig, SANDBOX_CONSTANTS } from '../config/sandboxConfig'; @@ -65,9 +63,7 @@ export class SandboxIsolationService { } async getEnvironmentsByDeveloper(developerId: string): Promise { - return Array.from(this.environments.values()).filter( - (env) => env.developerId === developerId - ); + return Array.from(this.environments.values()).filter((env) => env.developerId === developerId); } async updateEnvironment( @@ -164,11 +160,7 @@ export class SandboxIsolationService { } } - private validateRateLimits( - rateLimits: RateLimit, - errors: string[], - _warnings: string[] - ): void { + private validateRateLimits(rateLimits: RateLimit, errors: string[], _warnings: string[]): void { if (rateLimits.requestsPerMinute <= 0) { errors.push('Invalid requestsPerMinute rate limit'); } @@ -180,14 +172,8 @@ export class SandboxIsolationService { } } - async registerDeveloper( - email: string, - name: string, - company: string - ): Promise { - const existingDeveloper = Array.from(this.developers.values()).find( - (d) => d.email === email - ); + async registerDeveloper(email: string, name: string, company: string): Promise { + const existingDeveloper = Array.from(this.developers.values()).find((d) => d.email === email); if (existingDeveloper) { throw new Error('Developer already registered'); @@ -268,8 +254,9 @@ export class SandboxIsolationService { developer.onboardingStatus.step = developer.onboardingStatus.steps.filter( (s) => s.completed ).length; - developer.onboardingStatus.completed = - developer.onboardingStatus.steps.every((s) => s.completed); + developer.onboardingStatus.completed = developer.onboardingStatus.steps.every( + (s) => s.completed + ); this.developers.set(developerId, developer); return developer; @@ -300,7 +287,16 @@ export class SandboxIsolationService { private generateTestSubscriptions(): SandboxTestData['subscriptions'] { const categories = ['streaming', 'software', 'gaming', 'productivity', 'fitness']; - const names = ['Netflix', 'Spotify', 'Adobe CC', 'Slack', 'Gym Membership', 'GitHub Pro', 'Figma', 'Notion']; + const names = [ + 'Netflix', + 'Spotify', + 'Adobe CC', + 'Slack', + 'Gym Membership', + 'GitHub Pro', + 'Figma', + 'Notion', + ]; return names.map((name, index) => ({ id: `sub_test_${index + 1}`, @@ -315,9 +311,11 @@ export class SandboxIsolationService { })); } - private generateTestPayments(subscriptions: SandboxTestData['subscriptions']): SandboxTestData['payments'] { + private generateTestPayments( + subscriptions: SandboxTestData['subscriptions'] + ): SandboxTestData['payments'] { const payments: SandboxTestData['payments'] = []; - const methods: Array<'card' | 'crypto' | 'bank'> = ['card', 'crypto', 'bank']; + const methods: ('card' | 'crypto' | 'bank')[] = ['card', 'crypto', 'bank']; subscriptions.forEach((sub) => { for (let i = 0; i < 3; i++) { diff --git a/sandbox/services/sandboxService.ts b/sandbox/services/sandboxService.ts index 9ff0936d..c9841367 100644 --- a/sandbox/services/sandboxService.ts +++ b/sandbox/services/sandboxService.ts @@ -23,7 +23,6 @@ export class SandboxService { config?: Partial ): Promise { const envId = this.generateEnvironmentId(); - const defaultResourceLimits = this.getDefaultResourceLimits(); const environment: SandboxEnvironment = { id: envId, @@ -91,9 +90,7 @@ export class SandboxService { } async getEnvironmentsByDeveloper(developerId: string): Promise { - return Array.from(this.environments.values()).filter( - (env) => env.developerId === developerId - ); + return Array.from(this.environments.values()).filter((env) => env.developerId === developerId); } async updateEnvironment( @@ -130,10 +127,7 @@ export class SandboxService { return this.configs.get(envId) || null; } - async updateConfig( - envId: string, - config: Partial - ): Promise { + async updateConfig(envId: string, config: Partial): Promise { const existingConfig = this.configs.get(envId); if (!existingConfig) return null; @@ -160,19 +154,14 @@ export class SandboxService { return this.metrics.get(envId) || null; } - async recordRequest( - envId: string, - responseTime: number, - isError: boolean - ): Promise { + async recordRequest(envId: string, responseTime: number, isError: boolean): Promise { const metrics = this.metrics.get(envId); if (!metrics) return; metrics.requestCount++; if (isError) metrics.errorCount++; metrics.avgResponseTime = - (metrics.avgResponseTime * (metrics.requestCount - 1) + responseTime) / - metrics.requestCount; + (metrics.avgResponseTime * (metrics.requestCount - 1) + responseTime) / metrics.requestCount; metrics.lastActivity = new Date(); this.metrics.set(envId, metrics); @@ -184,14 +173,16 @@ export class SandboxService { if (!env || !metrics) return null; const isWithinLimits = this.checkResourceLimits( - env.config.features ? { - maxRequestsPerMinute: env.config.rateLimits.requestsPerMinute, - maxRequestsPerDay: env.config.rateLimits.requestsPerDay, - maxStorageMB: 100, - maxConcurrentConnections: env.config.rateLimits.maxConcurrentRequests, - maxSubscriptions: 50, - maxWebhooks: 5, - } : this.getDefaultResourceLimits(), + env.config.features + ? { + maxRequestsPerMinute: env.config.rateLimits.requestsPerMinute, + maxRequestsPerDay: env.config.rateLimits.requestsPerDay, + maxStorageMB: 100, + maxConcurrentConnections: env.config.rateLimits.maxConcurrentRequests, + maxSubscriptions: 50, + maxWebhooks: 5, + } + : this.getDefaultResourceLimits(), metrics ); @@ -199,14 +190,16 @@ export class SandboxService { environmentId: envId, developerId: env.developerId, dataNamespace: `sandbox_${envId}`, - resourceQuota: env.config.features ? { - maxRequestsPerMinute: env.config.rateLimits.requestsPerMinute, - maxRequestsPerDay: env.config.rateLimits.requestsPerDay, - maxStorageMB: 100, - maxConcurrentConnections: env.config.rateLimits.maxConcurrentRequests, - maxSubscriptions: 50, - maxWebhooks: 5, - } : this.getDefaultResourceLimits(), + resourceQuota: env.config.features + ? { + maxRequestsPerMinute: env.config.rateLimits.requestsPerMinute, + maxRequestsPerDay: env.config.rateLimits.requestsPerDay, + maxStorageMB: 100, + maxConcurrentConnections: env.config.rateLimits.maxConcurrentRequests, + maxSubscriptions: 50, + maxWebhooks: 5, + } + : this.getDefaultResourceLimits(), currentUsage: metrics, isWithinLimits, }; @@ -266,22 +259,16 @@ export class SandboxService { category: categories[index % categories.length], price: Math.floor(Math.random() * 50) + 5, currency: 'USD', - billingCycle: (['monthly', 'yearly', 'weekly'] as const)[ - Math.floor(Math.random() * 3) - ], + billingCycle: (['monthly', 'yearly', 'weekly'] as const)[Math.floor(Math.random() * 3)], status: 'active' as const, - startDate: new Date( - Date.now() - Math.random() * 365 * 24 * 60 * 60 * 1000 - ), - nextBillingDate: new Date( - Date.now() + Math.random() * 30 * 24 * 60 * 60 * 1000 - ), + startDate: new Date(Date.now() - Math.random() * 365 * 24 * 60 * 60 * 1000), + nextBillingDate: new Date(Date.now() + Math.random() * 30 * 24 * 60 * 60 * 1000), })); } private generateTestPayments(subscriptions: TestSubscription[]): TestPayment[] { const payments: TestPayment[] = []; - const methods: Array<'card' | 'crypto' | 'bank'> = ['card', 'crypto', 'bank']; + const methods: ('card' | 'crypto' | 'bank')[] = ['card', 'crypto', 'bank']; subscriptions.forEach((sub) => { for (let i = 0; i < 3; i++) { @@ -290,13 +277,9 @@ export class SandboxService { subscriptionId: sub.id, amount: sub.price, currency: sub.currency, - status: (['pending', 'completed', 'failed'] as const)[ - Math.floor(Math.random() * 3) - ], + status: (['pending', 'completed', 'failed'] as const)[Math.floor(Math.random() * 3)], method: methods[Math.floor(Math.random() * methods.length)], - timestamp: new Date( - Date.now() - Math.random() * 90 * 24 * 60 * 60 * 1000 - ), + timestamp: new Date(Date.now() - Math.random() * 90 * 24 * 60 * 60 * 1000), }); } }); @@ -337,10 +320,7 @@ export class SandboxService { ]; } - private checkResourceLimits( - limits: SandboxResourceLimits, - metrics: SandboxMetrics - ): boolean { + private checkResourceLimits(limits: SandboxResourceLimits, metrics: SandboxMetrics): boolean { return ( metrics.requestCount < limits.maxRequestsPerDay && metrics.storageUsedMB < limits.maxStorageMB && diff --git a/sandbox/services/usageTrackingService.ts b/sandbox/services/usageTrackingService.ts index 1bf1f9c0..dc9cbaa0 100644 --- a/sandbox/services/usageTrackingService.ts +++ b/sandbox/services/usageTrackingService.ts @@ -1,8 +1,4 @@ -import { - UsageMetrics, - HourlyUsage, - DailyUsage, -} from '../types/sandbox'; +import { UsageMetrics } from '../types/sandbox'; export class UsageTrackingService { private usageData: Map = new Map(); @@ -60,26 +56,18 @@ export class UsageTrackingService { statusCode?: number; } ): Promise { - let filtered = this.requestLog.filter( - (entry) => entry.environmentId === environmentId - ); + let filtered = this.requestLog.filter((entry) => entry.environmentId === environmentId); if (options?.startDate) { - filtered = filtered.filter( - (entry) => entry.timestamp >= options.startDate! - ); + filtered = filtered.filter((entry) => entry.timestamp >= options.startDate!); } if (options?.endDate) { - filtered = filtered.filter( - (entry) => entry.timestamp <= options.endDate! - ); + filtered = filtered.filter((entry) => entry.timestamp <= options.endDate!); } if (options?.statusCode) { - filtered = filtered.filter( - (entry) => entry.statusCode === options.statusCode - ); + filtered = filtered.filter((entry) => entry.statusCode === options.statusCode); } filtered.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); @@ -111,9 +99,7 @@ export class UsageTrackingService { successfulRequests: metrics.successfulRequests, failedRequests: metrics.failedRequests, successRate: - metrics.totalRequests > 0 - ? (metrics.successfulRequests / metrics.totalRequests) * 100 - : 0, + metrics.totalRequests > 0 ? (metrics.successfulRequests / metrics.totalRequests) * 100 : 0, averageResponseTime: metrics.averageResponseTime, requestsLast24Hours: last24Hours.length, requestsLast7Days: last7Days.length, @@ -124,9 +110,7 @@ export class UsageTrackingService { async resetUsage(environmentId: string): Promise { this.usageData.delete(environmentId); - this.requestLog = this.requestLog.filter( - (entry) => entry.environmentId !== environmentId - ); + this.requestLog = this.requestLog.filter((entry) => entry.environmentId !== environmentId); return true; } @@ -159,9 +143,7 @@ export class UsageTrackingService { } private calculateAverageResponseTime(environmentId: string): number { - const entries = this.requestLog.filter( - (entry) => entry.environmentId === environmentId - ); + const entries = this.requestLog.filter((entry) => entry.environmentId === environmentId); if (entries.length === 0) return 0; @@ -169,11 +151,7 @@ export class UsageTrackingService { return Math.round(total / entries.length); } - private updateHourlyUsage( - metrics: UsageMetrics, - statusCode: number, - responseTime: number - ): void { + private updateHourlyUsage(metrics: UsageMetrics, statusCode: number, responseTime: number): void { const currentHour = new Date().getHours(); const hourlyData = metrics.last24Hours[currentHour]; @@ -189,11 +167,7 @@ export class UsageTrackingService { } } - private updateDailyUsage( - metrics: UsageMetrics, - statusCode: number, - responseTime: number - ): void { + private updateDailyUsage(metrics: UsageMetrics, statusCode: number, responseTime: number): void { const today = new Date().toISOString().split('T')[0]; const dailyData = metrics.last7Days.find((d) => d.date === today); @@ -203,16 +177,12 @@ export class UsageTrackingService { dailyData.errors++; } dailyData.avgResponseTime = Math.round( - (dailyData.avgResponseTime * (dailyData.requests - 1) + responseTime) / - dailyData.requests + (dailyData.avgResponseTime * (dailyData.requests - 1) + responseTime) / dailyData.requests ); } } - private getTopEndpoints( - environmentId: string, - limit: number - ): EndpointUsage[] { + private getTopEndpoints(environmentId: string, limit: number): EndpointUsage[] { const endpointMap = new Map(); this.requestLog @@ -232,10 +202,7 @@ export class UsageTrackingService { const errorMap = new Map(); this.requestLog - .filter( - (entry) => - entry.environmentId === environmentId && entry.statusCode >= 400 - ) + .filter((entry) => entry.environmentId === environmentId && entry.statusCode >= 400) .forEach((entry) => { errorMap.set(entry.statusCode, (errorMap.get(entry.statusCode) || 0) + 1); }); diff --git a/sandbox/utils/sandboxUtils.ts b/sandbox/utils/sandboxUtils.ts index 3f0692ba..0094ab68 100644 --- a/sandbox/utils/sandboxUtils.ts +++ b/sandbox/utils/sandboxUtils.ts @@ -25,7 +25,10 @@ export class SandboxUtils { return { valid: true }; } - static calculateResourceUsage(testData: SandboxTestData): { storageMB: number; itemCount: number } { + static calculateResourceUsage(testData: SandboxTestData): { + storageMB: number; + itemCount: number; + } { const jsonString = JSON.stringify(testData); const bytes = new TextEncoder().encode(jsonString).length; const storageMB = bytes / (1024 * 1024); @@ -41,24 +44,37 @@ export class SandboxUtils { static checkResourceLimits( limits: SandboxResourceLimits, - currentUsage: { requestsPerMinute: number; requestsPerDay: number; storageMB: number; connections: number } + currentUsage: { + requestsPerMinute: number; + requestsPerDay: number; + storageMB: number; + connections: number; + } ): { withinLimits: boolean; violations: string[] } { const violations: string[] = []; if (currentUsage.requestsPerMinute > limits.maxRequestsPerMinute) { - violations.push(`Requests per minute (${currentUsage.requestsPerMinute}) exceeds limit (${limits.maxRequestsPerMinute})`); + violations.push( + `Requests per minute (${currentUsage.requestsPerMinute}) exceeds limit (${limits.maxRequestsPerMinute})` + ); } if (currentUsage.requestsPerDay > limits.maxRequestsPerDay) { - violations.push(`Requests per day (${currentUsage.requestsPerDay}) exceeds limit (${limits.maxRequestsPerDay})`); + violations.push( + `Requests per day (${currentUsage.requestsPerDay}) exceeds limit (${limits.maxRequestsPerDay})` + ); } if (currentUsage.storageMB > limits.maxStorageMB) { - violations.push(`Storage (${currentUsage.storageMB}MB) exceeds limit (${limits.maxStorageMB}MB)`); + violations.push( + `Storage (${currentUsage.storageMB}MB) exceeds limit (${limits.maxStorageMB}MB)` + ); } if (currentUsage.connections > limits.maxConcurrentConnections) { - violations.push(`Connections (${currentUsage.connections}) exceeds limit (${limits.maxConcurrentConnections})`); + violations.push( + `Connections (${currentUsage.connections}) exceeds limit (${limits.maxConcurrentConnections})` + ); } return { @@ -73,7 +89,7 @@ export class SandboxUtils { } if (Array.isArray(data)) { - return data.map(item => this.sanitizeForSandbox(item)); + return data.map((item) => this.sanitizeForSandbox(item)); } if (typeof data === 'object' && data !== null) { @@ -116,7 +132,10 @@ export class SandboxUtils { }; } - static createSandboxHeaders(envId: string, additionalHeaders: Record = {}): Record { + static createSandboxHeaders( + envId: string, + additionalHeaders: Record = {} + ): Record { return { 'X-Sandbox-Environment': envId, 'X-Sandbox-Mode': 'true', @@ -125,7 +144,11 @@ export class SandboxUtils { }; } - static parseSandboxHeaders(headers: Record): { envId?: string; isSandbox: boolean; requestId?: string } { + static parseSandboxHeaders(headers: Record): { + envId?: string; + isSandbox: boolean; + requestId?: string; + } { return { envId: headers['X-Sandbox-Environment'], isSandbox: headers['X-Sandbox-Mode'] === 'true', diff --git a/sandbox/utils/testDataGenerator.ts b/sandbox/utils/testDataGenerator.ts index 09d1eff9..9a93cb3a 100644 --- a/sandbox/utils/testDataGenerator.ts +++ b/sandbox/utils/testDataGenerator.ts @@ -42,8 +42,28 @@ export class TestDataGenerator { const domains = ['gmail.com', 'yahoo.com', 'outlook.com', 'company.io']; for (let i = 0; i < count; i++) { - const firstName = this.randomFrom(['Alice', 'Bob', 'Charlie', 'Diana', 'Eve', 'Frank', 'Grace', 'Henry', 'Ivy', 'Jack']); - const lastName = this.randomFrom(['Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller', 'Davis']); + const firstName = this.randomFrom([ + 'Alice', + 'Bob', + 'Charlie', + 'Diana', + 'Eve', + 'Frank', + 'Grace', + 'Henry', + 'Ivy', + 'Jack', + ]); + const lastName = this.randomFrom([ + 'Smith', + 'Johnson', + 'Williams', + 'Brown', + 'Jones', + 'Garcia', + 'Miller', + 'Davis', + ]); const domain = this.randomFrom(domains); users.push({ @@ -143,12 +163,15 @@ export class TestDataGenerator { return subscriptions; } - static generatePayments( - subscriptions: TestSubscription[], - count: number - ): TestPayment[] { + static generatePayments(subscriptions: TestSubscription[], count: number): TestPayment[] { const payments: TestPayment[] = []; - const statuses: TestPayment['status'][] = ['completed', 'completed', 'completed', 'pending', 'failed']; + const statuses: TestPayment['status'][] = [ + 'completed', + 'completed', + 'completed', + 'pending', + 'failed', + ]; for (let i = 0; i < count; i++) { const subscription = this.randomFrom(subscriptions); diff --git a/sdks/javascript/src/errors.ts b/sdks/javascript/src/errors.ts index 7a956dbc..bbbe4bf0 100644 --- a/sdks/javascript/src/errors.ts +++ b/sdks/javascript/src/errors.ts @@ -1,5 +1,9 @@ export class SubTrackrError extends Error { - constructor(public message: string, public statusCode?: number, public code?: string) { + constructor( + public message: string, + public statusCode?: number, + public code?: string + ) { super(message); this.name = 'SubTrackrError'; } diff --git a/src/animations/index.ts b/src/animations/index.ts index 9c306f0d..9a53bb9d 100644 --- a/src/animations/index.ts +++ b/src/animations/index.ts @@ -9,7 +9,11 @@ export * from '../hooks/useAnimationPerformance'; // Common animation components export * from '../components/common/SkeletonLoader'; -export * from '../components/common/SharedElement'; +export { + SharedElement, + SharedElementTransitionProvider, + type SharedElementProps, +} from '../components/common/SharedElement'; export * from '../components/common/ScreenTransitions'; export * from '../components/common/GestureAnimations'; diff --git a/src/components/UsageDashboard.tsx b/src/components/UsageDashboard.tsx index 4aa7a4e9..61bb76a8 100644 --- a/src/components/UsageDashboard.tsx +++ b/src/components/UsageDashboard.tsx @@ -9,7 +9,7 @@ export const UsageDashboard = () => { return ( Usage & Billing - + API Calls 85,000 / 100,000 diff --git a/src/components/common/ScreenTemplates.tsx b/src/components/common/ScreenTemplates.tsx index 7ac1a171..4420aa3f 100644 --- a/src/components/common/ScreenTemplates.tsx +++ b/src/components/common/ScreenTemplates.tsx @@ -111,7 +111,11 @@ export function ListScreen({ style={styles.scroll} refreshControl={ onRefresh ? ( - + ) : undefined }> {data.length === 0 ? ( @@ -123,7 +127,9 @@ export function ListScreen({ onAction={onEmptyAction} /> ) : ( - data.map((item, index) => {renderItem(item, index)}) + data.map((item, index) => ( + {renderItem(item, index)} + )) )} diff --git a/src/components/common/SwipeableCard.tsx b/src/components/common/SwipeableCard.tsx index a7ffe163..d8ddc559 100644 --- a/src/components/common/SwipeableCard.tsx +++ b/src/components/common/SwipeableCard.tsx @@ -9,7 +9,7 @@ import { View, } from 'react-native'; -import { borderRadius, shadows, spacing, typography } from '../../utils/constants'; +import { borderRadius, colors, shadows, spacing, typography } from '../../utils/constants'; import { buildGestureDebugLabel, GestureDirection, @@ -17,7 +17,6 @@ import { triggerGestureFeedback, validateHorizontalSwipe, } from '../../services/gestureService'; -import { useThemeColors } from '../../hooks/useThemeColors'; interface SwipeableCardProps { children: React.ReactNode; @@ -44,8 +43,6 @@ export const SwipeableCard: React.FC = ({ accessibilityLabel, debugEnabled = false, }) => { - const colors = useThemeColors(); - const styles = React.useMemo(() => createStyles(colors), [colors]); const translateX = useRef(new Animated.Value(0)).current; const draggingRef = useRef(false); const longPressTriggeredRef = useRef(false); @@ -180,47 +177,49 @@ export const SwipeableCard: React.FC = ({ ); }; -function createStyles(colors: ReturnType) { - return StyleSheet.create({ - wrapper: { - marginBottom: spacing.md, - }, - actionBackground: { - ...StyleSheet.absoluteFillObject, - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingHorizontal: spacing.lg, - borderRadius: borderRadius.lg, - backgroundColor: colors.brand.primary + '20', - }, - leftActionText: { - ...typography.caption, - color: colors.accent, - fontWeight: '700', - }, - rightActionText: { - ...typography.caption, - color: colors.status.success, - fontWeight: '700', - }, - animatedCard: { - borderRadius: borderRadius.lg, - ...shadows.sm, - }, - pressable: { - borderRadius: borderRadius.lg, - }, - debugBadge: { - marginTop: spacing.xs, - backgroundColor: colors.background.card, - borderRadius: borderRadius.md, - paddingHorizontal: spacing.sm, - paddingVertical: spacing.xs, - }, - debugText: { - ...typography.small, - color: colors.text.secondary, - }, - }); -} +const styles = StyleSheet.create({ + wrapper: { + marginBottom: spacing.md, + }, + actionBackground: { + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: spacing.lg, + borderRadius: borderRadius.lg, + backgroundColor: 'rgba(99, 102, 241, 0.12)', + }, + leftActionText: { + ...typography.caption, + color: colors.accent, + fontWeight: '700', + }, + rightActionText: { + ...typography.caption, + color: colors.success, + fontWeight: '700', + }, + animatedCard: { + borderRadius: borderRadius.lg, + ...shadows.sm, + }, + pressable: { + borderRadius: borderRadius.lg, + }, + debugBadge: { + marginTop: spacing.xs, + backgroundColor: colors.surface, + borderRadius: borderRadius.md, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + }, + debugText: { + ...typography.small, + color: colors.textSecondary, + }, +}); diff --git a/src/components/developer/DeveloperComponents.tsx b/src/components/developer/DeveloperComponents.tsx index eac9ab94..afc652fd 100644 --- a/src/components/developer/DeveloperComponents.tsx +++ b/src/components/developer/DeveloperComponents.tsx @@ -46,8 +46,8 @@ export const StatCard: React.FC = ({ label, value, trend, trendDi trendDirection === 'up' ? colors.success : trendDirection === 'down' - ? colors.error - : colors.textSecondary; + ? colors.error + : colors.textSecondary; return ( diff --git a/src/components/home/StatsCard.tsx b/src/components/home/StatsCard.tsx index 867f2901..4fea1b5f 100644 --- a/src/components/home/StatsCard.tsx +++ b/src/components/home/StatsCard.tsx @@ -10,14 +10,12 @@ interface StatsCardProps { currency?: string; } - export const StatsCard: React.FC = ({ totalMonthlySpend, totalActive, onWalletPress, currency = 'USD', }) => { - return ( {/* Monthly Spend Card - Primary Focus */} @@ -29,7 +27,7 @@ export const StatsCard: React.FC = ({ currency )}`}> Monthly Spend @@ -42,7 +40,6 @@ export const StatsCard: React.FC = ({ importantForAccessibility="no"> {formatCurrencyCompact(totalMonthlySpend, currency)} - {/* Active Count Card */} diff --git a/src/components/subscription/SubscriptionCard.tsx b/src/components/subscription/SubscriptionCard.tsx index d5b24ea1..8a3c03b9 100644 --- a/src/components/subscription/SubscriptionCard.tsx +++ b/src/components/subscription/SubscriptionCard.tsx @@ -18,7 +18,6 @@ import { useSettingsStore } from '../../store/settingsStore'; import { currencyService } from '../../services/currencyService'; import { SubscriptionIcon } from './SubscriptionIcon'; - export interface SubscriptionCardProps { subscription: Subscription; onPress: (subscription: Subscription) => void; @@ -68,7 +67,6 @@ export const SubscriptionCard: React.FC = React.memo( rates ); - return ( = React.memo( ]}> /{formatBillingCycle(subscription.billingCycle)} - diff --git a/src/screens/AffiliateDashboardScreen.tsx b/src/screens/AffiliateDashboardScreen.tsx index 0b3d2718..35e14f12 100644 --- a/src/screens/AffiliateDashboardScreen.tsx +++ b/src/screens/AffiliateDashboardScreen.tsx @@ -14,24 +14,16 @@ import { colors, spacing, typography, borderRadius } from '../utils/constants'; import { useAffiliateStore } from '../store/affiliateStore'; import { useWalletStore } from '../store/walletStore'; import { Card } from '../components/common/Card'; -import { useNavigation } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { RootStackParamList } from '../navigation/types'; -import { AffiliateStatus, AffiliateProgram, Commission } from '../types/affiliate'; +import { AffiliateStatus } from '../types/affiliate'; const AffiliateDashboardScreen: React.FC = () => { - const navigation = useNavigation>(); const { affiliates, programs, commissions, metrics, isLoading, - error, registerAffiliate, - trackReferral, - calculateCommission, - payoutCommission, updateAffiliateStatus, getMetrics, } = useAffiliateStore(); @@ -59,22 +51,6 @@ const AffiliateDashboardScreen: React.FC = () => { Alert.alert('Success', 'You are now an affiliate!'); }, [address, selectedProgram, registerAffiliate]); - const handlePayout = useCallback( - async (affiliateId: string) => { - const affiliate = affiliates.find((a) => a.id === affiliateId); - if (!affiliate || affiliate.pendingPayout < affiliate.paymentThreshold) { - Alert.alert( - 'Minimum Threshold', - `You need at least $${affiliate?.paymentThreshold} to request a payout` - ); - return; - } - await payoutCommission(affiliateId); - Alert.alert('Success', 'Payout requested!'); - }, - [affiliates, payoutCommission] - ); - const handleToggleStatus = useCallback( async (affiliateId: string, newStatus: AffiliateStatus) => { await updateAffiliateStatus(affiliateId, newStatus); @@ -124,9 +100,7 @@ const AffiliateDashboardScreen: React.FC = () => { {affiliate.referrerAddress.slice(-4)} - - {affiliate.totalReferrals} referrals - + {affiliate.totalReferrals} referrals ${affiliate.totalEarnings.toFixed(2)} earned @@ -141,8 +115,8 @@ const AffiliateDashboardScreen: React.FC = () => { affiliate.status === AffiliateStatus.ACTIVE ? colors.success : affiliate.status === AffiliateStatus.PAUSED - ? colors.warning - : colors.danger, + ? colors.warning + : colors.error, }, ]}> {affiliate.status} @@ -150,17 +124,13 @@ const AffiliateDashboardScreen: React.FC = () => { {affiliate.status === AffiliateStatus.ACTIVE ? ( - handleToggleStatus(affiliate.id, AffiliateStatus.PAUSED) - }> + onPress={() => handleToggleStatus(affiliate.id, AffiliateStatus.PAUSED)}> Pause ) : ( - handleToggleStatus(affiliate.id, AffiliateStatus.ACTIVE) - }> + onPress={() => handleToggleStatus(affiliate.id, AffiliateStatus.ACTIVE)}> Resume )} @@ -191,8 +161,8 @@ const AffiliateDashboardScreen: React.FC = () => { {program.commissionConfig.type === 'percentage' ? `${program.commissionConfig.rate}%` : program.commissionConfig.type === 'flat' - ? `$${program.commissionConfig.rate}` - : 'Tiered'} + ? `$${program.commissionConfig.rate}` + : 'Tiered'} commission @@ -227,8 +197,8 @@ const AffiliateDashboardScreen: React.FC = () => { commission.status === 'paid' ? colors.success : commission.status === 'approved' - ? colors.warning - : colors.textSecondary, + ? colors.warning + : colors.textSecondary, }, ]}> {commission.status} @@ -256,9 +226,7 @@ const AffiliateDashboardScreen: React.FC = () => { Affiliate Dashboard - - Track referrals and earn commissions - + Track referrals and earn commissions {renderMetricsCard()} @@ -283,9 +251,7 @@ const AffiliateDashboardScreen: React.FC = () => { Select Program - - Choose an affiliate program to join - + Choose an affiliate program to join {programs.map((program) => ( { onPress={() => setSelectedProgram(program.id)}> {program.name} - - {program.description} - + {program.description} - {selectedProgram === program.id && ( - - )} + {selectedProgram === program.id && } ))} @@ -319,9 +281,7 @@ const AffiliateDashboardScreen: React.FC = () => { onPress={() => setProgramModalVisible(false)}> Cancel - + Join Program @@ -348,19 +308,19 @@ const styles = StyleSheet.create({ loadingText: { marginTop: spacing.sm, color: colors.textSecondary, - fontSize: typography.fontSizeMd, + fontSize: typography.body2.fontSize, }, header: { padding: spacing.md, paddingTop: spacing.lg, }, title: { - fontSize: typography.fontSizeXl, - fontWeight: typography.fontWeightBold, + fontSize: typography.h2.fontSize, + fontWeight: typography.h2.fontWeight, color: colors.text, }, subtitle: { - fontSize: typography.fontSizeMd, + fontSize: typography.body2.fontSize, color: colors.textSecondary, marginTop: spacing.xs, }, @@ -370,8 +330,8 @@ const styles = StyleSheet.create({ marginTop: 0, }, metricsTitle: { - fontSize: typography.fontSizeMd, - fontWeight: typography.fontWeightBold, + fontSize: typography.body.fontSize, + fontWeight: '700', color: colors.text, marginBottom: spacing.md, }, @@ -389,12 +349,12 @@ const styles = StyleSheet.create({ borderRadius: borderRadius.md, }, metricValue: { - fontSize: typography.fontSizeLg, - fontWeight: typography.fontWeightBold, + fontSize: typography.h3.fontSize, + fontWeight: typography.h3.fontWeight, color: colors.text, }, metricLabel: { - fontSize: typography.fontSizeSm, + fontSize: typography.small.fontSize, color: colors.textSecondary, marginTop: spacing.xs, }, @@ -407,12 +367,12 @@ const styles = StyleSheet.create({ borderTopColor: colors.border, }, conversionLabel: { - fontSize: typography.fontSizeMd, + fontSize: typography.body2.fontSize, color: colors.textSecondary, }, conversionValue: { - fontSize: typography.fontSizeMd, - fontWeight: typography.fontWeightBold, + fontSize: typography.body2.fontSize, + fontWeight: '700', color: colors.primary, }, listCard: { @@ -421,13 +381,13 @@ const styles = StyleSheet.create({ marginTop: 0, }, listTitle: { - fontSize: typography.fontSizeMd, - fontWeight: typography.fontWeightBold, + fontSize: typography.body.fontSize, + fontWeight: '700', color: colors.text, marginBottom: spacing.md, }, emptyText: { - fontSize: typography.fontSizeMd, + fontSize: typography.body2.fontSize, color: colors.textSecondary, textAlign: 'center', paddingVertical: spacing.lg, @@ -443,9 +403,8 @@ const styles = StyleSheet.create({ flex: 1, }, affiliateAddress: { - fontSize: typography.fontSizeMd, + fontSize: typography.body2.fontSize, color: colors.text, - fontFamily: typography.fontFamilyMono, }, affiliateStats: { flexDirection: 'row', @@ -453,7 +412,7 @@ const styles = StyleSheet.create({ gap: spacing.md, }, affiliateStat: { - fontSize: typography.fontSizeSm, + fontSize: typography.small.fontSize, color: colors.textSecondary, }, affiliateActions: { @@ -467,8 +426,8 @@ const styles = StyleSheet.create({ }, statusBadgeText: { color: colors.text, - fontSize: typography.fontSizeXs, - fontWeight: typography.fontWeightMedium, + fontSize: typography.small.fontSize, + fontWeight: '600', textTransform: 'capitalize', }, pauseButton: { @@ -480,7 +439,7 @@ const styles = StyleSheet.create({ }, pauseButtonText: { color: colors.warning, - fontSize: typography.fontSizeSm, + fontSize: typography.small.fontSize, }, resumeButton: { paddingHorizontal: spacing.sm, @@ -491,7 +450,7 @@ const styles = StyleSheet.create({ }, resumeButtonText: { color: colors.success, - fontSize: typography.fontSizeSm, + fontSize: typography.small.fontSize, }, programItem: { flexDirection: 'row', @@ -504,12 +463,12 @@ const styles = StyleSheet.create({ flex: 1, }, programName: { - fontSize: typography.fontSizeMd, + fontSize: typography.body.fontSize, color: colors.text, - fontWeight: typography.fontWeightMedium, + fontWeight: '600', }, programDescription: { - fontSize: typography.fontSizeSm, + fontSize: typography.body2.fontSize, color: colors.textSecondary, marginTop: spacing.xs, }, @@ -517,12 +476,12 @@ const styles = StyleSheet.create({ alignItems: 'flex-end', }, rateValue: { - fontSize: typography.fontSizeMd, - fontWeight: typography.fontWeightBold, + fontSize: typography.body.fontSize, + fontWeight: '700', color: colors.primary, }, rateLabel: { - fontSize: typography.fontSizeXs, + fontSize: typography.small.fontSize, color: colors.textSecondary, }, commissionItem: { @@ -536,12 +495,11 @@ const styles = StyleSheet.create({ flex: 1, }, commissionId: { - fontSize: typography.fontSizeSm, + fontSize: typography.body2.fontSize, color: colors.text, - fontFamily: typography.fontFamilyMono, }, commissionDate: { - fontSize: typography.fontSizeXs, + fontSize: typography.small.fontSize, color: colors.textSecondary, marginTop: spacing.xs, }, @@ -549,8 +507,8 @@ const styles = StyleSheet.create({ alignItems: 'flex-end', }, amountValue: { - fontSize: typography.fontSizeMd, - fontWeight: typography.fontWeightBold, + fontSize: typography.body.fontSize, + fontWeight: '700', color: colors.text, }, commissionStatus: { @@ -561,8 +519,8 @@ const styles = StyleSheet.create({ }, commissionStatusText: { color: colors.text, - fontSize: typography.fontSizeXs, - fontWeight: typography.fontWeightMedium, + fontSize: typography.small.fontSize, + fontWeight: '600', textTransform: 'capitalize', }, registerButton: { @@ -574,8 +532,8 @@ const styles = StyleSheet.create({ }, registerButtonText: { color: colors.text, - fontSize: typography.fontSizeMd, - fontWeight: typography.fontWeightBold, + fontSize: typography.body.fontSize, + fontWeight: '700', }, modalOverlay: { flex: 1, @@ -590,13 +548,13 @@ const styles = StyleSheet.create({ maxHeight: '70%', }, modalTitle: { - fontSize: typography.fontSizeLg, - fontWeight: typography.fontWeightBold, + fontSize: typography.h3.fontSize, + fontWeight: '700', color: colors.text, marginBottom: spacing.xs, }, modalSubtitle: { - fontSize: typography.fontSizeMd, + fontSize: typography.body2.fontSize, color: colors.textSecondary, marginBottom: spacing.lg, }, @@ -616,12 +574,12 @@ const styles = StyleSheet.create({ flex: 1, }, programOptionName: { - fontSize: typography.fontSizeMd, - fontWeight: typography.fontWeightMedium, + fontSize: typography.body.fontSize, + fontWeight: '600', color: colors.text, }, programOptionDesc: { - fontSize: typography.fontSizeSm, + fontSize: typography.body2.fontSize, color: colors.textSecondary, marginTop: spacing.xs, }, @@ -658,8 +616,8 @@ const styles = StyleSheet.create({ }, cancelButtonText: { color: colors.text, - fontSize: typography.fontSizeMd, - fontWeight: typography.fontWeightMedium, + fontSize: typography.body.fontSize, + fontWeight: '600', }, confirmButton: { flex: 1, @@ -670,9 +628,9 @@ const styles = StyleSheet.create({ }, confirmButtonText: { color: colors.text, - fontSize: typography.fontSizeMd, - fontWeight: typography.fontWeightBold, + fontSize: typography.body.fontSize, + fontWeight: '700', }, }); -export default AffiliateDashboardScreen; \ No newline at end of file +export default AffiliateDashboardScreen; diff --git a/src/screens/AnalyticsScreen.tsx b/src/screens/AnalyticsScreen.tsx index 534e7a92..bbaa3013 100644 --- a/src/screens/AnalyticsScreen.tsx +++ b/src/screens/AnalyticsScreen.tsx @@ -19,7 +19,6 @@ import { calculateSubscriptionAnalytics } from '../services/analyticsService'; import { formatCurrency } from '../utils/formatting'; import { useThemeColors } from '../hooks/useThemeColors'; - const { width: screenWidth } = Dimensions.get('window'); const CHART_WIDTH = screenWidth - spacing.xl * 2; const CHART_HEIGHT = 200; @@ -37,7 +36,6 @@ const AnalyticsScreen: React.FC = () => { calculateStats(); }, [subscriptions, calculateStats, preferredCurrency, exchangeRates]); - const categoryData = useMemo(() => { const categories = Object.values(SubscriptionCategory); return categories @@ -106,7 +104,6 @@ const AnalyticsScreen: React.FC = () => { else if (sub.billingCycle === BillingCycle.YEARLY) total += priceInPreferred / 12; else if (sub.billingCycle === BillingCycle.WEEKLY) total += priceInPreferred * 4; } - } }); return { month, amount: total }; @@ -198,7 +195,6 @@ const AnalyticsScreen: React.FC = () => { importantForAccessibility="no"> {formatCurrency(stats.totalMonthlySpend, preferredCurrency)} - { importantForAccessibility="no"> {formatCurrency(stats.totalYearlySpend, preferredCurrency)} - @@ -304,7 +299,6 @@ const AnalyticsScreen: React.FC = () => { textAnchor="middle"> {formatCurrency(data.amount, preferredCurrency)} - )} ); @@ -392,7 +386,6 @@ const AnalyticsScreen: React.FC = () => { {formatCurrency(stats.totalYearlySpend, preferredCurrency)} - diff --git a/src/screens/ApiKeyManagementScreen.tsx b/src/screens/ApiKeyManagementScreen.tsx index 2029ca97..b418c804 100644 --- a/src/screens/ApiKeyManagementScreen.tsx +++ b/src/screens/ApiKeyManagementScreen.tsx @@ -125,9 +125,7 @@ const ApiKeyManagementScreen: React.FC = () => { - - Environment: Sandbox (Development) - + Environment: Sandbox (Development) Keys are created for the current sandbox environment @@ -146,14 +144,10 @@ const ApiKeyManagementScreen: React.FC = () => { - handleCopyKey(showNewKey)}> + handleCopyKey(showNewKey)}> Copy Key - setShowNewKey(null)}> + setShowNewKey(null)}> Dismiss @@ -184,8 +178,8 @@ const ApiKeyManagementScreen: React.FC = () => { key.status === ApiKeyStatus.ACTIVE ? colors.success : key.status === ApiKeyStatus.REVOKED - ? colors.error - : colors.warning, + ? colors.error + : colors.warning, }, ]}> {key.status} @@ -196,16 +190,15 @@ const ApiKeyManagementScreen: React.FC = () => { - - {apiKeyService.maskApiKey(key.key)} - + {apiKeyService.maskApiKey(key.key)} Permissions: {(key.permissions ?? key.scopes ?? ['read']).join(', ')} - Rate: {key.rateLimit?.requestsPerMinute ?? 60}/min · {key.rateLimit?.requestsPerDay ?? 10000}/day + Rate: {key.rateLimit?.requestsPerMinute ?? 60}/min ·{' '} + {key.rateLimit?.requestsPerDay ?? 10000}/day {key.lastUsedAt && ( diff --git a/src/screens/CalendarIntegrationScreen.tsx b/src/screens/CalendarIntegrationScreen.tsx index 4db03dcd..a039fba6 100644 --- a/src/screens/CalendarIntegrationScreen.tsx +++ b/src/screens/CalendarIntegrationScreen.tsx @@ -2,7 +2,6 @@ import React, { useEffect, useCallback } from 'react'; import { Alert, Linking, - Platform, SafeAreaView, ScrollView, Share, @@ -132,9 +131,15 @@ const CalendarIntegrationScreen: React.FC = () => { message: payload.ical, title: payload.filename, }); - Alert.alert('Calendar exported', `Exported ${payload.events.length} events to ${payload.filename}`); + Alert.alert( + 'Calendar exported', + `Exported ${payload.events.length} events to ${payload.filename}` + ); } catch (exportError) { - Alert.alert('Export failed', exportError instanceof Error ? exportError.message : 'Could not export calendar.'); + Alert.alert( + 'Export failed', + exportError instanceof Error ? exportError.message : 'Could not export calendar.' + ); } }, [subscriptions, timezone, exportCalendar]); @@ -424,13 +429,17 @@ const CalendarIntegrationScreen: React.FC = () => { Set your preferred timezone for calendar events. Current: {timezone}. - + {SUBSCRIPTION_TIMEZONES.map((tz) => ( setTimezone(tz)}> - + {tz} @@ -446,23 +455,26 @@ const CalendarIntegrationScreen: React.FC = () => { Check for conflicts - {scheduleConflicts.length > 0 ? ( - scheduleConflicts.slice(0, 5).map((conflict) => ( - - {conflict.date} - - {conflict.conflictingSubscriptions.length} subscriptions — {conflict.totalAmount.toFixed(2)} USD total - - {conflict.conflictingSubscriptions.map((sub) => ( - - {sub.name}: {sub.currency} {sub.amount.toFixed(2)} + {scheduleConflicts.length > 0 + ? scheduleConflicts.slice(0, 5).map((conflict) => ( + + {conflict.date} + + {conflict.conflictingSubscriptions.length} subscriptions —{' '} + {conflict.totalAmount.toFixed(2)} USD total - ))} - - )) - ) : scheduleConflicts.length === 0 && ( - No conflicts detected. Tap "Check for conflicts" to scan. - )} + {conflict.conflictingSubscriptions.map((sub) => ( + + {sub.name}: {sub.currency} {sub.amount.toFixed(2)} + + ))} + + )) + : scheduleConflicts.length === 0 && ( + + No conflicts detected. Tap "Check for conflicts" to scan. + + )} @@ -480,7 +492,9 @@ const CalendarIntegrationScreen: React.FC = () => { {payment.currency} {payment.amount.toFixed(2)} — {payment.status} - {new Date(payment.scheduledDate).toLocaleDateString()} + + {new Date(payment.scheduledDate).toLocaleDateString()} + {payment.status === 'pending' && ( { - const { - campaigns, - isLoading, - error, - createCampaign, - updateCampaign, - deleteCampaign, - launchCampaign, - pauseCampaign, - getCampaignAnalytics, - } = useCampaignStore(); + const { campaigns, isLoading, createCampaign, deleteCampaign, launchCampaign, pauseCampaign } = + useCampaignStore(); const [createModalVisible, setCreateModalVisible] = useState(false); const [newCampaign, setNewCampaign] = useState({ @@ -103,10 +88,7 @@ const CampaignManagementScreen: React.FC = () => { {campaign.name} + style={[styles.statusBadge, { backgroundColor: getStatusColor(campaign.status) }]}> {campaign.status} {getTypeLabel(campaign.type)} @@ -147,9 +129,7 @@ const CampaignManagementScreen: React.FC = () => { {analytics && ( - - {analytics.totalRecipients} - + {analytics.totalRecipients} Recipients @@ -280,8 +260,7 @@ const CampaignManagementScreen: React.FC = () => { key={channel} style={[ styles.channelOption, - newCampaign.channels.includes(channel) && - styles.channelOptionSelected, + newCampaign.channels.includes(channel) && styles.channelOptionSelected, ]} onPress={() => { const channels = newCampaign.channels.includes(channel) @@ -292,8 +271,7 @@ const CampaignManagementScreen: React.FC = () => { {channel} @@ -302,9 +280,7 @@ const CampaignManagementScreen: React.FC = () => { - + Create Campaign @@ -328,9 +304,7 @@ const CampaignManagementScreen: React.FC = () => { Campaign Management - - Create and manage marketing campaigns - + Create and manage marketing campaigns { {campaigns.length === 0 ? ( No campaigns yet - - Create your first campaign to get started - + Create your first campaign to get started ) : ( campaigns.map(renderCampaignItem) @@ -372,19 +344,19 @@ const styles = StyleSheet.create({ loadingText: { marginTop: spacing.sm, color: colors.textSecondary, - fontSize: typography.fontSizeMd, + fontSize: typography.body2.fontSize, }, header: { padding: spacing.md, paddingTop: spacing.lg, }, title: { - fontSize: typography.fontSizeXl, - fontWeight: typography.fontWeightBold, + fontSize: typography.h2.fontSize, + fontWeight: typography.h2.fontWeight, color: colors.text, }, subtitle: { - fontSize: typography.fontSizeMd, + fontSize: typography.body2.fontSize, color: colors.textSecondary, marginTop: spacing.xs, }, @@ -397,8 +369,8 @@ const styles = StyleSheet.create({ }, newButtonText: { color: colors.text, - fontSize: typography.fontSizeMd, - fontWeight: typography.fontWeightBold, + fontSize: typography.body.fontSize, + fontWeight: '700', }, campaignCard: { padding: spacing.md, @@ -411,8 +383,8 @@ const styles = StyleSheet.create({ marginBottom: spacing.md, }, campaignName: { - fontSize: typography.fontSizeMd, - fontWeight: typography.fontWeightBold, + fontSize: typography.body.fontSize, + fontWeight: '700', color: colors.text, }, campaignMeta: { @@ -428,12 +400,12 @@ const styles = StyleSheet.create({ }, statusBadgeText: { color: colors.text, - fontSize: typography.fontSizeXs, - fontWeight: typography.fontWeightMedium, + fontSize: typography.small.fontSize, + fontWeight: '600', textTransform: 'capitalize', }, campaignType: { - fontSize: typography.fontSizeSm, + fontSize: typography.body2.fontSize, color: colors.textSecondary, }, campaignActions: { @@ -448,8 +420,8 @@ const styles = StyleSheet.create({ }, launchButtonText: { color: colors.text, - fontSize: typography.fontSizeSm, - fontWeight: typography.fontWeightMedium, + fontSize: typography.small.fontSize, + fontWeight: '600', }, pauseButton: { paddingHorizontal: spacing.sm, @@ -459,19 +431,19 @@ const styles = StyleSheet.create({ }, pauseButtonText: { color: colors.text, - fontSize: typography.fontSizeSm, - fontWeight: typography.fontWeightMedium, + fontSize: typography.small.fontSize, + fontWeight: '600', }, deleteButton: { paddingHorizontal: spacing.sm, paddingVertical: spacing.xs, borderRadius: borderRadius.sm, borderWidth: 1, - borderColor: colors.danger, + borderColor: colors.error, }, deleteButtonText: { - color: colors.danger, - fontSize: typography.fontSizeSm, + color: colors.error, + fontSize: typography.small.fontSize, }, analyticsGrid: { flexDirection: 'row', @@ -485,12 +457,12 @@ const styles = StyleSheet.create({ alignItems: 'center', }, analyticsValue: { - fontSize: typography.fontSizeMd, - fontWeight: typography.fontWeightBold, + fontSize: typography.body.fontSize, + fontWeight: '700', color: colors.text, }, analyticsLabel: { - fontSize: typography.fontSizeXs, + fontSize: typography.small.fontSize, color: colors.textSecondary, marginTop: spacing.xs, }, @@ -506,7 +478,7 @@ const styles = StyleSheet.create({ backgroundColor: colors.background, }, channelBadgeText: { - fontSize: typography.fontSizeXs, + fontSize: typography.small.fontSize, color: colors.textSecondary, textTransform: 'uppercase', }, @@ -516,12 +488,12 @@ const styles = StyleSheet.create({ alignItems: 'center', }, emptyText: { - fontSize: typography.fontSizeMd, + fontSize: typography.body2.fontSize, color: colors.textSecondary, textAlign: 'center', }, emptySubtext: { - fontSize: typography.fontSizeSm, + fontSize: typography.small.fontSize, color: colors.textSecondary, textAlign: 'center', marginTop: spacing.xs, @@ -539,12 +511,12 @@ const styles = StyleSheet.create({ borderBottomColor: colors.border, }, closeButton: { - fontSize: typography.fontSizeMd, + fontSize: typography.body.fontSize, color: colors.primary, }, modalTitle: { - fontSize: typography.fontSizeLg, - fontWeight: typography.fontWeightBold, + fontSize: typography.h3.fontSize, + fontWeight: '700', color: colors.text, }, modalScroll: { @@ -555,7 +527,7 @@ const styles = StyleSheet.create({ marginBottom: spacing.lg, }, inputLabel: { - fontSize: typography.fontSizeSm, + fontSize: typography.body2.fontSize, color: colors.textSecondary, marginBottom: spacing.sm, }, @@ -563,7 +535,7 @@ const styles = StyleSheet.create({ backgroundColor: colors.surface, borderRadius: borderRadius.md, padding: spacing.md, - fontSize: typography.fontSizeMd, + fontSize: typography.body.fontSize, color: colors.text, borderWidth: 1, borderColor: colors.border, @@ -590,11 +562,11 @@ const styles = StyleSheet.create({ borderColor: colors.primary, }, typeOptionText: { - fontSize: typography.fontSizeSm, + fontSize: typography.body2.fontSize, color: colors.text, }, typeOptionTextSelected: { - fontWeight: typography.fontWeightBold, + fontWeight: '700', }, channelGrid: { flexDirection: 'row', @@ -613,12 +585,12 @@ const styles = StyleSheet.create({ borderColor: colors.primary, }, channelOptionText: { - fontSize: typography.fontSizeSm, + fontSize: typography.body2.fontSize, color: colors.text, textTransform: 'uppercase', }, channelOptionTextSelected: { - fontWeight: typography.fontWeightBold, + fontWeight: '700', }, createButton: { backgroundColor: colors.primary, @@ -629,9 +601,9 @@ const styles = StyleSheet.create({ }, createButtonText: { color: colors.text, - fontSize: typography.fontSizeMd, - fontWeight: typography.fontWeightBold, + fontSize: typography.body.fontSize, + fontWeight: '700', }, }); -export default CampaignManagementScreen; \ No newline at end of file +export default CampaignManagementScreen; diff --git a/src/screens/CancellationFlowScreen.tsx b/src/screens/CancellationFlowScreen.tsx index 96ad8112..371cc9cb 100644 --- a/src/screens/CancellationFlowScreen.tsx +++ b/src/screens/CancellationFlowScreen.tsx @@ -81,7 +81,9 @@ const CancellationFlowScreen: React.FC = ({ route, navigation }) => { {OFFER_TYPE_ICONS[offer.type] ?? '🎁'} {offer.title} - {offer.abVariant === 'A' ? 'Popular' : 'Best Value'} + + {offer.abVariant === 'A' ? 'Popular' : 'Best Value'} + {offer.description} diff --git a/src/screens/DeveloperPortalScreen.tsx b/src/screens/DeveloperPortalScreen.tsx index 87017952..3b41c791 100644 --- a/src/screens/DeveloperPortalScreen.tsx +++ b/src/screens/DeveloperPortalScreen.tsx @@ -54,7 +54,11 @@ const DeveloperPortalScreen: React.FC = () => { Alert.alert('Required fields', 'Name and email are required.'); return; } - await createDeveloperProfile(profileForm.name, profileForm.email, profileForm.company || undefined); + await createDeveloperProfile( + profileForm.name, + profileForm.email, + profileForm.company || undefined + ); await completeOnboardingStep(DeveloperOnboardingStep.CREATE_ACCOUNT); setShowOnboarding(false); }; @@ -63,7 +67,10 @@ const DeveloperPortalScreen: React.FC = () => { const name = newKeyName || 'Default Key'; try { const key = await generateApiKey(name); - Alert.alert('API Key Generated', `Your new API key:\n\n${key}\n\nCopy it now, it won't be shown again.`); + Alert.alert( + 'API Key Generated', + `Your new API key:\n\n${key}\n\nCopy it now, it won't be shown again.` + ); setNewKeyName(''); } catch { Alert.alert('Error', 'Failed to generate API key.'); @@ -161,31 +168,28 @@ const DeveloperPortalScreen: React.FC = () => { Developer Portal - - Welcome back, {developerProfile?.name || 'Developer'} - + Welcome back, {developerProfile?.name || 'Developer'} - {[SandboxEnvironment.DEVELOPMENT, SandboxEnvironment.STAGING, SandboxEnvironment.PRODUCTION].map( - (env) => ( - switchEnvironment(env)} - /> - ) - )} + {[ + SandboxEnvironment.DEVELOPMENT, + SandboxEnvironment.STAGING, + SandboxEnvironment.PRODUCTION, + ].map((env) => ( + switchEnvironment(env)} + /> + ))} - + { {guide.difficulty} · {guide.estimatedTime} - {guide.isCompleted && ( - - )} + {guide.isCompleted && } ))} @@ -330,8 +332,8 @@ const DeveloperPortalScreen: React.FC = () => { sub.status === 'active' ? colors.success : sub.status === 'paused' - ? colors.warning - : colors.error, + ? colors.warning + : colors.error, }, ]}> {sub.status} diff --git a/src/screens/DocumentationPortalScreen.tsx b/src/screens/DocumentationPortalScreen.tsx index de1ff3c0..0449256c 100644 --- a/src/screens/DocumentationPortalScreen.tsx +++ b/src/screens/DocumentationPortalScreen.tsx @@ -1,12 +1,5 @@ import React, { useState } from 'react'; -import { - View, - Text, - StyleSheet, - ScrollView, - SafeAreaView, - TouchableOpacity, -} from 'react-native'; +import { View, Text, StyleSheet, ScrollView, SafeAreaView, TouchableOpacity } from 'react-native'; import { Card } from '../components/common/Card'; import { colors, spacing, typography, borderRadius } from '../utils/constants'; @@ -28,7 +21,8 @@ const DOC_SECTIONS: DocSection[] = [ subsections: [ { title: 'Base URL', - content: 'Sandbox: https://api.sandbox.subtrackr.dev/v1\nProduction: https://api.subtrackr.dev/v1', + content: + 'Sandbox: https://api.sandbox.subtrackr.dev/v1\nProduction: https://api.subtrackr.dev/v1', }, { title: 'Authentication', @@ -37,8 +31,7 @@ const DOC_SECTIONS: DocSection[] = [ }, { title: 'Rate Limits', - content: - 'Sandbox: 60 requests/minute, 10,000 requests/day\nProduction: Varies by plan', + content: 'Sandbox: 60 requests/minute, 10,000 requests/day\nProduction: Varies by plan', }, ], }, @@ -50,7 +43,8 @@ const DOC_SECTIONS: DocSection[] = [ subsections: [ { title: 'GET /subscriptions', - content: 'List all subscriptions with optional filtering and pagination.\n\nQuery params: status, category, page, limit', + content: + 'List all subscriptions with optional filtering and pagination.\n\nQuery params: status, category, page, limit', }, { title: 'POST /subscriptions', @@ -146,12 +140,13 @@ const DOC_SECTIONS: DocSection[] = [ subsections: [ { title: 'Error Format', - content: 'All errors return: { "error": { "code": "string", "message": "string", "details": {} } }', + content: + 'All errors return: { "error": { "code": "string", "message": "string", "details": {} } }', }, { title: 'Common Error Codes', content: - '400 - Bad Request: Invalid parameters\n401 - Unauthorized: Invalid or missing API key\n403 - Forbidden: Insufficient permissions\n404 - Not Found: Resource doesn\'t exist\n429 - Rate Limited: Too many requests\n500 - Server Error: Internal error', + "400 - Bad Request: Invalid parameters\n401 - Unauthorized: Invalid or missing API key\n403 - Forbidden: Insufficient permissions\n404 - Not Found: Resource doesn't exist\n429 - Rate Limited: Too many requests\n500 - Server Error: Internal error", }, ], }, @@ -166,9 +161,7 @@ const DocumentationPortalScreen: React.FC = () => { API Documentation - - Complete reference for the SubTrackr API - + Complete reference for the SubTrackr API @@ -181,13 +174,12 @@ const DocumentationPortalScreen: React.FC = () => { setExpandedSection(expandedSection === section.id ? null : section.id)}> + onPress={() => + setExpandedSection(expandedSection === section.id ? null : section.id) + }> {section.icon} + style={[styles.tocText, expandedSection === section.id && styles.tocTextActive]}> {section.title} @@ -205,9 +197,7 @@ const DocumentationPortalScreen: React.FC = () => { {section.icon} {section.title} - - {expandedSection === section.id ? '▼' : '▶'} - + {expandedSection === section.id ? '▼' : '▶'} {expandedSection === section.id && ( diff --git a/src/screens/FraudDashboard.tsx b/src/screens/FraudDashboard.tsx index f26dd4b0..bb261eb6 100644 --- a/src/screens/FraudDashboard.tsx +++ b/src/screens/FraudDashboard.tsx @@ -73,15 +73,40 @@ const FraudDashboard: React.FC = () => { - {renderMetric('Total checks', analytics.totalChecks.toString(), 'Subscriptions reviewed', colors.accent)} - {renderMetric('Blocked', analytics.blocked.toString(), 'Automated hard stops', colors.error)} - {renderMetric('Flagged', analytics.flagged.toString(), 'Queued for review', colors.warning)} + {renderMetric( + 'Total checks', + analytics.totalChecks.toString(), + 'Subscriptions reviewed', + colors.accent + )} + {renderMetric( + 'Blocked', + analytics.blocked.toString(), + 'Automated hard stops', + colors.error + )} + {renderMetric( + 'Flagged', + analytics.flagged.toString(), + 'Queued for review', + colors.warning + )} {renderMetric('Avg risk', `${analytics.avgRisk}`, 'Aggregate risk score', colors.primary)} - {renderMetric('Velocity alerts', analytics.velocityAlerts.toString(), 'Rapid creation detected', colors.secondary)} - {renderMetric('Anomaly alerts', analytics.anomalyAlerts.toString(), 'Usage deviates from baseline', colors.accent)} + {renderMetric( + 'Velocity alerts', + analytics.velocityAlerts.toString(), + 'Rapid creation detected', + colors.secondary + )} + {renderMetric( + 'Anomaly alerts', + analytics.anomalyAlerts.toString(), + 'Usage deviates from baseline', + colors.accent + )} {renderMetric( 'Chargeback predictions', analytics.chargebackPredictions.toString(), @@ -121,19 +146,27 @@ const FraudDashboard: React.FC = () => { - approveSubscription(item.subscriptionId)}> + approveSubscription(item.subscriptionId)}> Approve - resolveCase(item.subscriptionId, 'flag')}> + resolveCase(item.subscriptionId, 'flag')}> Flag - blockSubscription(item.subscriptionId)}> + blockSubscription(item.subscriptionId)}> Block ))} - {reviewQueue.length === 0 ? No cases awaiting manual review. : null} + {reviewQueue.length === 0 ? ( + No cases awaiting manual review. + ) : null} @@ -181,7 +214,8 @@ const FraudDashboard: React.FC = () => { {report.averageRisk} - {report.totalSubscriptions} subs · {report.flaggedSubscriptions} flagged · {report.blockedSubscriptions} blocked + {report.totalSubscriptions} subs · {report.flaggedSubscriptions} flagged ·{' '} + {report.blockedSubscriptions} blocked Manual review {report.manualReviewCount} @@ -201,21 +235,45 @@ const FraudDashboard: React.FC = () => { Approved - + {analytics.approved} Flagged - + {analytics.flagged} Blocked - + {analytics.blocked} diff --git a/src/screens/GroupManagementScreen.tsx b/src/screens/GroupManagementScreen.tsx index 4b094d05..a40cf754 100644 --- a/src/screens/GroupManagementScreen.tsx +++ b/src/screens/GroupManagementScreen.tsx @@ -39,7 +39,9 @@ const GroupManagementScreen: React.FC = () => { Seats: {analytics?.activeSeats ?? group.members.length}/{group.planSharingRules.seatLimit} - Outstanding: ${analytics?.outstandingBalance.toFixed(2) ?? '0.00'} + + Outstanding: ${analytics?.outstandingBalance.toFixed(2) ?? '0.00'} +