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'}
+
diff --git a/src/screens/IntegrationGuidesScreen.tsx b/src/screens/IntegrationGuidesScreen.tsx
index cdc9e395..1a8fa58c 100644
--- a/src/screens/IntegrationGuidesScreen.tsx
+++ b/src/screens/IntegrationGuidesScreen.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';
import { useSandboxStore } from '../store/sandboxStore';
@@ -73,17 +66,15 @@ const IntegrationGuidesScreen: React.FC = () => {
setSelectedCategory(null)}>
-
+
All
{Object.entries(CATEGORY_LABELS).map(([key, { label, icon }]) => (
setSelectedCategory(
selectedCategory === key ? null : (key as IntegrationGuideCategory)
@@ -118,7 +109,9 @@ const IntegrationGuidesScreen: React.FC = () => {
{guide.difficulty}
@@ -142,16 +135,12 @@ const IntegrationGuidesScreen: React.FC = () => {
- setExpandedStep(expandedStep === index ? null : index)
- }>
+ onPress={() => setExpandedStep(expandedStep === index ? null : index)}>
{index + 1}
{step.title}
-
- {expandedStep === index ? '▼' : '▶'}
-
+ {expandedStep === index ? '▼' : '▶'}
{expandedStep === index && (
diff --git a/src/screens/LoyaltyDashboardScreen.tsx b/src/screens/LoyaltyDashboardScreen.tsx
index c9708423..63f8853e 100644
--- a/src/screens/LoyaltyDashboardScreen.tsx
+++ b/src/screens/LoyaltyDashboardScreen.tsx
@@ -10,226 +10,78 @@ import {
ActivityIndicator,
Modal,
FlatList,
- Share,
} from 'react-native';
-import { FlashList } from '@shopify/flash-list';
import { colors, spacing, typography, borderRadius } from '../utils/constants';
-import { useThemeColors } from '../hooks/useThemeColors';
import { useLoyaltyStore } from '../store/loyaltyStore';
import { useWalletStore } from '../store/walletStore';
-import { useGamificationStore } from '../store/gamificationStore';
import { Card } from '../components/common/Card';
-feat/issues-394-405-414-386
import { LoyaltyTier, TierBenefits } from '../types/loyalty';
-import {
- StreakCard,
- AchievementsList,
- TierProgressBar,
- RewardsCatalog,
-} from '../components/gamification/LoyaltyComponents';
-import { LoyaltyTier, RewardType, TierBenefits, PointTxType, StreakInfo } from '../types/loyalty';
-main
const LoyaltyDashboardScreen: React.FC = () => {
- const colors = useThemeColors();
const {
loyaltyStatus,
transactions,
rewards,
program,
- streak,
-feat/issues-394-405-414-386
- achievements,
- newlyUnlocked,
isLoading,
initializeProgram,
redeemPoints,
- clearNewlyUnlocked,
- evaluateAchievements,
- referral,
- isLoading,
- initializeProgram,
- fetchLoyaltyStatus,
- accumulatePoints,
- redeemPoints,
- earnReferralBonus,
- generateReferralCode,
-main
} = useLoyaltyStore();
const { address } = useWalletStore();
- const { earnedBadges, earnedAchievements } = useGamificationStore();
const [modalVisible, setModalVisible] = useState(false);
const [selectedReward, setSelectedReward] = useState('');
- const [badgeModalVisible, setBadgeModalVisible] = useState(false);
- // Initialize program on mount
useEffect(() => {
-feat/issues-394-405-414-386
- if (!program) initializeProgram();
- }, [program, initializeProgram]);
-
if (!program) {
initializeProgram();
}
- if (address) {
- fetchLoyaltyStatus(address);
- }
- }, [program, initializeProgram, address, fetchLoyaltyStatus]);
-main
-
- // Retroactive achievement evaluation on mount
- useEffect(() => {
- evaluateAchievements();
- }, []); // eslint-disable-line react-hooks/exhaustive-deps
-
- // Tier upgrade check every minute
- useEffect(() => {
- if (!address || !loyaltyStatus) return;
- const timer = setInterval(() => useLoyaltyStore.getState().checkTierUpgrade(), 60_000);
- return () => clearInterval(timer);
- }, [address, loyaltyStatus]);
+ }, [program, initializeProgram]);
-feat/issues-394-405-414-386
- // Show newly unlocked achievement toasts
useEffect(() => {
- if (newlyUnlocked.length === 0) return;
- const names = newlyUnlocked.map((a) => `${a.icon} ${a.name}`).join('\n');
- Alert.alert('Achievement Unlocked! 🎉', names);
- clearNewlyUnlocked();
- }, [newlyUnlocked, clearNewlyUnlocked]);
-
- const handleShareReferral = useCallback(async () => {
- const code = generateReferralCode();
- try {
- await Share.share({
- message: `Join SubTrackr and use my referral code: ${code}. You'll earn bonus points!`,
- title: 'Invite a Friend',
- });
- } catch {
- // user cancelled
+ if (address && loyaltyStatus) {
+ const timer = setInterval(() => {
+ useLoyaltyStore.getState().checkTierUpgrade();
+ }, 60000);
+ return () => clearInterval(timer);
}
- }, [generateReferralCode]);
-main
+ }, [address, loyaltyStatus]);
const handleRedeemReward = useCallback(async () => {
if (!selectedReward) {
Alert.alert('Error', 'Please select a reward');
return;
}
+
const success = await redeemPoints(selectedReward);
- Alert.alert(success ? 'Success' : 'Error', success ? 'Reward redeemed!' : 'Not enough points or reward unavailable');
+ if (success) {
+ Alert.alert('Success', 'Reward redeemed successfully!');
+ } else {
+ Alert.alert('Error', 'Not enough points or reward unavailable');
+ }
setModalVisible(false);
setSelectedReward('');
}, [selectedReward, redeemPoints]);
const getTierColor = (tier: LoyaltyTier): string => {
switch (tier) {
-feat/issues-394-405-414-386
- case LoyaltyTier.PLATINUM: return '#E5E4E2';
- case LoyaltyTier.GOLD: return '#FFD700';
- case LoyaltyTier.SILVER: return '#C0C0C0';
- default: return '#CD7F32';
-
case LoyaltyTier.PLATINUM:
- return colors.textSecondary;
+ return '#E5E4E2';
case LoyaltyTier.GOLD:
- return colors.status.warning;
+ return '#FFD700';
case LoyaltyTier.SILVER:
- return colors.border.default;
+ return '#C0C0C0';
default:
- return colors.brand.secondary;
-main
+ return '#CD7F32';
}
};
const getNextTierInfo = (): TierBenefits | null => {
if (!program || !loyaltyStatus) return null;
- const idx = program.tiers.findIndex((t) => t.tier === loyaltyStatus.tier);
- return idx >= program.tiers.length - 1 ? null : program.tiers[idx + 1];
- };
-
- feat/issues-394-405-414-386
- // ── Render helpers ─────────────────────────────────────────────────────────
-
- const renderStreakCard = () => {
- if (!loyaltyStatus) return null;
- const currentStreak = loyaltyStatus.streak || streak.current;
- return (
-
-
- 🔥
-
-
- {currentStreak > 0 ? `${currentStreak}-day streak` : 'Start a streak!'}
-
-
- {currentStreak >= 10
- ? 'Amazing! You earned a streak bonus!'
- : currentStreak >= 5
- ? 'Keep going! Almost at bonus milestone.'
- : 'Pay on time to build your streak.'}
-
-
-
- {currentStreak > 0 && (
-
-
-
-
-
- {10 - (currentStreak % 10)} charges to next streak bonus
-
-
- )}
-
- );
- };
-
- const renderReferralCard = () => (
-
- Refer a Friend
-
- Earn {referral.bonusPoints} bonus points for each friend who joins!
-
-
- Share Referral Code
-
- {referral.totalReferrals > 0 && (
-
- {referral.totalReferrals} friend{referral.totalReferrals > 1 ? 's' : ''} joined
-
- )}
-
- );
-
- const renderBadgesCard = () => {
- if (earnedBadges.length === 0 && earnedAchievements.length === 0) return null;
- return (
-
-
- Badges & Achievements
- setBadgeModalVisible(true)}>
- View all →
-
-
-
- {earnedBadges.slice(0, 4).map((badge, idx) => (
-
- 🏆
- {badge}
-
- ))}
-
-
- );
+ const currentTierIndex = program.tiers.findIndex((t) => t.tier === loyaltyStatus.tier);
+ if (currentTierIndex >= program.tiers.length - 1) return null;
+ return program.tiers[currentTierIndex + 1];
};
-main
const renderStatusCard = () => {
if (!loyaltyStatus) {
@@ -256,23 +108,24 @@ main
- {loyaltyStatus.points.toLocaleString()}
+ {loyaltyStatus.points}
Available Points
- {/* Tier progress bar */}
-
-
{nextTier && pointsToNextTier > 0 && (
+
+
+ {pointsToNextTier.toLocaleString()} points to {nextTier.tier}
+
+
@@ -293,8 +146,6 @@ main
);
};
-feat/issues-394-405-414-386
-
const renderRewardsCard = () => (
@@ -307,10 +158,9 @@ feat/issues-394-405-414-386
- r.isActive)}
keyExtractor={(item) => item.id}
- scrollEnabled={false}
renderItem={({ item: reward }) => (
);
-main
const renderTransactionsCard = () => (
Points History
{transactions.length === 0 ? (
No transactions yet
) : (
- transactions.slice(0, 15).map((tx) => (
+ transactions.slice(0, 10).map((tx) => (
{tx.description}
-
- {tx.type === PointTxType.EARNED && 'Earned'}
- {tx.type === PointTxType.REDEEMED && 'Redeemed'}
- {tx.type === PointTxType.EXPIRED && 'Expired'}
- {tx.type === PointTxType.REFERRAL_BONUS && 'Referral'}
- {tx.type === PointTxType.STREAK_BONUS && 'Streak Bonus'}
- {tx.type === PointTxType.ACHIEVEMENT && 'Achievement'}
-
{new Date(tx.createdAt).toLocaleDateString()}
- 0 ? styles.positiveAmount : styles.negativeAmount]}>
- {tx.amount > 0 ? '+' : ''}{tx.amount} pts
+ 0 ? styles.positiveAmount : styles.negativeAmount,
+ ]}>
+ {tx.amount > 0 ? '+' : ''}
+ {tx.amount} pts
))
@@ -364,11 +210,7 @@ main
);
-feat/issues-394-405-414-386
- const renderTierBenefits = () => {
-
- const renderTierComparison = () => {
- main
+ const renderMembers = () => {
if (!program) return null;
return (
@@ -405,64 +247,31 @@ feat/issues-394-405-414-386
Earn points, unlock rewards
- {/* Status + tier progress */}
{renderStatusCard()}
-feat/issues-394-405-414-386
-
- {/* Streak card */}
-
-
-
-
- {/* Achievements */}
-
-
-
-
- {/* Rewards catalog with inline redemption */}
-
- {
- setSelectedReward(id);
- setModalVisible(true);
- }}
- />
-
-
- {/* Points history */}
- {renderTransactionsCard()}
-
- {/* Tier benefits */}
- {renderTierBenefits()}
-
- {renderStreakCard()}
- {renderBadgesCard()}
- {renderReferralCard()}
{renderRewardsCard()}
{renderTransactionsCard()}
- {renderTierComparison()}
-main
+ {renderMembers()}
- {/* Redemption confirmation modal */}
setModalVisible(false)}>
Redeem Reward
Select a reward to redeem your points
- r.isActive)}
keyExtractor={(item) => item.id}
renderItem={({ item: reward }) => (
setSelectedReward(reward.id)}>
{reward.name}
@@ -476,7 +285,10 @@ main
{ setModalVisible(false); setSelectedReward(''); }}>
+ onPress={() => {
+ setModalVisible(false);
+ setSelectedReward('');
+ }}>
Cancel
@@ -486,99 +298,16 @@ main
-
- setBadgeModalVisible(false)}>
-
-
- Badges & Achievements
-
- {earnedBadges.length} badges earned
-
- `${idx}`}
- renderItem={({ item: badge }) => (
-
- 🏆
- {badge}
-
- )}
- />
- setBadgeModalVisible(false)}>
- Close
-
-
-
-
);
};
const styles = StyleSheet.create({
- container: { flex: 1, backgroundColor: colors.background },
+ container: {
+ flex: 1,
+ backgroundColor: colors.background,
+ },
scrollView: { flex: 1 },
-feat/issues-394-405-414-386
- loadingContainer: { flex: 1, justifyContent: 'center', alignItems: 'center' },
- loadingText: { marginTop: spacing.sm, color: colors.textSecondary, fontSize: typography.fontSizeMd },
- header: { padding: spacing.md, paddingTop: spacing.lg },
- title: { fontSize: typography.fontSizeXl, fontWeight: typography.fontWeightBold, color: colors.text },
- subtitle: { fontSize: typography.fontSizeMd, color: colors.textSecondary, marginTop: spacing.xs },
- section: { marginHorizontal: spacing.md, marginBottom: spacing.sm },
- statusCard: { padding: spacing.md, margin: spacing.md, marginTop: 0 },
- tierHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: spacing.md },
- tierBadge: { paddingHorizontal: spacing.md, paddingVertical: spacing.xs, borderRadius: borderRadius.md },
- tierBadgeText: { color: colors.text, fontSize: typography.fontSizeSm, fontWeight: typography.fontWeightBold },
- memberSince: { fontSize: typography.fontSizeSm, color: colors.textSecondary },
- pointsDisplay: { alignItems: 'center', paddingVertical: spacing.lg },
- pointsValue: { fontSize: 48, fontWeight: typography.fontWeightBold, color: colors.text },
- pointsLabel: { fontSize: typography.fontSizeMd, color: colors.textSecondary, marginTop: spacing.xs },
- progressSection: { marginTop: spacing.md },
- progressBar: { height: 8, backgroundColor: colors.border, borderRadius: 4, overflow: 'hidden' },
- progressFill: { height: '100%', backgroundColor: colors.primary },
- statsGrid: { flexDirection: 'row', justifyContent: 'space-around', paddingTop: spacing.lg, borderTopWidth: 1, borderTopColor: colors.border, marginTop: spacing.lg },
- statItem: { alignItems: 'center' },
- statValue: { fontSize: typography.fontSizeLg, fontWeight: typography.fontWeightBold, color: colors.text },
- statLabel: { fontSize: typography.fontSizeSm, color: colors.textSecondary, marginTop: spacing.xs },
- transactionsCard: { padding: spacing.md, margin: spacing.md, marginTop: 0 },
- transactionsTitle: { fontSize: typography.fontSizeMd, fontWeight: typography.fontWeightBold, color: colors.text, marginBottom: spacing.md },
- transactionItem: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: spacing.sm, borderBottomWidth: 1, borderBottomColor: colors.border },
- transactionInfo: { flex: 1 },
- transactionDesc: { fontSize: typography.fontSizeSm, color: colors.text },
- transactionDate: { fontSize: typography.fontSizeXs, color: colors.textSecondary, marginTop: spacing.xs },
- transactionAmount: { fontSize: typography.fontSizeSm, fontWeight: typography.fontWeightBold },
- positiveAmount: { color: colors.success },
- negativeAmount: { color: colors.danger },
- membersCard: { padding: spacing.md, margin: spacing.md, marginTop: 0, marginBottom: spacing.lg },
- membersTitle: { fontSize: typography.fontSizeMd, fontWeight: typography.fontWeightBold, color: colors.text, marginBottom: spacing.md },
- tierItem: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: spacing.sm, borderBottomWidth: 1, borderBottomColor: colors.border },
- tierInfo: { flexDirection: 'row', alignItems: 'center' },
- tierDot: { width: 12, height: 12, borderRadius: 6, marginRight: spacing.sm },
- tierName: { fontSize: typography.fontSizeSm, color: colors.text, fontWeight: typography.fontWeightMedium },
- tierThreshold: { fontSize: typography.fontSizeSm, color: colors.textSecondary },
- emptyText: { fontSize: typography.fontSizeMd, color: colors.textSecondary, textAlign: 'center' },
- emptySubtext: { fontSize: typography.fontSizeSm, color: colors.textSecondary, textAlign: 'center', marginTop: spacing.xs },
- modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' },
- modalContent: { backgroundColor: colors.surface, borderTopLeftRadius: borderRadius.lg, borderTopRightRadius: borderRadius.lg, padding: spacing.lg, maxHeight: '70%' },
- modalTitle: { fontSize: typography.fontSizeLg, fontWeight: typography.fontWeightBold, color: colors.text, marginBottom: spacing.xs },
- modalSubtitle: { fontSize: typography.fontSizeMd, color: colors.textSecondary, marginBottom: spacing.lg },
- rewardOption: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', padding: spacing.md, borderRadius: borderRadius.md, marginBottom: spacing.sm, backgroundColor: colors.background },
- rewardOptionSelected: { borderWidth: 2, borderColor: colors.primary },
- rewardOptionInfo: { flex: 1 },
- rewardOptionName: { fontSize: typography.fontSizeMd, fontWeight: typography.fontWeightMedium, color: colors.text },
- rewardOptionDesc: { fontSize: typography.fontSizeSm, color: colors.textSecondary, marginTop: spacing.xs },
- rewardOptionCost: { fontSize: typography.fontSizeMd, fontWeight: typography.fontWeightBold, color: colors.primary },
- modalButtons: { flexDirection: 'row', justifyContent: 'space-between', marginTop: spacing.lg, gap: spacing.md },
- cancelButton: { flex: 1, backgroundColor: colors.background, borderRadius: borderRadius.md, padding: spacing.md, alignItems: 'center' },
- cancelButtonText: { color: colors.text, fontSize: typography.fontSizeMd, fontWeight: typography.fontWeightMedium },
- confirmButton: { flex: 1, backgroundColor: colors.primary, borderRadius: borderRadius.md, padding: spacing.md, alignItems: 'center' },
- confirmButtonText: { color: colors.text, fontSize: typography.fontSizeMd, fontWeight: typography.fontWeightBold },
-
loadingContainer: {
flex: 1,
justifyContent: 'center',
@@ -587,19 +316,19 @@ feat/issues-394-405-414-386
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,
},
@@ -621,11 +350,11 @@ feat/issues-394-405-414-386
},
tierBadgeText: {
color: colors.text,
- fontSize: typography.fontSizeSm,
- fontWeight: typography.fontWeightBold,
+ fontSize: typography.small.fontSize,
+ fontWeight: '700',
},
memberSince: {
- fontSize: typography.fontSizeSm,
+ fontSize: typography.small.fontSize,
color: colors.textSecondary,
},
pointsDisplay: {
@@ -634,11 +363,11 @@ feat/issues-394-405-414-386
},
pointsValue: {
fontSize: 48,
- fontWeight: typography.fontWeightBold,
+ fontWeight: '700',
color: colors.text,
},
pointsLabel: {
- fontSize: typography.fontSizeMd,
+ fontSize: typography.body2.fontSize,
color: colors.textSecondary,
marginTop: spacing.xs,
},
@@ -651,7 +380,7 @@ feat/issues-394-405-414-386
marginBottom: spacing.xs,
},
progressText: {
- fontSize: typography.fontSizeSm,
+ fontSize: typography.small.fontSize,
color: colors.textSecondary,
},
progressBar: {
@@ -676,12 +405,12 @@ feat/issues-394-405-414-386
alignItems: 'center',
},
statValue: {
- fontSize: typography.fontSizeLg,
- fontWeight: typography.fontWeightBold,
+ fontSize: typography.h3.fontSize,
+ fontWeight: typography.h3.fontWeight,
color: colors.text,
},
statLabel: {
- fontSize: typography.fontSizeSm,
+ fontSize: typography.small.fontSize,
color: colors.textSecondary,
marginTop: spacing.xs,
},
@@ -697,14 +426,14 @@ feat/issues-394-405-414-386
marginBottom: spacing.md,
},
rewardsTitle: {
- fontSize: typography.fontSizeMd,
- fontWeight: typography.fontWeightBold,
+ fontSize: typography.body.fontSize,
+ fontWeight: '700',
color: colors.text,
},
redeemLink: {
- fontSize: typography.fontSizeMd,
+ fontSize: typography.body.fontSize,
color: colors.primary,
- fontWeight: typography.fontWeightMedium,
+ fontWeight: '600',
},
rewardItem: {
flexDirection: 'row',
@@ -718,12 +447,12 @@ feat/issues-394-405-414-386
flex: 1,
},
rewardName: {
- fontSize: typography.fontSizeMd,
+ fontSize: typography.body.fontSize,
color: colors.text,
- fontWeight: typography.fontWeightMedium,
+ fontWeight: '600',
},
rewardDesc: {
- fontSize: typography.fontSizeSm,
+ fontSize: typography.body2.fontSize,
color: colors.textSecondary,
marginTop: spacing.xs,
},
@@ -731,12 +460,12 @@ feat/issues-394-405-414-386
alignItems: 'flex-end',
},
costValue: {
- fontSize: typography.fontSizeMd,
- fontWeight: typography.fontWeightBold,
+ fontSize: typography.body.fontSize,
+ fontWeight: '700',
color: colors.primary,
},
costLabel: {
- fontSize: typography.fontSizeXs,
+ fontSize: typography.small.fontSize,
color: colors.textSecondary,
},
transactionsCard: {
@@ -745,8 +474,8 @@ feat/issues-394-405-414-386
marginTop: 0,
},
transactionsTitle: {
- fontSize: typography.fontSizeMd,
- fontWeight: typography.fontWeightBold,
+ fontSize: typography.body.fontSize,
+ fontWeight: '700',
color: colors.text,
marginBottom: spacing.md,
},
@@ -761,23 +490,23 @@ feat/issues-394-405-414-386
flex: 1,
},
transactionDesc: {
- fontSize: typography.fontSizeSm,
+ fontSize: typography.body2.fontSize,
color: colors.text,
},
transactionDate: {
- fontSize: typography.fontSizeXs,
+ fontSize: typography.small.fontSize,
color: colors.textSecondary,
marginTop: spacing.xs,
},
transactionAmount: {
- fontSize: typography.fontSizeSm,
- fontWeight: typography.fontWeightBold,
+ fontSize: typography.body2.fontSize,
+ fontWeight: '700',
},
positiveAmount: {
color: colors.success,
},
negativeAmount: {
- color: colors.danger,
+ color: colors.error,
},
membersCard: {
padding: spacing.md,
@@ -786,8 +515,8 @@ feat/issues-394-405-414-386
marginBottom: spacing.lg,
},
membersTitle: {
- fontSize: typography.fontSizeMd,
- fontWeight: typography.fontWeightBold,
+ fontSize: typography.body.fontSize,
+ fontWeight: '700',
color: colors.text,
marginBottom: spacing.md,
},
@@ -810,144 +539,24 @@ feat/issues-394-405-414-386
marginRight: spacing.sm,
},
tierName: {
- fontSize: typography.fontSizeSm,
+ fontSize: typography.body2.fontSize,
color: colors.text,
- fontWeight: typography.fontWeightMedium,
+ fontWeight: '600',
},
tierThreshold: {
- fontSize: typography.fontSizeSm,
+ fontSize: typography.body2.fontSize,
color: colors.textSecondary,
},
emptyText: {
- fontSize: typography.fontSizeMd,
+ fontSize: typography.body2.fontSize,
color: colors.textSecondary,
textAlign: 'center',
},
emptySubtext: {
- fontSize: typography.fontSizeSm,
- color: colors.textSecondary,
- textAlign: 'center',
- marginTop: spacing.xs,
- },
- transactionType: {
- fontSize: typography.fontSizeXs,
- color: colors.primary,
- marginTop: spacing.xs,
- },
- streakCard: {
- padding: spacing.md,
- margin: spacing.md,
- marginTop: 0,
- },
- streakHeader: {
- flexDirection: 'row',
- alignItems: 'center',
- },
- streakIcon: {
- fontSize: 32,
- marginRight: spacing.md,
- },
- streakInfo: {
- flex: 1,
- },
- streakValue: {
- fontSize: typography.fontSizeMd,
- fontWeight: typography.fontWeightBold,
- color: colors.text,
- },
- streakSubtext: {
- fontSize: typography.fontSizeSm,
- color: colors.textSecondary,
- marginTop: spacing.xs,
- },
- streakProgress: {
- marginTop: spacing.md,
- },
- streakBar: {
- height: 6,
- backgroundColor: colors.border,
- borderRadius: 3,
- overflow: 'hidden',
- },
- streakFill: {
- height: '100%',
- backgroundColor: '#FF6B35',
- },
- streakMilestone: {
- fontSize: typography.fontSizeXs,
- color: colors.textSecondary,
- marginTop: spacing.xs,
- },
- referralCard: {
- padding: spacing.md,
- margin: spacing.md,
- marginTop: 0,
- },
- referralTitle: {
- fontSize: typography.fontSizeMd,
- fontWeight: typography.fontWeightBold,
- color: colors.text,
- marginBottom: spacing.xs,
- },
- referralDesc: {
- fontSize: typography.fontSizeSm,
+ fontSize: typography.small.fontSize,
color: colors.textSecondary,
- marginBottom: spacing.md,
- },
- shareButton: {
- backgroundColor: colors.primary,
- borderRadius: borderRadius.md,
- padding: spacing.md,
- alignItems: 'center',
- },
- shareButtonText: {
- color: colors.text,
- fontSize: typography.fontSizeMd,
- fontWeight: typography.fontWeightBold,
- },
- referralStats: {
- fontSize: typography.fontSizeSm,
- color: colors.success,
- marginTop: spacing.sm,
textAlign: 'center',
- },
- badgesCard: {
- padding: spacing.md,
- margin: spacing.md,
- marginTop: 0,
- },
- badgesHeader: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- alignItems: 'center',
- marginBottom: spacing.md,
- },
- badgesTitle: {
- fontSize: typography.fontSizeMd,
- fontWeight: typography.fontWeightBold,
- color: colors.text,
- },
- badgesViewAll: {
- fontSize: typography.fontSizeSm,
- color: colors.primary,
- },
- badgeRow: {
- flexDirection: 'row',
- flexWrap: 'wrap',
- gap: spacing.sm,
- },
- badgeItem: {
- alignItems: 'center',
- width: 60,
- },
- badgeIcon: {
- fontSize: 28,
- },
- badgeName: {
- fontSize: typography.fontSizeXs,
- color: colors.textSecondary,
marginTop: spacing.xs,
- textAlign: 'center',
},
modalOverlay: {
flex: 1,
@@ -962,13 +571,13 @@ feat/issues-394-405-414-386
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,
},
@@ -989,18 +598,18 @@ feat/issues-394-405-414-386
flex: 1,
},
rewardOptionName: {
- fontSize: typography.fontSizeMd,
- fontWeight: typography.fontWeightMedium,
+ fontSize: typography.body.fontSize,
+ fontWeight: '600',
color: colors.text,
},
rewardOptionDesc: {
- fontSize: typography.fontSizeSm,
+ fontSize: typography.body2.fontSize,
color: colors.textSecondary,
marginTop: spacing.xs,
},
rewardOptionCost: {
- fontSize: typography.fontSizeMd,
- fontWeight: typography.fontWeightBold,
+ fontSize: typography.body.fontSize,
+ fontWeight: '700',
color: colors.primary,
},
modalButtons: {
@@ -1018,8 +627,8 @@ feat/issues-394-405-414-386
},
cancelButtonText: {
color: colors.text,
- fontSize: typography.fontSizeMd,
- fontWeight: typography.fontWeightMedium,
+ fontSize: typography.body.fontSize,
+ fontWeight: '600',
},
confirmButton: {
flex: 1,
@@ -1030,10 +639,9 @@ feat/issues-394-405-414-386
},
confirmButtonText: {
color: colors.text,
- fontSize: typography.fontSizeMd,
- fontWeight: typography.fontWeightBold,
+ fontSize: typography.body.fontSize,
+ fontWeight: '700',
},
-main
});
export default LoyaltyDashboardScreen;
diff --git a/src/screens/MerchantOnboardingScreen.tsx b/src/screens/MerchantOnboardingScreen.tsx
index 3be9ec35..a39f0baf 100644
--- a/src/screens/MerchantOnboardingScreen.tsx
+++ b/src/screens/MerchantOnboardingScreen.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useCallback, useEffect } from 'react';
+import React, { useState, useCallback } from 'react';
import {
View,
Text,
@@ -9,14 +9,10 @@ import {
TextInput,
Alert,
ActivityIndicator,
- Modal,
} from 'react-native';
import { colors, spacing, typography, borderRadius } from '../utils/constants';
import { useMerchantStore } from '../store/merchantStore';
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 {
OnboardingStep,
OnboardingStatus,
@@ -24,45 +20,15 @@ import {
MerchantOnboardingFormData,
} from '../types/merchant';
-// ── Welcome tour steps ────────────────────────────────────────────────────────
-
-const TOUR_STEPS = [
- { icon: '🏢', title: 'Business Info', desc: 'Tell us about your business' },
- { icon: '📄', title: 'Documents', desc: 'Upload ID and business license' },
- { icon: '💳', title: 'Payment Setup', desc: 'Configure how you get paid' },
- { icon: '✅', title: 'Review', desc: 'Submit for compliance screening' },
-];
-
-// ── Status colors ─────────────────────────────────────────────────────────────
-
-const STATUS_COLORS: Record = {
- [OnboardingStatus.VERIFIED]: colors.success,
- [OnboardingStatus.REJECTED]: colors.danger,
- [OnboardingStatus.PENDING_REVIEW]: colors.warning,
- [OnboardingStatus.IN_PROGRESS]: colors.primary,
- [OnboardingStatus.NOT_STARTED]: colors.textSecondary,
- [OnboardingStatus.EXPIRED]: colors.danger,
-};
-
-// ── Component ─────────────────────────────────────────────────────────────────
-
const MerchantOnboardingScreen: React.FC = () => {
- const navigation = useNavigation>();
const {
onboarding,
isLoading,
- error,
startOnboarding,
- saveProgress,
submitDocument,
- retryRejectedDocument,
nextStep,
previousStep,
- runComplianceScreening,
- configurePayment,
requestVerification,
- completeWelcomeTour,
- canResume,
} = useMerchantStore();
const [formData, setFormData] = useState({
@@ -72,125 +38,49 @@ const MerchantOnboardingScreen: React.FC = () => {
phoneNumber: '',
email: '',
});
- const [showTour, setShowTour] = useState(false);
- const [tourStep, setTourStep] = useState(0);
- const [walletAddress, setWalletAddress] = useState('');
-
- // Pre-fill form from saved progress on mount
- useEffect(() => {
- if (onboarding?.formData) {
- setFormData((prev) => ({ ...prev, ...(onboarding.formData as MerchantOnboardingFormData) }));
- }
- }, []); // eslint-disable-line react-hooks/exhaustive-deps
-
- // Show error alerts
- useEffect(() => {
- if (error) Alert.alert('Error', error);
- }, [error]);
-
- // ── Handlers ────────────────────────────────────────────────────────────────
const handleStartOnboarding = useCallback(async () => {
if (!formData.businessName || !formData.email) {
- Alert.alert('Error', 'Business name and email are required');
+ Alert.alert('Error', 'Please fill in required fields');
return;
}
- if (canResume()) {
- Alert.alert(
- 'Resume Onboarding',
- 'You have an incomplete onboarding session. Would you like to resume it?',
- [
- { text: 'Start Fresh', onPress: () => startOnboarding(formData) },
- { text: 'Resume', onPress: () => startOnboarding(formData) },
- ],
- );
- } else {
- await startOnboarding(formData);
- setShowTour(true);
- }
- }, [formData, startOnboarding, canResume]);
-
- const handleSaveAndExit = useCallback(() => {
- saveProgress(formData);
- Alert.alert('Saved', 'Your progress has been saved. You can resume later.');
- navigation.goBack();
- }, [formData, saveProgress, navigation]);
+ await startOnboarding(formData);
+ }, [formData, startOnboarding]);
const handleDocumentUpload = useCallback(
async (docType: DocumentType) => {
- // In production: launch image picker and get real URI
- const mockUri = `file://doc_${docType}_${Date.now()}.jpg`;
- await submitDocument(docType, mockUri);
- Alert.alert('Uploaded', 'Document submitted for review');
- },
- [submitDocument],
- );
-
- const handleRetryDocument = useCallback(
- async (docId: string) => {
- const mockUri = `file://retry_${docId}_${Date.now()}.jpg`;
- await retryRejectedDocument(docId, mockUri);
- Alert.alert('Re-uploaded', 'Document resubmitted');
+ await submitDocument(docType, `doc_${Date.now()}`);
+ Alert.alert('Success', 'Document uploaded successfully');
},
- [retryRejectedDocument],
+ [submitDocument]
);
- const handleConfigurePayment = useCallback(() => {
- if (!walletAddress) {
- Alert.alert('Error', 'Please enter a Stellar wallet address');
- return;
- }
- configurePayment({ method: 'stellar_xlm', walletAddress });
- Alert.alert('Saved', 'Payment method configured');
- }, [walletAddress, configurePayment]);
-
- const handleRunCompliance = useCallback(async () => {
- try {
- const result = await runComplianceScreening();
- if (!result.passed) {
- Alert.alert(
- 'Compliance Failed',
- result.sanctionsHit
- ? 'Your country is on the sanctions list. We cannot proceed.'
- : 'Compliance check failed. Please contact support.',
- );
- } else {
- Alert.alert('Compliance Passed', 'Your business passed all compliance checks ✅');
- }
- } catch {
- Alert.alert('Error', 'Compliance check failed. Please try again.');
- }
- }, [runComplianceScreening]);
-
- const handleNextStep = useCallback(async () => {
- saveProgress(formData);
- await nextStep();
- }, [formData, saveProgress, nextStep]);
-
- const handleTourFinish = useCallback(() => {
- setShowTour(false);
- completeWelcomeTour();
- }, [completeWelcomeTour]);
-
- // ── Render helpers ──────────────────────────────────────────────────────────
-
const renderStepIndicator = () => {
if (!onboarding) return null;
+
return (
{onboarding.steps.map((step, index) => {
- const currentIdx = onboarding.steps.indexOf(onboarding.currentStep);
const isActive = step === onboarding.currentStep;
- const isCompleted = currentIdx > index;
+ const isCompleted = onboarding.steps.indexOf(onboarding.currentStep) > index;
+
return (
-
-
+
+
{isCompleted ? '✓' : index + 1}
-
- {step.replace(/_/g, ' ')}
+
+ {step.replace('_', ' ')}
);
@@ -202,168 +92,180 @@ const MerchantOnboardingScreen: React.FC = () => {
const renderBusinessInfoStep = () => (
Business Information
- {(['businessName', 'businessType', 'country', 'phoneNumber', 'email'] as const).map((field) => (
-
-
- {field.replace(/([A-Z])/g, ' $1').replace(/^./, (s) => s.toUpperCase())}
- {(field === 'businessName' || field === 'email') ? ' *' : ''}
-
- setFormData((prev) => ({ ...prev, [field]: text }))}
- placeholder={`Enter ${field.replace(/([A-Z])/g, ' $1').toLowerCase()}`}
- placeholderTextColor={colors.textSecondary}
- keyboardType={field === 'email' ? 'email-address' : field === 'phoneNumber' ? 'phone-pad' : 'default'}
- autoCapitalize={field === 'email' ? 'none' : 'words'}
- />
-
- ))}
-
- );
-
- const renderDocumentStep = () => {
- const docTypes =
- onboarding?.currentStep === OnboardingStep.ID_DOCUMENT
- ? [DocumentType.ID_FRONT, DocumentType.ID_BACK]
- : [DocumentType.BUSINESS_LICENSE];
-
- return (
-
- Document Upload
- Upload clear photos of the required documents
- {docTypes.map((docType) => {
- const uploaded = onboarding?.documents.find((d) => d.type === docType);
- const isRejected = uploaded?.status === 'rejected';
- return (
- isRejected && uploaded ? handleRetryDocument(uploaded.id) : handleDocumentUpload(docType)}
- accessibilityRole="button"
- accessibilityLabel={`Upload ${docType.replace(/_/g, ' ')}`}>
- {uploaded ? (isRejected ? '❌' : '✅') : '📄'}
- {docType.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())}
-
- {isRejected ? 'Tap to re-upload' : uploaded ? 'Uploaded — pending review' : 'Tap to upload'}
-
-
- );
- })}
+
+ Business Name *
+ setFormData({ ...formData, businessName: text })}
+ placeholder="Enter business name"
+ placeholderTextColor={colors.textSecondary}
+ />
+
+
+ Business Type
+ setFormData({ ...formData, businessType: text })}
+ placeholder="e.g., LLC, Corporation"
+ placeholderTextColor={colors.textSecondary}
+ />
+
+
+ Country
+ setFormData({ ...formData, country: text })}
+ placeholder="Enter country"
+ placeholderTextColor={colors.textSecondary}
+ />
+
+
+ Phone Number
+ setFormData({ ...formData, phoneNumber: text })}
+ placeholder="Enter phone number"
+ placeholderTextColor={colors.textSecondary}
+ keyboardType="phone-pad"
+ />
+
+
+ Email *
+ setFormData({ ...formData, email: text })}
+ placeholder="Enter email"
+ placeholderTextColor={colors.textSecondary}
+ keyboardType="email-address"
+ autoCapitalize="none"
+ />
- );
- };
-
- const renderPaymentStep = () => (
-
- Payment Setup
- Configure how you receive subscription payments
- {onboarding?.paymentSetup ? (
-
- ✅ Payment configured: {onboarding.paymentSetup.method}
- {onboarding.paymentSetup.walletAddress && (
- {onboarding.paymentSetup.walletAddress}
- )}
-
- ) : (
- <>
-
- Stellar Wallet Address *
-
-
-
- Save Payment Method
-
- >
- )}
);
- const renderComplianceStep = () => (
+ const renderDocumentStep = () => (
- Compliance Screening
- We run sanctions and PEP checks to keep the platform safe.
- {onboarding?.compliance ? (
-
-
- Result
-
- {onboarding.compliance.passed ? '✅ Passed' : '❌ Failed'}
-
-
-
- Sanctions
- {onboarding.compliance.sanctionsHit ? 'Hit' : 'Clear'}
-
-
- PEP
- {onboarding.compliance.pepHit ? 'Hit' : 'Clear'}
-
-
- ) : (
-
- Run Compliance Check
-
- )}
+ Document Upload
+
+ Please upload the required documents for verification
+
+
+ handleDocumentUpload(DocumentType.ID_FRONT)}
+ accessibilityRole="button"
+ accessibilityLabel="Upload ID document front">
+ 📄
+ ID Document (Front)
+ Tap to upload
+
+
+ handleDocumentUpload(DocumentType.ID_BACK)}
+ accessibilityRole="button"
+ accessibilityLabel="Upload ID document back">
+ 📄
+ ID Document (Back)
+ Tap to upload
+
+
+ handleDocumentUpload(DocumentType.BUSINESS_LICENSE)}
+ accessibilityRole="button"
+ accessibilityLabel="Upload business license">
+ 🏢
+ Business License
+ Tap to upload
+
);
const renderReviewStep = () => (
Review & Submit
+
+ Review your information and submit for verification
+
+
- {([
- ['Business Name', onboarding?.formData?.businessName],
- ['Business Type', onboarding?.formData?.businessType || 'N/A'],
- ['Country', onboarding?.formData?.country || 'N/A'],
- ['Email', onboarding?.formData?.email],
- ['Documents', `${onboarding?.documents.length ?? 0} uploaded`],
- ['Payment', onboarding?.paymentSetup ? onboarding.paymentSetup.method : 'Not configured'],
- ['Compliance', onboarding?.compliance ? (onboarding.compliance.passed ? '✅ Passed' : '❌ Failed') : 'Not run'],
- ] as [string, string | undefined][]).map(([label, value]) => (
-
- {label}
- {value ?? '—'}
-
- ))}
+
+ Business Name
+ {formData.businessName}
+
+
+ Business Type
+ {formData.businessType || 'N/A'}
+
+
+ Country
+ {formData.country || 'N/A'}
+
+
+ Email
+ {formData.email}
+
+
+ Documents
+ {onboarding?.documents.length || 0} uploaded
+
- {onboarding?.verificationDeadline && (
-
- ⏱ Verification deadline: {new Date(onboarding.verificationDeadline).toLocaleDateString()}
-
- )}
-
+
+
Submit for Verification
);
- const renderStatusCard = () => {
+ const renderStatus = () => {
if (!onboarding) return null;
+
+ const statusColors: Record = {
+ [OnboardingStatus.VERIFIED]: colors.success,
+ [OnboardingStatus.REJECTED]: colors.danger,
+ [OnboardingStatus.PENDING_REVIEW]: colors.warning,
+ [OnboardingStatus.IN_PROGRESS]: colors.primary,
+ };
+
return (
Verification Status
-
- {onboarding.status.replace(/_/g, ' ')}
+
+ {onboarding.status.replace('_', ' ')}
{onboarding.verificationResult && (
<>
- Tier
+ Verification Tier
{onboarding.verificationResult.tier}
Monthly Limit
- ${onboarding.verificationResult.limits.monthlyVolume.toLocaleString()}
+
+ ${onboarding.verificationResult.limits.monthlyVolume.toLocaleString()}
+
+
+
+ Max Transactions
+
+ {onboarding.verificationResult.limits.maxTransactions.toLocaleString()}
+
>
)}
@@ -371,25 +273,12 @@ const MerchantOnboardingScreen: React.FC = () => {
);
};
- const renderCurrentStep = () => {
- if (!onboarding) return null;
- switch (onboarding.currentStep) {
- case OnboardingStep.BUSINESS_INFO: return renderBusinessInfoStep();
- case OnboardingStep.ID_DOCUMENT: return renderDocumentStep();
- case OnboardingStep.BUSINESS_LICENSE: return renderDocumentStep();
- case OnboardingStep.REVIEW: return renderReviewStep();
- default: return null;
- }
- };
-
- // ── Main render ─────────────────────────────────────────────────────────────
-
if (isLoading) {
return (
- Processing...
+ Loading...
);
@@ -406,21 +295,29 @@ const MerchantOnboardingScreen: React.FC = () => {
{onboarding ? (
<>
{renderStepIndicator()}
- {renderStatusCard()}
- {renderCurrentStep()}
+ {renderStatus()}
+ {onboarding.currentStep === OnboardingStep.BUSINESS_INFO && renderBusinessInfoStep()}
+ {onboarding.currentStep === OnboardingStep.ID_DOCUMENT && renderDocumentStep()}
+ {onboarding.currentStep === OnboardingStep.BUSINESS_LICENSE && renderDocumentStep()}
+ {onboarding.currentStep === OnboardingStep.REVIEW && renderReviewStep()}
{onboarding.steps.indexOf(onboarding.currentStep) > 0 && (
-
+
Back
)}
-
- Save & Exit
-
{onboarding.currentStep !== OnboardingStep.REVIEW && (
-
- Next →
+
+ Next
)}
@@ -429,138 +326,268 @@ const MerchantOnboardingScreen: React.FC = () => {
Get Started
- Complete our merchant verification process to start accepting subscription payments on Stellar.
+ Complete our merchant verification process to start accepting subscription payments
-
- Business Name *
- setFormData((p) => ({ ...p, businessName: t }))}
- placeholder="Enter business name"
- placeholderTextColor={colors.textSecondary}
- />
-
-
- Email *
- setFormData((p) => ({ ...p, email: t }))}
- placeholder="Enter email"
- placeholderTextColor={colors.textSecondary}
- keyboardType="email-address"
- autoCapitalize="none"
- />
-
-
+
Start Onboarding
)}
-
- {/* Welcome tour modal */}
-
-
-
- {TOUR_STEPS[tourStep].icon}
- {TOUR_STEPS[tourStep].title}
- {TOUR_STEPS[tourStep].desc}
-
- {TOUR_STEPS.map((_, i) => (
-
- ))}
-
-
-
- Skip
-
- {
- if (tourStep < TOUR_STEPS.length - 1) setTourStep((s) => s + 1);
- else handleTourFinish();
- }}
- accessibilityRole="button">
-
- {tourStep < TOUR_STEPS.length - 1 ? 'Next' : 'Get Started'}
-
-
-
-
-
-
);
};
-// ── Styles ────────────────────────────────────────────────────────────────────
-
const styles = StyleSheet.create({
- container: { flex: 1, backgroundColor: colors.background },
- scrollView: { flex: 1 },
- loadingContainer: { flex: 1, justifyContent: 'center', alignItems: 'center' },
- loadingText: { marginTop: spacing.sm, color: colors.textSecondary, fontSize: typography.fontSizeMd },
- header: { padding: spacing.md, paddingTop: spacing.lg },
- title: { fontSize: typography.fontSizeXl, fontWeight: typography.fontWeightBold, color: colors.text },
- subtitle: { fontSize: typography.fontSizeMd, color: colors.textSecondary, marginTop: spacing.xs },
- stepIndicator: { flexDirection: 'row', justifyContent: 'space-between', paddingHorizontal: spacing.md, marginBottom: spacing.md },
- stepItem: { alignItems: 'center', flex: 1 },
- stepCircle: { width: 32, height: 32, borderRadius: 16, backgroundColor: colors.border, justifyContent: 'center', alignItems: 'center' },
- stepCircleActive: { backgroundColor: colors.primary },
- stepCircleCompleted: { backgroundColor: colors.success },
- stepNumber: { color: colors.textSecondary, fontSize: typography.fontSizeSm, fontWeight: typography.fontWeightBold },
- stepNumberActive: { color: colors.text },
- stepLabel: { marginTop: spacing.xs, fontSize: typography.fontSizeXs, color: colors.textSecondary, textAlign: 'center' },
- stepLabelActive: { color: colors.primary, fontWeight: typography.fontWeightBold },
- stepContent: { padding: spacing.md },
- sectionTitle: { fontSize: typography.fontSizeLg, fontWeight: typography.fontWeightBold, color: colors.text, marginBottom: spacing.md },
- stepDescription: { fontSize: typography.fontSizeMd, color: colors.textSecondary, marginBottom: spacing.md },
- inputGroup: { marginBottom: spacing.md },
- inputLabel: { fontSize: typography.fontSizeSm, color: colors.textSecondary, marginBottom: spacing.xs },
- input: { backgroundColor: colors.surface, borderRadius: borderRadius.md, padding: spacing.md, fontSize: typography.fontSizeMd, color: colors.text, borderWidth: 1, borderColor: colors.border },
- uploadBox: { backgroundColor: colors.surface, borderRadius: borderRadius.md, padding: spacing.lg, alignItems: 'center', marginBottom: spacing.md, borderWidth: 1, borderColor: colors.border, borderStyle: 'dashed' },
- uploadBoxDone: { borderColor: colors.success, borderStyle: 'solid' },
- uploadBoxRejected: { borderColor: colors.danger, borderStyle: 'solid' },
- uploadIcon: { fontSize: 32, marginBottom: spacing.sm },
- uploadText: { fontSize: typography.fontSizeMd, color: colors.text, fontWeight: typography.fontWeightMedium },
- uploadHint: { fontSize: typography.fontSizeSm, color: colors.textSecondary, marginTop: spacing.xs },
- summaryCard: { padding: spacing.md, marginBottom: spacing.md },
- summaryRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: spacing.sm, borderBottomWidth: 1, borderBottomColor: colors.border },
- summaryLabel: { fontSize: typography.fontSizeSm, color: colors.textSecondary },
- summaryValue: { fontSize: typography.fontSizeSm, color: colors.text, fontWeight: typography.fontWeightMedium },
- submitButton: { backgroundColor: colors.primary, borderRadius: borderRadius.md, padding: spacing.md, alignItems: 'center', marginTop: spacing.md },
- submitButtonText: { color: colors.text, fontSize: typography.fontSizeMd, fontWeight: typography.fontWeightBold },
- navigationButtons: { flexDirection: 'row', padding: spacing.md, gap: spacing.sm },
- backButton: { flex: 1, backgroundColor: colors.surface, borderRadius: borderRadius.md, padding: spacing.md, alignItems: 'center' },
- backButtonText: { color: colors.text, fontSize: typography.fontSizeMd, fontWeight: typography.fontWeightMedium },
- saveExitButton: { flex: 1, backgroundColor: colors.surface, borderRadius: borderRadius.md, padding: spacing.md, alignItems: 'center', borderWidth: 1, borderColor: colors.border },
- saveExitText: { color: colors.textSecondary, fontSize: typography.fontSizeSm },
- nextButton: { flex: 1, backgroundColor: colors.primary, borderRadius: borderRadius.md, padding: spacing.md, alignItems: 'center' },
- nextButtonText: { color: colors.text, fontSize: typography.fontSizeMd, fontWeight: typography.fontWeightBold },
- statusCard: { padding: spacing.md, margin: spacing.md, marginTop: 0 },
- statusTitle: { fontSize: typography.fontSizeMd, fontWeight: typography.fontWeightBold, color: colors.text, marginBottom: spacing.sm },
- statusRow: { flexDirection: 'row', alignItems: 'center', marginBottom: spacing.sm },
- statusBadge: { paddingHorizontal: spacing.md, paddingVertical: spacing.xs, borderRadius: borderRadius.md },
- statusBadgeText: { color: colors.text, fontSize: typography.fontSizeSm, fontWeight: typography.fontWeightMedium, textTransform: 'capitalize' },
- startCard: { padding: spacing.lg, margin: spacing.md, alignItems: 'center' },
- startTitle: { fontSize: typography.fontSizeLg, fontWeight: typography.fontWeightBold, color: colors.text, marginBottom: spacing.sm },
- startDescription: { fontSize: typography.fontSizeMd, color: colors.textSecondary, textAlign: 'center', marginBottom: spacing.lg },
- startButton: { backgroundColor: colors.primary, borderRadius: borderRadius.md, paddingVertical: spacing.md, paddingHorizontal: spacing.xl, alignItems: 'center', width: '100%' },
- startButtonText: { color: colors.text, fontSize: typography.fontSizeMd, fontWeight: typography.fontWeightBold },
- tourOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'center', alignItems: 'center', padding: spacing.lg },
- tourCard: { backgroundColor: colors.surface, borderRadius: borderRadius.lg, padding: spacing.xl, alignItems: 'center', width: '100%' },
- tourIcon: { fontSize: 56, marginBottom: spacing.md },
- tourTitle: { fontSize: typography.fontSizeLg, fontWeight: typography.fontWeightBold, color: colors.text, marginBottom: spacing.sm },
- tourDesc: { fontSize: typography.fontSizeMd, color: colors.textSecondary, textAlign: 'center', marginBottom: spacing.lg },
- tourDots: { flexDirection: 'row', marginBottom: spacing.lg, gap: spacing.xs },
- tourDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: colors.border },
- tourDotActive: { backgroundColor: colors.primary, width: 20 },
- tourButtons: { flexDirection: 'row', justifyContent: 'space-between', width: '100%', alignItems: 'center' },
- tourSkip: { color: colors.textSecondary, fontSize: typography.fontSizeMd },
- tourNext: { backgroundColor: colors.primary, borderRadius: borderRadius.md, paddingVertical: spacing.sm, paddingHorizontal: spacing.lg },
- tourNextText: { color: colors.text, fontWeight: typography.fontWeightBold },
+ container: {
+ flex: 1,
+ backgroundColor: colors.background,
+ },
+ scrollView: {
+ flex: 1,
+ },
+ loadingContainer: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ loadingText: {
+ marginTop: spacing.sm,
+ color: colors.textSecondary,
+ fontSize: typography.fontSizeMd,
+ },
+ header: {
+ padding: spacing.md,
+ paddingTop: spacing.lg,
+ },
+ title: {
+ fontSize: typography.fontSizeXl,
+ fontWeight: typography.fontWeightBold,
+ color: colors.text,
+ },
+ subtitle: {
+ fontSize: typography.fontSizeMd,
+ color: colors.textSecondary,
+ marginTop: spacing.xs,
+ },
+ stepIndicator: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ paddingHorizontal: spacing.md,
+ marginBottom: spacing.md,
+ },
+ stepItem: {
+ alignItems: 'center',
+ flex: 1,
+ },
+ stepCircle: {
+ width: 32,
+ height: 32,
+ borderRadius: 16,
+ backgroundColor: colors.border,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ stepCircleActive: {
+ backgroundColor: colors.primary,
+ },
+ stepCircleCompleted: {
+ backgroundColor: colors.success,
+ },
+ stepNumber: {
+ color: colors.textSecondary,
+ fontSize: typography.fontSizeSm,
+ fontWeight: typography.fontWeightBold,
+ },
+ stepNumberActive: {
+ color: colors.text,
+ },
+ stepLabel: {
+ marginTop: spacing.xs,
+ fontSize: typography.fontSizeXs,
+ color: colors.textSecondary,
+ },
+ stepLabelActive: {
+ color: colors.primary,
+ fontWeight: typography.fontWeightBold,
+ },
+ stepContent: {
+ padding: spacing.md,
+ },
+ sectionTitle: {
+ fontSize: typography.fontSizeLg,
+ fontWeight: typography.fontWeightBold,
+ color: colors.text,
+ marginBottom: spacing.md,
+ },
+ stepDescription: {
+ fontSize: typography.fontSizeMd,
+ color: colors.textSecondary,
+ marginBottom: spacing.md,
+ },
+ inputGroup: {
+ marginBottom: spacing.md,
+ },
+ inputLabel: {
+ fontSize: typography.fontSizeSm,
+ color: colors.textSecondary,
+ marginBottom: spacing.xs,
+ },
+ input: {
+ backgroundColor: colors.surface,
+ borderRadius: borderRadius.md,
+ padding: spacing.md,
+ fontSize: typography.fontSizeMd,
+ color: colors.text,
+ borderWidth: 1,
+ borderColor: colors.border,
+ },
+ uploadBox: {
+ backgroundColor: colors.surface,
+ borderRadius: borderRadius.md,
+ padding: spacing.lg,
+ alignItems: 'center',
+ marginBottom: spacing.md,
+ borderWidth: 1,
+ borderColor: colors.border,
+ borderStyle: 'dashed',
+ },
+ uploadIcon: {
+ fontSize: 32,
+ marginBottom: spacing.sm,
+ },
+ uploadText: {
+ fontSize: typography.fontSizeMd,
+ color: colors.text,
+ fontWeight: typography.fontWeightMedium,
+ },
+ uploadHint: {
+ fontSize: typography.fontSizeSm,
+ color: colors.textSecondary,
+ marginTop: spacing.xs,
+ },
+ summaryCard: {
+ padding: spacing.md,
+ marginBottom: spacing.md,
+ },
+ summaryRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ paddingVertical: spacing.sm,
+ borderBottomWidth: 1,
+ borderBottomColor: colors.border,
+ },
+ summaryLabel: {
+ fontSize: typography.fontSizeSm,
+ color: colors.textSecondary,
+ },
+ summaryValue: {
+ fontSize: typography.fontSizeSm,
+ color: colors.text,
+ fontWeight: typography.fontWeightMedium,
+ },
+ submitButton: {
+ backgroundColor: colors.primary,
+ borderRadius: borderRadius.md,
+ padding: spacing.md,
+ alignItems: 'center',
+ },
+ submitButtonText: {
+ color: colors.text,
+ fontSize: typography.fontSizeMd,
+ fontWeight: typography.fontWeightBold,
+ },
+ navigationButtons: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ padding: spacing.md,
+ gap: spacing.md,
+ },
+ backButton: {
+ flex: 1,
+ backgroundColor: colors.surface,
+ borderRadius: borderRadius.md,
+ padding: spacing.md,
+ alignItems: 'center',
+ marginRight: spacing.sm,
+ },
+ backButtonText: {
+ color: colors.text,
+ fontSize: typography.fontSizeMd,
+ fontWeight: typography.fontWeightMedium,
+ },
+ nextButton: {
+ flex: 1,
+ backgroundColor: colors.primary,
+ borderRadius: borderRadius.md,
+ padding: spacing.md,
+ alignItems: 'center',
+ },
+ nextButtonText: {
+ color: colors.text,
+ fontSize: typography.fontSizeMd,
+ fontWeight: typography.fontWeightBold,
+ },
+ statusCard: {
+ padding: spacing.md,
+ margin: spacing.md,
+ marginTop: 0,
+ },
+ statusTitle: {
+ fontSize: typography.fontSizeMd,
+ fontWeight: typography.fontWeightBold,
+ color: colors.text,
+ marginBottom: spacing.sm,
+ },
+ statusRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginBottom: spacing.sm,
+ },
+ statusBadge: {
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.xs,
+ borderRadius: borderRadius.md,
+ },
+ statusBadgeText: {
+ color: colors.text,
+ fontSize: typography.fontSizeSm,
+ fontWeight: typography.fontWeightMedium,
+ textTransform: 'capitalize',
+ },
+ startCard: {
+ padding: spacing.lg,
+ margin: spacing.md,
+ alignItems: 'center',
+ },
+ startTitle: {
+ fontSize: typography.fontSizeLg,
+ fontWeight: typography.fontWeightBold,
+ color: colors.text,
+ marginBottom: spacing.sm,
+ },
+ startDescription: {
+ fontSize: typography.fontSizeMd,
+ color: colors.textSecondary,
+ textAlign: 'center',
+ marginBottom: spacing.lg,
+ },
+ startButton: {
+ backgroundColor: colors.primary,
+ borderRadius: borderRadius.md,
+ paddingVertical: spacing.md,
+ paddingHorizontal: spacing.xl,
+ alignItems: 'center',
+ },
+ startButtonText: {
+ color: colors.text,
+ fontSize: typography.fontSizeMd,
+ fontWeight: typography.fontWeightBold,
+ },
});
export default MerchantOnboardingScreen;
diff --git a/src/screens/RoleManagementScreen.tsx b/src/screens/RoleManagementScreen.tsx
index 83469845..f3243051 100644
--- a/src/screens/RoleManagementScreen.tsx
+++ b/src/screens/RoleManagementScreen.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useMemo } from 'react';
+import React, { useState } from 'react';
import {
SafeAreaView,
ScrollView,
@@ -10,7 +10,6 @@ import {
} from 'react-native';
import { Card } from '../components/common/Card';
-import { Button } from '../components/common/Button';
import { borderRadius, colors, spacing, typography } from '../utils/constants';
type Role = 'Admin' | 'Merchant' | 'Subscriber' | 'Auditor';
@@ -105,8 +104,24 @@ const PERMISSION_LABELS: Record = {
const ROLE_PERMISSIONS: Record = {
Admin: Object.keys(PERMISSION_LABELS) as Permission[],
- Merchant: ['CreatePlan', 'DeactivatePlan', 'SetPlanQuotas', 'SetRevenueRule', 'ViewPlans', 'ViewSubscriptions'],
- Subscriber: ['Subscribe', 'CancelSubscription', 'PauseSubscription', 'ResumeSubscription', 'ChargeSubscription', 'RequestRefund', 'RequestTransfer', 'AcceptTransfer'],
+ Merchant: [
+ 'CreatePlan',
+ 'DeactivatePlan',
+ 'SetPlanQuotas',
+ 'SetRevenueRule',
+ 'ViewPlans',
+ 'ViewSubscriptions',
+ ],
+ Subscriber: [
+ 'Subscribe',
+ 'CancelSubscription',
+ 'PauseSubscription',
+ 'ResumeSubscription',
+ 'ChargeSubscription',
+ 'RequestRefund',
+ 'RequestTransfer',
+ 'AcceptTransfer',
+ ],
Auditor: ['ViewAnalytics', 'ViewAuditLog', 'ViewPlans', 'ViewSubscriptions'],
};
@@ -118,41 +133,67 @@ const SAMPLE_USERS: UserRoleEntry[] = [
];
const SAMPLE_HISTORY: RoleChangeEntry[] = [
- { id: 1, user: 'GABCD...1234', role: 'Admin', action: 'Granted', changedBy: 'System', timestamp: Date.now() - 86400000 },
- { id: 2, user: 'GEFGH...5678', role: 'Merchant', action: 'Granted', changedBy: 'GABCD...1234', timestamp: Date.now() - 43200000 },
- { id: 3, user: 'GIJKL...9012', role: 'Subscriber', action: 'Granted', changedBy: 'GABCD...1234', timestamp: Date.now() - 21600000 },
+ {
+ id: 1,
+ user: 'GABCD...1234',
+ role: 'Admin',
+ action: 'Granted',
+ changedBy: 'System',
+ timestamp: Date.now() - 86400000,
+ },
+ {
+ id: 2,
+ user: 'GEFGH...5678',
+ role: 'Merchant',
+ action: 'Granted',
+ changedBy: 'GABCD...1234',
+ timestamp: Date.now() - 43200000,
+ },
+ {
+ id: 3,
+ user: 'GIJKL...9012',
+ role: 'Subscriber',
+ action: 'Granted',
+ changedBy: 'GABCD...1234',
+ timestamp: Date.now() - 21600000,
+ },
];
const SAMPLE_DELEGATIONS: DelegationEntry[] = [
- { delegator: 'GEFGH...5678', delegate: 'GQRST...7890', permission: 'ViewPlans', expiresAt: Date.now() + 3600000 },
+ {
+ delegator: 'GEFGH...5678',
+ delegate: 'GQRST...7890',
+ permission: 'ViewPlans',
+ expiresAt: Date.now() + 3600000,
+ },
];
const RoleManagementScreen: React.FC = () => {
- const [activeTab, setActiveTab] = useState<'users' | 'permissions' | 'history' | 'delegations'>('users');
+ const [activeTab, setActiveTab] = useState<'users' | 'permissions' | 'history' | 'delegations'>(
+ 'users'
+ );
const [users] = useState(SAMPLE_USERS);
const [history] = useState(SAMPLE_HISTORY);
const [delegations] = useState(SAMPLE_DELEGATIONS);
const handleGrantRole = (user: UserRoleEntry, role: Role) => {
- Alert.alert(
- 'Grant Role',
- `Grant ${role} role to ${user.label}?`,
- [
- { text: 'Cancel', style: 'cancel' },
- { text: 'Grant', onPress: () => Alert.alert('Success', `${role} role granted to ${user.label}`) },
- ],
- );
+ Alert.alert('Grant Role', `Grant ${role} role to ${user.label}?`, [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Grant',
+ onPress: () => Alert.alert('Success', `${role} role granted to ${user.label}`),
+ },
+ ]);
};
const handleRevokeRole = (user: UserRoleEntry, role: Role) => {
- Alert.alert(
- 'Revoke Role',
- `Revoke ${role} role from ${user.label}?`,
- [
- { text: 'Cancel', style: 'cancel' },
- { text: 'Revoke', onPress: () => Alert.alert('Success', `${role} role revoked from ${user.label}`) },
- ],
- );
+ Alert.alert('Revoke Role', `Revoke ${role} role from ${user.label}?`, [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Revoke',
+ onPress: () => Alert.alert('Success', `${role} role revoked from ${user.label}`),
+ },
+ ]);
};
const renderUserRow = (user: UserRoleEntry) => (
@@ -180,8 +221,9 @@ const RoleManagementScreen: React.FC = () => {
(hasRole ? handleRevokeRole(user, role) : handleGrantRole(user, role))}
- >
+ onPress={() =>
+ hasRole ? handleRevokeRole(user, role) : handleGrantRole(user, role)
+ }>
{role}
@@ -213,7 +255,9 @@ const RoleManagementScreen: React.FC = () => {
const renderHistoryRow = (entry: RoleChangeEntry) => (
-
+
@@ -230,7 +274,8 @@ const RoleManagementScreen: React.FC = () => {
- {del.delegator} → {del.delegate}
+ {del.delegator} →{' '}
+ {del.delegate}
{PERMISSION_LABELS[del.permission]}
@@ -259,8 +304,7 @@ const RoleManagementScreen: React.FC = () => {
setActiveTab(tab.key)}
- >
+ onPress={() => setActiveTab(tab.key)}>
{tab.label}
@@ -272,9 +316,7 @@ const RoleManagementScreen: React.FC = () => {
{activeTab === 'users' && (
All Users
-
- Tap a role to grant or revoke it for a user
-
+ Tap a role to grant or revoke it for a user
{users.map(renderUserRow)}
)}
@@ -282,9 +324,7 @@ const RoleManagementScreen: React.FC = () => {
{activeTab === 'permissions' && (
Permission Map
-
- Each role grants the following permissions
-
+ Each role grants the following permissions
{(Object.keys(PERMISSION_LABELS) as Permission[]).map(renderPermissionRow)}
@@ -297,9 +337,7 @@ const RoleManagementScreen: React.FC = () => {
Chronological log of all role grants and revocations
-
- {history.map(renderHistoryRow)}
-
+ {history.map(renderHistoryRow)}
)}
diff --git a/src/screens/SandboxDashboardScreen.tsx b/src/screens/SandboxDashboardScreen.tsx
index 2e9112c6..0a6e17e2 100644
--- a/src/screens/SandboxDashboardScreen.tsx
+++ b/src/screens/SandboxDashboardScreen.tsx
@@ -56,38 +56,33 @@ const SandboxDashboardScreen: React.FC = () => {
Sandbox Dashboard
-
- Manage your sandbox environment and test data
-
+ Manage your sandbox environment and test data
Environment
- {[SandboxEnvironment.DEVELOPMENT, SandboxEnvironment.STAGING, SandboxEnvironment.PRODUCTION].map(
- (env) => (
- switchEnvironment(env)}
- />
- )
- )}
+ {[
+ SandboxEnvironment.DEVELOPMENT,
+ SandboxEnvironment.STAGING,
+ SandboxEnvironment.PRODUCTION,
+ ].map((env) => (
+ switchEnvironment(env)}
+ />
+ ))}
-
- {sandboxConfig.description}
-
+ {sandboxConfig.description}
-
+
@@ -101,7 +96,9 @@ const SandboxDashboardScreen: React.FC = () => {
Max API Calls
- {(sandboxConfig.maxApiCalls ?? 10000).toLocaleString()}
+
+ {(sandboxConfig.maxApiCalls ?? 10000).toLocaleString()}
+
Data Reset Interval
@@ -109,7 +106,11 @@ const SandboxDashboardScreen: React.FC = () => {
Status
-
+
{sandboxConfig.isActive ? 'Active' : 'Inactive'}
@@ -119,14 +120,14 @@ const SandboxDashboardScreen: React.FC = () => {
Allowed Features
- {(sandboxConfig.allowedFeatures ?? []).length} features
+
+ {(sandboxConfig.allowedFeatures ?? []).length} features
+
{(sandboxConfig.allowedFeatures ?? []).map((feature: string) => (
-
- {feature.replace(/_/g, ' ')}
-
+ {feature.replace(/_/g, ' ')}
))}
@@ -164,8 +165,8 @@ const SandboxDashboardScreen: React.FC = () => {
sub.status === 'active'
? colors.success
: sub.status === 'paused'
- ? colors.warning
- : colors.error,
+ ? colors.warning
+ : colors.error,
},
]}>
{sub.status}
@@ -208,13 +209,15 @@ const SandboxDashboardScreen: React.FC = () => {
{(usageStats.topErrors ?? []).length > 0 && (
<>
Top Errors
- {(usageStats.topErrors ?? []).map((error: { code: number; count: number; message: string }) => (
-
- {error.code}
- {error.message}
- {error.count}x
-
- ))}
+ {(usageStats.topErrors ?? []).map(
+ (error: { code: number; count: number; message: string }) => (
+
+ {error.code}
+ {error.message}
+ {error.count}x
+
+ )
+ )}
>
)}
diff --git a/src/screens/SandboxDetailScreen.tsx b/src/screens/SandboxDetailScreen.tsx
index 469a0ec8..d049a959 100644
--- a/src/screens/SandboxDetailScreen.tsx
+++ b/src/screens/SandboxDetailScreen.tsx
@@ -13,7 +13,6 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { RootStackParamList } from '../navigation/types';
import { useSandboxStore } from '../store/sandboxStore';
import { colors } from '../utils/constants';
-import { testDataGenerator } from '../services/sandbox/testDataGenerator';
type NavigationProp = NativeStackNavigationProp;
@@ -43,10 +42,7 @@ export default function SandboxDetailScreen() {
return (
No sandbox selected
- navigation.goBack()}
- >
+ navigation.goBack()}>
Go Back
@@ -96,8 +92,7 @@ export default function SandboxDetailScreen() {
style={[
styles.statusBadge,
selectedSandbox.isActive ? styles.statusActive : styles.statusInactive,
- ]}
- >
+ ]}>
{selectedSandbox.isActive ? 'Active' : 'Inactive'}
@@ -175,9 +170,7 @@ export default function SandboxDetailScreen() {
)}
-
- {sandboxSubscriptions.length} Test Subscriptions
-
+ {sandboxSubscriptions.length} Test Subscriptions
{sandboxSubscriptions.length === 0 ? (
@@ -199,9 +192,7 @@ export default function SandboxDetailScreen() {
{sub.category}
{sub.billingCycle}
-
- {sub.isActive ? 'Active' : 'Inactive'}
-
+ {sub.isActive ? 'Active' : 'Inactive'}
{sub.isCryptoEnabled && (
Crypto: {sub.cryptoToken}
)}
@@ -224,9 +215,7 @@ export default function SandboxDetailScreen() {
📈
No Usage Data
-
- Start making API calls to see usage statistics
-
+ Start making API calls to see usage statistics
) : (
<>
@@ -261,8 +250,7 @@ export default function SandboxDetailScreen() {
style={[
styles.usageStatus,
record.statusCode < 400 ? styles.statusSuccess : styles.statusError,
- ]}
- >
+ ]}>
{record.statusCode}
{record.responseTime}ms
@@ -281,8 +269,7 @@ export default function SandboxDetailScreen() {
navigation.navigate('ApiKeyManagement' as any)}
- >
+ onPress={() => navigation.navigate('ApiKeyManagement' as any)}>
Manage API Keys
@@ -319,35 +306,27 @@ export default function SandboxDetailScreen() {
setActiveTab('overview')}
- >
+ onPress={() => setActiveTab('overview')}>
Overview
setActiveTab('data')}
- >
+ onPress={() => setActiveTab('data')}>
Test Data
setActiveTab('usage')}
- >
-
- Usage
-
+ onPress={() => setActiveTab('usage')}>
+ Usage
setActiveTab('keys')}
- >
-
- Keys
-
+ onPress={() => setActiveTab('keys')}>
+ Keys
diff --git a/src/screens/SandboxScreen.tsx b/src/screens/SandboxScreen.tsx
index 1103f6e3..e4290d3d 100644
--- a/src/screens/SandboxScreen.tsx
+++ b/src/screens/SandboxScreen.tsx
@@ -12,22 +12,15 @@ import {
TextInput,
ActivityIndicator,
} from 'react-native';
-import { useNavigation } from '@react-navigation/native';
-import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { colors, spacing, typography, borderRadius } from '../utils/constants';
import { useSandboxStore } from '../store/sandboxStore';
import { SandboxEnvironment, SandboxStatus } from '../types/sandbox';
-import { RootStackParamList } from '../navigation/types';
-
-type SandboxNavigationProp = NativeStackNavigationProp;
const SandboxScreen: React.FC = () => {
- const navigation = useNavigation();
const {
sandboxes,
currentSandbox,
subscriptions,
- transactions,
metrics,
isLoading,
error,
@@ -174,10 +167,7 @@ const SandboxScreen: React.FC = () => {
Your Sandboxes
- setShowCreateModal(true)}
- >
+ setShowCreateModal(true)}>
+ New
@@ -188,10 +178,7 @@ const SandboxScreen: React.FC = () => {
Create a sandbox to start testing your integration
- setShowCreateModal(true)}
- >
+ setShowCreateModal(true)}>
Create Sandbox
@@ -203,18 +190,12 @@ const SandboxScreen: React.FC = () => {
styles.sandboxCard,
currentSandbox?.id === sandbox.id && styles.sandboxCardActive,
]}
- onPress={() => handleSelectSandbox(sandbox.id)}
- >
+ onPress={() => handleSelectSandbox(sandbox.id)}>
{sandbox.name}
-
+
{getEnvironmentLabel(sandbox.environment)}
@@ -223,14 +204,8 @@ const SandboxScreen: React.FC = () => {
style={[
styles.badge,
{ backgroundColor: `${getStatusColor(sandbox.status)}20` },
- ]}
- >
-
+ ]}>
+
{sandbox.status}
@@ -239,21 +214,15 @@ const SandboxScreen: React.FC = () => {
- handleToggleSandbox(sandbox.id, sandbox.status)
- }
- >
+ onPress={() => handleToggleSandbox(sandbox.id, sandbox.status)}>
{sandbox.status === SandboxStatus.ACTIVE ? '⏸' : '▶'}
handleDeleteSandbox(sandbox.id)}
- >
-
- ✕
-
+ onPress={() => handleDeleteSandbox(sandbox.id)}>
+ ✕
@@ -285,9 +254,7 @@ const SandboxScreen: React.FC = () => {
Transactions
-
- ${metrics.totalVolume.toFixed(2)}
-
+ ${metrics.totalVolume.toFixed(2)}
Total Volume
@@ -299,17 +266,13 @@ const SandboxScreen: React.FC = () => {
setShowTestDataModal(true)}
- >
+ onPress={() => setShowTestDataModal(true)}>
Generate Data
-
- Reset
-
+ onPress={handleResetSandbox}>
+ Reset
@@ -321,9 +284,7 @@ const SandboxScreen: React.FC = () => {
return (
-
- Test Subscriptions ({subscriptions.length})
-
+ Test Subscriptions ({subscriptions.length})
{subscriptions.slice(0, 5).map((sub) => (
@@ -341,9 +302,7 @@ const SandboxScreen: React.FC = () => {
))}
{subscriptions.length > 5 && (
-
- +{subscriptions.length - 5} more subscriptions
-
+ +{subscriptions.length - 5} more subscriptions
)}
);
@@ -354,8 +313,7 @@ const SandboxScreen: React.FC = () => {
visible={showCreateModal}
animationType="slide"
transparent
- onRequestClose={() => setShowCreateModal(false)}
- >
+ onRequestClose={() => setShowCreateModal(false)}>
Create Sandbox
@@ -378,15 +336,12 @@ const SandboxScreen: React.FC = () => {
styles.environmentOption,
selectedEnvironment === env && styles.environmentOptionActive,
]}
- onPress={() => setSelectedEnvironment(env)}
- >
+ onPress={() => setSelectedEnvironment(env)}>
+ selectedEnvironment === env && styles.environmentOptionTextActive,
+ ]}>
{getEnvironmentLabel(env)}
@@ -396,14 +351,10 @@ const SandboxScreen: React.FC = () => {
setShowCreateModal(false)}
- >
+ onPress={() => setShowCreateModal(false)}>
Cancel
-
+
Create
@@ -417,8 +368,7 @@ const SandboxScreen: React.FC = () => {
visible={showTestDataModal}
animationType="slide"
transparent
- onRequestClose={() => setShowTestDataModal(false)}
- >
+ onRequestClose={() => setShowTestDataModal(false)}>
Generate Test Data
@@ -436,14 +386,10 @@ const SandboxScreen: React.FC = () => {
setShowTestDataModal(false)}
- >
+ onPress={() => setShowTestDataModal(false)}>
Cancel
-
+
Generate
@@ -462,13 +408,10 @@ const SandboxScreen: React.FC = () => {
onRefresh={onRefresh}
tintColor={colors.primary}
/>
- }
- >
+ }>
Sandbox
-
- Test your integration in an isolated environment
-
+ Test your integration in an isolated environment
{isLoading && !refreshing ? (
diff --git a/src/screens/SupportDashboardScreen.tsx b/src/screens/SupportDashboardScreen.tsx
index 648dacf8..9fec92e7 100644
--- a/src/screens/SupportDashboardScreen.tsx
+++ b/src/screens/SupportDashboardScreen.tsx
@@ -25,11 +25,22 @@ const SupportDashboardScreen: React.FC = () => {
Priority: {ticket.priority}
Status: {ticket.status}
Subscription: {ticket.subscriptionId}
- {ticket.externalTicketId ? External: {ticket.externalTicketId} : null}
+ {ticket.externalTicketId ? (
+ External: {ticket.externalTicketId}
+ ) : null}
-
);
diff --git a/src/screens/TaxSettingsScreen.tsx b/src/screens/TaxSettingsScreen.tsx
index ed638108..a5d1c5a1 100644
--- a/src/screens/TaxSettingsScreen.tsx
+++ b/src/screens/TaxSettingsScreen.tsx
@@ -26,7 +26,11 @@ const TaxSettingsScreen: React.FC = () => {
};
const handleReport = () => {
- createReport('US-CA', new Date('2026-05-01T00:00:00.000Z'), new Date('2026-05-31T23:59:59.999Z'));
+ createReport(
+ 'US-CA',
+ new Date('2026-05-01T00:00:00.000Z'),
+ new Date('2026-05-31T23:59:59.999Z')
+ );
};
return (
diff --git a/src/services/FEATURE_GATING_README.md b/src/services/FEATURE_GATING_README.md
index 24cb418c..5d91b865 100644
--- a/src/services/FEATURE_GATING_README.md
+++ b/src/services/FEATURE_GATING_README.md
@@ -9,6 +9,7 @@ The Feature Gating System provides subscription-based access control for SubTrac
### Core Components
#### 1. **Feature Flags Service** (`featureFlags.ts`)
+
The main service that handles all feature access logic.
```typescript
@@ -29,6 +30,7 @@ if (result.hasAccess) {
```
**Key Methods:**
+
- `checkFeatureAccess()` - Check if user can access a feature
- `getAvailableFeatures()` - Get all features for a tier
- `getFeature()` - Get feature details
@@ -37,6 +39,7 @@ if (result.hasAccess) {
- `getRemainingUsage()` - Get remaining usage for a limit
#### 2. **Feature Configuration** (`config/features.ts`)
+
Defines all features, their tiers, limits, and settings.
```typescript
@@ -44,10 +47,7 @@ export const FEATURE_CONFIG: FeatureConfig = {
globalRolloutPercentage: 100,
abTestEnabled: true,
plans: {
- [SubscriptionTier.FREE]: [
- FeatureId.BASIC_SUBSCRIPTION_TRACKING,
- FeatureId.BASIC_ANALYTICS,
- ],
+ [SubscriptionTier.FREE]: [FeatureId.BASIC_SUBSCRIPTION_TRACKING, FeatureId.BASIC_ANALYTICS],
[SubscriptionTier.PREMIUM]: [
// ... premium features
],
@@ -68,6 +68,7 @@ export const FEATURE_CONFIG: FeatureConfig = {
```
#### 3. **React Hooks** (`hooks/useFeatureAccess.ts`)
+
Custom hooks for easy integration in React components.
```typescript
@@ -90,10 +91,11 @@ const { hasExceededLimit, getRemainingUsage } = useFeatureLimits();
#### 4. **UI Components**
##### FeatureGate Component
+
Conditionally renders content based on feature access.
```typescript
- {
// Handle feature updates
}}
@@ -127,10 +131,11 @@ Admin dashboard for managing features.
```
##### SubscriptionPlans Component
+
Display available subscription plans.
```typescript
- {
// Handle plan selection
@@ -141,6 +146,7 @@ Display available subscription plans.
## Subscription Tiers
### FREE Tier
+
- **Price:** $0/month
- **Max Subscriptions:** 5
- **Max Categories:** 3
@@ -150,6 +156,7 @@ Display available subscription plans.
- Push notifications
### BASIC Tier
+
- **Price:** $4.99/month
- **Max Subscriptions:** 25
- **Max Categories:** 8
@@ -159,6 +166,7 @@ Display available subscription plans.
- All FREE features
### PREMIUM Tier
+
- **Price:** $9.99/month
- **Max Subscriptions:** 100
- **Max Categories:** 20
@@ -169,6 +177,7 @@ Display available subscription plans.
- All BASIC features
### ENTERPRISE Tier
+
- **Price:** $29.99/month
- **Max Subscriptions:** Unlimited
- **Max Categories:** Unlimited
@@ -183,6 +192,7 @@ Display available subscription plans.
## Key Features
### 1. Tier-Based Access Control
+
Features are assigned to subscription tiers. Users can only access features their tier includes.
```typescript
@@ -191,6 +201,7 @@ const feature = FEATURE_CONFIG.features[FeatureId.ADVANCED_ANALYTICS];
```
### 2. Gradual Rollout
+
Roll out features to a percentage of users within a tier.
```typescript
@@ -201,11 +212,13 @@ const feature = {
```
**How it works:**
+
- Uses deterministic hashing of user IDs
- Ensures consistent experience across sessions
- Prevents flicker when user reloads
### 3. A/B Testing
+
Test feature variations with different user groups.
```typescript
@@ -219,10 +232,12 @@ const { abTestGroup, isInAbTest } = await featureFlagsService.checkFeatureAccess
```
**Storage:**
+
- A/B test assignments are persisted in AsyncStorage
- Consistent assignment across app restarts
### 4. Feature Dependencies
+
Features can depend on other features being available first.
```typescript
@@ -234,6 +249,7 @@ const feature = {
```
### 5. Usage Limits
+
Enforce per-tier limits on feature usage.
```typescript
@@ -250,6 +266,7 @@ const exceeded = featureFlagsService.hasExceededLimit(
## Usage Examples
### Example 1: Gating a Feature in a Screen
+
```typescript
import { useFeatureAccess } from '../hooks/useFeatureAccess';
import { FeatureGate } from '../components/common/FeatureGate';
@@ -257,7 +274,7 @@ import { FeatureId } from '../types/feature';
export const AnalyticsScreen = () => {
return (
- }
>
@@ -268,6 +285,7 @@ export const AnalyticsScreen = () => {
```
### Example 2: Checking Multiple Features
+
```typescript
import { useMultipleFeatureAccess } from '../hooks/useFeatureAccess';
@@ -291,6 +309,7 @@ export const PaymentMethodsScreen = () => {
```
### Example 3: Enforcing Usage Limits
+
```typescript
import { useFeatureLimits } from '../hooks/useFeatureAccess';
import { FeatureLimitGate } from '../components/common/FeatureGate';
@@ -322,6 +341,7 @@ export const AddSubscriptionButton = () => {
```
### Example 4: Admin Feature Management
+
```typescript
import { FeatureManagement } from '../components/admin/FeatureManagement';
@@ -340,6 +360,7 @@ export const AdminPanel = () => {
## Best Practices
### 1. Always Use Feature Gates for New Features
+
Wrap new features in FeatureGate components, even if all tiers initially have access.
```typescript
@@ -349,6 +370,7 @@ Wrap new features in FeatureGate components, even if all tiers initially have ac
```
### 2. Provide Clear Upgrade Prompts
+
When a feature is not accessible, show users what they need to upgrade to.
```typescript
@@ -359,6 +381,7 @@ When a feature is not accessible, show users what they need to upgrade to.
```
### 3. Test with Different Tiers
+
During development, test your features with each subscription tier.
```typescript
@@ -370,20 +393,22 @@ useUserStore.setState({ subscriptionTier: SubscriptionTier.PREMIUM });
```
### 4. Monitor Rollout Percentages
+
Start with low rollout percentages and gradually increase.
```typescript
// Day 1: 10% of premium users
-rolloutPercentage: 10
+rolloutPercentage: 10;
// Day 3: 50% of premium users
-rolloutPercentage: 50
+rolloutPercentage: 50;
// Day 7: 100% rollout
-rolloutPercentage: 100
+rolloutPercentage: 100;
```
### 5. Set Up Dependencies Carefully
+
Document feature dependencies to prevent access issues.
```typescript
@@ -396,6 +421,7 @@ const dependencies = {
## Adding New Features
### Step 1: Add Feature ID
+
```typescript
// src/types/feature.ts
export enum FeatureId {
@@ -405,6 +431,7 @@ export enum FeatureId {
```
### Step 2: Configure Feature
+
```typescript
// src/config/features.ts
[FeatureId.NEW_FEATURE]: {
@@ -420,6 +447,7 @@ export enum FeatureId {
```
### Step 3: Add to Plans
+
```typescript
// src/config/features.ts
plans: {
@@ -432,6 +460,7 @@ plans: {
```
### Step 4: Use in Components
+
```typescript
@@ -441,18 +470,21 @@ plans: {
## Troubleshooting
### Feature Not Showing Up
+
1. Check if feature is enabled: `feature.enabled === true`
2. Verify user tier includes feature: `tierAccess.includes(userTier)`
3. Check rollout percentage: `rolloutPercentage >= 100` during testing
4. Check dependencies are met
### A/B Test Not Working
+
1. Ensure `abTestEnabled: true` in FEATURE_CONFIG
2. Verify A/B test groups are defined
3. Check AsyncStorage permissions
4. Verify user ID is set: `featureFlagsService.setUserId(userId)`
### Usage Limits Not Enforcing
+
1. Check limit key exists in `getFeatureLimits()`
2. Verify current usage calculation is accurate
3. Ensure FeatureLimitGate is wrapping the component
@@ -463,12 +495,15 @@ plans: {
### FeatureFlagsService
#### `setUserId(userId: string): void`
+
Set the current user ID for rollout and A/B test calculations.
#### `async checkFeatureAccess(featureId: FeatureId, userTier: SubscriptionTier, userId?: string): Promise`
+
Check if a user has access to a feature.
**Returns:**
+
```typescript
{
hasAccess: boolean;
@@ -480,21 +515,27 @@ Check if a user has access to a feature.
```
#### `getAvailableFeatures(userTier: SubscriptionTier): FeatureId[]`
+
Get all features available to a tier.
#### `getFeature(featureId: FeatureId): FeatureFlag | null`
+
Get feature details.
#### `getAllFeatures(): Record`
+
Get all features.
#### `getFeatureLimits(userTier: SubscriptionTier): Record`
+
Get usage limits for a tier. Returns -1 for unlimited.
#### `hasExceededLimit(userTier: SubscriptionTier, limitKey: string, currentUsage: number): boolean`
+
Check if a usage limit is exceeded.
#### `getRemainingUsage(userTier: SubscriptionTier, limitKey: string, currentUsage: number): number`
+
Get remaining usage before limit is hit. Returns -1 for unlimited.
## Related Files
diff --git a/src/services/__tests__/slaService.test.ts b/src/services/__tests__/slaService.test.ts
index 5db39b5d..7346c4e9 100644
--- a/src/services/__tests__/slaService.test.ts
+++ b/src/services/__tests__/slaService.test.ts
@@ -19,12 +19,7 @@ import {
buildSlaDashboardReport,
SLA_DEFAULTS,
} from '../slaService';
-import type {
- SlaAvailabilityEvent,
- SlaBreach,
- SlaConfig,
- SlaStatus,
-} from '../../types/sla';
+import type { SlaAvailabilityEvent, SlaBreach, SlaConfig, SlaStatus } from '../../types/sla';
// ---------------------------------------------------------------------------
// Helpers
@@ -118,28 +113,36 @@ describe('normalizeSlaConfig', () => {
describe('calculateAvailabilityImpact', () => {
it('healthy state has zero impact', () => {
- const impact = calculateAvailabilityImpact(makeEvent({ state: 'healthy', durationSeconds: 3_600 }));
+ const impact = calculateAvailabilityImpact(
+ makeEvent({ state: 'healthy', durationSeconds: 3_600 })
+ );
expect(impact.downtimeSeconds).toBe(0);
expect(impact.partialOutageSeconds).toBe(0);
expect(impact.maintenanceSeconds).toBe(0);
});
it('full_outage counts 100% of duration as downtime', () => {
- const impact = calculateAvailabilityImpact(makeEvent({ state: 'full_outage', durationSeconds: 3_600 }));
+ const impact = calculateAvailabilityImpact(
+ makeEvent({ state: 'full_outage', durationSeconds: 3_600 })
+ );
expect(impact.downtimeSeconds).toBe(3_600 * 1); // weight = 1
expect(impact.partialOutageSeconds).toBe(0);
expect(impact.maintenanceSeconds).toBe(0);
});
it('partial_outage counts 50% of duration as downtime', () => {
- const impact = calculateAvailabilityImpact(makeEvent({ state: 'partial_outage', durationSeconds: 3_600 }));
+ const impact = calculateAvailabilityImpact(
+ makeEvent({ state: 'partial_outage', durationSeconds: 3_600 })
+ );
expect(impact.downtimeSeconds).toBe(3_600 * 0.5); // weight = 0.5
expect(impact.partialOutageSeconds).toBe(3_600);
expect(impact.maintenanceSeconds).toBe(0);
});
it('maintenance window has zero downtime impact (SLA exclusion)', () => {
- const impact = calculateAvailabilityImpact(makeEvent({ state: 'maintenance', durationSeconds: 7_200 }));
+ const impact = calculateAvailabilityImpact(
+ makeEvent({ state: 'maintenance', durationSeconds: 7_200 })
+ );
expect(impact.downtimeSeconds).toBe(0);
expect(impact.partialOutageSeconds).toBe(0);
expect(impact.maintenanceSeconds).toBe(7_200);
diff --git a/src/services/__tests__/walletService.test.ts b/src/services/__tests__/walletService.test.ts
index 60ffa134..7aed62f5 100644
--- a/src/services/__tests__/walletService.test.ts
+++ b/src/services/__tests__/walletService.test.ts
@@ -358,7 +358,14 @@ describe('WalletServiceManager', () => {
});
try {
- await mgr.createSablierStream('0xToken', '10', Date.now(), Date.now() + 86400000, '0xRecipient', 1);
+ await mgr.createSablierStream(
+ '0xToken',
+ '10',
+ Date.now(),
+ Date.now() + 86400000,
+ '0xRecipient',
+ 1
+ );
fail('expected to throw');
} catch (e) {
expect(e).toBeInstanceOf(WalletError);
@@ -382,7 +389,12 @@ describe('WalletServiceManager', () => {
it('preserves cause stack when cause is an Error', () => {
const cause = new Error('rpc timeout');
- const err = new WalletError(WalletErrorCode.UNKNOWN, 'Something went wrong.', undefined, cause);
+ const err = new WalletError(
+ WalletErrorCode.UNKNOWN,
+ 'Something went wrong.',
+ undefined,
+ cause
+ );
expect(err.stack).toContain('Caused by:');
});
});
diff --git a/src/services/accountingExport.ts b/src/services/accountingExport.ts
index 58ce396b..a6b8c440 100644
--- a/src/services/accountingExport.ts
+++ b/src/services/accountingExport.ts
@@ -304,9 +304,7 @@ function filterSubscriptions(
subscriptions: Subscription[],
options: Pick
): Subscription[] {
- let result = options.includeInactive
- ? subscriptions
- : subscriptions.filter((s) => s.isActive);
+ let result = options.includeInactive ? subscriptions : subscriptions.filter((s) => s.isActive);
if (options.dateFrom !== undefined) {
result = result.filter(
@@ -354,9 +352,7 @@ export function buildAccountingExportCsv(
if (options.includeDeferredRevenue) {
headers.push('DeferredRevenue');
selected.forEach((s, i) => {
- rows[i].push(
- Number(options.deferredRevenueMap?.[s.id] ?? 0).toFixed(2)
- );
+ rows[i].push(Number(options.deferredRevenueMap?.[s.id] ?? 0).toFixed(2));
});
}
diff --git a/src/services/analyticsService.ts b/src/services/analyticsService.ts
index e6b5ee5b..2786009c 100644
--- a/src/services/analyticsService.ts
+++ b/src/services/analyticsService.ts
@@ -32,8 +32,6 @@ export interface SubscriptionAnalyticsReport {
mrr: number;
arr: number;
ltv: number;
- arpu: number;
- subscriberCount: number;
churn: ChurnMetrics;
revenueTrend: RevenuePoint[];
cohorts: CohortMetric[];
@@ -43,9 +41,13 @@ export interface SubscriptionAnalyticsReport {
const MONTHS_PER_YEAR = 12;
const WEEKS_PER_MONTH = 4.345;
-export const toMonthlyRevenue = (subscription: Pick): number => {
- if (subscription.billingCycle === BillingCycle.YEARLY) return subscription.price / MONTHS_PER_YEAR;
- if (subscription.billingCycle === BillingCycle.WEEKLY) return subscription.price * WEEKS_PER_MONTH;
+export const toMonthlyRevenue = (
+ subscription: Pick
+): number => {
+ if (subscription.billingCycle === BillingCycle.YEARLY)
+ return subscription.price / MONTHS_PER_YEAR;
+ if (subscription.billingCycle === BillingCycle.WEEKLY)
+ return subscription.price * WEEKS_PER_MONTH;
return subscription.price;
};
@@ -70,11 +72,15 @@ export const calculateSubscriptionAnalytics = (
(sum, subscription) => sum + toMonthlyRevenue(subscription),
0
);
- const netChurnRate = mrr + churnedRevenue > 0 ? Math.max(0, (churnedRevenue - expansionRevenue) / (mrr + churnedRevenue)) : 0;
+ const netChurnRate =
+ mrr + churnedRevenue > 0
+ ? Math.max(0, (churnedRevenue - expansionRevenue) / (mrr + churnedRevenue))
+ : 0;
const averageMonthlyRevenue = active.length ? mrr / active.length : 0;
- const ltv = grossChurnRate > 0 ? averageMonthlyRevenue / grossChurnRate : averageMonthlyRevenue * MONTHS_PER_YEAR;
- const arpu = active.length > 0 ? mrr / active.length : 0;
- const subscriberCount = active.length;
+ const ltv =
+ grossChurnRate > 0
+ ? averageMonthlyRevenue / grossChurnRate
+ : averageMonthlyRevenue * MONTHS_PER_YEAR;
const cohorts = Array.from(
subscriptions.reduce((map, subscription) => {
@@ -122,8 +128,6 @@ export const calculateSubscriptionAnalytics = (
mrr,
arr,
ltv,
- arpu,
- subscriberCount,
churn: {
grossChurnRate,
netChurnRate,
diff --git a/src/services/calendarService.ts b/src/services/calendarService.ts
index d1104fc1..12c8ccab 100644
--- a/src/services/calendarService.ts
+++ b/src/services/calendarService.ts
@@ -258,7 +258,10 @@ function escapeICalText(text: string): string {
function formatICalDate(isoString: string): string {
const d = new Date(isoString);
- return d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
+ return d
+ .toISOString()
+ .replace(/[-:]/g, '')
+ .replace(/\.\d{3}/, '');
}
export function generateICalendarExport(
@@ -319,7 +322,7 @@ export function generateICalendarExport(
export function detectScheduleConflicts(
subscriptions: Subscription[],
- existingEvents: CalendarSyncedEvent[]
+ _existingEvents: CalendarSyncedEvent[]
): ScheduleConflict[] {
const conflictsByDate = new Map();
@@ -362,9 +365,7 @@ export function calculateProratedAdjustment(
const msPerDay = 24 * 60 * 60 * 1000;
const daysRemaining = Math.max(0, Math.round((nextBilling.getTime() - now.getTime()) / msPerDay));
- const proratedAmount = Number(
- ((subscription.price / daysInCycle) * daysRemaining).toFixed(2)
- );
+ const proratedAmount = Number(((subscription.price / daysInCycle) * daysRemaining).toFixed(2));
return {
originalAmount: subscription.price,
@@ -464,7 +465,10 @@ export function isDSTTransitionPeriod(date: Date, timezone: string): boolean {
return currentOffset !== laterOffset;
}
-export function adjustForDST(event: CalendarEventTemplate, timezone: string): CalendarEventTemplate {
+export function adjustForDST(
+ event: CalendarEventTemplate,
+ timezone: string
+): CalendarEventTemplate {
const startDate = new Date(event.startAt);
if (isDSTTransitionPeriod(startDate, timezone)) {
const offset = getTimezoneOffset(startDate, timezone);
diff --git a/src/services/groupService.ts b/src/services/groupService.ts
index e91ed249..81434138 100644
--- a/src/services/groupService.ts
+++ b/src/services/groupService.ts
@@ -80,7 +80,8 @@ export const joinGroupWithInvite = (
const invite = group.invites.find((entry) => entry.id === inviteId);
if (!invite || invite.status !== 'pending') throw new Error('Invite is not available');
if (invite.expiresAt.getTime() < Date.now()) throw new Error('Invite has expired');
- if (group.members.length >= group.planSharingRules.seatLimit) throw new Error('Member limit reached');
+ if (group.members.length >= group.planSharingRules.seatLimit)
+ throw new Error('Member limit reached');
const member: GroupMember = {
address: invite.inviteeAddress,
@@ -102,8 +103,12 @@ export const joinGroupWithInvite = (
};
};
-export const removeGroupMember = (group: SubscriptionGroup, memberAddress: string): SubscriptionGroup => {
- if (memberAddress === group.owner) throw new Error('Transfer ownership before removing the owner');
+export const removeGroupMember = (
+ group: SubscriptionGroup,
+ memberAddress: string
+): SubscriptionGroup => {
+ if (memberAddress === group.owner)
+ throw new Error('Transfer ownership before removing the owner');
const member = group.members.find((entry) => entry.address === memberAddress);
if (!member) return group;
diff --git a/src/services/notificationService.ts b/src/services/notificationService.ts
index 3dda8c31..a99f19f2 100644
--- a/src/services/notificationService.ts
+++ b/src/services/notificationService.ts
@@ -295,9 +295,7 @@ export async function presentDunningWarningNotification(
});
}
-export async function presentDunningSuspendedNotification(
- sub: Subscription
-): Promise {
+export async function presentDunningSuspendedNotification(sub: Subscription): Promise {
if (!isNotificationsSupported()) return;
const status = await getPermissionStatus();
if (status !== Notifications.PermissionStatus.GRANTED) return;
@@ -317,9 +315,7 @@ export async function presentDunningSuspendedNotification(
});
}
-export async function presentDunningCancelledNotification(
- sub: Subscription
-): Promise {
+export async function presentDunningCancelledNotification(sub: Subscription): Promise {
if (!isNotificationsSupported()) return;
const status = await getPermissionStatus();
if (status !== Notifications.PermissionStatus.GRANTED) return;
@@ -339,9 +335,7 @@ export async function presentDunningCancelledNotification(
});
}
-export async function presentDunningRecoveryNotification(
- sub: Subscription
-): Promise {
+export async function presentDunningRecoveryNotification(sub: Subscription): Promise {
if (!isNotificationsSupported()) return;
const status = await getPermissionStatus();
if (status !== Notifications.PermissionStatus.GRANTED) return;
diff --git a/src/services/oraclePriceService.ts b/src/services/oraclePriceService.ts
index 4bf2f3ba..135048a8 100644
--- a/src/services/oraclePriceService.ts
+++ b/src/services/oraclePriceService.ts
@@ -28,14 +28,25 @@ export class OraclePriceService {
const cacheKey = `${token}:${quote}`;
const cached = this.cache.get(cacheKey);
if (cached && Date.now() - cached.fetchedAt < cached.ttl) {
- return { token, quote, price: cached.price, decimals: 7, timestamp: cached.fetchedAt, source: 'primary' };
+ return {
+ token,
+ quote,
+ price: cached.price,
+ decimals: 7,
+ timestamp: cached.fetchedAt,
+ source: 'primary',
+ };
}
try {
const response = await fetch(`${this.baseUrl}/price/${token}/${quote}`);
if (!response.ok) return null;
const data: OraclePrice = await response.json();
- this.cache.set(cacheKey, { price: data.price, fetchedAt: Date.now(), ttl: this.defaultTtlMs });
+ this.cache.set(cacheKey, {
+ price: data.price,
+ fetchedAt: Date.now(),
+ ttl: this.defaultTtlMs,
+ });
return data;
} catch {
return null;
@@ -52,9 +63,10 @@ export class OraclePriceService {
const cryptoAmount = subscription.cryptoAmount ?? subscription.price;
const fiatPrice = (cryptoAmount * oraclePrice.price) / 10 ** oraclePrice.decimals;
- const deviationBps = subscription.price > 0
- ? Math.abs(Math.round(((fiatPrice - subscription.price) / subscription.price) * 10000))
- : 0;
+ const deviationBps =
+ subscription.price > 0
+ ? Math.abs(Math.round(((fiatPrice - subscription.price) / subscription.price) * 10000))
+ : 0;
return {
...subscription,
diff --git a/src/services/sandbox/developerOnboardingService.ts b/src/services/sandbox/developerOnboardingService.ts
index 754d10c8..e0e68a99 100644
--- a/src/services/sandbox/developerOnboardingService.ts
+++ b/src/services/sandbox/developerOnboardingService.ts
@@ -1,9 +1,5 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
-import {
- DeveloperProfile,
- DeveloperOnboardingStep,
- SandboxEnvironment,
-} from '../../types/sandbox';
+import { DeveloperProfile, DeveloperOnboardingStep, SandboxEnvironment } from '../../types/sandbox';
import { sandboxService } from './sandboxService';
import { apiKeyService } from './apiKeyService';
diff --git a/src/services/sandbox/developerPortalService.ts b/src/services/sandbox/developerPortalService.ts
index 4aad85a8..31e74844 100644
--- a/src/services/sandbox/developerPortalService.ts
+++ b/src/services/sandbox/developerPortalService.ts
@@ -591,9 +591,7 @@ class DeveloperPortalService {
return INTEGRATION_GUIDES.find((g) => g.id === id) || null;
}
- getIntegrationGuidesByDifficulty(
- difficulty: IntegrationGuide['difficulty']
- ): IntegrationGuide[] {
+ getIntegrationGuidesByDifficulty(difficulty: IntegrationGuide['difficulty']): IntegrationGuide[] {
return INTEGRATION_GUIDES.filter((g) => g.difficulty === difficulty);
}
@@ -639,9 +637,7 @@ class DeveloperPortalService {
}
for (const [id] of this.developers) {
- const stepsData = await AsyncStorage.getItem(
- `${DEVELOPER_STORAGE_KEY}-onboarding-${id}`
- );
+ const stepsData = await AsyncStorage.getItem(`${DEVELOPER_STORAGE_KEY}-onboarding-${id}`);
if (stepsData) {
this.onboardingSteps.set(id, JSON.parse(stepsData));
} else {
diff --git a/src/services/sandbox/documentationService.ts b/src/services/sandbox/documentationService.ts
index 57d21f5d..ba77ac53 100644
--- a/src/services/sandbox/documentationService.ts
+++ b/src/services/sandbox/documentationService.ts
@@ -13,12 +13,14 @@ const INTEGRATION_GUIDES: IntegrationGuide[] = [
{
id: 'step-1',
title: 'Create a Developer Account',
- content: 'Sign up for a SubTrackr developer account to access the API and sandbox environment.',
+ content:
+ 'Sign up for a SubTrackr developer account to access the API and sandbox environment.',
},
{
id: 'step-2',
title: 'Generate API Keys',
- content: 'Navigate to the API Keys section in your developer portal and generate a new key pair.',
+ content:
+ 'Navigate to the API Keys section in your developer portal and generate a new key pair.',
},
{
id: 'step-3',
diff --git a/src/services/sandbox/sandboxService.ts b/src/services/sandbox/sandboxService.ts
index 59b29f2f..98186ce8 100644
--- a/src/services/sandbox/sandboxService.ts
+++ b/src/services/sandbox/sandboxService.ts
@@ -57,10 +57,7 @@ const ENV_FEATURES: Record = {
'invoice_generate',
'export_data',
],
- [SandboxEnvironment.TESTING]: [
- 'subscription_read',
- 'analytics_read',
- ],
+ [SandboxEnvironment.TESTING]: ['subscription_read', 'analytics_read'],
[SandboxEnvironment.PRODUCTION]: [
'subscription_create',
'subscription_read',
@@ -161,7 +158,10 @@ class SandboxService {
try {
const stored = await AsyncStorage.getItem(SANDBOX_DATA_KEY);
if (stored) {
- const parsed = JSON.parse(stored) as Record;
+ const parsed = JSON.parse(stored) as Record<
+ string,
+ { testSubscriptions: TestSubscription[] }
+ >;
for (const [env, data] of Object.entries(parsed)) {
const envData = this.environmentData.get(env as SandboxEnvironment);
if (envData && data.testSubscriptions) {
@@ -246,30 +246,52 @@ class SandboxService {
private generateTestDataForEnvironment(environment: SandboxEnvironment): TestSubscription[] {
const testNames: Record = {
[SandboxEnvironment.DEVELOPMENT]: [
- 'Netflix Premium', 'Spotify Family', 'Adobe Creative Cloud',
- 'GitHub Teams', 'Figma Professional', 'Notion Team',
- 'Slack Business+', 'Zoom Pro', 'Dropbox Business', 'Microsoft 365',
+ 'Netflix Premium',
+ 'Spotify Family',
+ 'Adobe Creative Cloud',
+ 'GitHub Teams',
+ 'Figma Professional',
+ 'Notion Team',
+ 'Slack Business+',
+ 'Zoom Pro',
+ 'Dropbox Business',
+ 'Microsoft 365',
],
[SandboxEnvironment.STAGING]: [
- 'Disney+ Bundle', 'Apple One', 'YouTube Premium',
- 'Atlassian Cloud', 'Linear Pro', 'Vercel Pro',
- 'Netlify Pro', 'Heroku Basic', 'DigitalOcean', 'AWS Free Tier',
+ 'Disney+ Bundle',
+ 'Apple One',
+ 'YouTube Premium',
+ 'Atlassian Cloud',
+ 'Linear Pro',
+ 'Vercel Pro',
+ 'Netlify Pro',
+ 'Heroku Basic',
+ 'DigitalOcean',
+ 'AWS Free Tier',
],
[SandboxEnvironment.TESTING]: [
- 'Test Service A', 'Test Service B', 'Test Service C',
- 'Test Service D', 'Test Service E',
+ 'Test Service A',
+ 'Test Service B',
+ 'Test Service C',
+ 'Test Service D',
+ 'Test Service E',
],
[SandboxEnvironment.PRODUCTION]: [
- 'Netflix Premium', 'Spotify Family', 'Adobe Creative Cloud',
- 'GitHub Teams', 'Figma Professional',
+ 'Netflix Premium',
+ 'Spotify Family',
+ 'Adobe Creative Cloud',
+ 'GitHub Teams',
+ 'Figma Professional',
],
};
const prices: Record = {
- [SandboxEnvironment.DEVELOPMENT]: [15.99, 14.99, 54.99, 4.00, 12.00, 8.00, 12.50, 13.33, 9.99, 6.00],
- [SandboxEnvironment.STAGING]: [13.99, 16.95, 11.99, 7.75, 8.00, 20.00, 19.00, 7.00, 5.00, 0.00],
+ [SandboxEnvironment.DEVELOPMENT]: [
+ 15.99, 14.99, 54.99, 4.0, 12.0, 8.0, 12.5, 13.33, 9.99, 6.0,
+ ],
+ [SandboxEnvironment.STAGING]: [13.99, 16.95, 11.99, 7.75, 8.0, 20.0, 19.0, 7.0, 5.0, 0.0],
[SandboxEnvironment.TESTING]: [9.99, 19.99, 29.99, 4.99, 14.99],
- [SandboxEnvironment.PRODUCTION]: [15.99, 14.99, 54.99, 4.00, 12.00],
+ [SandboxEnvironment.PRODUCTION]: [15.99, 14.99, 54.99, 4.0, 12.0],
};
const names = testNames[environment];
@@ -367,7 +389,9 @@ class SandboxService {
this.getCurrentEnvData().isolatedStorage.clear();
}
- async checkRateLimit(apiKeyId: string): Promise<{ allowed: boolean; remaining: number; retryAfter?: number }> {
+ async checkRateLimit(
+ apiKeyId: string
+ ): Promise<{ allowed: boolean; remaining: number; retryAfter?: number }> {
const envData = this.getCurrentEnvData();
const rateLimit = this.config.rateLimit;
const now = Date.now();
@@ -406,7 +430,10 @@ class SandboxService {
return { valid: true };
}
- getEnvironmentStats(): Record {
+ getEnvironmentStats(): Record<
+ SandboxEnvironment,
+ { subscriptionCount: number; storageKeys: number }
+ > {
const stats: Record = {};
for (const [env, envData] of this.environmentData.entries()) {
stats[env] = {
diff --git a/src/services/sandbox/testDataGenerator.ts b/src/services/sandbox/testDataGenerator.ts
index 87d63994..ee2fc542 100644
--- a/src/services/sandbox/testDataGenerator.ts
+++ b/src/services/sandbox/testDataGenerator.ts
@@ -1,12 +1,5 @@
-import {
- TestDataConfig,
- SandboxEnvironment,
-} from '../../types/sandbox';
-import {
- Subscription,
- SubscriptionCategory,
- BillingCycle,
-} from '../../types/subscription';
+import { TestDataConfig, SandboxEnvironment } from '../../types/sandbox';
+import { Subscription, SubscriptionCategory, BillingCycle } from '../../types/subscription';
const DEFAULT_TEST_CONFIG: TestDataConfig = {
subscriptions: 10,
@@ -19,14 +12,57 @@ const DEFAULT_TEST_CONFIG: TestDataConfig = {
};
const SAMPLE_SUBSCRIPTION_NAMES: Record = {
- [SubscriptionCategory.STREAMING]: ['Netflix', 'Disney+', 'Hulu', 'HBO Max', 'Apple TV+', 'Paramount+'],
- [SubscriptionCategory.SOFTWARE]: ['Adobe Creative Cloud', 'Microsoft 365', 'Slack', 'Notion', 'Figma'],
- [SubscriptionCategory.GAMING]: ['Xbox Game Pass', 'PlayStation Plus', 'Nintendo Online', 'EA Play', 'Steam'],
+ [SubscriptionCategory.STREAMING]: [
+ 'Netflix',
+ 'Disney+',
+ 'Hulu',
+ 'HBO Max',
+ 'Apple TV+',
+ 'Paramount+',
+ ],
+ [SubscriptionCategory.SOFTWARE]: [
+ 'Adobe Creative Cloud',
+ 'Microsoft 365',
+ 'Slack',
+ 'Notion',
+ 'Figma',
+ ],
+ [SubscriptionCategory.GAMING]: [
+ 'Xbox Game Pass',
+ 'PlayStation Plus',
+ 'Nintendo Online',
+ 'EA Play',
+ 'Steam',
+ ],
[SubscriptionCategory.PRODUCTIVITY]: ['Todoist', 'Evernote', 'Asana', 'Trello', 'Monday.com'],
- [SubscriptionCategory.FITNESS]: ['Peloton', 'Fitbit Premium', 'Strava', 'MyFitnessPal', 'Headspace'],
- [SubscriptionCategory.EDUCATION]: ['Coursera', 'Udemy', 'MasterClass', 'Duolingo Plus', 'Skillshare'],
- [SubscriptionCategory.FINANCE]: ['Mint Premium', 'YNAB', 'Robinhood Gold', 'Bloomberg', 'TradingView'],
- [SubscriptionCategory.OTHER]: ['Amazon Prime', 'Costco', 'Sam\'s Club', 'Box Subscription', 'Custom Service'],
+ [SubscriptionCategory.FITNESS]: [
+ 'Peloton',
+ 'Fitbit Premium',
+ 'Strava',
+ 'MyFitnessPal',
+ 'Headspace',
+ ],
+ [SubscriptionCategory.EDUCATION]: [
+ 'Coursera',
+ 'Udemy',
+ 'MasterClass',
+ 'Duolingo Plus',
+ 'Skillshare',
+ ],
+ [SubscriptionCategory.FINANCE]: [
+ 'Mint Premium',
+ 'YNAB',
+ 'Robinhood Gold',
+ 'Bloomberg',
+ 'TradingView',
+ ],
+ [SubscriptionCategory.OTHER]: [
+ 'Amazon Prime',
+ 'Costco',
+ "Sam's Club",
+ 'Box Subscription',
+ 'Custom Service',
+ ],
};
const CRYPTO_TOKENS = ['ETH', 'USDC', 'DAI', 'WBTC', 'MATIC'];
@@ -60,7 +96,9 @@ class TestDataGenerator {
category,
price: this.randomPrice(fullConfig.priceRange.min, fullConfig.priceRange.max),
currency: this.randomFromArray(fullConfig.currencies),
- billingCycle: this.randomFromArray(fullConfig.billingCycles as BillingCycle[]) as BillingCycle,
+ billingCycle: this.randomFromArray(
+ fullConfig.billingCycles as BillingCycle[]
+ ) as BillingCycle,
nextBillingDate: this.randomFutureDate(),
isActive,
notificationsEnabled: Math.random() > 0.3,
@@ -119,12 +157,12 @@ class TestDataGenerator {
return new Date(now.getTime() - daysToSubtract * 24 * 60 * 60 * 1000);
}
- generateUsageData(subscriptionCount: number): Array<{
+ generateUsageData(subscriptionCount: number): {
date: Date;
requests: number;
errors: number;
avgResponseTime: number;
- }> {
+ }[] {
const data = [];
const now = new Date();
diff --git a/src/services/taxService.ts b/src/services/taxService.ts
index c1feed90..2f7cbd07 100644
--- a/src/services/taxService.ts
+++ b/src/services/taxService.ts
@@ -15,10 +15,7 @@ const isRateActive = (rate: TaxRate, transactionDate: Date): boolean => {
);
};
-export const calculateTaxAmount = (
- config: TaxConfig,
- input: TaxCalculationInput
-): TaxAmount => {
+export const calculateTaxAmount = (config: TaxConfig, input: TaxCalculationInput): TaxAmount => {
const exemption = config.exemptions.find(
(entry) =>
entry.region === input.region &&
@@ -31,7 +28,7 @@ export const calculateTaxAmount = (
);
const reverseCharge = config.reverseChargeRegions.includes(input.region);
- const rateBps = exemption || reverseCharge ? 0 : rate?.rateBps ?? 0;
+ const rateBps = exemption || reverseCharge ? 0 : (rate?.rateBps ?? 0);
const tax = Number(((input.amount * rateBps) / 10_000).toFixed(2));
return {
@@ -40,7 +37,7 @@ export const calculateTaxAmount = (
subtotal: input.amount,
tax,
total: Number((input.amount + tax).toFixed(2)),
- taxType: reverseCharge ? 'reverse_charge' : rate?.taxType ?? 'sales_tax',
+ taxType: reverseCharge ? 'reverse_charge' : (rate?.taxType ?? 'sales_tax'),
rateBps,
exempt: Boolean(exemption),
};
diff --git a/src/services/walletService.ts b/src/services/walletService.ts
index dfa954e0..90cfbb7a 100644
--- a/src/services/walletService.ts
+++ b/src/services/walletService.ts
@@ -1,5 +1,5 @@
import { ethers } from 'ethers';
-import { Framework, SFError } from '@superfluid-finance/sdk-core';
+import { Framework } from '@superfluid-finance/sdk-core';
import { ERC20__factory, getContractAddress } from '../contracts';
import { getEvmRpcUrl } from '../config/evm';
@@ -20,25 +20,35 @@ import {
// ── Structured error handling ──────────────────────────────────────
-import {
- AppError,
- WalletError,
- WalletErrorCode,
- ContractError,
- ContractErrorCode,
- NetworkError,
- NetworkErrorCode,
-} from '../errors';
-
-export {
- AppError,
- WalletError,
- WalletErrorCode,
- ContractError,
- ContractErrorCode,
- NetworkError,
- NetworkErrorCode,
-};
+export enum WalletErrorCode {
+ NOT_CONNECTED = 'WALLET_NOT_CONNECTED',
+ USER_REJECTED = 'USER_REJECTED',
+ NETWORK_MISMATCH = 'NETWORK_MISMATCH',
+ BALANCE_FETCH_FAILED = 'BALANCE_FETCH_FAILED',
+ GAS_ESTIMATION_FAILED = 'GAS_ESTIMATION_FAILED',
+ STREAM_CREATION_FAILED = 'STREAM_CREATION_FAILED',
+ APPROVAL_FAILED = 'APPROVAL_FAILED',
+ INVALID_PARAMS = 'INVALID_PARAMS',
+ UNKNOWN = 'UNKNOWN',
+}
+
+export class WalletError extends Error {
+ readonly code: WalletErrorCode;
+ readonly userMessage: string;
+ readonly recovery?: string;
+
+ constructor(code: WalletErrorCode, userMessage: string, recovery?: string, cause?: unknown) {
+ super(userMessage);
+ this.name = 'WalletError';
+ this.code = code;
+ this.userMessage = userMessage;
+ this.recovery = recovery;
+ // Preserve original stack if available
+ if (cause instanceof Error && cause.stack) {
+ this.stack = `${this.stack}\nCaused by: ${cause.stack}`;
+ }
+ }
+}
// ── Error rate tracker ─────────────────────────────────────────────
@@ -254,11 +264,11 @@ export class WalletServiceManager {
return balances;
} catch (error) {
- throw new NetworkError(
- NetworkErrorCode.RPC_ERROR,
+ throw toWalletError(
+ error,
+ WalletErrorCode.BALANCE_FETCH_FAILED,
'Unable to fetch token balances.',
- 'Check your network connection and try again.',
- error
+ 'Check your network connection and try again.'
);
}
}
@@ -277,11 +287,11 @@ export class WalletServiceManager {
provider = this.getProvider(chainId);
gasPrice = await this.resolveGasPrice(provider);
} catch (error) {
- throw new NetworkError(
- NetworkErrorCode.RPC_ERROR,
+ throw toWalletError(
+ error,
+ WalletErrorCode.GAS_ESTIMATION_FAILED,
'Could not retrieve gas price.',
- 'Check your network connection and try again.',
- error
+ 'Check your network connection and try again.'
);
}
@@ -383,51 +393,35 @@ export class WalletServiceManager {
chainId: number
): Promise {
const signer = this.getWalletSigner();
- try {
- const network = await signer.provider!.getNetwork();
- if (network.chainId !== chainId) {
- throw new WalletError(
- WalletErrorCode.NETWORK_MISMATCH,
- `Wallet network (${network.chainId}) does not match selected chain (${chainId}). Switch network in your wallet.`
- );
- }
-
- const { createOp } = await this.buildSuperfluidCreateFlowContext(
- tokenSymbol,
- amountPerMonth,
- recipient,
- chainId,
- signer
+ const network = await signer.provider!.getNetwork();
+ if (network.chainId !== chainId) {
+ throw new Error(
+ `Wallet network (${network.chainId}) does not match selected chain (${chainId}). Switch network in your wallet.`
);
+ }
- const populated = await createOp.getPopulatedTransactionRequest(signer, 1.2);
- const gasLimit = populated.gasLimit;
- if (!gasLimit) {
- throw new ContractError(
- ContractErrorCode.EXECUTION_FAILED,
- 'Could not estimate gas for Superfluid createFlow'
- );
- }
-
- const gasPrice = await signer.provider!.getGasPrice();
- const estimatedCostWei = gasPrice.mul(gasLimit);
+ const { createOp } = await this.buildSuperfluidCreateFlowContext(
+ tokenSymbol,
+ amountPerMonth,
+ recipient,
+ chainId,
+ signer
+ );
- return {
- gasLimit: gasLimit.toString(),
- gasPrice: ethers.utils.formatUnits(gasPrice, 'gwei'),
- estimatedCost: ethers.utils.formatEther(estimatedCostWei),
- };
- } catch (error) {
- if (error instanceof AppError) {
- throw error;
- }
- throw new ContractError(
- ContractErrorCode.EXECUTION_FAILED,
- 'Superfluid gas estimation failed.',
- 'Check your token balance and try again.',
- error
- );
+ const populated = await createOp.getPopulatedTransactionRequest(signer, 1.2);
+ const gasLimit = populated.gasLimit;
+ if (!gasLimit) {
+ throw new Error('Could not estimate gas for Superfluid createFlow');
}
+
+ const gasPrice = await signer.provider!.getGasPrice();
+ const estimatedCostWei = gasPrice.mul(gasLimit);
+
+ return {
+ gasLimit: gasLimit.toString(),
+ gasPrice: ethers.utils.formatUnits(gasPrice, 'gwei'),
+ estimatedCost: ethers.utils.formatEther(estimatedCostWei),
+ };
}
async createSuperfluidStream(
@@ -477,11 +471,11 @@ export class WalletServiceManager {
'Open your wallet and approve the transaction to continue.'
);
}
- throw new ContractError(
- ContractErrorCode.EXECUTION_FAILED,
+ throw toWalletError(
+ error,
+ WalletErrorCode.STREAM_CREATION_FAILED,
'Stream creation failed.',
- 'Check your token balance and try again.',
- error
+ 'Check your token balance and try again.'
);
}
}
@@ -569,11 +563,11 @@ export class WalletServiceManager {
'Open your wallet and approve the transaction to continue.'
);
}
- throw new ContractError(
- ContractErrorCode.EXECUTION_FAILED,
+ throw toWalletError(
+ error,
+ WalletErrorCode.STREAM_CREATION_FAILED,
'Stream creation failed.',
- 'Check your token balance and allowance, then try again.',
- error
+ 'Check your token balance and allowance, then try again.'
);
}
}
@@ -665,11 +659,11 @@ export class WalletServiceManager {
'Open your wallet and approve the request to continue.'
);
}
- throw new ContractError(
- ContractErrorCode.EXECUTION_FAILED,
+ throw toWalletError(
+ error,
+ WalletErrorCode.APPROVAL_FAILED,
'Token approval failed.',
- 'Check your wallet connection and try again.',
- error
+ 'Check your wallet connection and try again.'
);
}
}
@@ -731,16 +725,25 @@ export enum PaymentMethodErrorCode {
FALLBACK_FAILED = 'FALLBACK_FAILED',
}
-export class PaymentMethodError extends AppError {
+export class PaymentMethodError extends Error {
+ readonly code: PaymentMethodErrorCode;
+ readonly userMessage: string;
+ readonly recovery?: string;
+
constructor(
- code: PaymentMethodErrorCode | string,
+ code: PaymentMethodErrorCode,
userMessage: string,
recovery?: string,
cause?: unknown
) {
- super(code, userMessage, recovery, cause);
+ super(userMessage);
this.name = 'PaymentMethodError';
- Object.setPrototypeOf(this, new.target.prototype);
+ this.code = code;
+ this.userMessage = userMessage;
+ this.recovery = recovery;
+ if (cause instanceof Error && cause.stack) {
+ this.stack = `${this.stack}\nCaused by: ${cause.stack}`;
+ }
}
}
@@ -748,7 +751,14 @@ const MAX_PAYMENT_METHODS_PER_USER = 10;
const EXPIRY_WARNING_DAYS = 30;
const TOKEN_TYPE_TO_NATIVE_SYMBOL: Record> = {
[CHAIN_IDS.ETHEREUM]: { XLM: '', USDC: 'USDC', ETH: 'ETH', NATIVE: 'ETH', MATIC: '', ARB: '' },
- [CHAIN_IDS.POLYGON]: { XLM: '', USDC: 'USDC', ETH: 'ETH', NATIVE: 'MATIC', MATIC: 'MATIC', ARB: '' },
+ [CHAIN_IDS.POLYGON]: {
+ XLM: '',
+ USDC: 'USDC',
+ ETH: 'ETH',
+ NATIVE: 'MATIC',
+ MATIC: 'MATIC',
+ ARB: '',
+ },
[CHAIN_IDS.ARBITRUM]: { XLM: '', USDC: 'USDC', ETH: 'ETH', NATIVE: 'ETH', MATIC: '', ARB: 'ARB' },
};
@@ -812,7 +822,11 @@ export class PaymentMethodService {
errors.push('Label is required');
}
- if (!data.maxSpendPerInterval || isNaN(Number(data.maxSpendPerInterval)) || Number(data.maxSpendPerInterval) <= 0) {
+ if (
+ !data.maxSpendPerInterval ||
+ isNaN(Number(data.maxSpendPerInterval)) ||
+ Number(data.maxSpendPerInterval) <= 0
+ ) {
errors.push('Max spend per interval must be a positive number');
}
@@ -850,7 +864,10 @@ export class PaymentMethodService {
try {
const provider = new ethers.providers.JsonRpcProvider(getEvmRpcUrl(method.chainId));
- const erc20Abi = ['function decimals() view returns (uint8)', 'function symbol() view returns (string)'];
+ const erc20Abi = [
+ 'function decimals() view returns (uint8)',
+ 'function symbol() view returns (string)',
+ ];
const contract = new ethers.Contract(method.tokenAddress, erc20Abi, provider);
const decimals = await contract.decimals();
@@ -887,15 +904,21 @@ export class PaymentMethodService {
}
getPrimaryMethods(methods: PaymentMethod[]): PaymentMethod[] {
- return methods.filter((m) => m.priority === PaymentPriority.PRIMARY && m.isActive && m.isVerified);
+ return methods.filter(
+ (m) => m.priority === PaymentPriority.PRIMARY && m.isActive && m.isVerified
+ );
}
getBackupMethods(methods: PaymentMethod[]): PaymentMethod[] {
- return methods.filter((m) => m.priority === PaymentPriority.BACKUP && m.isActive && m.isVerified);
+ return methods.filter(
+ (m) => m.priority === PaymentPriority.BACKUP && m.isActive && m.isVerified
+ );
}
getFallbackMethods(methods: PaymentMethod[]): PaymentMethod[] {
- return methods.filter((m) => m.priority === PaymentPriority.FALLBACK && m.isActive && m.isVerified);
+ return methods.filter(
+ (m) => m.priority === PaymentPriority.FALLBACK && m.isActive && m.isVerified
+ );
}
getActiveVerifiedMethods(methods: PaymentMethod[]): PaymentMethod[] {
@@ -968,7 +991,10 @@ export class PaymentMethodService {
balance = await contract.balanceOf(conn.address);
}
- const required = ethers.utils.parseUnits(requiredAmount, method.tokenType === TokenType.USDC ? 6 : 18);
+ const required = ethers.utils.parseUnits(
+ requiredAmount,
+ method.tokenType === TokenType.USDC ? 6 : 18
+ );
return {
sufficient: balance.gte(required),
balance: balance.toString(),
@@ -1083,7 +1109,10 @@ export class PaymentMethodService {
continue;
}
- if (method.maxSpendPerInterval && ethers.BigNumber.from(amount).gt(method.maxSpendPerInterval)) {
+ if (
+ method.maxSpendPerInterval &&
+ ethers.BigNumber.from(amount).gt(method.maxSpendPerInterval)
+ ) {
attempt.status = 'failed';
attempt.failureReason = `Amount ${amount} exceeds max spend per interval ${method.maxSpendPerInterval}`;
attempt.resolvedAt = new Date();
diff --git a/src/store/__tests__/slaStore.test.ts b/src/store/__tests__/slaStore.test.ts
index 0448490a..ed1890a0 100644
--- a/src/store/__tests__/slaStore.test.ts
+++ b/src/store/__tests__/slaStore.test.ts
@@ -92,9 +92,8 @@ beforeEach(() => {
(AsyncStorage.setItem as jest.Mock).mockClear();
(AsyncStorage.getItem as jest.Mock).mockClear();
(AsyncStorage.removeItem as jest.Mock).mockClear();
- const notify = (
- jest.requireMock('../../services/notificationService') as NotificationServiceMock
- ).presentSlaBreachNotification;
+ const notify = (jest.requireMock('../../services/notificationService') as NotificationServiceMock)
+ .presentSlaBreachNotification;
notify.mockClear();
resetStore();
});
@@ -344,10 +343,6 @@ describe('detectSlaBreach', () => {
});
it('creates a breach when uptime is below target', async () => {
- const notify = (
- jest.requireMock('../../services/notificationService') as NotificationServiceMock
- ).presentSlaBreachNotification;
-
await act(async () => {
await s().configureSla('detect-breach-merchant', {
uptimeTarget: 99.9,
diff --git a/src/store/affiliateStore.ts b/src/store/affiliateStore.ts
index b7ef2fe6..e2947f2e 100644
--- a/src/store/affiliateStore.ts
+++ b/src/store/affiliateStore.ts
@@ -1,5 +1,5 @@
import { create } from 'zustand';
-import { persist, createJSONStorage, StateStorage } from 'zustand/middleware';
+import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import {
Affiliate,
@@ -10,7 +10,6 @@ import {
AffiliateStatus,
CommissionType,
} from '../types/affiliate';
-import { CACHE_CONSTANTS } from '../utils/constants/values';
const STORAGE_KEY = 'subtrackr-affiliate';
const STORE_VERSION = 1;
@@ -65,10 +64,7 @@ const defaultPrograms: AffiliateProgram[] = [
},
];
-const calculateTieredCommission = (
- amount: number,
- config: CommissionConfig
-): number => {
+const calculateTieredCommission = (amount: number, config: CommissionConfig): number => {
if (config.type !== CommissionType.TIERED || !config.tierThresholds || !config.tierRates) {
return amount * (config.rate / 100);
}
@@ -139,9 +135,7 @@ export const useAffiliateStore = create()(
set({
affiliates: affiliates.map((a) =>
- a.id === affiliateId
- ? { ...a, totalReferrals: a.totalReferrals + 1 }
- : a
+ a.id === affiliateId ? { ...a, totalReferrals: a.totalReferrals + 1 } : a
),
commissions: [
...get().commissions,
@@ -227,14 +221,12 @@ export const useAffiliateStore = create()(
updateAffiliateStatus: async (affiliateId: string, status: AffiliateStatus) => {
set((state) => ({
- affiliates: state.affiliates.map((a) =>
- a.id === affiliateId ? { ...a, status } : a
- ),
+ affiliates: state.affiliates.map((a) => (a.id === affiliateId ? { ...a, status } : a)),
}));
},
getMetrics: () => {
- const { affiliates, commissions } = get();
+ const { affiliates } = get();
const totalEarnings = affiliates.reduce((sum, a) => sum + a.totalEarnings, 0);
const pendingPayout = affiliates.reduce((sum, a) => sum + a.pendingPayout, 0);
const totalReferrals = affiliates.reduce((sum, a) => sum + a.totalReferrals, 0);
@@ -262,4 +254,4 @@ export const useAffiliateStore = create()(
}),
}
)
-);
\ No newline at end of file
+);
diff --git a/src/store/calendarStore.ts b/src/store/calendarStore.ts
index ca02acda..ef49ac2a 100644
--- a/src/store/calendarStore.ts
+++ b/src/store/calendarStore.ts
@@ -235,7 +235,13 @@ export const useCalendarStore = create()(
},
addOneTimePayment: (subscriptionId, amount, currency, scheduledDate, description) => {
- const payment = scheduleOneTimePayment(subscriptionId, amount, currency, scheduledDate, description);
+ const payment = scheduleOneTimePayment(
+ subscriptionId,
+ amount,
+ currency,
+ scheduledDate,
+ description
+ );
set((state) => ({
oneTimePayments: [...state.oneTimePayments, payment],
}));
diff --git a/src/store/developerPortalStore.ts b/src/store/developerPortalStore.ts
index 29ebb638..d826fc89 100644
--- a/src/store/developerPortalStore.ts
+++ b/src/store/developerPortalStore.ts
@@ -2,14 +2,13 @@ import { create } from 'zustand';
import {
DeveloperProfile,
ApiKey,
- ApiKeyPermission,
ApiKeyStatus,
UsageStats,
- UsageRecord,
- OnboardingStep,
+ UsageMetric,
DocumentationSection,
IntegrationGuide,
-} from '../types/developerPortal';
+ OnboardingStepInfo,
+} from '../types/sandbox';
import { developerPortalService } from '../services/sandbox/developerPortalService';
import { apiKeyService } from '../services/sandbox/apiKeyService';
import { usageTrackingService } from '../services/sandbox/usageTrackingService';
@@ -19,8 +18,8 @@ interface DeveloperPortalState {
developer: DeveloperProfile | null;
apiKeys: ApiKey[];
usageStats: UsageStats | null;
- recentUsage: UsageRecord[];
- onboardingSteps: OnboardingStep[];
+ recentUsage: UsageMetric[];
+ onboardingSteps: OnboardingStepInfo[];
documentation: DocumentationSection[];
integrationGuides: IntegrationGuide[];
isLoading: boolean;
@@ -39,7 +38,6 @@ interface DeveloperPortalState {
createApiKey: (
developerId: string,
name: string,
- permissions?: ApiKeyPermission[],
options?: { rateLimit?: number; dailyLimit?: number; expiresAt?: Date }
) => Promise;
revokeApiKey: (keyId: string) => Promise;
@@ -100,10 +98,7 @@ export const useDeveloperPortalStore = create()((set, get)
fetchDeveloper: async (developerId: string) => {
set({ isLoading: true, error: null });
try {
- await Promise.all([
- developerPortalService.loadDevelopers(),
- apiKeyService.loadApiKeys(),
- ]);
+ await Promise.all([developerPortalService.loadDevelopers(), apiKeyService.loadApiKeys()]);
const developer = await developerPortalService.getDeveloper(developerId);
if (!developer) {
@@ -137,10 +132,7 @@ export const useDeveloperPortalStore = create()((set, get)
set({ isLoading: true, error: null });
try {
- const updated = await developerPortalService.updateDeveloper(
- developer.id,
- updates
- );
+ const updated = await developerPortalService.updateDeveloper(developer.id, updates);
set({ developer: updated, isLoading: false });
} catch (error) {
set({
@@ -183,10 +175,7 @@ export const useDeveloperPortalStore = create()((set, get)
isLoading: false,
}));
- await developerPortalService.completeOnboardingStep(
- developerId,
- 'generate-api-key'
- );
+ await developerPortalService.completeOnboardingStep(developerId, 'generate-api-key');
const steps = await developerPortalService.getOnboardingSteps(developerId);
set({ onboardingSteps: steps });
@@ -278,10 +267,7 @@ export const useDeveloperPortalStore = create()((set, get)
fetchRecentUsage: async (developerId, limit) => {
set({ isLoading: true, error: null });
try {
- const recentUsage = await usageTrackingService.getRecentMetrics(
- developerId,
- limit
- );
+ const recentUsage = await usageTrackingService.getRecentMetrics(developerId, limit);
set({ recentUsage, isLoading: false });
} catch (error) {
set({
@@ -304,10 +290,7 @@ export const useDeveloperPortalStore = create()((set, get)
completeOnboardingStep: async (developerId, stepId) => {
try {
- const steps = await developerPortalService.completeOnboardingStep(
- developerId,
- stepId
- );
+ const steps = await developerPortalService.completeOnboardingStep(developerId, stepId);
if (steps) {
set({ onboardingSteps: steps });
}
diff --git a/src/store/fraudStore.ts b/src/store/fraudStore.ts
index f9563247..0d886275 100644
--- a/src/store/fraudStore.ts
+++ b/src/store/fraudStore.ts
@@ -1,7 +1,6 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
-import { LoadingState, idle, loading, success, failure } from '../types/loadingState';
import {
FraudAction,
FraudAnalytics,
@@ -9,7 +8,7 @@ import {
FraudMerchantRecord,
FraudReport,
FraudRiskScore,
- FraudSignal,
+ FraudReviewStatus,
FraudSubscriptionRecord,
} from '../types/fraud';
@@ -65,9 +64,24 @@ const subscriptionSeeds: FraudSubscriptionRecord[] = [
reason: 'Usage burst and fast creation cadence',
usagePattern: 'burst',
signals: [
- { kind: 'velocity', score: 28, detail: 'Created alongside two other subscriptions', observedAt: nowIso() },
- { kind: 'usage-anomaly', score: 30, detail: 'Observed usage is 3x the expected baseline', observedAt: nowIso() },
- { kind: 'chargeback', score: 20, detail: 'Recent dispute behavior is elevated', observedAt: nowIso() },
+ {
+ kind: 'velocity',
+ score: 28,
+ detail: 'Created alongside two other subscriptions',
+ observedAt: nowIso(),
+ },
+ {
+ kind: 'usage-anomaly',
+ score: 30,
+ detail: 'Observed usage is 3x the expected baseline',
+ observedAt: nowIso(),
+ },
+ {
+ kind: 'chargeback',
+ score: 20,
+ detail: 'Recent dispute behavior is elevated',
+ observedAt: nowIso(),
+ },
],
isBlocked: false,
isFlagged: true,
@@ -89,8 +103,18 @@ const subscriptionSeeds: FraudSubscriptionRecord[] = [
reason: 'Chargeback history and rapid subscription creation',
usagePattern: 'erratic',
signals: [
- { kind: 'velocity', score: 35, detail: 'Second subscription within the same day', observedAt: nowIso() },
- { kind: 'chargeback', score: 35, detail: 'Chargeback history predicts blocked outcome', observedAt: nowIso() },
+ {
+ kind: 'velocity',
+ score: 35,
+ detail: 'Second subscription within the same day',
+ observedAt: nowIso(),
+ },
+ {
+ kind: 'chargeback',
+ score: 35,
+ detail: 'Chargeback history predicts blocked outcome',
+ observedAt: nowIso(),
+ },
],
isBlocked: true,
isFlagged: true,
@@ -111,7 +135,14 @@ const subscriptionSeeds: FraudSubscriptionRecord[] = [
action: 'approve',
reason: 'Usage profile is stable',
usagePattern: 'normal',
- signals: [{ kind: 'velocity', score: 6, detail: 'Low velocity but within threshold', observedAt: nowIso() }],
+ signals: [
+ {
+ kind: 'velocity',
+ score: 6,
+ detail: 'Low velocity but within threshold',
+ observedAt: nowIso(),
+ },
+ ],
isBlocked: false,
isFlagged: false,
},
@@ -132,9 +163,24 @@ const subscriptionSeeds: FraudSubscriptionRecord[] = [
reason: 'Chargeback prediction and anomalous usage behavior',
usagePattern: 'burst',
signals: [
- { kind: 'usage-anomaly', score: 30, detail: 'Observed usage is far above baseline', observedAt: nowIso() },
- { kind: 'chargeback', score: 35, detail: 'Repeated disputes indicate high risk', observedAt: nowIso() },
- { kind: 'velocity', score: 27, detail: 'Fast subscription creation detected', observedAt: nowIso() },
+ {
+ kind: 'usage-anomaly',
+ score: 30,
+ detail: 'Observed usage is far above baseline',
+ observedAt: nowIso(),
+ },
+ {
+ kind: 'chargeback',
+ score: 35,
+ detail: 'Repeated disputes indicate high risk',
+ observedAt: nowIso(),
+ },
+ {
+ kind: 'velocity',
+ score: 27,
+ detail: 'Fast subscription creation detected',
+ observedAt: nowIso(),
+ },
],
isBlocked: true,
isFlagged: true,
@@ -190,9 +236,14 @@ const reviewSeeds: FraudCase[] = [
];
const averageRisk = (items: FraudSubscriptionRecord[]): number =>
- items.length ? Math.round(items.reduce((sum, item) => sum + item.riskScore, 0) / items.length) : 0;
+ items.length
+ ? Math.round(items.reduce((sum, item) => sum + item.riskScore, 0) / items.length)
+ : 0;
-const computeAnalytics = (subscriptions: FraudSubscriptionRecord[], reviewQueue: FraudCase[]): FraudAnalytics => {
+const computeAnalytics = (
+ subscriptions: FraudSubscriptionRecord[],
+ reviewQueue: FraudCase[]
+): FraudAnalytics => {
const approved = subscriptions.filter((item) => item.action === 'approve').length;
const flagged = subscriptions.filter((item) => item.action === 'flag').length;
const blocked = subscriptions.filter((item) => item.action === 'block').length;
@@ -245,7 +296,6 @@ interface FraudState {
analytics: FraudAnalytics;
loading: boolean;
error: string | null;
- loadingState: LoadingState;
refreshFraudSignals: () => void;
assessRisk: (subscriberId: string) => FraudRiskScore[];
flagSubscription: (subscriptionId: string) => void;
@@ -286,10 +336,18 @@ const buildMerchantReport = (
blockedSubscriptions: scoped.filter((item) => item.action === 'block').length,
manualReviewCount: scopedCases.filter((item) => item.status !== 'reviewed').length,
averageRisk: averageRisk(scoped),
- velocityAlerts: scoped.filter((item) => item.signals.some((signal) => signal.kind === 'velocity')).length,
- anomalyAlerts: scoped.filter((item) => item.signals.some((signal) => signal.kind === 'usage-anomaly')).length,
- chargebackPredictions: scoped.filter((item) => item.signals.some((signal) => signal.kind === 'chargeback')).length,
- highRiskSubscribers: new Set(scoped.filter((item) => item.riskScore >= 50).map((item) => item.subscriberId)).size,
+ velocityAlerts: scoped.filter((item) =>
+ item.signals.some((signal) => signal.kind === 'velocity')
+ ).length,
+ anomalyAlerts: scoped.filter((item) =>
+ item.signals.some((signal) => signal.kind === 'usage-anomaly')
+ ).length,
+ chargebackPredictions: scoped.filter((item) =>
+ item.signals.some((signal) => signal.kind === 'chargeback')
+ ).length,
+ highRiskSubscribers: new Set(
+ scoped.filter((item) => item.riskScore >= 50).map((item) => item.subscriberId)
+ ).size,
recentCases: scopedCases.slice(0, 5),
};
};
@@ -298,39 +356,47 @@ export const useFraudStore = create()(
persist(
(set, get) => ({
merchants: merchantSeeds.map((merchant) => ({ ...merchant })),
- subscriptions: subscriptionSeeds.map((item) => ({ ...item, signals: item.signals.map((signal) => ({ ...signal })) })),
+ subscriptions: subscriptionSeeds.map((item) => ({
+ ...item,
+ signals: item.signals.map((signal) => ({ ...signal })),
+ })),
assessments: hydrateAssessments(subscriptionSeeds),
reviewQueue: hydrateReviewQueue(reviewSeeds),
analytics: computeAnalytics(subscriptionSeeds, reviewSeeds),
loading: false,
- loadingState: idle(),
error: null,
refreshFraudSignals: () => {
- set({ loading: true, loadingState: loading() });
- try {
- const { subscriptions, reviewQueue, merchants } = get();
- set({
- analytics: computeAnalytics(subscriptions, reviewQueue),
- assessments: hydrateAssessments(subscriptions),
- merchants: merchants.map((merchant) => ({
- ...merchant,
- averageRisk: buildMerchantReport(merchants, subscriptions, reviewQueue, merchant.id).averageRisk,
- blockedSubscriptions: buildMerchantReport(merchants, subscriptions, reviewQueue, merchant.id).blockedSubscriptions,
- activeSubscriptions: buildMerchantReport(merchants, subscriptions, reviewQueue, merchant.id).totalSubscriptions,
- status:
- buildMerchantReport(merchants, subscriptions, reviewQueue, merchant.id).averageRisk >= 60
- ? 'high-risk'
- : buildMerchantReport(merchants, subscriptions, reviewQueue, merchant.id).averageRisk >= 35
- ? 'watch'
- : 'healthy',
- })),
- loading: false,
- loadingState: success(),
- });
- } catch (e) {
- set({ loading: false, loadingState: failure(e as Error) });
- }
+ const { subscriptions, reviewQueue, merchants } = get();
+ set({
+ analytics: computeAnalytics(subscriptions, reviewQueue),
+ assessments: hydrateAssessments(subscriptions),
+ merchants: merchants.map((merchant) => ({
+ ...merchant,
+ averageRisk: buildMerchantReport(merchants, subscriptions, reviewQueue, merchant.id)
+ .averageRisk,
+ blockedSubscriptions: buildMerchantReport(
+ merchants,
+ subscriptions,
+ reviewQueue,
+ merchant.id
+ ).blockedSubscriptions,
+ activeSubscriptions: buildMerchantReport(
+ merchants,
+ subscriptions,
+ reviewQueue,
+ merchant.id
+ ).totalSubscriptions,
+ status:
+ buildMerchantReport(merchants, subscriptions, reviewQueue, merchant.id).averageRisk >=
+ 60
+ ? 'high-risk'
+ : buildMerchantReport(merchants, subscriptions, reviewQueue, merchant.id)
+ .averageRisk >= 35
+ ? 'watch'
+ : 'healthy',
+ })),
+ });
},
assessRisk: (subscriberId: string) => {
@@ -354,6 +420,8 @@ export const useFraudStore = create()(
if (!current) return;
const score = scoreSubscription(current);
+ const action: FraudAction = score.totalScore >= 80 ? 'block' : 'flag';
+ const status: FraudReviewStatus = score.totalScore >= 80 ? 'escalated' : 'pending';
const nextCase: FraudCase = {
caseId: subscriptionId,
subscriptionId,
@@ -362,8 +430,8 @@ export const useFraudStore = create()(
merchantName: current.merchantName,
subscriptionName: current.subscriptionName,
riskScore: score.totalScore,
- action: score.totalScore >= 80 ? 'block' : 'flag',
- status: score.totalScore >= 80 ? 'escalated' : 'pending',
+ action,
+ status,
reason: score.reason,
createdAt: nowIso(),
updatedAt: nowIso(),
@@ -376,14 +444,20 @@ export const useFraudStore = create()(
isFlagged: true,
isBlocked: nextCase.action === 'block',
}),
- reviewQueue: [nextCase, ...state.reviewQueue.filter((entry) => entry.subscriptionId !== subscriptionId)],
+ reviewQueue: [
+ nextCase,
+ ...state.reviewQueue.filter((entry) => entry.subscriptionId !== subscriptionId),
+ ],
analytics: computeAnalytics(
updateSubscription(state.subscriptions, subscriptionId, {
action: nextCase.action,
isFlagged: true,
isBlocked: nextCase.action === 'block',
}),
- [nextCase, ...state.reviewQueue.filter((entry) => entry.subscriptionId !== subscriptionId)]
+ [
+ nextCase,
+ ...state.reviewQueue.filter((entry) => entry.subscriptionId !== subscriptionId),
+ ]
),
}));
},
@@ -397,7 +471,12 @@ export const useFraudStore = create()(
});
const reviewQueue = state.reviewQueue.map((entry) =>
entry.subscriptionId === subscriptionId
- ? { ...entry, status: 'reviewed', action: 'approve', updatedAt: nowIso() }
+ ? {
+ ...entry,
+ status: 'reviewed' as FraudReviewStatus,
+ action: 'approve' as FraudAction,
+ updatedAt: nowIso(),
+ }
: entry
);
return {
@@ -417,7 +496,12 @@ export const useFraudStore = create()(
});
const reviewQueue = state.reviewQueue.map((entry) =>
entry.subscriptionId === subscriptionId
- ? { ...entry, status: 'escalated', action: 'block', updatedAt: nowIso() }
+ ? {
+ ...entry,
+ status: 'escalated' as FraudReviewStatus,
+ action: 'block' as FraudAction,
+ updatedAt: nowIso(),
+ }
: entry
);
return {
@@ -440,7 +524,11 @@ export const useFraudStore = create()(
? {
...entry,
action,
- status: action === 'approve' ? 'reviewed' : action === 'block' ? 'escalated' : 'pending',
+ status: (action === 'approve'
+ ? 'reviewed'
+ : action === 'block'
+ ? 'escalated'
+ : 'pending') satisfies FraudReviewStatus,
updatedAt: nowIso(),
}
: entry
diff --git a/src/store/groupStore.ts b/src/store/groupStore.ts
index 8977e84f..fd0a1420 100644
--- a/src/store/groupStore.ts
+++ b/src/store/groupStore.ts
@@ -35,7 +35,8 @@ const updateGroup = (
groups: SubscriptionGroup[],
groupId: GroupId,
updater: (group: SubscriptionGroup) => SubscriptionGroup
-): SubscriptionGroup[] => groups.map((group) => (group.groupId === groupId ? updater(group) : group));
+): SubscriptionGroup[] =>
+ groups.map((group) => (group.groupId === groupId ? updater(group) : group));
export const useGroupStore = create((set, get) => ({
groups: [],
@@ -78,7 +79,9 @@ export const useGroupStore = create((set, get) => ({
removeMember: (groupId, memberAddress) => {
try {
set((state) => ({
- groups: updateGroup(state.groups, groupId, (group) => removeGroupMember(group, memberAddress)),
+ groups: updateGroup(state.groups, groupId, (group) =>
+ removeGroupMember(group, memberAddress)
+ ),
error: null,
}));
} catch (error) {
diff --git a/src/store/invoiceStore.ts b/src/store/invoiceStore.ts
index 90cb6c19..cacd999b 100644
--- a/src/store/invoiceStore.ts
+++ b/src/store/invoiceStore.ts
@@ -1,5 +1,5 @@
import { create } from 'zustand';
-import { persist, createJSONStorage, StateStorage } from 'zustand/middleware';
+import { persist, createJSONStorage, type StateStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import {
DEFAULT_INVOICE_CONFIG,
@@ -12,8 +12,8 @@ import {
CustomerTaxStatus,
TaxRemittanceReport,
TaxRemittanceLineItem,
- TaxType,
- DigitalGoodsClass,
+ DigitalGoodsCategory,
+ RemittanceStatus,
TaxRateEntry,
MidCycleTaxChange,
TaxInvoiceGenerationInput,
@@ -152,19 +152,19 @@ const flushPendingWrites = async (): Promise => {
};
const debouncedAsyncStorage: StateStorage = {
- getItem: async (name) => {
+ getItem: async (name: string) => {
if (pendingWrites.has(name)) return pendingWrites.get(name) ?? null;
await writeQueue;
return AsyncStorage.getItem(name);
},
- setItem: async (name, value) => {
+ setItem: async (name: string, value: string) => {
pendingWrites.set(name, value);
if (writeTimer) clearTimeout(writeTimer);
writeTimer = setTimeout(() => {
void flushPendingWrites();
}, WRITE_DEBOUNCE_MS);
},
- removeItem: async (name) => {
+ removeItem: async (name: string) => {
pendingWrites.delete(name);
if (writeTimer && pendingWrites.size === 0) {
clearTimeout(writeTimer);
@@ -188,7 +188,7 @@ interface InvoiceState {
customerTaxStatuses: Record;
taxRemittanceLines: TaxRemittanceLineItem[];
taxRemittanceReports: TaxRemittanceReport[];
- digitalGoodsClasses: Record;
+ digitalGoodsClasses: Record;
generateInvoiceFromSubscription: (
data: InvoiceFormData,
@@ -213,11 +213,11 @@ interface InvoiceState {
lookupTaxRate: (
jurisdiction: TaxJurisdiction,
- digitalGoodsClass?: DigitalGoodsClass
+ digitalGoodsClass?: DigitalGoodsCategory
) => TaxRateEntry | null;
resolveEffectiveTaxRateBps: (
jurisdiction: TaxJurisdiction,
- digitalGoodsClass?: DigitalGoodsClass
+ digitalGoodsClass?: DigitalGoodsCategory
) => number;
addTaxRemittanceLine: (line: TaxRemittanceLineItem) => void;
@@ -230,19 +230,19 @@ interface InvoiceState {
getTaxRemittanceReports: () => TaxRemittanceReport[];
getTaxRemittanceReport: (reportId: string) => TaxRemittanceReport | undefined;
- setDigitalGoodsClass: (planId: string, goodsClass: DigitalGoodsClass) => void;
- getDigitalGoodsClass: (planId: string) => DigitalGoodsClass;
+ setDigitalGoodsClass: (planId: string, goodsClass: DigitalGoodsCategory) => void;
+ getDigitalGoodsClass: (planId: string) => DigitalGoodsCategory;
calculateMidCycleTax: (
jurisdictionKey: string,
subtotal: number,
periodStart: Date,
periodEnd: Date,
- rateChanges: Array<{
+ rateChanges: {
oldRateBps: number;
newRateBps: number;
effectiveFrom: Date;
- }>
+ }[]
) => MidCycleTaxChange[];
}
@@ -462,10 +462,7 @@ export const useInvoiceStore = create()(
calculateTotals: (id) => {
const invoice = get().invoices.find((entry) => entry.id === id);
if (!invoice) return null;
- return calculateInvoiceTotals(
- invoice.lineItems,
- invoice.lineItems[0]?.taxRateBps ?? 0
- );
+ return calculateInvoiceTotals(invoice.lineItems, invoice.lineItems[0]?.taxRateBps ?? 0);
},
setCustomerTaxStatus: (subscriberId, status) => {
@@ -485,7 +482,7 @@ export const useInvoiceStore = create()(
});
},
- isCustomerTaxExempt: (subscriberId, jurisdictionKey) => {
+ isCustomerTaxExempt: (subscriberId, _jurisdictionKey) => {
const status = get().customerTaxStatuses[subscriberId];
return checkIsTaxExempt(status ?? null);
},
@@ -499,7 +496,7 @@ export const useInvoiceStore = create()(
return true;
},
- lookupTaxRate: (jurisdiction, digitalGoodsClass) => {
+ lookupTaxRate: (jurisdiction, _digitalGoodsClass) => {
const keys = jurisdictionFallbackKeys(jurisdiction);
const rates = get().taxRates;
for (const key of keys) {
@@ -538,25 +535,44 @@ export const useInvoiceStore = create()(
if (existing) {
existing.taxableAmount += line.taxableAmount;
existing.taxCollected += line.taxCollected;
- existing.transactionCount += line.transactionCount;
+ existing.transactionCount =
+ (existing.transactionCount ?? 0) + (line.transactionCount ?? 1);
} else {
- aggregated.set(groupKey, { ...line });
+ aggregated.set(groupKey, { ...line, transactionCount: line.transactionCount ?? 1 });
}
}
const lineItems = Array.from(aggregated.values());
const totalTaxCollected = lineItems.reduce((sum, l) => sum + l.taxCollected, 0);
const totalTaxableAmount = lineItems.reduce((sum, l) => sum + l.taxableAmount, 0);
+ const transactionCount = lineItems.reduce((sum, l) => sum + (l.transactionCount ?? 0), 0);
+
+ const primaryLine = lineItems[0];
+ const jurisdictionParts = primaryLine?.jurisdictionKey?.split('::') ?? [];
+ const jurisdiction: TaxJurisdiction = {
+ country: jurisdictionParts[0] ?? 'Unknown',
+ state: jurisdictionParts[1],
+ city: jurisdictionParts[2],
+ taxType: primaryLine?.taxType ?? get().config.defaultTaxType,
+ rateBps: primaryLine?.rateBps ?? 0,
+ label: primaryLine?.jurisdictionKey ?? 'Unknown',
+ effectiveDate: periodStart,
+ };
const report: TaxRemittanceReport = {
+ id: reportId,
reportId,
generatedAt: new Date(),
periodStart,
periodEnd,
merchant: merchantId,
+ jurisdiction,
lineItems,
totalTaxCollected,
totalTaxableAmount,
+ totalTaxRemitted: 0,
+ transactionCount,
+ status: RemittanceStatus.DRAFT,
};
set((state) => ({
@@ -581,7 +597,7 @@ export const useInvoiceStore = create()(
},
getDigitalGoodsClass: (planId) =>
- get().digitalGoodsClasses[planId] ?? DigitalGoodsClass.ELECTRONIC_SERVICE,
+ get().digitalGoodsClasses[planId] ?? DigitalGoodsCategory.ONLINE_SERVICE,
calculateMidCycleTax: (jurisdictionKey, subtotal, periodStart, periodEnd, rateChanges) => {
const periodDuration = periodEnd.getTime() - periodStart.getTime();
diff --git a/src/store/loyaltyStore.ts b/src/store/loyaltyStore.ts
index 8bbd37c7..c5060793 100644
--- a/src/store/loyaltyStore.ts
+++ b/src/store/loyaltyStore.ts
@@ -5,79 +5,36 @@ import {
LoyaltyStatus,
LoyaltyTier,
PointsTransaction,
- PointTxType,
Reward,
RewardType,
TierBenefits,
LoyaltyProgram,
- StreakInfo,
- ReferralInfo,
} from '../types/loyalty';
-import { AchievementTrigger } from '../types/gamification';
-import { useGamificationStore } from './gamificationStore';
const STORAGE_KEY = 'subtrackr-loyalty';
-const STORE_VERSION = 2;
-
-feat/issues-394-405-414-386
-// ── Gamification types ───────────────────────────────────────────────────────
-
-export interface Achievement {
- id: string;
- name: string;
- description: string;
- icon: string;
- condition: (state: Pick) => boolean;
- unlockedAt?: Date;
+const STORE_VERSION = 1;
interface LoyaltyState {
loyaltyStatus: LoyaltyStatus | null;
transactions: PointsTransaction[];
rewards: Reward[];
program: LoyaltyProgram | null;
- streak: StreakInfo;
- referral: ReferralInfo;
isLoading: boolean;
error: string | null;
initializeProgram: () => Promise;
- fetchLoyaltyStatus: (address: string) => Promise;
accumulatePoints: (subscriberId: string, subscriptionId: string, amount: number) => Promise;
redeemPoints: (rewardId: string) => Promise;
- redeemPointsForDiscount: (points: number, chargeAmount: number) => Promise;
checkTierUpgrade: () => void;
expirePoints: () => void;
- earnReferralBonus: (referrerAddress: string) => Promise;
- generateReferralCode: () => string;
-main
}
-export interface StreakData {
- current: number;
- longest: number;
- lastPaymentDate: string | null; // ISO date string (date only)
- frozenUntil?: string | null; // streak freeze mechanic
-}
-
-// ── Helpers ──────────────────────────────────────────────────────────────────
-
-const generateUniqueId = (): string =>
- `${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 8)}`;
-
-const toDateStr = (d: Date): string => d.toISOString().slice(0, 10);
-
-const daysBetween = (a: string, b: string): number =>
- Math.round((new Date(b).getTime() - new Date(a).getTime()) / 86_400_000);
-
-const getTierFromPoints = (points: number): LoyaltyTier => {
- if (points >= 15000) return LoyaltyTier.PLATINUM;
- if (points >= 5000) return LoyaltyTier.GOLD;
- if (points >= 1000) return LoyaltyTier.SILVER;
- return LoyaltyTier.BRONZE;
+const generateUniqueId = (): string => {
+ const timestamp = Date.now().toString(36);
+ const randomComponent = Math.random().toString(36).substring(2, 8);
+ return `${timestamp}-${randomComponent}`;
};
-// ── Default data ─────────────────────────────────────────────────────────────
-
const defaultTierBenefits: TierBenefits[] = [
{
tier: LoyaltyTier.BRONZE,
@@ -164,95 +121,11 @@ const defaultRewards: Reward[] = [
},
];
-/** Achievement definitions — conditions evaluated after every state change. */
-export const ACHIEVEMENTS: Achievement[] = [
- {
- id: 'first-payment',
- name: 'First Payment',
- description: 'Make your first on-time payment',
- icon: '🎉',
- condition: ({ transactions }) => transactions.some((t) => t.type === 'earn'),
- },
- {
- id: 'streak-7',
- name: 'Week Warrior',
- description: '7-day payment streak',
- icon: '🔥',
- condition: ({ streak }) => streak.current >= 7,
- },
- {
- id: 'streak-30',
- name: 'Monthly Master',
- description: '30-day payment streak',
- icon: '⚡',
- condition: ({ streak }) => streak.current >= 30,
- },
- {
- id: 'silver-tier',
- name: 'Silver Member',
- description: 'Reach Silver tier',
- icon: '🥈',
- condition: ({ loyaltyStatus }) =>
- loyaltyStatus !== null &&
- [LoyaltyTier.SILVER, LoyaltyTier.GOLD, LoyaltyTier.PLATINUM].includes(loyaltyStatus.tier),
- },
- {
- id: 'gold-tier',
- name: 'Gold Member',
- description: 'Reach Gold tier',
- icon: '🥇',
- condition: ({ loyaltyStatus }) =>
- loyaltyStatus !== null &&
- [LoyaltyTier.GOLD, LoyaltyTier.PLATINUM].includes(loyaltyStatus.tier),
- },
- {
- id: 'points-1000',
- name: 'Points Collector',
- description: 'Earn 1,000 lifetime points',
- icon: '💎',
- condition: ({ loyaltyStatus }) => (loyaltyStatus?.lifetimePoints ?? 0) >= 1000,
- },
- {
- id: 'first-redemption',
- name: 'Redeemer',
- description: 'Redeem a reward for the first time',
- icon: '🎁',
- condition: ({ transactions }) => transactions.some((t) => t.type === 'redeem'),
- },
-];
-
-feat/issues-394-405-414-386
-// ── Store interface ──────────────────────────────────────────────────────────
-
-interface LoyaltyState {
- loyaltyStatus: LoyaltyStatus | null;
- transactions: PointsTransaction[];
- rewards: Reward[];
- program: LoyaltyProgram | null;
- streak: StreakData;
- achievements: Achievement[];
- newlyUnlocked: Achievement[]; // cleared after UI reads them
- isLoading: boolean;
- error: string | null;
- /** Mutex flag to prevent concurrent points mutations */
- _pointsMutex: boolean;
-
- initializeProgram: () => Promise;
- accumulatePoints: (subscriberId: string, subscriptionId: string, amount: number) => Promise;
- redeemPoints: (rewardId: string) => Promise;
- checkTierUpgrade: () => void;
- expirePoints: () => void;
- recordPayment: (date?: Date) => void;
- freezeStreak: (days: number) => void;
- clearNewlyUnlocked: () => void;
- /** Retroactively evaluate all achievements against current state */
- evaluateAchievements: () => Achievement[];
-}
-
-// ── Store ────────────────────────────────────────────────────────────────────
-
-const getTierBenefits = (tier: LoyaltyTier): TierBenefits | undefined => {
- return defaultTierBenefits.find((t) => t.tier === tier);
+const getTierFromPoints = (points: number): LoyaltyTier => {
+ if (points >= 15000) return LoyaltyTier.PLATINUM;
+ if (points >= 5000) return LoyaltyTier.GOLD;
+ if (points >= 1000) return LoyaltyTier.SILVER;
+ return LoyaltyTier.BRONZE;
};
const calculatePointsExpiration = (pointsExpirationDays: number, memberSince: Date): Date => {
@@ -260,12 +133,6 @@ const calculatePointsExpiration = (pointsExpirationDays: number, memberSince: Da
expirationDate.setDate(expirationDate.getDate() + pointsExpirationDays);
return expirationDate;
};
- main
-
-const generateReferralCode = (address: string): string => {
- const suffix = address.slice(-6).toUpperCase();
- return `SUBTRACKR-${suffix}`;
-};
export const useLoyaltyStore = create()(
persist(
@@ -274,17 +141,8 @@ export const useLoyaltyStore = create()(
transactions: [],
rewards: defaultRewards,
program: null,
- feat/issues-394-405-414-386
- streak: { current: 0, longest: 0, lastPaymentDate: null, frozenUntil: null },
- achievements: ACHIEVEMENTS.map((a) => ({ ...a, unlockedAt: undefined })),
- newlyUnlocked: [],
-
- streak: { current: 0, lastChargeAt: null, isActive: false },
- referral: { code: '', bonusPoints: 100, totalReferrals: 0 },
- main
isLoading: false,
error: null,
- _pointsMutex: false,
initializeProgram: async () => {
const program: LoyaltyProgram = {
@@ -298,93 +156,17 @@ export const useLoyaltyStore = create()(
set({ program });
},
- feat/issues-394-405-414-386
- accumulatePoints: async (subscriberId, subscriptionId, amount) => {
- // Race condition guard: spin-wait up to 500ms
- const deadline = Date.now() + 500;
- while (get()._pointsMutex && Date.now() < deadline) {
- await new Promise((r) => setTimeout(r, 10));
- }
- set({ _pointsMutex: true });
-
- fetchLoyaltyStatus: async (address: string) => {
- set({ isLoading: true, error: null });
- try {
- // TODO: replace with actual contract call:
- // const result = await subscriptionContract.get_loyalty_status(address)
- // const { points, lifetime, streak, spent, tier } = result;
- const existing = get().loyaltyStatus;
- const currentTier = existing?.tier || getTierFromPoints(existing?.lifetimePoints || 0);
- const refCode = generateReferralCode(address);
- set({
- loyaltyStatus: {
- subscriberId: address,
- tier: currentTier,
- points: existing?.points || 0,
- lifetimePoints: existing?.lifetimePoints || 0,
- totalSpent: existing?.totalSpent || 0,
- memberSince: existing?.memberSince || new Date(),
- streak: existing?.streak || 0,
- },
- referral: {
- code: refCode,
- bonusPoints: 100,
- totalReferrals: 0,
- },
- });
- } catch (err: any) {
- set({ error: err.message });
- } finally {
- set({ isLoading: false });
- }
- },
-
accumulatePoints: async (subscriberId: string, subscriptionId: string, amount: number) => {
- const { program, transactions, loyaltyStatus, streak } = get();
+ const { program, transactions, loyaltyStatus } = get();
if (!program) return;
-main
- try {
- const { program, transactions, loyaltyStatus } = get();
- if (!program) return;
-
-feat/issues-394-405-414-386
- const pointsEarned = Math.floor(amount * program.pointsPerDollar);
- const currentPoints = loyaltyStatus?.points ?? 0;
- const lifetimePoints = loyaltyStatus?.lifetimePoints ?? 0;
- const totalSpent = loyaltyStatus?.totalSpent ?? 0;
-
- const transaction: PointsTransaction = {
- id: generateUniqueId(),
- subscriberId,
- amount: pointsEarned,
- type: 'earn',
- subscriptionId,
- description: 'Points earned from subscription',
- createdAt: new Date(),
- expiresAt: new Date(Date.now() + program.pointsExpirationDays * 86_400_000),
- };
-
- const newPoints = currentPoints + pointsEarned;
- const newLifetime = lifetimePoints + pointsEarned;
-
- const newStatus: LoyaltyStatus = {
- subscriberId,
- tier: getTierFromPoints(newLifetime),
- points: newPoints,
- lifetimePoints: newLifetime,
- totalSpent: totalSpent + amount,
- memberSince: loyaltyStatus?.memberSince ?? new Date(),
- pointsExpirationDate: new Date(
- Date.now() + program.pointsExpirationDays * 86_400_000,
- ),
- };
+ const pointsEarned = Math.floor(amount * program.pointsPerDollar);
const transaction: PointsTransaction = {
id: generateUniqueId(),
subscriberId,
amount: pointsEarned,
- type: PointTxType.EARNED,
+ type: 'earn',
subscriptionId,
description: `Points earned from subscription`,
createdAt: new Date(),
@@ -393,9 +175,7 @@ feat/issues-394-405-414-386
const currentPoints = loyaltyStatus?.points || 0;
const lifetimePoints = loyaltyStatus?.lifetimePoints || 0;
const totalSpent = loyaltyStatus?.totalSpent || 0;
- const currentStreak = streak.current;
- const newStreak = currentStreak + 1;
const newStatus: LoyaltyStatus = {
subscriberId,
tier: getTierFromPoints(currentPoints + pointsEarned),
@@ -407,140 +187,60 @@ feat/issues-394-405-414-386
program.pointsExpirationDays,
loyaltyStatus?.memberSince || new Date()
),
- streak: newStreak,
};
- // Streak bonus every 10 consecutive charges
- if (newStreak > 0 && newStreak % 10 === 0) {
- const bonusPts = Math.floor(newStreak / 10) * 100;
- newStatus.points += bonusPts;
- newStatus.lifetimePoints += bonusPts;
- const bonusTx: PointsTransaction = {
- id: generateUniqueId(),
- subscriberId,
- amount: bonusPts,
- type: PointTxType.STREAK_BONUS,
- description: `${newStreak}-day streak bonus!`,
- createdAt: new Date(),
- };
- set({
- transactions: [...transactions, transaction, bonusTx],
- loyaltyStatus: newStatus,
- streak: { current: newStreak, lastChargeAt: new Date(), isActive: true },
- });
-
- // Trigger gamification checks
- useGamificationStore.getState().checkAchievements(AchievementTrigger.STREAK_MILESTONE, { streak: newStreak });
- return;
- }
-
set({
transactions: [...transactions, transaction],
loyaltyStatus: newStatus,
- streak: { current: newStreak, lastChargeAt: new Date(), isActive: true },
});
-
- // Trigger gamification checks
- useGamificationStore.getState().checkAchievements(
- AchievementTrigger.POINTS_MILESTONE,
- { lifetimePoints: newStatus.lifetimePoints },
- );
- useGamificationStore.getState().checkAchievements(
- AchievementTrigger.STREAK_MILESTONE,
- { streak: newStreak },
- );
},
-main
- set({ transactions: [...transactions, transaction], loyaltyStatus: newStatus });
- get().evaluateAchievements();
- } finally {
- set({ _pointsMutex: false });
- }
- },
+ redeemPoints: async (rewardId: string) => {
+ const { rewards, loyaltyStatus } = get();
+ const reward = rewards.find((r) => r.id === rewardId);
- redeemPoints: async (rewardId) => {
- const deadline = Date.now() + 500;
- while (get()._pointsMutex && Date.now() < deadline) {
- await new Promise((r) => setTimeout(r, 10));
- }
- set({ _pointsMutex: true });
-
-feat/issues-394-405-414-386
- try {
- const { rewards, loyaltyStatus } = get();
- const reward = rewards.find((r) => r.id === rewardId);
- if (!reward?.isActive || !loyaltyStatus) return false;
- if (loyaltyStatus.points < reward.pointsCost) return false;
+ if (!reward || !loyaltyStatus) return false;
+ if (!reward.isActive) return false;
+ if (loyaltyStatus.points < reward.pointsCost) return false;
- // TODO: call contract redeem_loyalty_points(reward.pointsCost, chargeAmount)
const transaction: PointsTransaction = {
id: generateUniqueId(),
subscriberId: loyaltyStatus.subscriberId,
amount: -reward.pointsCost,
- type: PointTxType.REDEEMED,
+ type: 'redeem',
description: `Redeemed: ${reward.name}`,
createdAt: new Date(),
};
-main
- const transaction: PointsTransaction = {
- id: generateUniqueId(),
- subscriberId: loyaltyStatus.subscriberId,
- amount: -reward.pointsCost,
- type: 'redeem',
- description: `Redeemed: ${reward.name}`,
- createdAt: new Date(),
- };
-
- set({
- transactions: [...get().transactions, transaction],
- loyaltyStatus: { ...loyaltyStatus, points: loyaltyStatus.points - reward.pointsCost },
- });
- get().evaluateAchievements();
- return true;
- } finally {
- set({ _pointsMutex: false });
- }
- },
+ set({
+ transactions: [...get().transactions, transaction],
+ loyaltyStatus: {
+ ...loyaltyStatus,
+ points: loyaltyStatus.points - reward.pointsCost,
+ },
+ });
- redeemPointsForDiscount: async (points: number, chargeAmount: number) => {
- // TODO: call contract redeem_loyalty_points(points, chargeAmount)
- // Returns discount amount
- const discountBps = Math.min(Math.floor(points / 100), 5000);
- const discount = Math.floor((chargeAmount * discountBps) / 10000);
- return discount;
+ return true;
},
checkTierUpgrade: () => {
const { loyaltyStatus } = get();
if (!loyaltyStatus) return;
+
const newTier = getTierFromPoints(loyaltyStatus.lifetimePoints);
if (newTier !== loyaltyStatus.tier) {
- set({ loyaltyStatus: { ...loyaltyStatus, tier: newTier } });
- get().evaluateAchievements();
+ set({
+ loyaltyStatus: {
+ ...loyaltyStatus,
+ tier: newTier,
+ },
+ });
}
},
expirePoints: () => {
const { loyaltyStatus, transactions } = get();
if (!loyaltyStatus?.pointsExpirationDate) return;
- if (new Date() <= loyaltyStatus.pointsExpirationDate) return;
-
-feat/issues-394-405-414-386
- const expiredTx: PointsTransaction = {
- id: generateUniqueId(),
- subscriberId: loyaltyStatus.subscriberId,
- amount: -loyaltyStatus.points,
- type: 'expire',
- description: 'Points expired',
- createdAt: new Date(),
- };
- set({
- transactions: [...transactions, expiredTx],
- loyaltyStatus: { ...loyaltyStatus, points: 0, pointsExpirationDate: undefined },
- });
- },
const now = new Date();
if (now > loyaltyStatus.pointsExpirationDate) {
@@ -548,112 +248,20 @@ feat/issues-394-405-414-386
id: generateUniqueId(),
subscriberId: loyaltyStatus.subscriberId,
amount: -loyaltyStatus.points,
- type: PointTxType.EXPIRED,
+ type: 'expire',
description: 'Points expired',
createdAt: new Date(),
};
-main
-
- recordPayment: (date = new Date()) => {
- const { streak } = get();
- const today = toDateStr(date);
- const { lastPaymentDate, frozenUntil } = streak;
-
- // Streak freeze: if frozen, don't break streak
- if (frozenUntil && today <= frozenUntil) {
- set({ streak: { ...streak, lastPaymentDate: today } });
- return;
- }
- let newCurrent = streak.current;
- if (!lastPaymentDate) {
- newCurrent = 1;
- } else {
- const diff = daysBetween(lastPaymentDate, today);
- if (diff === 0) return; // same day, no change
- if (diff === 1) {
- newCurrent = streak.current + 1;
- } else {
- newCurrent = 1; // streak broken
- }
+ set({
+ transactions: [...transactions, expiredTransaction],
+ loyaltyStatus: {
+ ...loyaltyStatus,
+ points: 0,
+ pointsExpirationDate: undefined,
+ },
+ });
}
-
- const newStreak: StreakData = {
- current: newCurrent,
- longest: Math.max(streak.longest, newCurrent),
- lastPaymentDate: today,
- frozenUntil: null,
- };
- set({ streak: newStreak });
- get().evaluateAchievements();
- },
-
- freezeStreak: (days) => {
- const { streak } = get();
- const until = toDateStr(new Date(Date.now() + days * 86_400_000));
- set({ streak: { ...streak, frozenUntil: until } });
- },
-
- clearNewlyUnlocked: () => set({ newlyUnlocked: [] }),
-
- evaluateAchievements: () => {
- const state = get();
- const { achievements, loyaltyStatus, streak, transactions } = state;
- const context = { loyaltyStatus, streak, transactions };
- const newlyUnlocked: Achievement[] = [];
-
- const updated = achievements.map((a) => {
- if (a.unlockedAt) return a; // already unlocked
- if (a.condition(context)) {
- const unlocked = { ...a, unlockedAt: new Date() };
- newlyUnlocked.push(unlocked);
- return unlocked;
- }
- return a;
- });
-
- set({ achievements: updated, newlyUnlocked });
- return newlyUnlocked;
- },
-
- earnReferralBonus: async (referrerAddress: string) => {
- const { program, loyaltyStatus, referral, transactions } = get();
- if (!program || !loyaltyStatus) return;
-
- // TODO: call contract earn_referral_bonus(referrerAddress)
- const bonusPts = referral.bonusPoints;
-
- const bonusTx: PointsTransaction = {
- id: generateUniqueId(),
- subscriberId: referrerAddress,
- amount: bonusPts,
- type: PointTxType.REFERRAL_BONUS,
- description: 'Referral bonus',
- createdAt: new Date(),
- };
-
- const newTotalReferrals = referral.totalReferrals + 1;
- set({
- transactions: [...transactions, bonusTx],
- referral: { ...referral, totalReferrals: newTotalReferrals },
- loyaltyStatus: {
- ...loyaltyStatus,
- points: loyaltyStatus.points + bonusPts,
- lifetimePoints: loyaltyStatus.lifetimePoints + bonusPts,
- },
- });
-
- // Trigger gamification checks
- useGamificationStore.getState().checkAchievements(
- AchievementTrigger.REFERRAL_MADE,
- { totalReferrals: newTotalReferrals },
- );
- },
-
- generateReferralCode: () => {
- const { loyaltyStatus } = get();
- if (!loyaltyStatus) return '';
- return generateReferralCode(loyaltyStatus.subscriberId);
},
}),
{
@@ -665,13 +273,7 @@ main
transactions: state.transactions,
rewards: state.rewards,
program: state.program,
- streak: state.streak,
- feat/issues-394-405-414-386
- achievements: state.achievements,
-
- referral: state.referral,
-main
}),
- },
- ),
+ }
+ )
);
diff --git a/src/store/merchantStore.ts b/src/store/merchantStore.ts
index 09194d04..57b4fa6e 100644
--- a/src/store/merchantStore.ts
+++ b/src/store/merchantStore.ts
@@ -12,95 +12,35 @@ import {
} from '../types/merchant';
const STORAGE_KEY = 'subtrackr-merchant-onboarding';
-const STORE_VERSION = 2;
-
-// ── Extended types ────────────────────────────────────────────────────────────
-
-export interface ComplianceResult {
- passed: boolean;
- sanctionsHit: boolean;
- pepHit: boolean;
- checkedAt: Date;
- notes?: string;
-}
-
-export interface PaymentSetup {
- method: 'stellar_xlm' | 'stellar_usdc' | 'bank_transfer';
- walletAddress?: string;
- bankAccountLast4?: string;
- configuredAt: Date;
-}
-
-export interface ExtendedMerchantOnboarding extends MerchantOnboarding {
- formData: Partial;
- compliance?: ComplianceResult;
- paymentSetup?: PaymentSetup;
- welcomeTourCompleted: boolean;
- /** ISO timestamp of last save for resume detection */
- savedAt: string;
- /** Verification timeout: ISO timestamp */
- verificationDeadline?: string;
-}
-
-// ── Helpers ───────────────────────────────────────────────────────────────────
-
-const generateUniqueId = (): string =>
- `${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 8)}`;
-
-const ONBOARDING_EXPIRY_DAYS = 30;
-
-const getDefaultSteps = (): OnboardingStep[] => [
- OnboardingStep.BUSINESS_INFO,
- OnboardingStep.ID_DOCUMENT,
- OnboardingStep.BUSINESS_LICENSE,
- OnboardingStep.REVIEW,
-];
-
-/** Simulate compliance screening (sanctions + PEP check). */
-const runComplianceCheck = async (
- data: Partial,
-): Promise => {
- // In production this calls a real KYB/AML provider.
- // Blocked countries list (simplified).
- const BLOCKED_COUNTRIES = ['KP', 'IR', 'SY', 'CU'];
- const sanctionsHit = BLOCKED_COUNTRIES.includes((data.country ?? '').toUpperCase());
- const pepHit = false; // placeholder
- return {
- passed: !sanctionsHit && !pepHit,
- sanctionsHit,
- pepHit,
- checkedAt: new Date(),
- };
-};
-
-// ── Store interface ───────────────────────────────────────────────────────────
+const STORE_VERSION = 1;
interface MerchantState {
- onboarding: ExtendedMerchantOnboarding | null;
+ onboarding: MerchantOnboarding | null;
isLoading: boolean;
error: string | null;
- /** Start or resume an onboarding session. */
startOnboarding: (data: MerchantOnboardingFormData) => Promise;
- /** Save current form data without advancing step (save-and-resume). */
- saveProgress: (data: Partial) => void;
submitDocument: (docType: DocumentType, uri: string) => Promise;
- retryRejectedDocument: (docId: string, newUri: string) => Promise;
nextStep: () => Promise;
- previousStep: () => void;
- runComplianceScreening: () => Promise;
- configurePayment: (setup: Omit) => void;
+ previousStep: () => Promise;
requestVerification: () => Promise;
- approveVerification: (tier: VerificationTier, notes?: string) => void;
- rejectVerification: (reason: string) => void;
- completeWelcomeTour: () => void;
+ approveVerification: (tier: VerificationTier, notes?: string) => Promise;
+ rejectVerification: (reason: string) => Promise;
getOnboardingStatus: () => OnboardingStatus;
- /** True if a previous incomplete session exists and can be resumed. */
- canResume: () => boolean;
- clearOnboarding: () => void;
}
-// ── Store ─────────────────────────────────────────────────────────────────────
+const generateUniqueId = (): string => {
+ const timestamp = Date.now().toString(36);
+ const randomComponent = Math.random().toString(36).substring(2, 8);
+ return `${timestamp}-${randomComponent}`;
+};
+
+const getDefaultSteps = (): OnboardingStep[] => [
+ OnboardingStep.BUSINESS_INFO,
+ OnboardingStep.ID_DOCUMENT,
+ OnboardingStep.BUSINESS_LICENSE,
+ OnboardingStep.REVIEW,
+];
export const useMerchantStore = create()(
persist(
@@ -109,63 +49,29 @@ export const useMerchantStore = create()(
isLoading: false,
error: null,
- startOnboarding: async (data) => {
+ startOnboarding: async (data: MerchantOnboardingFormData) => {
set({ isLoading: true, error: null });
try {
- const existing = get().onboarding;
- // Resume if an in-progress session exists and hasn't expired
- if (existing && existing.status === OnboardingStatus.IN_PROGRESS) {
- const savedAt = new Date(existing.savedAt);
- const expired =
- Date.now() - savedAt.getTime() > ONBOARDING_EXPIRY_DAYS * 86_400_000;
- if (!expired) {
- set({
- onboarding: {
- ...existing,
- formData: { ...existing.formData, ...data },
- savedAt: new Date().toISOString(),
- },
- isLoading: false,
- });
- return;
- }
- }
-
- const now = new Date();
- const newOnboarding: ExtendedMerchantOnboarding = {
+ const newOnboarding: MerchantOnboarding = {
id: generateUniqueId(),
merchantAddress: data.email,
steps: getDefaultSteps(),
currentStep: OnboardingStep.BUSINESS_INFO,
status: OnboardingStatus.IN_PROGRESS,
documents: [],
- formData: data,
- welcomeTourCompleted: false,
- savedAt: now.toISOString(),
- startedAt: now,
- updatedAt: now,
- expiresAt: new Date(now.getTime() + ONBOARDING_EXPIRY_DAYS * 86_400_000),
+ startedAt: new Date(),
+ updatedAt: new Date(),
};
set({ onboarding: newOnboarding, isLoading: false });
- } catch (err) {
- set({ error: err instanceof Error ? err.message : 'Failed to start onboarding', isLoading: false });
+ } catch (error) {
+ set({
+ error: error instanceof Error ? error.message : 'Failed to start onboarding',
+ isLoading: false,
+ });
}
},
- saveProgress: (data) => {
- const { onboarding } = get();
- if (!onboarding) return;
- set({
- onboarding: {
- ...onboarding,
- formData: { ...onboarding.formData, ...data },
- savedAt: new Date().toISOString(),
- updatedAt: new Date(),
- },
- });
- },
-
- submitDocument: async (docType, uri) => {
+ submitDocument: async (docType: DocumentType, uri: string) => {
set({ isLoading: true, error: null });
try {
const { onboarding } = get();
@@ -179,96 +85,57 @@ export const useMerchantStore = create()(
status: 'pending',
};
- // Replace existing doc of same type if present
- const docs = onboarding.documents.filter((d) => d.type !== docType);
set({
- onboarding: { ...onboarding, documents: [...docs, newDoc], updatedAt: new Date() },
+ onboarding: {
+ ...onboarding,
+ documents: [...onboarding.documents, newDoc],
+ updatedAt: new Date(),
+ },
+ isLoading: false,
+ });
+ } catch (error) {
+ set({
+ error: error instanceof Error ? error.message : 'Failed to submit document',
isLoading: false,
});
- } catch (err) {
- set({ error: err instanceof Error ? err.message : 'Failed to submit document', isLoading: false });
}
},
- retryRejectedDocument: async (docId, newUri) => {
- const { onboarding } = get();
- if (!onboarding) return;
- const docs = onboarding.documents.map((d) =>
- d.id === docId ? { ...d, uri: newUri, status: 'pending' as const, uploadedAt: new Date() } : d,
- );
- set({ onboarding: { ...onboarding, documents: docs, updatedAt: new Date() } });
- },
-
nextStep: async () => {
const { onboarding } = get();
if (!onboarding) return;
- const idx = onboarding.steps.indexOf(onboarding.currentStep);
- if (idx >= onboarding.steps.length - 1) return;
-
- const nextStep = onboarding.steps[idx + 1];
-
- // Auto-run compliance before REVIEW step
- if (nextStep === OnboardingStep.REVIEW && !onboarding.compliance) {
- await get().runComplianceScreening();
- }
+ const currentIndex = onboarding.steps.indexOf(onboarding.currentStep);
+ if (currentIndex >= onboarding.steps.length - 1) return;
+ const currentStep = onboarding.steps[currentIndex + 1];
const newStatus =
- nextStep === OnboardingStep.REVIEW
+ currentStep === OnboardingStep.REVIEW
? OnboardingStatus.PENDING_REVIEW
: OnboardingStatus.IN_PROGRESS;
set({
onboarding: {
- ...get().onboarding!,
- currentStep: nextStep,
+ ...onboarding,
+ currentStep,
status: newStatus,
- savedAt: new Date().toISOString(),
updatedAt: new Date(),
},
});
},
- previousStep: () => {
+ previousStep: async () => {
const { onboarding } = get();
if (!onboarding) return;
- const idx = onboarding.steps.indexOf(onboarding.currentStep);
- if (idx <= 0) return;
- set({
- onboarding: {
- ...onboarding,
- currentStep: onboarding.steps[idx - 1],
- status: OnboardingStatus.IN_PROGRESS,
- savedAt: new Date().toISOString(),
- updatedAt: new Date(),
- },
- });
- },
- runComplianceScreening: async () => {
- const { onboarding } = get();
- if (!onboarding) throw new Error('No onboarding in progress');
- set({ isLoading: true });
- try {
- const result = await runComplianceCheck(onboarding.formData);
- set({
- onboarding: { ...get().onboarding!, compliance: result, updatedAt: new Date() },
- isLoading: false,
- });
- return result;
- } catch (err) {
- set({ isLoading: false, error: err instanceof Error ? err.message : 'Compliance check failed' });
- throw err;
- }
- },
+ const currentIndex = onboarding.steps.indexOf(onboarding.currentStep);
+ if (currentIndex <= 0) return;
- configurePayment: (setup) => {
- const { onboarding } = get();
- if (!onboarding) return;
set({
onboarding: {
...onboarding,
- paymentSetup: { ...setup, configuredAt: new Date() },
+ currentStep: onboarding.steps[currentIndex - 1],
+ status: OnboardingStatus.IN_PROGRESS,
updatedAt: new Date(),
},
});
@@ -277,37 +144,45 @@ export const useMerchantStore = create()(
requestVerification: async () => {
const { onboarding } = get();
if (!onboarding) return;
- const deadline = new Date(Date.now() + 7 * 86_400_000).toISOString(); // 7-day timeout
+
set({
onboarding: {
...onboarding,
status: OnboardingStatus.PENDING_REVIEW,
- verificationDeadline: deadline,
updatedAt: new Date(),
},
});
},
- approveVerification: (tier, notes) => {
+ approveVerification: async (tier: VerificationTier, notes?: string) => {
const { onboarding } = get();
if (!onboarding) return;
+
const limits =
tier === VerificationTier.ENHANCED
- ? { monthlyVolume: 1_000_000, maxTransactions: 10_000 }
- : { monthlyVolume: 10_000, maxTransactions: 100 };
+ ? { monthlyVolume: 1000000, maxTransactions: 10000 }
+ : { monthlyVolume: 10000, maxTransactions: 100 };
+
set({
onboarding: {
...onboarding,
status: OnboardingStatus.VERIFIED,
- verificationResult: { isVerified: true, tier, reviewedAt: new Date(), reviewerNotes: notes, limits },
+ verificationResult: {
+ isVerified: true,
+ tier,
+ reviewedAt: new Date(),
+ reviewerNotes: notes,
+ limits,
+ },
updatedAt: new Date(),
},
});
},
- rejectVerification: (reason) => {
+ rejectVerification: async (reason: string) => {
const { onboarding } = get();
if (!onboarding) return;
+
set({
onboarding: {
...onboarding,
@@ -324,29 +199,16 @@ export const useMerchantStore = create()(
});
},
- completeWelcomeTour: () => {
- const { onboarding } = get();
- if (!onboarding) return;
- set({ onboarding: { ...onboarding, welcomeTourCompleted: true } });
- },
-
- getOnboardingStatus: () => get().onboarding?.status ?? OnboardingStatus.NOT_STARTED,
-
- canResume: () => {
+ getOnboardingStatus: () => {
const { onboarding } = get();
- if (!onboarding) return false;
- if (onboarding.status !== OnboardingStatus.IN_PROGRESS) return false;
- const savedAt = new Date(onboarding.savedAt);
- return Date.now() - savedAt.getTime() <= ONBOARDING_EXPIRY_DAYS * 86_400_000;
+ return onboarding?.status ?? OnboardingStatus.NOT_STARTED;
},
-
- clearOnboarding: () => set({ onboarding: null, error: null }),
}),
{
name: STORAGE_KEY,
version: STORE_VERSION,
storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => ({ onboarding: state.onboarding }),
- },
- ),
+ }
+ )
);
diff --git a/src/store/sandboxStore.ts b/src/store/sandboxStore.ts
index b187c2ce..6005950c 100644
--- a/src/store/sandboxStore.ts
+++ b/src/store/sandboxStore.ts
@@ -106,12 +106,54 @@ const DEFAULT_RATE_LIMIT = {
};
const DEFAULT_ONBOARDING_STEPS: OnboardingStepInfo[] = [
- { id: DeveloperOnboardingStep.WELCOME, title: 'Welcome', description: 'Learn about the developer portal', step: DeveloperOnboardingStep.WELCOME, completed: false, required: true },
- { id: DeveloperOnboardingStep.CREATE_ACCOUNT, title: 'Create Account', description: 'Set up your developer profile', step: DeveloperOnboardingStep.CREATE_ACCOUNT, completed: false, required: true },
- { id: DeveloperOnboardingStep.GENERATE_API_KEY, title: 'Generate API Key', description: 'Create your sandbox API key', step: DeveloperOnboardingStep.GENERATE_API_KEY, completed: false, required: true },
- { id: DeveloperOnboardingStep.EXPLORE_SANDBOX, title: 'Explore Sandbox', description: 'Test the sandbox environment', step: DeveloperOnboardingStep.EXPLORE_SANDBOX, completed: false, required: false },
- { id: DeveloperOnboardingStep.BUILD_INTEGRATION, title: 'Build Integration', description: 'Build your integration', step: DeveloperOnboardingStep.BUILD_INTEGRATION, completed: false, required: false },
- { id: DeveloperOnboardingStep.GO_LIVE, title: 'Go Live', description: 'Switch to production', step: DeveloperOnboardingStep.GO_LIVE, completed: false, required: false },
+ {
+ id: DeveloperOnboardingStep.WELCOME,
+ title: 'Welcome',
+ description: 'Learn about the developer portal',
+ step: DeveloperOnboardingStep.WELCOME,
+ completed: false,
+ required: true,
+ },
+ {
+ id: DeveloperOnboardingStep.CREATE_ACCOUNT,
+ title: 'Create Account',
+ description: 'Set up your developer profile',
+ step: DeveloperOnboardingStep.CREATE_ACCOUNT,
+ completed: false,
+ required: true,
+ },
+ {
+ id: DeveloperOnboardingStep.GENERATE_API_KEY,
+ title: 'Generate API Key',
+ description: 'Create your sandbox API key',
+ step: DeveloperOnboardingStep.GENERATE_API_KEY,
+ completed: false,
+ required: true,
+ },
+ {
+ id: DeveloperOnboardingStep.EXPLORE_SANDBOX,
+ title: 'Explore Sandbox',
+ description: 'Test the sandbox environment',
+ step: DeveloperOnboardingStep.EXPLORE_SANDBOX,
+ completed: false,
+ required: false,
+ },
+ {
+ id: DeveloperOnboardingStep.BUILD_INTEGRATION,
+ title: 'Build Integration',
+ description: 'Build your integration',
+ step: DeveloperOnboardingStep.BUILD_INTEGRATION,
+ completed: false,
+ required: false,
+ },
+ {
+ id: DeveloperOnboardingStep.GO_LIVE,
+ title: 'Go Live',
+ description: 'Switch to production',
+ step: DeveloperOnboardingStep.GO_LIVE,
+ completed: false,
+ required: false,
+ },
];
const DEFAULT_INTEGRATION_GUIDES: IntegrationGuide[] = [
@@ -123,9 +165,24 @@ const DEFAULT_INTEGRATION_GUIDES: IntegrationGuide[] = [
difficulty: 'beginner',
estimatedTime: '15 minutes',
steps: [
- { title: 'Install SDK', content: 'npm install @subtrackr/sdk', codeExample: 'npm install @subtrackr/sdk', language: 'bash' },
- { title: 'Initialize Client', content: 'Create a SubTrackr client with your API key.', codeExample: `const client = new SubTrackr({\n apiKey: 'sk_sandbox_your_key',\n});`, language: 'typescript' },
- { title: 'Make First Request', content: 'List subscriptions to verify setup.', codeExample: `const subs = await client.subscriptions.list();\nconsole.log(subs.data);`, language: 'typescript' },
+ {
+ title: 'Install SDK',
+ content: 'npm install @subtrackr/sdk',
+ codeExample: 'npm install @subtrackr/sdk',
+ language: 'bash',
+ },
+ {
+ title: 'Initialize Client',
+ content: 'Create a SubTrackr client with your API key.',
+ codeExample: `const client = new SubTrackr({\n apiKey: 'sk_sandbox_your_key',\n});`,
+ language: 'typescript',
+ },
+ {
+ title: 'Make First Request',
+ content: 'List subscriptions to verify setup.',
+ codeExample: `const subs = await client.subscriptions.list();\nconsole.log(subs.data);`,
+ language: 'typescript',
+ },
],
tags: ['setup', 'quickstart'],
isCompleted: false,
@@ -138,7 +195,12 @@ const DEFAULT_INTEGRATION_GUIDES: IntegrationGuide[] = [
difficulty: 'intermediate',
estimatedTime: '30 minutes',
steps: [
- { title: 'Create Subscription', content: 'Use POST to create subscriptions.', codeExample: `const sub = await client.subscriptions.create({\n name: 'Pro Plan',\n price: 29.99,\n currency: 'USD',\n billingCycle: 'monthly',\n});`, language: 'typescript' },
+ {
+ title: 'Create Subscription',
+ content: 'Use POST to create subscriptions.',
+ codeExample: `const sub = await client.subscriptions.create({\n name: 'Pro Plan',\n price: 29.99,\n currency: 'USD',\n billingCycle: 'monthly',\n});`,
+ language: 'typescript',
+ },
{ title: 'Update Status', content: 'Pause, resume, or cancel subscriptions.' },
],
tags: ['subscriptions', 'billing'],
@@ -179,9 +241,7 @@ const DEFAULT_INTEGRATION_GUIDES: IntegrationGuide[] = [
category: IntegrationGuideCategory.ANALYTICS_REPORTING,
difficulty: 'intermediate',
estimatedTime: '20 minutes',
- steps: [
- { title: 'Fetch Analytics', content: 'Retrieve subscription metrics via API.' },
- ],
+ steps: [{ title: 'Fetch Analytics', content: 'Retrieve subscription metrics via API.' }],
tags: ['analytics', 'reporting'],
isCompleted: false,
},
@@ -213,7 +273,13 @@ interface SandboxState {
testSubscriptions: TestSubscription[];
subscriptions: TestSubscription[];
sandboxSubscriptions: TestSubscription[];
- transactions: Array<{ id: string; type: string; amount: number; status: string; timestamp: Date }>;
+ transactions: {
+ id: string;
+ type: string;
+ amount: number;
+ status: string;
+ timestamp: Date;
+ }[];
metrics: SandboxMetrics;
onboardingSteps: OnboardingStepInfo[];
integrationGuides: IntegrationGuide[];
@@ -222,13 +288,19 @@ interface SandboxState {
error: AppError | null;
fetchSandboxes: (developerId: string) => Promise;
- createSandbox: (name: string, description: string, environment: SandboxEnvironment) => Promise;
+ createSandbox: (
+ name: string,
+ description: string,
+ environment: SandboxEnvironment
+ ) => Promise;
selectSandbox: (sandbox: SandboxConfig | string) => void;
deleteSandbox: (id: string) => Promise;
pauseSandbox: (id: string) => Promise;
resumeSandbox: (id: string) => Promise;
toggleSandboxStatus: (id: string) => Promise;
- generateTestData: (sandboxIdOrConfig?: string | { subscriptionCount?: number; transactionCount?: number }) => Promise;
+ generateTestData: (
+ sandboxIdOrConfig?: string | { subscriptionCount?: number; transactionCount?: number }
+ ) => Promise;
resetSandbox: () => void;
resetTestData: () => void;
refreshMetrics: () => Promise;
@@ -240,7 +312,12 @@ interface SandboxState {
completeOnboardingStep: (stepId: string) => void;
createDeveloperProfile: (name: string, email: string, company?: string) => Promise;
generateApiKey: (name: string) => Promise;
- createApiKey: (input: { name: string; description?: string; sandboxId: string; scopes: ApiKeyScope[] }) => Promise;
+ createApiKey: (input: {
+ name: string;
+ description?: string;
+ sandboxId: string;
+ scopes: ApiKeyScope[];
+ }) => Promise;
revokeApiKey: (id: string) => Promise;
reactivateApiKey: (id: string) => Promise;
deleteApiKey: (id: string) => Promise;
@@ -254,6 +331,7 @@ const defaultSandboxConfig: SandboxConfig = {
environment: SandboxEnvironment.DEVELOPMENT,
name: 'Development Sandbox',
description: 'Primary sandbox for development and testing',
+ status: SandboxStatus.ACTIVE,
isActive: true,
dataIsolation: true,
rateLimit: DEFAULT_RATE_LIMIT,
@@ -294,7 +372,10 @@ export const useSandboxStore = create()(
set({ isLoading: false });
} catch (err) {
set({
- error: errorHandler.handleError(err as Error, { action: 'fetchSandboxes', timestamp: new Date() }),
+ error: errorHandler.handleError(err as Error, {
+ action: 'fetchSandboxes',
+ timestamp: new Date(),
+ }),
isLoading: false,
});
}
@@ -322,16 +403,20 @@ export const useSandboxStore = create()(
}));
} catch (err) {
set({
- error: errorHandler.handleError(err as Error, { action: 'createSandbox', timestamp: new Date() }),
+ error: errorHandler.handleError(err as Error, {
+ action: 'createSandbox',
+ timestamp: new Date(),
+ }),
isLoading: false,
});
}
},
selectSandbox: (sandboxOrId) => {
- const sandbox = typeof sandboxOrId === 'string'
- ? get().sandboxes.find((s) => s.id === sandboxOrId) || null
- : sandboxOrId;
+ const sandbox =
+ typeof sandboxOrId === 'string'
+ ? get().sandboxes.find((s) => s.id === sandboxOrId) || null
+ : sandboxOrId;
set({ selectedSandbox: sandbox, currentSandbox: sandbox });
},
@@ -340,7 +425,8 @@ export const useSandboxStore = create()(
const remaining = state.sandboxes.filter((s) => s.id !== id);
return {
sandboxes: remaining,
- currentSandbox: state.currentSandbox?.id === id ? remaining[0] || null : state.currentSandbox,
+ currentSandbox:
+ state.currentSandbox?.id === id ? remaining[0] || null : state.currentSandbox,
selectedSandbox: state.selectedSandbox?.id === id ? null : state.selectedSandbox,
};
});
@@ -349,22 +435,28 @@ export const useSandboxStore = create()(
pauseSandbox: async (id) => {
set((state) => ({
sandboxes: state.sandboxes.map((s) =>
- s.id === id ? { ...s, isActive: false, status: SandboxStatus.PAUSED, updatedAt: new Date() } : s
+ s.id === id
+ ? { ...s, isActive: false, status: SandboxStatus.PAUSED, updatedAt: new Date() }
+ : s
),
- currentSandbox: state.currentSandbox?.id === id
- ? { ...state.currentSandbox, isActive: false, status: SandboxStatus.PAUSED }
- : state.currentSandbox,
+ currentSandbox:
+ state.currentSandbox?.id === id
+ ? { ...state.currentSandbox, isActive: false, status: SandboxStatus.PAUSED }
+ : state.currentSandbox,
}));
},
resumeSandbox: async (id) => {
set((state) => ({
sandboxes: state.sandboxes.map((s) =>
- s.id === id ? { ...s, isActive: true, status: SandboxStatus.ACTIVE, updatedAt: new Date() } : s
+ s.id === id
+ ? { ...s, isActive: true, status: SandboxStatus.ACTIVE, updatedAt: new Date() }
+ : s
),
- currentSandbox: state.currentSandbox?.id === id
- ? { ...state.currentSandbox, isActive: true, status: SandboxStatus.ACTIVE }
- : state.currentSandbox,
+ currentSandbox:
+ state.currentSandbox?.id === id
+ ? { ...state.currentSandbox, isActive: true, status: SandboxStatus.ACTIVE }
+ : state.currentSandbox,
}));
},
@@ -378,11 +470,23 @@ export const useSandboxStore = create