diff --git a/contract/src/errors.rs b/contract/src/errors.rs index 7ac7226d..bbb376be 100644 --- a/contract/src/errors.rs +++ b/contract/src/errors.rs @@ -45,6 +45,7 @@ pub enum ContractError { /// Returned when the batch size exceeds the maximum allowed BatchTooLarge = 20, /// Returned when a merchant attempts to withdraw with no accrued revenue + SubscriptionAlreadyActive = 21, ZeroBalanceAvailable = 21, /// Returned when attempting to subscribe to a frozen merchant MerchantFrozen = 22, diff --git a/contract/src/lib.rs b/contract/src/lib.rs index ac31259d..a2594f97 100644 --- a/contract/src/lib.rs +++ b/contract/src/lib.rs @@ -252,16 +252,68 @@ impl FlowPay { trial_period: Option, referrer: Option
, ) { - subscribe_inner( - &env, - user, - merchant, + ensure_contract_not_paused(&env); + user.require_auth(); + + if whitelist::is_whitelist_enabled(&env) { + if !whitelist::is_whitelisted(&env, &merchant) { + env.panic_with_error(ContractError::MerchantNotWhitelisted); + } + } + + if whitelist::is_frozen(&env, &merchant) { + env.panic_with_error(ContractError::MerchantFrozen); + } + if interval < 60 { + env.panic_with_error(ContractError::IntervalMustBePositive); + } + + use soroban_sdk::xdr::ToXdr; + if token.clone().to_xdr(&env).get(7) == Some(0) { + env.panic_with_error(ContractError::InvalidTokenAddress); + } + + validation::check_allowance(&env, &user, &token, amount); + if interval < 60 { + env.panic_with_error(ContractError::IntervalTooShort); + } + + if interval < min_interval::get_min_interval(&env) { + env.panic_with_error(ContractError::IntervalTooShort); + } + + let token_client = token::Client::new(&env, &token); + let allowance = token_client.allowance(&user, &env.current_contract_address()); + if allowance < amount { + env.panic_with_error(ContractError::InsufficientAllowance); + } + + let now = env.ledger().timestamp(); + let trial_duration = trial_period.unwrap_or(0); + let last_charged = now + trial_duration; + + let existing = storage::get_subscription(&env, &user); + let should_increment = existing.as_ref().map_or(true, |s| !s.active); + + let sub = Subscription { + merchant: merchant.clone(), amount, interval, - token, - trial_period, - referrer, - ); + last_charged, + active: true, + paused: false, + token: token.clone(), + referrer: referrer.clone(), + label: Symbol::new(&env, "default"), + trial_duration, + }; + + env.storage() + .persistent() + .set(&DataKey::Subscription(user.clone()), &sub); + + extend_subscription_ttl(&env, &user); + subscribe_inner(&env, user.clone(), merchant.clone(), amount, interval, token.clone(), trial_period, referrer.clone()); } pub fn subscribe_with_metadata( @@ -278,19 +330,69 @@ impl FlowPay { if label.len() > 64 { env.panic_with_error(ContractError::MetadataLabelTooLong); } + subscribe_inner(&env, user.clone(), merchant.clone(), amount, interval, token.clone(), trial_period, referrer.clone()); + subscription_metadata::set_metadata(&env, &user, label.clone()); + + if whitelist::is_frozen(&env, &merchant) { + env.panic_with_error(ContractError::MerchantFrozen); + } + + validation::require_valid_amount(&env, amount); + if interval == 0 { + env.panic_with_error(ContractError::IntervalMustBePositive); + } + + use soroban_sdk::xdr::ToXdr; + if token.clone().to_xdr(&env).get(7) == Some(0) { + env.panic_with_error(ContractError::InvalidTokenAddress); + } + + validation::check_allowance(&env, &user, &token, amount); + + if interval < min_interval::get_min_interval(&env) { + env.panic_with_error(ContractError::IntervalTooShort); + } + + let token_client = token::Client::new(&env, &token); + let allowance = token_client.allowance(&user, &env.current_contract_address()); + if allowance < amount { + env.panic_with_error(ContractError::InsufficientAllowance); + } + + let now = env.ledger().timestamp(); + let trial_duration = trial_period.unwrap_or(0); + let last_charged = now + trial_duration; - subscribe_inner( - &env, - user.clone(), - merchant, + let existing = storage::get_subscription(&env, &user); + let should_increment = existing.as_ref().map_or(true, |s| !s.active); + + let sub = Subscription { + merchant: merchant.clone(), amount, interval, - token, - trial_period, - referrer, - ); + last_charged, + active: true, + paused: false, + token: token.clone(), + referrer: referrer.clone(), + label: Symbol::new(&env, ""), // deprecated: use SubscriptionMeta storage instead + trial_duration, + }; - let _ = subscription_metadata::set_metadata(&env, &user, label); + env.storage() + .persistent() + .set(&DataKey::Subscription(user.clone()), &sub); + + extend_subscription_ttl(&env, &user); + + if should_increment { + subscription_count::increment(&env); + subscription_count::append_subscriber_index(&env, &user); + } + referral::store_referral(&env, &user, &referrer); + merchant_stats::increment_subscriber_count(&env, &sub.merchant); + events::publish_subscribed(&env, &user, &sub); + let _ = subscription_metadata::set_metadata(&env, &user, label.clone()); } /// Charges the next due recurring payment for `user`. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b636fd7c..cef572b1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -862,24 +862,6 @@ "node": ">=12" } }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, "node_modules/@esbuild/netbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", @@ -897,24 +879,6 @@ "node": ">=12" } }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, "node_modules/@esbuild/openbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", @@ -932,24 +896,6 @@ "node": ">=12" } }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, "node_modules/@esbuild/sunos-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", @@ -5427,420 +5373,6 @@ } } }, - "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, "node_modules/vitest/node_modules/@vitest/mocker": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", @@ -5868,50 +5400,6 @@ } } }, - "node_modules/vitest/node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" - } - }, "node_modules/vitest/node_modules/vite": { "version": "8.0.10", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", diff --git a/frontend/src/__tests__/__mocks__/freighter.ts b/frontend/src/__tests__/__mocks__/freighter.ts new file mode 100644 index 00000000..d9abc84a --- /dev/null +++ b/frontend/src/__tests__/__mocks__/freighter.ts @@ -0,0 +1,12 @@ +import { vi } from "vitest"; + +export const mockFreighter = { + isConnected: vi.fn().mockResolvedValue(true), + getPublicKey: vi.fn().mockResolvedValue("GA_MOCK_USER_PUBLIC_KEY"), + getNetwork: vi.fn().mockResolvedValue({ + network: "TESTNET", + networkPassphrase: "Test SDF Network ; September 2015" + }), + // "Valid-looking XDR stub" as per acceptance criteria + signTransaction: vi.fn().mockResolvedValue("AAAAAgAAAABmock-signed-xdr-AAAAA="), +}; diff --git a/frontend/src/__tests__/__mocks__/stellar.ts b/frontend/src/__tests__/__mocks__/stellar.ts index e69de29b..cb0dbadc 100644 --- a/frontend/src/__tests__/__mocks__/stellar.ts +++ b/frontend/src/__tests__/__mocks__/stellar.ts @@ -0,0 +1,29 @@ +import { vi } from "vitest"; + +export const buildSubscribeTx = vi.fn().mockResolvedValue("mocked-subscribe-xdr"); +export const buildPayPerUseTx = vi.fn().mockResolvedValue("mocked-ppu-xdr"); +export const buildCancelTx = vi.fn().mockResolvedValue("mocked-cancel-xdr"); +export const buildPauseTx = vi.fn().mockResolvedValue("mocked-pause-xdr"); +export const buildResumeTx = vi.fn().mockResolvedValue("mocked-resume-xdr"); +export const buildApproveTx = vi.fn().mockResolvedValue("mocked-approve-xdr"); +export const buildSetDailyLimitTx = vi.fn().mockResolvedValue("mocked-set-daily-limit-xdr"); +export const getSubscription = vi.fn().mockResolvedValue(null); +export const getAllowance = vi.fn().mockResolvedValue(9999999999n); +export const fetchEvents = vi.fn().mockResolvedValue({ events: [] }); +export const getDailyLimit = vi.fn().mockResolvedValue(1000000000n); +export const getDailySpent = vi.fn().mockResolvedValue(0n); +export const getChargeHistory = vi.fn().mockResolvedValue([]); +export const getMerchantSubscribers = vi.fn().mockResolvedValue([]); +export const getMerchantRevenue = vi.fn().mockResolvedValue(0n); +export const getMerchantRevenueHistory = vi.fn().mockResolvedValue([]); + +export const server = { + sendTransaction: vi.fn().mockResolvedValue({ hash: "mocked-tx-hash" }), + getAccount: vi.fn(), + getEvents: vi.fn(), + simulateTransaction: vi.fn(), +}; + +export const CONTRACT_ID = "C_MOCK_CONTRACT"; +export const TOKEN_CONTRACT_ID = "C_MOCK_TOKEN"; +export const NETWORK_PASSPHRASE = "Test SDF Network ; September 2015"; diff --git a/frontend/src/__tests__/integration/subscribe-pay-cancel.test.tsx b/frontend/src/__tests__/integration/subscribe-pay-cancel.test.tsx new file mode 100644 index 00000000..e18a6b30 --- /dev/null +++ b/frontend/src/__tests__/integration/subscribe-pay-cancel.test.tsx @@ -0,0 +1,157 @@ +import React from "react"; +import { render, screen, fireEvent, act } from "@testing-library/react"; +import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"; +import App from "../../App"; +import { mockFreighter } from "../__mocks__/freighter"; +import * as stellarMocks from "../../stellar"; + +// Mock the wallet explicitly since we don't know the exact resolution path +const mockAnnounce = vi.fn(); +vi.mock("../../hooks/useAccessibility", () => ({ + useAccessibility: () => ({ + announcement: "", + announce: mockAnnounce, + }), +})); + +// Automatic mocking based on the __mocks__ directory +vi.mock("../../stellar"); + +describe("Integration: Subscribe -> Pay-Per-Use -> Cancel", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + + // Mock fetch to prevent outbound network traffic + fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: () => Promise.resolve({}), + } as any); + + // Mock Freighter on the window object + window.freighter = mockFreighter; + + // Reset stellar mocks to base states + vi.mocked(stellarMocks.getSubscription).mockResolvedValue(null); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + vi.useRealTimers(); + }); + + it("exercises the complete application flow", async () => { + render(); + + // 1. Connect Wallet + const connectBtn = await screen.findByRole("button", { name: /Connect Wallet/i }); + fireEvent.click(connectBtn); + + // Wait for the Dashboard to appear + const dashboardTitle = await screen.findByText(/No active subscription found./i); + expect(dashboardTitle).toBeInTheDocument(); + + // 2. Create Subscription + // Switch to Subscribe tab + const subscribeTab = await screen.findByRole("button", { name: /Subscribe/i }); + fireEvent.click(subscribeTab); + + // Fill the required form fields + const merchantInput = await screen.findByLabelText(/Merchant Address/i); + const amountInput = await screen.findByLabelText(/Amount \(XLM\)/i); + + fireEvent.change(merchantInput, { target: { value: "GB_MOCK_MERCHANT_ADDR" } }); + fireEvent.change(amountInput, { target: { value: "50" } }); + + // Submit the subscription + const submitBtn = await screen.findByRole("button", { name: /Subscribe Now/i }); + fireEvent.click(submitBtn); + + // Advance fake timers so transactions process + await act(async () => { + await vi.runAllTimersAsync(); + }); + + // Check announcement triggers for subscribe + expect(mockAnnounce).toHaveBeenCalledWith("Transaction submitted"); + expect(mockAnnounce).toHaveBeenCalledWith("Transaction confirmed"); + + // The component switches to Dashboard automatically, but we need to mock the subscription so it displays it + vi.mocked(stellarMocks.getSubscription).mockResolvedValue({ + merchant: "GB_MOCK_MERCHANT_ADDR", + amount: "500000000", + interval: 86400, + last_charged: Math.floor(Date.now() / 1000), + active: true, + paused: false, + }); + + // Advance polling loops so usePolling hits getSubscription + await act(async () => { + await vi.advanceTimersByTimeAsync(30000); + }); + + // Verify Dashboard displays the new subscription + const activeSubText = await screen.findByText(/Active Subscription/i); + expect(activeSubText).toBeInTheDocument(); + + // 3. Execute Pay-Per-Use + const ppuInput = await screen.findByPlaceholderText(/Amount in XLM/i); + fireEvent.change(ppuInput, { target: { value: "5" } }); + + // Advance debounce + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + }); + + const payNowBtn = await screen.findByRole("button", { name: /Pay now/i }); + fireEvent.click(payNowBtn); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + // Verify PP announcement triggers + expect(mockAnnounce).toHaveBeenCalledWith("Transaction submitted"); + expect(mockAnnounce).toHaveBeenCalledWith("Transaction confirmed"); + + // Verify success toast appears + const toast = await screen.findByText(/Paid!/i); + expect(toast).toBeInTheDocument(); + + // 4. Cancel Subscription + const cancelBtn = await screen.findByRole("button", { name: /^Cancel$/i }); + fireEvent.click(cancelBtn); + + const confirmModalBtn = await screen.findByRole("button", { name: /Confirm/i }); + fireEvent.click(confirmModalBtn); + + // Provide empty subscription to mimic cancellation on next poll + vi.mocked(stellarMocks.getSubscription).mockResolvedValue(null); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + // Verify cancel announcement triggers + expect(mockAnnounce).toHaveBeenCalledWith("Transaction submitted"); + expect(mockAnnounce).toHaveBeenCalledWith("Transaction confirmed"); + + // Advance polling loop to reload dashboard + await act(async () => { + await vi.advanceTimersByTimeAsync(30000); + }); + + // Verify Dashboard updates and displays no active subscription + const noActiveSubText = await screen.findByText(/No active subscription found./i); + expect(noActiveSubText).toBeInTheDocument(); + + // Verify exactly 6 announcements occurred + expect(mockAnnounce).toHaveBeenCalledTimes(6); + + // Verify zero real fetch requests were sent + expect(fetchSpy).toHaveBeenCalledTimes(0); + }); +}); diff --git a/frontend/src/__tests__/stellar.test.ts b/frontend/src/__tests__/stellar.test.ts index 989a05f7..84929f95 100644 --- a/frontend/src/__tests__/stellar.test.ts +++ b/frontend/src/__tests__/stellar.test.ts @@ -1,6 +1,7 @@ +import { nativeToScVal, xdr, Address } from "@stellar/stellar-sdk"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { nativeToScVal, Address } from "@stellar/stellar-sdk"; import { ScValDecoder, ScValDecodeError } from "../services/scval"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; // 1. Intercept the Stellar SDK's Server class safely with a standalone mock implementation vi.mock("@stellar/stellar-sdk/rpc", () => { @@ -59,31 +60,31 @@ describe("fetchEvents", () => { }); it("filters by event name correctly", async () => { - const result = await fetchEvents("subscribed"); + const { events: result } = await fetchEvents("subscribed"); - expect(result.events).toHaveLength(2); - expect(result.events[0].eventName).toBe("subscribed"); - expect(result.events[0].address).toBe("user_A"); - expect(result.events[1].address).toBe("user_B"); + expect(result).toHaveLength(2); + expect(result[0].eventName).toBe("subscribed"); + expect(result[0].address).toBe("user_A"); + expect(result[1].address).toBe("user_B"); }); it("filters by address when provided", async () => { - const result = await fetchEvents("subscribed", "user_A"); + const { events: result } = await fetchEvents("subscribed", "user_A"); - expect(result.events).toHaveLength(1); - expect(result.events[0].eventName).toBe("subscribed"); - expect(result.events[0].address).toBe("user_A"); - expect(result.events[0].data).toEqual({ amount: 1000 }); + expect(result).toHaveLength(1); + expect(result[0].eventName).toBe("subscribed"); + expect(result[0].address).toBe("user_A"); + expect(result[0].data).toEqual({ amount: 1000 }); }); it("returns empty array on error", async () => { // Override default resolution to force an architecture crash getEventsMock.mockRejectedValue(new Error("Stellar RPC Network Failure")); - const result = await fetchEvents("subscribed"); + const { events: result } = await fetchEvents("subscribed"); // The function's internal catch block should swallow the exception and output [] safely - expect(result.events).toEqual([]); + expect(result).toEqual([]); }); }); diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index 01679944..2db22c0d 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -1,5 +1,5 @@ -import React, { useState, useRef, useCallback, lazy, Suspense } from "react"; -import { buildPayPerUseTx } from "../stellar"; +import React, { useState, useEffect, useRef, lazy, Suspense, useCallback } from "react"; +import { buildCancelTx, buildPayPerUseTx } from "../stellar"; import { friendlyError } from "../utils/errors"; import SubscriptionCard from "./SubscriptionCard"; import SubscriptionCardSkeleton from "./Skeleton"; @@ -13,7 +13,7 @@ import DailyLimitModal from "./DailyLimitModal"; import IncreaseAllowanceModal from "./IncreaseAllowanceModal"; import AllowanceDisplay from "./AllowanceDisplay"; import ToastContainer from "./Toast"; -import { useSubscriptionSync } from "../hooks/useSubscriptionSync"; +import { useSubscription } from "../hooks/useSubscription"; import { usePolling } from "../hooks/usePolling"; import { useToast } from "../hooks/useToast"; import { useRpcHealth } from "../hooks/useRpcHealth"; @@ -40,7 +40,9 @@ export default function Dashboard({ const { subscription: sub, loading, refresh } = useSubscriptionSync(userKey, refreshTrigger); const { toasts, addToast, removeToast } = useToast(); const { status: rpcStatus, latencyMs: rpcLatency, error: rpcError } = useRpcHealth(); + const cancelTx = useTransaction(); const ppuTx = useTransaction(); + const [showConfirm, setShowConfirm] = useState(false); const [showDailyLimit, setShowDailyLimit] = useState(false); const [showIncreaseAllowance, setShowIncreaseAllowance] = useState(false); const [allowanceRefresh, setAllowanceRefresh] = useState(0); @@ -63,25 +65,56 @@ export default function Dashboard({ : [] ); - const handlePayPerUse = useCallback( - async (stroops: bigint) => { - announce("Transaction submitted"); - try { - const hash = await ppuTx.submit(async () => { - const xdr = await buildPayPerUseTx(userKey, stroops); - return onSign(xdr); - }); - addToast("Paid!", "success", hash); - announce("Transaction confirmed"); - onPayPerUse?.(stroops); - } catch (e: unknown) { - const msg = `Error: ${friendlyError(e instanceof Error ? e.message : String(e))}`; - addToast(msg, "error"); - announce(msg); + if (key === "x" && sub?.active && !showConfirm) { + e.preventDefault(); + setShowConfirm(true); } - }, - [userKey, onSign, announce, addToast, onPayPerUse, ppuTx] - ); + + if (key === "p" && sub?.active) { + e.preventDefault(); + ppuInputRef.current?.focus(); + } + } + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [sub?.active, showConfirm]); + + async function performCancel() { + setShowConfirm(false); + announce("Transaction submitted"); + try { + const hash = await cancelTx.submit(async () => { + const xdr = await buildCancelTx(userKey); + return onSign(xdr); + }); + addToast("Cancelled.", "success", hash); + announce("Transaction confirmed"); + onCancelled?.(); + refresh(); + } catch (e: unknown) { + const msg = `Error: ${friendlyError(e instanceof Error ? e.message : String(e))}`; + addToast(msg, "error"); + announce(msg); + } + } + + const handlePayPerUse = useCallback(async (stroops: bigint) => { + announce("Transaction submitted"); + try { + const hash = await ppuTx.submit(async () => { + const xdr = await buildPayPerUseTx(userKey, stroops); + return onSign(xdr); + }); + addToast("Paid!", "success", hash); + announce("Transaction confirmed"); + onPayPerUse?.(stroops); + } catch (e: unknown) { + const msg = `Error: ${friendlyError(e instanceof Error ? e.message : String(e))}`; + addToast(msg, "error"); + announce(msg); + } + }, [userKey, onSign, announce, addToast, onPayPerUse, ppuTx]); if (loading) return ( @@ -102,6 +135,7 @@ export default function Dashboard({ ); + const cancelPending = cancelTx.status === "pending"; const ppuPending = ppuTx.status === "pending"; return ( @@ -128,10 +162,15 @@ export default function Dashboard({ subscription={sub} userKey={userKey} onSign={onSign} + onCancel={() => setShowConfirm(true)} + onPause={onSign} onRefresh={refresh} - onCancelled={onCancelled} /> + {cancelPending && ( +

Confirming cancellation…

+ )} + {sub.active && ( <>
@@ -181,6 +220,14 @@ export default function Dashboard({ /> )} + {showConfirm && ( + setShowConfirm(false)} + /> + )} + {showIncreaseAllowance && sub?.active && ( Promise; onRefresh: () => void; onCancelled?: () => void; + onCancel?: () => void; + onPause?: (xdr: string) => Promise; } function formatInterval(secs: number): string { @@ -48,6 +50,8 @@ export default function SubscriptionCard({ onSign, onRefresh, onCancelled, + onCancel, + onPause, }: SubscriptionCardProps & { userKey: string }) { const { mutate } = useSubscriptionSync(userKey); const { merchant, amount, interval, last_charged, active, paused, trial_duration } = subscription; @@ -100,7 +104,13 @@ export default function SubscriptionCard({ const handlePause = async () => { try { - await pause(); + await mutate('pause', async () => { + const { buildPauseTx } = await import("../stellar"); + const xdr = await buildPauseTx(userKey); + return (onPause ?? onSign)(xdr); + }, { paused: true }); + + setPauseStatus("Paused successfully."); setShowPauseConfirm(false); } catch { // pauseTx.error holds the failure reason @@ -173,7 +183,13 @@ export default function SubscriptionCard({ Pause